navidrome/ui/src/layout/PlaylistsSubMenu.test.jsx
Deluan Quintão deaa5e6c02
feat(ui): playlist favourites (heart, list filter, sidebar favourites-only toggle) (#5805)
* 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).
2026-07-18 14:45:47 -04:00

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')
})
})