feat(ui): add Refresh Metadata action to the album and artist pages (#6078)

The Refresh Metadata action was only reachable from the Album and Artist context menus, so it could not be triggered from AlbumShow or ArtistShow. This adds an icon-only button, with a tooltip, to the action toolbar on both detail pages. Like the menu entry, it is only rendered for admins.

The button is built on react-admin's Button rather than a plain IconButton: the surrounding toolbars use the former, so the theme colour and the icon-only swap at the xs breakpoint are inherited instead of restated. The dataProvider call and its two notifications move into a new useRefreshMetadata hook, which ContextMenus now shares, keeping a single copy of that logic.
This commit is contained in:
Deluan Quintão 2026-09-02 23:29:31 -04:00 • committed by GitHub
commit afb3a2f881
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 298 additions and 8 deletions

View file

@ -27,7 +27,7 @@ import {
} from '../actions'
import { formatBytes } from '../utils'
import config from '../config'
import { ToggleFieldsMenu } from '../common'
import { RefreshMetadataButton, ToggleFieldsMenu } from '../common'
const useStyles = makeStyles({
toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' },
@ -138,6 +138,11 @@ const AlbumActions = ({
<CloudDownloadOutlinedIcon />
</AlbumButton>
)}
<RefreshMetadataButton
resource="album"
record={record}
size="small"
/>
</div>
<div>{isNotSmall && <ToggleFieldsMenu resource="albumSong" />}</div>
</div>

View file

@ -0,0 +1,68 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import AlbumActions from './AlbumActions'
const { mockConfig, mockPermissions } = vi.hoisted(() => ({
mockConfig: {
enableSharing: false,
enableDownloads: false,
losslessFormats: 'FLAC,WAV',
},
mockPermissions: { value: 'admin' },
}))
vi.mock('../config', () => ({ default: mockConfig }))
vi.mock('react-redux', () => ({
useDispatch: () => vi.fn(),
useSelector: () => ({}),
}))
const mockRefreshMetadata = vi.fn()
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useNotify: () => vi.fn(),
useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }),
usePermissions: () => ({ permissions: mockPermissions.value }),
useTranslate: () => (x) => x,
}
})
describe('AlbumActions', () => {
const record = { id: 'al1', name: 'Album', size: 1024 }
const refreshLabel = 'resources.album.actions.refresh'
beforeEach(() => {
vi.clearAllMocks()
mockPermissions.value = 'admin'
mockRefreshMetadata.mockResolvedValue({ data: { id: 'al1' } })
})
const renderAlbumActions = () =>
render(
<ThemeProvider theme={createTheme()}>
<AlbumActions record={record} ids={[]} data={{}} />
</ThemeProvider>,
)
it('refreshes the album metadata for admins', async () => {
renderAlbumActions()
fireEvent.click(screen.getByRole('button', { name: refreshLabel }))
await waitFor(() =>
expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1'),
)
})
it('hides the action for non-admin users', () => {
mockPermissions.value = 'regular'
renderAlbumActions()
expect(
screen.queryByRole('button', { name: refreshLabel }),
).not.toBeInTheDocument()
})
})

View file

@ -26,6 +26,7 @@ import {
import config from '../config'
import { formatBytes } from '../utils'
import { artistDownloadSize } from '../common/artist'
import { RefreshMetadataButton } from '../common/RefreshMetadataButton'
const useStyles = makeStyles((theme) => ({
toolbar: {
@ -33,6 +34,7 @@ const useStyles = makeStyles((theme) => ({
padding: '0 !important',
background: 'transparent',
boxShadow: 'none',
alignItems: 'center',
'& .MuiToolbar-root': {
minHeight: 'auto',
padding: '0 !important',
@ -174,6 +176,11 @@ const ArtistActions = ({ className, record, ...rest }) => {
icon={<CloudDownloadOutlinedIcon />}
/>
)}
<RefreshMetadataButton
resource="artist"
record={record}
size={isMobile ? 'small' : 'medium'}
/>
</TopToolbar>
)
}

View file

@ -18,20 +18,26 @@ vi.mock('../subsonic', () => ({
default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() },
}))
const { mockConfig } = vi.hoisted(() => ({
const { mockConfig, mockPermissions } = vi.hoisted(() => ({
mockConfig: { enableSharing: true, enableDownloads: true },
mockPermissions: { value: 'admin' },
}))
vi.mock('../config', () => ({ default: mockConfig }))
const mockNotify = vi.fn()
const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] })
const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } })
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useNotify: () => mockNotify,
useDataProvider: () => ({ getList: mockGetList }),
useDataProvider: () => ({
getList: mockGetList,
refreshMetadata: mockRefreshMetadata,
}),
usePermissions: () => ({ permissions: mockPermissions.value }),
useTranslate: () => (x) => x,
}
})
@ -64,6 +70,7 @@ describe('ArtistActions', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
mockConfig.enableSharing = true
mockConfig.enableDownloads = true
mockPermissions.value = 'admin'
const songWithReplayGain = {
id: 'rec1',
@ -290,4 +297,25 @@ describe('ArtistActions', () => {
expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument()
})
})
describe('Refresh metadata action', () => {
const refreshLabel = 'resources.album.actions.refresh'
it('refreshes the artist metadata for admins', async () => {
renderArtistActions()
fireEvent.click(screen.getByRole('button', { name: refreshLabel }))
await waitFor(() =>
expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'),
)
})
it('hides the action for non-admin users', () => {
mockPermissions.value = 'regular'
renderArtistActions()
expect(
screen.queryByRole('button', { name: refreshLabel }),
).not.toBeInTheDocument()
})
})
})

View file

@ -30,6 +30,7 @@ import { LoveButton } from './LoveButton'
import config from '../config'
import { formatBytes } from '../utils'
import { artistDownloadSize } from './artist'
import { useRefreshMetadata } from './useRefreshMetadata'
const useStyles = makeStyles({
noWrap: {
@ -75,6 +76,7 @@ const ContextMenu = ({
const translate = useTranslate()
const notify = useNotify()
const { permissions } = usePermissions()
const refreshMetadata = useRefreshMetadata()
const [anchorEl, setAnchorEl] = useState(null)
const isArtist = resource === 'artist'
@ -139,11 +141,7 @@ const ContextMenu = ({
enabled: permissions === 'admin',
needData: false,
label: translate('resources.album.actions.refresh'),
action: (record) =>
dataProvider
.refreshMetadata(resource, record.id)
.then(() => notify('message.metadataRefreshStarted'))
.catch(() => notify('ra.page.error', 'warning')),
action: (record) => refreshMetadata(resource, record.id),
},
...(!hideInfo && {
info: {

View file

@ -0,0 +1,62 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import Tooltip from '@material-ui/core/Tooltip'
import { makeStyles } from '@material-ui/core/styles'
import { Button, usePermissions, useTranslate } from 'react-admin'
import { MdRefresh } from 'react-icons/md'
import { useRefreshMetadata } from './useRefreshMetadata'
const useStyles = makeStyles({
// Tooltip needs a ref-holding child, and react-admin's Button does not forward one.
wrapper: { display: 'inline-flex', verticalAlign: 'middle' },
button: { minWidth: 'auto' },
})
// react-admin's Button, not an IconButton: the toolbars use it, so colour and the icon-only swap
// at xs match without restating either rule.
export const RefreshMetadataButton = ({
resource,
record,
className,
size,
}) => {
const translate = useTranslate()
const { permissions } = usePermissions()
const refreshMetadata = useRefreshMetadata()
const classes = useStyles()
const handleClick = useCallback(
() => refreshMetadata(resource, record?.id),
[refreshMetadata, resource, record],
)
if (permissions !== 'admin' || !record?.id) return null
const label = translate('resources.album.actions.refresh')
return (
<Tooltip title={label}>
<span className={classes.wrapper}>
<Button
aria-label={label}
className={className}
classes={{ button: classes.button }}
size={size}
onClick={handleClick}
>
<MdRefresh />
</Button>
</span>
</Tooltip>
)
}
RefreshMetadataButton.propTypes = {
resource: PropTypes.oneOf(['album', 'artist']).isRequired,
record: PropTypes.object,
className: PropTypes.string,
size: PropTypes.oneOf(['small', 'medium']),
}
RefreshMetadataButton.defaultProps = {
size: 'small',
}

View file

@ -0,0 +1,104 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { RefreshMetadataButton } from './RefreshMetadataButton'
const mockNotify = vi.fn()
const mockRefreshMetadata = vi.fn()
const { mockPermissions } = vi.hoisted(() => ({
mockPermissions: { value: 'admin' },
}))
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useNotify: () => mockNotify,
useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }),
usePermissions: () => ({ permissions: mockPermissions.value }),
useTranslate: () => (x) => x,
}
})
describe('RefreshMetadataButton', () => {
const record = { id: 'al-1', name: 'Album' }
beforeEach(() => {
vi.clearAllMocks()
mockPermissions.value = 'admin'
mockRefreshMetadata.mockResolvedValue({ data: { id: 'al-1' } })
})
const renderButton = (props = {}) =>
render(
<ThemeProvider theme={createTheme()}>
<RefreshMetadataButton resource="album" record={record} {...props} />
</ThemeProvider>,
)
it('renders an icon-only button labelled by the refresh action', () => {
renderButton()
const button = screen.getByRole('button', {
name: 'resources.album.actions.refresh',
})
expect(button).toBeInTheDocument()
expect(button).toHaveTextContent('')
})
it('shows the label as a tooltip on hover', async () => {
renderButton()
fireEvent.mouseOver(
screen.getByRole('button', {
name: 'resources.album.actions.refresh',
}),
)
await waitFor(() =>
expect(screen.getByRole('tooltip')).toHaveTextContent(
'resources.album.actions.refresh',
),
)
})
it('renders nothing for non-admin users', () => {
mockPermissions.value = 'regular'
const { container } = renderButton()
expect(container).toBeEmptyDOMElement()
})
it('renders nothing without a record', () => {
const { container } = renderButton({ record: undefined })
expect(container).toBeEmptyDOMElement()
})
it('requests a refresh for the record and notifies success', async () => {
renderButton()
fireEvent.click(screen.getByRole('button'))
await waitFor(() =>
expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al-1'),
)
await waitFor(() =>
expect(mockNotify).toHaveBeenCalledWith('message.metadataRefreshStarted'),
)
})
it('passes the artist resource through', async () => {
renderButton({ resource: 'artist', record: { id: 'ar-1' } })
fireEvent.click(screen.getByRole('button'))
await waitFor(() =>
expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar-1'),
)
})
it('notifies a warning when the request fails', async () => {
mockRefreshMetadata.mockRejectedValue(new Error('boom'))
renderButton()
fireEvent.click(screen.getByRole('button'))
await waitFor(() =>
expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning'),
)
})
})

View file

@ -36,6 +36,8 @@ export * from './useTraceUpdate'
export * from './Writable'
export * from './SongSimpleList'
export * from './RatingField'
export * from './RefreshMetadataButton'
export * from './useRefreshMetadata'
export * from './useRating'
export * from './useSelectedFields'
export * from './ToggleFieldsMenu'

View file

@ -0,0 +1,16 @@
import { useCallback } from 'react'
import { useDataProvider, useNotify } from 'react-admin'
export const useRefreshMetadata = () => {
const dataProvider = useDataProvider()
const notify = useNotify()
return useCallback(
(resource, id) =>
dataProvider
.refreshMetadata(resource, id)
.then(() => notify('message.metadataRefreshStarted'))
.catch(() => notify('ra.page.error', 'warning')),
[dataProvider, notify],
)
}