Merge 8f00af2ab8420a1b8e2b411ee4c6719d1c6bb0bb into dbd26ba2e71d0a5b79dba873a2beeff59f1cd8dd

This commit is contained in:
Adrián Sánchez Zapico 2026-08-31 08:23:22 +03:00 committed by GitHub
commit 0d255f0f5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 192 additions and 6 deletions

View File

@ -0,0 +1,44 @@
import React from 'react'
import SvgIcon from '@material-ui/core/SvgIcon'
import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay'
import { MdOutlineAutoAwesome } from 'react-icons/md'
import { useTranslate } from 'react-admin'
import { isSmartPlaylist } from './playlistUtils'
// Both icons name themselves: MenuItemLink clones its leftIcon with
// titleAccess=primaryText, which for a playlist entry is a node, not a string.
// Neither holds a ref (react-icons components don't accept one), so wrap them
// in an element of your own to hang a Tooltip off.
export const SmartPlaylistIcon = (props) => {
const translate = useTranslate()
const label = translate('resources.playlist.message.smartPlaylist')
return (
<SvgIcon
{...props}
component={MdOutlineAutoAwesome}
// react-icons replaces SvgIcon's children, dropping the <title> it builds
// from titleAccess, so pass its own `title` prop too. titleAccess still
// earns the icon its role="img".
title={label}
titleAccess={label}
/>
)
}
export const RegularPlaylistIcon = (props) => {
const translate = useTranslate()
return (
<PlaylistPlayIcon
{...props}
titleAccess={translate('resources.playlist.name', { smart_count: 1 })}
/>
)
}
export const PlaylistTypeIcon = ({ record, ...props }) =>
isSmartPlaylist(record) ? (
<SmartPlaylistIcon {...props} />
) : (
<RegularPlaylistIcon {...props} />
)

View File

@ -0,0 +1,42 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { PlaylistTypeIcon } from './PlaylistTypeIcon'
vi.mock('react-admin', () => ({
useTranslate: () => (key) => key,
}))
describe('<PlaylistTypeIcon />', () => {
beforeEach(() => {
localStorage.clear()
})
it('marks a playlist with rules as smart', () => {
render(<PlaylistTypeIcon record={{ id: 'pl-1', rules: { all: [] } }} />)
expect(
screen.getByTitle('resources.playlist.message.smartPlaylist'),
).not.toBeNull()
})
it('uses the plain playlist icon when there are no rules', () => {
render(<PlaylistTypeIcon record={{ id: 'pl-2', rules: null }} />)
expect(screen.getByTitle('resources.playlist.name')).not.toBeNull()
expect(
screen.queryByTitle('resources.playlist.message.smartPlaylist'),
).toBeNull()
})
it('names the smart icon for screen readers', () => {
// react-icons discards the <title> SvgIcon builds from titleAccess, so the
// icon has to pass react-icons' own `title` prop as well
const { container } = render(
<PlaylistTypeIcon record={{ id: 'pl-3', rules: { all: [] } }} />,
)
const svg = container.querySelector('svg')
expect(svg.getAttribute('role')).toBe('img')
expect(svg.querySelector('title').textContent).toBe(
'resources.playlist.message.smartPlaylist',
)
})
})

View File

@ -50,3 +50,4 @@ export * from './ImageUploadOverlay'
export * from './ArtworkAvatar'
export * from './useImageLoadingState'
export * from './useImageUrl'
export * from './PlaylistTypeIcon'

View File

@ -232,7 +232,8 @@
"duplicate_song": "Add duplicated songs",
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
"noPlaylistsFound": "No playlists found",
"noPlaylists": "No playlists available"
"noPlaylists": "No playlists available",
"smartPlaylist": "Smart playlist"
}
},
"radio": {

View File

@ -10,18 +10,31 @@ import {
import { useHistory } from 'react-router-dom'
import QueueMusicIcon from '@material-ui/icons/QueueMusic'
import { Typography } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined'
import FavoriteIcon from '@material-ui/icons/Favorite'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { BiListUl } from 'react-icons/bi'
import { useDrop } from 'react-dnd'
import SubMenu from './SubMenu'
import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common'
import {
canChangeTracks,
OverflowTooltip,
PlaylistTypeIcon,
useRefreshOnEvents,
} from '../common'
import { DraggableTypes } from '../consts'
import { setSidebarPlaylistsOnlyFavourites } from '../actions'
import config from '../config'
const useStyles = makeStyles((theme) => ({
// Narrower than MenuItemLink's default slot: these items are already indented
// by SubMenu, and the names need the horizontal room more than the icons do
icon: { minWidth: theme.spacing(4) },
}))
const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => {
const classes = useStyles()
const dataProvider = useDataProvider()
const notify = useNotify()
@ -50,6 +63,8 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => {
</Typography>
</OverflowTooltip>
}
leftIcon={<PlaylistTypeIcon record={pls} fontSize={'small'} />}
classes={{ icon: classes.icon }}
sidebarIsOpen={sidebarIsOpen}
dense={false}
/>

View File

@ -36,13 +36,19 @@ vi.mock('react-admin', async (importOriginal) => {
useDataProvider: () => ({ addToPlaylist: vi.fn() }),
useNotify: () => vi.fn(),
useQueryWithStore: (query) => mockUseQueryWithStore(query),
MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>,
MenuItemLink: ({ primaryText, leftIcon }) => (
<div>
{leftIcon}
{primaryText}
</div>
),
}
})
const playlists = {
'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' },
'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' },
'pl-3': { id: 'pl-3', name: 'Smart', ownerId: 'user-1', rules: { all: [] } },
}
const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA'
@ -155,6 +161,16 @@ describe('<PlaylistsSubMenu />', () => {
expect(JSON.stringify(lastQuery().payload)).toBe(before)
})
it('marks smart playlists with their own icon', () => {
renderMenu()
// useTranslate is mocked to echo the key
const smart = screen.getAllByTitle(
'resources.playlist.message.smartPlaylist',
)
expect(smart.length).toBe(1)
expect(screen.getAllByTitle('resources.playlist.name').length).toBe(2)
})
it('refetches when a playlist is starred locally (no SSE echo)', () => {
const store = renderMenu(
{ sidebarPlaylistsOnlyFavourites: true },

View File

@ -15,16 +15,19 @@ import {
useRecordContext,
BulkDeleteButton,
usePermissions,
useTranslate,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core'
import { Tooltip, useMediaQuery } from '@material-ui/core'
import {
ArtworkAvatar,
DurationField,
List,
LoveButton,
SmartPlaylistIcon,
Writable,
isSmartPlaylist,
isWritable,
useSelectedFields,
useResourceRefresh,
@ -38,6 +41,13 @@ const useStyles = makeStyles((theme) => ({
button: {
color: theme.palette.type === 'dark' ? 'white' : undefined,
},
smartIcon: {
display: 'inline-flex',
color: theme.palette.text.secondary,
marginRight: theme.spacing(0.75),
// Inline (not flex) so a name that wraps keeps the icon on its first line
verticalAlign: 'text-bottom',
},
}))
const PlaylistFilter = (props) => {
@ -149,6 +159,33 @@ const PlaylistListBulkActions = (props) => {
)
}
// Marks smart playlists (.nsp) in the name column, the only place in the list
// that tells them apart from playlists whose tracks the user picked by hand.
export const PlaylistNameField = ({ record }) => {
const classes = useStyles()
const translate = useTranslate()
if (!record) {
return null
}
return (
<span>
{isSmartPlaylist(record) && (
<Tooltip
title={translate('resources.playlist.message.smartPlaylist')}
placement={'top'}
>
{/* Tooltip needs a child that holds a ref; the icon does not */}
<span className={classes.smartIcon}>
<SmartPlaylistIcon fontSize={'small'} />
</span>
</Tooltip>
)}
{record.name}
</span>
)
}
PlaylistNameField.defaultProps = { source: 'name' }
// Datagrid reads `source`/`sortable`/`label` off this element for the column
// header; only record/resource are forwarded so they never leak onto the button.
export const PlaylistLove = ({ record, className }) => (
@ -198,7 +235,7 @@ const PlaylistList = (props) => {
>
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
<ArtworkAvatar source="id" variant="square" />
<TextField source="name" />
<PlaylistNameField />
{columns}
<Writable>
<EditButton />

View File

@ -1,7 +1,7 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { PlaylistLove } from './PlaylistList'
import { PlaylistLove, PlaylistNameField } from './PlaylistList'
vi.mock('../config', () => ({
default: { enableFavourites: true },
@ -13,6 +13,9 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'}
</button>
),
SmartPlaylistIcon: () => <span data-testid="smart-icon" />,
isSmartPlaylist: (pls) => !!pls.rules,
isWritable: () => true,
}))
describe('<PlaylistLove />', () => {
@ -32,3 +35,30 @@ describe('<PlaylistLove />', () => {
})
})
})
describe('<PlaylistNameField />', () => {
it('flags a smart playlist next to its name', () => {
render(
<PlaylistNameField
record={{ id: 'pl-1', name: 'Top Rock', rules: { all: [] } }}
/>,
)
expect(screen.getByText('Top Rock')).not.toBeNull()
expect(screen.getByTestId('smart-icon')).not.toBeNull()
})
it('shows no flag for a hand-picked playlist', () => {
render(<PlaylistNameField record={{ id: 'pl-2', name: 'Road Trip' }} />)
expect(screen.getByText('Road Trip')).not.toBeNull()
expect(screen.queryByTestId('smart-icon')).toBeNull()
})
it('renders nothing without a record', () => {
const { container } = render(<PlaylistNameField />)
expect(container.innerHTML).toBe('')
})
it('exposes the source so the datagrid keeps a sortable Name column', () => {
expect(PlaylistNameField.defaultProps).toEqual({ source: 'name' })
})
})