diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx index 1e89d3ec6..170211c7f 100644 --- a/ui/src/common/LibrarySelector.jsx +++ b/ui/src/common/LibrarySelector.jsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useDataProvider, useTranslate, useRefresh } from 'react-admin' +import { useTranslate, useRefresh } from 'react-admin' import { Box, Chip, @@ -15,8 +15,8 @@ import { makeStyles, } from '@material-ui/core' import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons' -import { setSelectedLibraries, setUserLibraries } from '../actions' -import { useRefreshOnEvents } from './useRefreshOnEvents' +import { setSelectedLibraries } from '../actions' +import { useUserLibraries } from './useUserLibraries' const useStyles = makeStyles((theme) => ({ root: { @@ -70,7 +70,6 @@ const useStyles = makeStyles((theme) => ({ const LibrarySelector = () => { const classes = useStyles() const dispatch = useDispatch() - const dataProvider = useDataProvider() const translate = useTranslate() const refresh = useRefresh() const [anchorEl, setAnchorEl] = useState(null) @@ -80,34 +79,9 @@ const LibrarySelector = () => { (state) => state.library, ) - // Load user's libraries when component mounts - const loadUserLibraries = useCallback(async () => { - const userId = localStorage.getItem('userId') - if (userId) { - try { - const { data } = await dataProvider.getOne('user', { id: userId }) - const libraries = data.libraries || [] - dispatch(setUserLibraries(libraries)) - } catch (error) { - // eslint-disable-next-line no-console - console.warn( - 'Could not load user libraries (this may be expected for non-admin users):', - error, - ) - } - } - }, [dataProvider, dispatch]) - - // Initial load - useEffect(() => { - loadUserLibraries() - }, [loadUserLibraries]) - - // Reload user libraries when library changes occur - useRefreshOnEvents({ - events: ['library', 'user'], - onRefresh: loadUserLibraries, - }) + // Keep the user's libraries loaded (also done at the Layout level so the data + // is available even when this selector isn't rendered). + useUserLibraries() // Don't render if user has no libraries or only has one library if (!userLibraries.length || userLibraries.length === 1) { diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 362a0ced3..6959ed41b 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -28,6 +28,7 @@ export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' export * from './useRefreshOnEvents' +export * from './useUserLibraries' export * from './useToggleLove' export * from './useTraceUpdate' export * from './Writable' diff --git a/ui/src/common/useUserLibraries.js b/ui/src/common/useUserLibraries.js new file mode 100644 index 000000000..7660ef8c4 --- /dev/null +++ b/ui/src/common/useUserLibraries.js @@ -0,0 +1,40 @@ +import { useCallback, useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { useDataProvider } from 'react-admin' +import { setUserLibraries } from '../actions' +import { useRefreshOnEvents } from './useRefreshOnEvents' + +/** + * Loads the current user's accessible libraries into the Redux store and keeps + * them refreshed when library/user events occur. Mount this once high in the + * tree (e.g. the Layout) so consumers like useSelectedLibraries always have the + * data available, regardless of whether the sidebar/LibrarySelector is open. + */ +export const useUserLibraries = () => { + const dispatch = useDispatch() + const dataProvider = useDataProvider() + + const loadUserLibraries = useCallback(async () => { + const userId = localStorage.getItem('userId') + if (!userId) return + try { + const { data } = await dataProvider.getOne('user', { id: userId }) + dispatch(setUserLibraries(data.libraries || [])) + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + 'Could not load user libraries (this may be expected for non-admin users):', + error, + ) + } + }, [dataProvider, dispatch]) + + useEffect(() => { + loadUserLibraries() + }, [loadUserLibraries]) + + useRefreshOnEvents({ + events: ['library', 'user'], + onRefresh: loadUserLibraries, + }) +} diff --git a/ui/src/common/useUserLibraries.test.js b/ui/src/common/useUserLibraries.test.js new file mode 100644 index 000000000..48035e745 --- /dev/null +++ b/ui/src/common/useUserLibraries.test.js @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { useUserLibraries } from './useUserLibraries' + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const mockDispatch = vi.fn() +const mockGetOne = vi.fn() + +vi.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, +})) + +vi.mock('react-admin', () => ({ + useDataProvider: () => ({ getOne: mockGetOne }), +})) + +vi.mock('./useRefreshOnEvents', () => ({ + useRefreshOnEvents: vi.fn(), +})) + +describe('useUserLibraries', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('loads the user libraries and dispatches them on mount', async () => { + localStorage.setItem('userId', 'u-1') + const libraries = [{ id: 1 }, { id: 2 }] + mockGetOne.mockResolvedValue({ data: { libraries } }) + + renderHook(() => useUserLibraries()) + await flushPromises() + + expect(mockGetOne).toHaveBeenCalledWith('user', { id: 'u-1' }) + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ data: libraries }), + ) + }) + + it('does not fetch when there is no userId', () => { + renderHook(() => useUserLibraries()) + + expect(mockGetOne).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('handles a failed fetch without dispatching', async () => { + localStorage.setItem('userId', 'u-1') + mockGetOne.mockRejectedValue(new Error('forbidden')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + renderHook(() => useUserLibraries()) + await flushPromises() + + expect(mockGetOne).toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx index 44cf9b42c..2648680c7 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.jsx @@ -7,7 +7,7 @@ import Menu from './Menu' import AppBar from './AppBar' import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' -import { useSearchRefocus } from '../common' +import { useSearchRefocus, useUserLibraries } from '../common' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, @@ -19,6 +19,7 @@ const Layout = (props) => { const classes = useStyles({ addPadding: queue.length > 0 }) const dispatch = useDispatch() useSearchRefocus() + useUserLibraries() const keyHandlers = { TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]),