mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge 8f00af2ab8420a1b8e2b411ee4c6719d1c6bb0bb into dbd26ba2e71d0a5b79dba873a2beeff59f1cd8dd
This commit is contained in:
commit
0d255f0f5e
44
ui/src/common/PlaylistTypeIcon.jsx
Normal file
44
ui/src/common/PlaylistTypeIcon.jsx
Normal 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} />
|
||||
)
|
||||
42
ui/src/common/PlaylistTypeIcon.test.jsx
Normal file
42
ui/src/common/PlaylistTypeIcon.test.jsx
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -50,3 +50,4 @@ export * from './ImageUploadOverlay'
|
||||
export * from './ArtworkAvatar'
|
||||
export * from './useImageLoadingState'
|
||||
export * from './useImageUrl'
|
||||
export * from './PlaylistTypeIcon'
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user