mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-27 02:37:21 +01:00
feat: add useRefreshOnEvents hook for custom refresh logic on event changes
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
202af6eab4
commit
340192693e
7 changed files with 433 additions and 61 deletions
|
|
@ -138,4 +138,8 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
|
|||
f.Events = append(f.Events, event)
|
||||
}
|
||||
|
||||
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
|
||||
f.Events = append(f.Events, event)
|
||||
}
|
||||
|
||||
var _ events.Broker = (*fakeEventBroker)(nil)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useDataProvider, useTranslate, useRefresh } from 'react-admin'
|
||||
import {
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '@material-ui/core'
|
||||
import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons'
|
||||
import { setSelectedLibraries, setUserLibraries } from '../actions'
|
||||
import { useRefreshOnEvents } from './useRefreshOnEvents'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
|
|
@ -74,76 +75,39 @@ const LibrarySelector = () => {
|
|||
const refresh = useRefresh()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState(Date.now())
|
||||
|
||||
const { userLibraries, selectedLibraries } = useSelector(
|
||||
(state) => state.library,
|
||||
)
|
||||
|
||||
// Watch for refresh events to reload libraries
|
||||
const refreshData = useSelector(
|
||||
(state) => state.activity?.refresh || { lastReceived: lastRefreshTime },
|
||||
)
|
||||
|
||||
// Load user's libraries when component mounts or when refresh events occur
|
||||
useEffect(() => {
|
||||
const loadUserLibraries = async () => {
|
||||
// Get userId from localStorage (not from identity which returns username)
|
||||
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,
|
||||
)
|
||||
}
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
loadUserLibraries()
|
||||
}, [dataProvider, dispatch])
|
||||
|
||||
// Watch for refresh events and reload libraries when they occur
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
const { resources, lastReceived } = refreshData
|
||||
|
||||
if (lastReceived <= lastRefreshTime) {
|
||||
return
|
||||
}
|
||||
loadUserLibraries()
|
||||
}, [loadUserLibraries])
|
||||
|
||||
const shouldReload = resources &&
|
||||
(resources['*'] === '*' ||
|
||||
resources.library ||
|
||||
resources.user)
|
||||
|
||||
if (shouldReload) {
|
||||
setLastRefreshTime(lastReceived)
|
||||
|
||||
const loadUserLibraries = 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 reload user libraries after refresh event:',
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUserLibraries()
|
||||
}
|
||||
}, [dataProvider, dispatch, refreshData, lastRefreshTime])
|
||||
// Reload user libraries when library changes occur
|
||||
useRefreshOnEvents({
|
||||
events: ['library'],
|
||||
onRefresh: loadUserLibraries,
|
||||
})
|
||||
|
||||
// Don't render if user has no libraries or only has one library
|
||||
if (!userLibraries.length || userLibraries.length === 1) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export * from './useAlbumsPerPage'
|
|||
export * from './useGetHandleArtistClick'
|
||||
export * from './useInterval'
|
||||
export * from './useResourceRefresh'
|
||||
export * from './useRefreshOnEvents'
|
||||
export * from './useToggleLove'
|
||||
export * from './useTraceUpdate'
|
||||
export * from './Writable'
|
||||
|
|
|
|||
109
ui/src/common/useRefreshOnEvents.jsx
Normal file
109
ui/src/common/useRefreshOnEvents.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
/**
|
||||
* A reusable hook for triggering custom reload logic when specific SSE events occur.
|
||||
*
|
||||
* This hook is ideal when:
|
||||
* - Your component displays derived/related data that isn't directly managed by react-admin
|
||||
* - You need custom loading logic that goes beyond simple dataProvider.getMany() calls
|
||||
* - Your data comes from non-standard endpoints or requires special processing
|
||||
* - You want to reload parent/related resources when child resources change
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Array<string>} options.events - Array of event types to listen for (e.g., ['library', 'user', '*'])
|
||||
* @param {Function} options.onRefresh - Async function to call when events occur.
|
||||
* Should be wrapped in useCallback with appropriate dependencies to avoid unnecessary re-renders.
|
||||
*
|
||||
* @example
|
||||
* // Example 1: LibrarySelector - Reload user data when library changes
|
||||
* const loadUserLibraries = useCallback(async () => {
|
||||
* const userId = localStorage.getItem('userId')
|
||||
* if (userId) {
|
||||
* const { data } = await dataProvider.getOne('user', { id: userId })
|
||||
* dispatch(setUserLibraries(data.libraries || []))
|
||||
* }
|
||||
* }, [dataProvider, dispatch])
|
||||
*
|
||||
* useRefreshOnEvents({
|
||||
* events: ['library', 'user'],
|
||||
* onRefresh: loadUserLibraries
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Example 2: Statistics Dashboard - Reload stats when any music data changes
|
||||
* const loadStats = useCallback(async () => {
|
||||
* const stats = await dataProvider.customRequest('GET', 'stats')
|
||||
* setDashboardStats(stats)
|
||||
* }, [dataProvider, setDashboardStats])
|
||||
*
|
||||
* useRefreshOnEvents({
|
||||
* events: ['album', 'song', 'artist'],
|
||||
* onRefresh: loadStats
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Example 3: Permission-based UI - Reload permissions when user changes
|
||||
* const loadPermissions = useCallback(async () => {
|
||||
* const authData = await authProvider.getPermissions()
|
||||
* setUserPermissions(authData)
|
||||
* }, [authProvider, setUserPermissions])
|
||||
*
|
||||
* useRefreshOnEvents({
|
||||
* events: ['user'],
|
||||
* onRefresh: loadPermissions
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Example 4: Listen to all events (use sparingly)
|
||||
* const reloadAll = useCallback(async () => {
|
||||
* // This will trigger on ANY refresh event
|
||||
* await reloadEverything()
|
||||
* }, [reloadEverything])
|
||||
*
|
||||
* useRefreshOnEvents({
|
||||
* events: ['*'],
|
||||
* onRefresh: reloadAll
|
||||
* })
|
||||
*/
|
||||
export const useRefreshOnEvents = ({ events, onRefresh }) => {
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState(Date.now())
|
||||
|
||||
const refreshData = useSelector(
|
||||
(state) => state.activity?.refresh || { lastReceived: lastRefreshTime },
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const { resources, lastReceived } = refreshData
|
||||
|
||||
// Only process if we have new events
|
||||
if (lastReceived <= lastRefreshTime) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any of the events we're interested in occurred
|
||||
const shouldRefresh =
|
||||
resources &&
|
||||
// Global refresh event
|
||||
(resources['*'] === '*' ||
|
||||
// Check for specific events we're listening to
|
||||
events.some((eventType) => {
|
||||
if (eventType === '*') {
|
||||
return true // Listen to all events
|
||||
}
|
||||
return resources[eventType] // Check if this specific event occurred
|
||||
}))
|
||||
|
||||
if (shouldRefresh) {
|
||||
setLastRefreshTime(lastReceived)
|
||||
|
||||
// Call the custom refresh function
|
||||
if (onRefresh) {
|
||||
onRefresh().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Error in useRefreshOnEvents onRefresh callback:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [refreshData, lastRefreshTime, events, onRefresh])
|
||||
}
|
||||
233
ui/src/common/useRefreshOnEvents.test.js
Normal file
233
ui/src/common/useRefreshOnEvents.test.js
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { vi } from 'vitest'
|
||||
import * as React from 'react'
|
||||
import * as Redux from 'react-redux'
|
||||
import { useRefreshOnEvents } from './useRefreshOnEvents'
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual('react')
|
||||
return {
|
||||
...actual,
|
||||
useState: vi.fn(),
|
||||
useEffect: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-redux', async () => {
|
||||
const actual = await vi.importActual('react-redux')
|
||||
return {
|
||||
...actual,
|
||||
useSelector: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useRefreshOnEvents', () => {
|
||||
const setState = vi.fn()
|
||||
const useStateMock = (initState) => [initState, setState]
|
||||
const onRefresh = vi.fn().mockResolvedValue()
|
||||
let lastTime
|
||||
let mockUseEffect
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(React, 'useState').mockImplementation(useStateMock)
|
||||
mockUseEffect = vi.spyOn(React, 'useEffect')
|
||||
lastTime = new Date(new Date().valueOf() + 1000)
|
||||
onRefresh.mockClear()
|
||||
setState.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('stores last time checked, to avoid redundant runs', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { library: ['lib-1'] }, // Need some resources to trigger the update
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
// Mock useEffect to immediately call the effect callback
|
||||
mockUseEffect.mockImplementation((callback) => callback())
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library'],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(setState).toHaveBeenCalledWith(lastTime)
|
||||
})
|
||||
|
||||
it("does not run again if lastTime didn't change", () => {
|
||||
vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState])
|
||||
const useSelectorMock = () => ({ lastReceived: lastTime })
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
// Mock useEffect to immediately call the effect callback
|
||||
mockUseEffect.mockImplementation((callback) => callback())
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library'],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(setState).not.toHaveBeenCalled()
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('Event listening and refresh triggering', () => {
|
||||
beforeEach(() => {
|
||||
// Mock useEffect to immediately call the effect callback
|
||||
mockUseEffect.mockImplementation((callback) => callback())
|
||||
})
|
||||
|
||||
it('triggers refresh when a watched event occurs', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { library: ['lib-1', 'lib-2'] },
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library'],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
expect(setState).toHaveBeenCalledWith(lastTime)
|
||||
})
|
||||
|
||||
it('triggers refresh when multiple watched events occur', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: {
|
||||
library: ['lib-1'],
|
||||
user: ['user-1'],
|
||||
album: ['album-1'], // This shouldn't trigger since it's not watched
|
||||
},
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library', 'user'],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
expect(setState).toHaveBeenCalledWith(lastTime)
|
||||
})
|
||||
|
||||
it('does not trigger refresh when unwatched events occur', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { album: ['album-1'], song: ['song-1'] },
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library', 'user'],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
expect(setState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('triggers refresh on global refresh event', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { '*': '*' },
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library'],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
expect(setState).toHaveBeenCalledWith(lastTime)
|
||||
})
|
||||
|
||||
it('triggers refresh when listening to all events with "*"', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { song: ['song-1'] },
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['*'],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
expect(setState).toHaveBeenCalledWith(lastTime)
|
||||
})
|
||||
|
||||
it('handles empty events array gracefully', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { library: ['lib-1'] },
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: [],
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
expect(setState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles missing onRefresh function gracefully', () => {
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { library: ['lib-1'] },
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
expect(() => {
|
||||
useRefreshOnEvents({
|
||||
events: ['library'],
|
||||
// onRefresh is undefined
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(setState).toHaveBeenCalledWith(lastTime)
|
||||
})
|
||||
|
||||
it('handles onRefresh errors gracefully', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
const failingRefresh = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Refresh failed'))
|
||||
|
||||
const useSelectorMock = () => ({
|
||||
lastReceived: lastTime,
|
||||
resources: { library: ['lib-1'] },
|
||||
})
|
||||
vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
|
||||
|
||||
useRefreshOnEvents({
|
||||
events: ['library'],
|
||||
onRefresh: failingRefresh,
|
||||
})
|
||||
|
||||
expect(failingRefresh).toHaveBeenCalledTimes(1)
|
||||
expect(setState).toHaveBeenCalledWith(lastTime)
|
||||
|
||||
// Wait for the promise to be rejected and handled
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Error in useRefreshOnEvents onRefresh callback:',
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,67 @@ import { useSelector } from 'react-redux'
|
|||
import { useState } from 'react'
|
||||
import { useRefresh, useDataProvider } from 'react-admin'
|
||||
|
||||
/**
|
||||
* A hook that automatically refreshes react-admin managed resources when refresh events are received via SSE.
|
||||
*
|
||||
* This hook is designed for components that display react-admin managed resources (like lists, shows, edits)
|
||||
* and need to stay in sync when those resources are modified elsewhere in the application.
|
||||
*
|
||||
* **When to use this hook:**
|
||||
* - Your component displays react-admin resources (albums, songs, artists, playlists, etc.)
|
||||
* - You want automatic refresh when those resources are created/updated/deleted
|
||||
* - Your data comes from standard dataProvider.getMany() calls
|
||||
* - You're using react-admin's data management (queries, mutations, caching)
|
||||
*
|
||||
* **When NOT to use this hook:**
|
||||
* - Your component displays derived/custom data not directly managed by react-admin
|
||||
* - You need custom reload logic beyond dataProvider.getMany()
|
||||
* - Your data comes from non-standard endpoints
|
||||
* - Use `useRefreshOnEvents` instead for these scenarios
|
||||
*
|
||||
* @param {...string} visibleResources - Resource names to watch for changes.
|
||||
* If no resources specified, watches all resources.
|
||||
* If '*' is included in resources, triggers full page refresh.
|
||||
*
|
||||
* @example
|
||||
* // Example 1: Album list - refresh when albums change
|
||||
* const AlbumList = () => {
|
||||
* useResourceRefresh('album')
|
||||
* return <List resource="album">...</List>
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Example 2: Album show page - refresh when album or its songs change
|
||||
* const AlbumShow = () => {
|
||||
* useResourceRefresh('album', 'song')
|
||||
* return <Show resource="album">...</Show>
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Example 3: Dashboard - refresh when any resource changes
|
||||
* const Dashboard = () => {
|
||||
* useResourceRefresh() // No parameters = watch all resources
|
||||
* return <div>...</div>
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Example 4: Library management page - watch library resources
|
||||
* const LibraryList = () => {
|
||||
* useResourceRefresh('library')
|
||||
* return <List resource="library">...</List>
|
||||
* }
|
||||
*
|
||||
* **How it works:**
|
||||
* - Listens to refresh events from the SSE connection
|
||||
* - When events arrive, checks if they match the specified visible resources
|
||||
* - For specific resource IDs: calls dataProvider.getMany(resource, {ids: [...]})
|
||||
* - For global refreshes: calls refresh() to reload the entire page
|
||||
* - Uses react-admin's built-in data management and caching
|
||||
*
|
||||
* **Event format expected:**
|
||||
* - Global refresh: { '*': '*' } or { someResource: ['*'] }
|
||||
* - Specific resources: { album: ['id1', 'id2'], song: ['id3'] }
|
||||
*/
|
||||
export const useResourceRefresh = (...visibleResources) => {
|
||||
const [lastTime, setLastTime] = useState(Date.now())
|
||||
const refresh = useRefresh()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const LibraryFilter = (props) => (
|
|||
|
||||
const LibraryList = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
useResourceRefresh()
|
||||
useResourceRefresh('library')
|
||||
|
||||
return (
|
||||
<List
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue