mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 22:07:08 +02:00
* feat(playlists): register starred REST filter on playlist repository
* feat(ui): add persisted sidebarPlaylistsOnlyFavourites setting
* feat(ui): playlist favourites heart column and list filter
* feat(ui): favourite heart on playlist details header
* feat(ui): sidebar favourites-only playlist toggle with live refresh
* fix(playlists): qualify id in REST filter to avoid ambiguous column
The annotation join in selectPlaylist made a bare id filter ambiguous, so
GET /api/playlist?id=X (react-admin getMany, used by the sidebar refetch and
useResourceRefresh) failed with 'ambiguous column name: id'. Register
idFilter("playlist") like the album/artist/mediafile repos.
* fix(ui): refresh favourites sidebar on local star toggle
The SSE broker skips the client that originated a star, so the acting client
never got the refreshResource echo and its favourites-only sidebar went stale.
Key the sidebar query on a fingerprint of locally-known starred playlists so a
star/unstar on this client refetches; SSE still covers other clients.
* style(ui): prettier-format PlaylistsSubMenu test
* feat(ui): refine playlist favourites layout
- Move the favourite heart column to just before the edit button
- Space the Playlists sidebar header text from its action icons
- Use a list icon instead of a cog for the playlist-management action
* feat(ui): make the playlist Favourite column toggleable
Move the heart into the toggleable fields map so users can show/hide it
from the column selector like the other optional columns, keeping it last
so it stays just before the edit button.
* refactor(ui): memoize sidebar star fingerprint and gate it on favourites-only
- Derive starFingerprint via useMemo on the playlist data reference instead
of recomputing sort/join inside useSelector on every app-wide dispatch.
- Only include the fingerprint in the query payload when favourites-only is
on, so a star toggle no longer refetches the sidebar when it shows all
playlists.
* fix(ui): don't refetch favourites sidebar on SSE events when showing all
When favourites-only is off the sidebar shows every playlist, so a star
event from another client changes nothing visible. Gate the SSE-driven
refresh counter (and its payload key) on onlyFavourites so the sidebar no
longer redraws on unrelated playlist star events.
* fix(ui): address automated review feedback on playlist favourites
- Ignore a persisted favourites-only preference when EnableFavourites is off,
so disabling the feature later can't strand a filtered sidebar (Codex P2).
- Make PlaylistLove's datagrid header props explicit (source/sortable via
defaultProps, className forwarded) instead of relying on prop pass-through.
- Add aria-label to the SubMenu secondary action button.
- Cover the PlaylistLove list column with tests (Codex P1).
180 lines
5.7 KiB
JavaScript
180 lines
5.7 KiB
JavaScript
import React from 'react'
|
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { Provider } from 'react-redux'
|
|
import { createStore, combineReducers } from 'redux'
|
|
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
|
import { settingsReducer, activityReducer } from '../reducers'
|
|
import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions'
|
|
import PlaylistsSubMenu from './PlaylistsSubMenu'
|
|
|
|
const mockUseQueryWithStore = vi.fn()
|
|
|
|
vi.mock('../config', () => ({
|
|
// losslessFormats is read at module-load time by common/QualityInfo.jsx,
|
|
// pulled in transitively via the '../common' barrel file
|
|
default: {
|
|
enableFavourites: true,
|
|
maxSidebarPlaylists: 100,
|
|
losslessFormats: '',
|
|
},
|
|
}))
|
|
|
|
vi.mock('react-dnd', () => ({
|
|
useDrop: () => [{}, () => {}],
|
|
}))
|
|
|
|
vi.mock('react-router-dom', () => ({
|
|
useHistory: () => ({ push: vi.fn() }),
|
|
}))
|
|
|
|
vi.mock('react-admin', async (importOriginal) => {
|
|
const actual = await importOriginal()
|
|
return {
|
|
...actual,
|
|
useTranslate: () => (x) => x,
|
|
useDataProvider: () => ({ addToPlaylist: vi.fn() }),
|
|
useNotify: () => vi.fn(),
|
|
useQueryWithStore: (query) => mockUseQueryWithStore(query),
|
|
MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>,
|
|
}
|
|
})
|
|
|
|
const playlists = {
|
|
'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' },
|
|
'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' },
|
|
}
|
|
|
|
const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA'
|
|
const adminReducer = (state = { resources: {} }, action) =>
|
|
action.type === SET_PLAYLIST_DATA
|
|
? { resources: { playlist: { data: action.data } } }
|
|
: state
|
|
|
|
const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => {
|
|
const store = createStore(
|
|
combineReducers({
|
|
settings: settingsReducer,
|
|
activity: activityReducer,
|
|
admin: adminReducer,
|
|
}),
|
|
{
|
|
settings: preloadedSettings,
|
|
activity: {},
|
|
admin: {
|
|
resources: preloadedPlaylistData
|
|
? { playlist: { data: preloadedPlaylistData } }
|
|
: {},
|
|
},
|
|
},
|
|
)
|
|
const theme = createTheme()
|
|
render(
|
|
<Provider store={store}>
|
|
<ThemeProvider theme={theme}>
|
|
<PlaylistsSubMenu
|
|
state={{ menuPlaylists: true, menuSharedPlaylists: true }}
|
|
setState={vi.fn()}
|
|
sidebarIsOpen={true}
|
|
dense={false}
|
|
/>
|
|
</ThemeProvider>
|
|
</Provider>,
|
|
)
|
|
return store
|
|
}
|
|
|
|
const lastQuery = () =>
|
|
mockUseQueryWithStore.mock.calls[
|
|
mockUseQueryWithStore.mock.calls.length - 1
|
|
][0]
|
|
|
|
describe('<PlaylistsSubMenu />', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
localStorage.setItem('userId', 'user-1')
|
|
mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true })
|
|
// SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom
|
|
window.matchMedia = (query) => ({
|
|
matches: false,
|
|
media: query,
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
})
|
|
// OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom
|
|
window.ResizeObserver = class {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
})
|
|
|
|
it('queries without a starred filter by default', () => {
|
|
renderMenu()
|
|
expect(lastQuery().payload.filter).toBeUndefined()
|
|
expect(screen.getByText('Mine')).not.toBeNull()
|
|
expect(screen.getByText('Theirs')).not.toBeNull()
|
|
})
|
|
|
|
it('adds the starred filter when favourites-only is enabled', () => {
|
|
renderMenu({ sidebarPlaylistsOnlyFavourites: true })
|
|
expect(lastQuery().payload.filter).toEqual({ starred: true })
|
|
})
|
|
|
|
it('toggles the setting when the heart action is clicked', () => {
|
|
const store = renderMenu()
|
|
fireEvent.click(screen.getByTitle('menu.onlyFavourites'))
|
|
expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true)
|
|
expect(lastQuery().payload.filter).toEqual({ starred: true })
|
|
})
|
|
|
|
it('refetches on a playlist SSE event when favourites-only is on', async () => {
|
|
const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true })
|
|
const before = lastQuery().payload.refresh
|
|
// useRefreshOnEvents compares Date.now() timestamps; make sure it advances
|
|
await act(() => new Promise((resolve) => setTimeout(resolve, 5)))
|
|
act(() => {
|
|
store.dispatch(
|
|
processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }),
|
|
)
|
|
})
|
|
expect(lastQuery().payload.refresh).toBe(before + 1)
|
|
})
|
|
|
|
it('does not change the query signature on an SSE event when favourites-only is off', async () => {
|
|
const store = renderMenu()
|
|
const before = JSON.stringify(lastQuery().payload)
|
|
await act(() => new Promise((resolve) => setTimeout(resolve, 5)))
|
|
act(() => {
|
|
store.dispatch(
|
|
processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }),
|
|
)
|
|
})
|
|
// Signature unchanged → useQueryWithStore dedupes, no wasted refetch
|
|
expect(lastQuery().payload.refresh).toBeUndefined()
|
|
expect(JSON.stringify(lastQuery().payload)).toBe(before)
|
|
})
|
|
|
|
it('refetches when a playlist is starred locally (no SSE echo)', () => {
|
|
const store = renderMenu(
|
|
{ sidebarPlaylistsOnlyFavourites: true },
|
|
{ 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } },
|
|
)
|
|
const before = lastQuery().payload.starFingerprint
|
|
act(() => {
|
|
store.dispatch({
|
|
type: SET_PLAYLIST_DATA,
|
|
data: {
|
|
'pl-1': {
|
|
id: 'pl-1',
|
|
name: 'Mine',
|
|
ownerId: 'user-1',
|
|
starred: true,
|
|
},
|
|
},
|
|
})
|
|
})
|
|
expect(lastQuery().payload.starFingerprint).not.toBe(before)
|
|
expect(lastQuery().payload.starFingerprint).toContain('pl-1')
|
|
})
|
|
})
|