diff --git a/ui/src/common/PlaylistTypeIcon.jsx b/ui/src/common/PlaylistTypeIcon.jsx new file mode 100644 index 000000000..dd3b2f5ee --- /dev/null +++ b/ui/src/common/PlaylistTypeIcon.jsx @@ -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 ( + 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 ( + + ) +} + +export const PlaylistTypeIcon = ({ record, ...props }) => + isSmartPlaylist(record) ? ( + + ) : ( + + ) diff --git a/ui/src/common/PlaylistTypeIcon.test.jsx b/ui/src/common/PlaylistTypeIcon.test.jsx new file mode 100644 index 000000000..d2cd17516 --- /dev/null +++ b/ui/src/common/PlaylistTypeIcon.test.jsx @@ -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('', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('marks a playlist with rules as smart', () => { + render() + expect( + screen.getByTitle('resources.playlist.message.smartPlaylist'), + ).not.toBeNull() + }) + + it('uses the plain playlist icon when there are no rules', () => { + render() + 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 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', + ) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 047f5b3cf..330bcfac3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -50,3 +50,4 @@ export * from './ImageUploadOverlay' export * from './ArtworkAvatar' export * from './useImageLoadingState' export * from './useImageUrl' +export * from './PlaylistTypeIcon' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index de96d47c0..0cf55eea2 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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": { diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx index f332f6810..1195c16ae 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -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} /> diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx index 617f60a4e..95cbcb304 100644 --- a/ui/src/layout/PlaylistsSubMenu.test.jsx +++ b/ui/src/layout/PlaylistsSubMenu.test.jsx @@ -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 }, diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index d2b17b108..6f136263b 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -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 /> diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx index 4fbc6d516..8e882804f 100644 --- a/ui/src/playlist/PlaylistList.test.jsx +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -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' }) + }) +})