diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx
index a860c85bb..5108bfaa1 100644
--- a/ui/src/album/AlbumList.jsx
+++ b/ui/src/album/AlbumList.jsx
@@ -6,7 +6,6 @@ import {
Filter,
NullableBooleanInput,
NumberInput,
- Pagination,
ReferenceArrayInput,
ReferenceInput,
SearchInput,
@@ -20,6 +19,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite'
import { withWidth } from '@material-ui/core'
import {
List,
+ Pagination,
Title,
useAlbumsPerPage,
useResourceRefresh,
diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx
index 935b0bab7..955a565d6 100644
--- a/ui/src/artist/ArtistShow.jsx
+++ b/ui/src/artist/ArtistShow.jsx
@@ -100,6 +100,7 @@ const ArtistShowLayout = (props) => {
const rowsPerPageOptions = [1, 2, 3].map((option) =>
Math.trunc(option * (perPage / 3)),
)
+ // react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key
pagination =
}
diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx
index 72c2d9482..0ae089460 100644
--- a/ui/src/common/List.jsx
+++ b/ui/src/common/List.jsx
@@ -2,6 +2,7 @@ import React from 'react'
import { List as RAList } from 'react-admin'
import config from '../config'
import { Pagination } from './Pagination'
+import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore'
import { Title } from './index'
export const List = (props) => {
@@ -15,7 +16,7 @@ export const List = (props) => {
/>
}
debounce={config.uiSearchDebounceMs}
- perPage={15}
+ perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)}
pagination={}
{...props}
/>
diff --git a/ui/src/common/List.test.jsx b/ui/src/common/List.test.jsx
new file mode 100644
index 000000000..5bc4b910f
--- /dev/null
+++ b/ui/src/common/List.test.jsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, vi } from 'vitest'
+import { List } from './List'
+
+// Only stub the heavy react-admin List controller (data fetching, router sync);
+// everything else, including our own Pagination/perPageStore wiring, stays real
+// so a bad import (the bug this test guards against) throws on render.
+vi.mock('react-admin', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ List: ({ children }) =>
{children}
,
+ }
+})
+
+describe('List', () => {
+ it('renders without throwing and shows its children', () => {
+ render(
+
+ list content
+
,
+ )
+ expect(screen.getByTestId('ra-list')).toBeInTheDocument()
+ expect(screen.getByText('list content')).toBeInTheDocument()
+ })
+})
diff --git a/ui/src/common/Pagination.jsx b/ui/src/common/Pagination.jsx
index e17d9e63e..dd18961ce 100644
--- a/ui/src/common/Pagination.jsx
+++ b/ui/src/common/Pagination.jsx
@@ -1,6 +1,29 @@
-import React from 'react'
-import { Pagination as RAPagination } from 'react-admin'
+import React, { useCallback } from 'react'
+import {
+ Pagination as RAPagination,
+ useListPaginationContext,
+} from 'react-admin'
+import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore'
-export const Pagination = (props) => (
-
-)
+export const Pagination = ({
+ rowsPerPageOptions = defaultRowsPerPageOptions,
+ ...props
+}) => {
+ const { resource, setPerPage } = useListPaginationContext()
+ // Persist only a selector-driven change: mount, URL params and responsive
+ // fallbacks never call setPerPage, so they can't overwrite the preference.
+ const handleSetPerPage = useCallback(
+ (value) => {
+ if (resource) setStoredPerPage(resource, value)
+ setPerPage(value)
+ },
+ [resource, setPerPage],
+ )
+ return (
+
+ )
+}
diff --git a/ui/src/common/Pagination.test.jsx b/ui/src/common/Pagination.test.jsx
new file mode 100644
index 000000000..a488aba91
--- /dev/null
+++ b/ui/src/common/Pagination.test.jsx
@@ -0,0 +1,62 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { Pagination } from './Pagination'
+
+// stub RA's Pagination so a test can invoke the injected setPerPage, i.e.
+// simulate an actual rows-per-page selection
+vi.mock('react-admin', async () => {
+ const React = await vi.importActual('react')
+ return {
+ Pagination: ({ setPerPage }) =>
+ React.createElement(
+ 'button',
+ { onClick: () => setPerPage(50) },
+ 'select 50',
+ ),
+ useListPaginationContext: vi.fn(),
+ }
+})
+
+describe('Pagination', () => {
+ let mockContext
+ let setPerPage
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ setPerPage = vi.fn()
+ const { useListPaginationContext } = await import('react-admin')
+ mockContext = vi.mocked(useListPaginationContext)
+ })
+
+ const selectPerPage = () => fireEvent.click(screen.getByText('select 50'))
+
+ it('persists the page size chosen in the selector', () => {
+ mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage })
+ render()
+ selectPerPage()
+ expect(localStorage.getItem('perPage.song')).toEqual('50')
+ })
+
+ it('still applies the change to the list', () => {
+ mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage })
+ render()
+ selectPerPage()
+ expect(setPerPage).toHaveBeenCalledWith(50)
+ })
+
+ it('does not persist a page size the user did not select', () => {
+ mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage })
+ render()
+ expect(localStorage.getItem('perPage.song')).toBeNull()
+ })
+
+ it('does not persist without a resource in context', () => {
+ mockContext.mockReturnValue({ perPage: 15, setPerPage })
+ render()
+ selectPerPage()
+ expect(localStorage.getItem('perPage.undefined')).toBeNull()
+ expect(setPerPage).toHaveBeenCalledWith(50)
+ })
+})
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 362a0ced3..ac8d7f62c 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -10,6 +10,7 @@ export * from './DurationField'
export * from './List'
export * from './MultiLineTextField'
export * from './Pagination'
+export * from './perPageStore'
export * from './PlayButton'
export * from './QuickFilter'
export * from './RangeField'
diff --git a/ui/src/common/perPageStore.js b/ui/src/common/perPageStore.js
new file mode 100644
index 000000000..52a8a8f29
--- /dev/null
+++ b/ui/src/common/perPageStore.js
@@ -0,0 +1,11 @@
+export const defaultRowsPerPageOptions = [15, 25, 50]
+
+const key = (resource) => `perPage.${resource}`
+
+export const getStoredPerPage = (resource, options, fallback = options[0]) => {
+ const stored = parseInt(localStorage.getItem(key(resource)), 10)
+ return options.includes(stored) ? stored : fallback
+}
+
+export const setStoredPerPage = (resource, perPage) =>
+ localStorage.setItem(key(resource), String(perPage))
diff --git a/ui/src/common/perPageStore.test.js b/ui/src/common/perPageStore.test.js
new file mode 100644
index 000000000..3aeba3ad8
--- /dev/null
+++ b/ui/src/common/perPageStore.test.js
@@ -0,0 +1,40 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { getStoredPerPage, setStoredPerPage } from './perPageStore'
+
+const options = [15, 25, 50]
+
+describe('perPageStore', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('round-trips a stored value', () => {
+ setStoredPerPage('song', 25)
+ expect(getStoredPerPage('song', options, 15)).toEqual(25)
+ })
+
+ it('keys values per resource', () => {
+ setStoredPerPage('song', 25)
+ setStoredPerPage('playlist', 50)
+ expect(getStoredPerPage('song', options, 15)).toEqual(25)
+ expect(getStoredPerPage('playlist', options, 15)).toEqual(50)
+ })
+
+ it('returns the fallback when nothing is stored', () => {
+ expect(getStoredPerPage('song', options, 15)).toEqual(15)
+ })
+
+ it('returns the fallback for garbage values', () => {
+ localStorage.setItem('perPage.song', 'bogus')
+ expect(getStoredPerPage('song', options, 15)).toEqual(15)
+ })
+
+ it('returns the fallback when the stored value is not a valid option', () => {
+ setStoredPerPage('album', 90)
+ expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18)
+ })
+
+ it('defaults the fallback to the first option', () => {
+ expect(getStoredPerPage('song', options)).toEqual(15)
+ })
+})
diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx
index 6a02bdeb7..0fb5616c3 100644
--- a/ui/src/common/useAlbumsPerPage.jsx
+++ b/ui/src/common/useAlbumsPerPage.jsx
@@ -1,4 +1,5 @@
import { useSelector } from 'react-redux'
+import { getStoredPerPage } from './perPageStore'
const getPerPage = (width) => {
if (width === 'xs') return 12
@@ -17,10 +18,15 @@ const getPerPageOptions = (width) => {
}
export const useAlbumsPerPage = (width) => {
- const perPage =
- useSelector(
- (state) => state?.admin.resources?.album?.list?.params?.perPage,
- ) || getPerPage(width)
+ const options = getPerPageOptions(width)
+ const sessionPerPage = useSelector(
+ (state) => state?.admin.resources?.album?.list?.params?.perPage,
+ )
+ // Use the session value only when it's valid for the current width, so a
+ // size picked at a wider breakpoint can't leave an out-of-range selector.
+ const perPage = options.includes(sessionPerPage)
+ ? sessionPerPage
+ : getStoredPerPage('album', options, getPerPage(width))
- return [perPage, getPerPageOptions(width)]
+ return [perPage, options]
}
diff --git a/ui/src/common/useAlbumsPerPage.test.jsx b/ui/src/common/useAlbumsPerPage.test.jsx
new file mode 100644
index 000000000..b194a6ef2
--- /dev/null
+++ b/ui/src/common/useAlbumsPerPage.test.jsx
@@ -0,0 +1,61 @@
+import { renderHook } from '@testing-library/react-hooks'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { useAlbumsPerPage } from './useAlbumsPerPage'
+import { setStoredPerPage } from './perPageStore'
+
+vi.mock('react-redux', () => ({
+ useSelector: vi.fn(),
+}))
+
+describe('useAlbumsPerPage', () => {
+ let mockUseSelector
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ const { useSelector } = await import('react-redux')
+ mockUseSelector = vi.mocked(useSelector)
+ })
+
+ const setReduxPerPage = (value) =>
+ mockUseSelector.mockImplementation((selector) =>
+ selector({
+ admin: {
+ resources: { album: { list: { params: { perPage: value } } } },
+ },
+ }),
+ )
+
+ it('prefers the redux session value over the stored one', () => {
+ setReduxPerPage(36)
+ setStoredPerPage('album', 72)
+ const { result } = renderHook(() => useAlbumsPerPage('lg'))
+ expect(result.current[0]).toEqual(36)
+ })
+
+ it('falls back to the stored value on fresh load', () => {
+ setReduxPerPage(undefined)
+ setStoredPerPage('album', 72)
+ const { result } = renderHook(() => useAlbumsPerPage('lg'))
+ expect(result.current[0]).toEqual(72)
+ })
+
+ it('ignores stored values invalid for the current width', () => {
+ setReduxPerPage(undefined)
+ setStoredPerPage('album', 72) // valid for lg, not for md
+ const { result } = renderHook(() => useAlbumsPerPage('md'))
+ expect(result.current[0]).toEqual(12)
+ })
+
+ it('returns the responsive default when nothing is stored', () => {
+ setReduxPerPage(undefined)
+ const { result } = renderHook(() => useAlbumsPerPage('xl'))
+ expect(result.current).toEqual([36, [18, 36, 72]])
+ })
+
+ it('ignores a redux value invalid for the current width', () => {
+ setReduxPerPage(72) // valid for lg, not for md
+ const { result } = renderHook(() => useAlbumsPerPage('md'))
+ expect(result.current[0]).toEqual(12)
+ })
+})
diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx
index 87d9f629f..a09f552d9 100644
--- a/ui/src/missing/MissingFilesList.jsx
+++ b/ui/src/missing/MissingFilesList.jsx
@@ -1,10 +1,15 @@
-import { List, SizeField, useResourceRefresh } from '../common/index'
+import {
+ List,
+ Pagination,
+ SizeField,
+ getStoredPerPage,
+ useResourceRefresh,
+} from '../common/index'
import {
Datagrid,
DateField,
TextField,
downloadCSV,
- Pagination,
Filter,
ReferenceInput,
useTranslate,
@@ -49,8 +54,10 @@ const BulkActionButtons = (props) => (
>
)
+const missingPerPageOptions = [50, 100, 200]
+
const MissingPagination = (props) => (
-
+
)
const MissingFilesList = (props) => {
@@ -63,7 +70,7 @@ const MissingFilesList = (props) => {
actions={}
filters={}
bulkActionButtons={}
- perPage={50}
+ perPage={getStoredPerPage('missing', missingPerPageOptions)}
pagination={}
>
diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx
index f0cb472b1..4e269be18 100644
--- a/ui/src/playlist/PlaylistShow.jsx
+++ b/ui/src/playlist/PlaylistShow.jsx
@@ -4,14 +4,21 @@ import {
ShowContextProvider,
useShowContext,
useShowController,
- Pagination,
Title as RaTitle,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import PlaylistDetails from './PlaylistDetails'
import PlaylistSongs from './PlaylistSongs'
import PlaylistActions from './PlaylistActions'
-import { Title, canChangeTracks, useResourceRefresh } from '../common'
+import {
+ Pagination,
+ Title,
+ canChangeTracks,
+ getStoredPerPage,
+ useResourceRefresh,
+} from '../common'
+
+const playlistTrackPerPageOptions = [100, 250, 500]
const useStyles = makeStyles(
(theme) => ({
@@ -41,7 +48,10 @@ const PlaylistShowLayout = (props) => {
reference="playlistTrack"
target="playlist_id"
sort={{ field: 'id', order: 'ASC' }}
- perPage={100}
+ perPage={getStoredPerPage(
+ 'playlistTrack',
+ playlistTrackPerPageOptions,
+ )}
filter={{ playlist_id: props.id }}
>
{
}
resource={'playlistTrack'}
exporter={false}
- pagination={}
+ pagination={
+
+ }
/>
)}
diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx
index 945bac519..ccdb9f1ef 100644
--- a/ui/src/radio/RadioList.jsx
+++ b/ui/src/radio/RadioList.jsx
@@ -16,6 +16,8 @@ import {
} from 'react-admin'
import {
List,
+ defaultRowsPerPageOptions,
+ getStoredPerPage,
useImageUrl,
ToggleFieldsMenu,
useSelectedFields,
@@ -135,7 +137,11 @@ const RadioList = ({ permissions, ...props }) => {
hasCreate={isAdmin}
actions={}
filters={}
- perPage={isXsmall ? 25 : 10}
+ perPage={getStoredPerPage(
+ 'radio',
+ defaultRowsPerPageOptions,
+ isXsmall ? 25 : 10,
+ )}
>
{isXsmall ? (
{
bulkActionButtons={}
actions={}
filters={}
- perPage={isXsmall ? 50 : 15}
+ perPage={getStoredPerPage(
+ 'song',
+ defaultRowsPerPageOptions,
+ isXsmall ? 50 : 15,
+ )}
>
{isXsmall ? (