fix(ui): add Rescan button to plugin list empty state (#5471)

* feat(ui): add Rescan button to plugin list empty state

When no plugins are installed and the folder watcher fails to detect new
plugins, users had no way to trigger a rescan. Extract RescanButton into
a shared component and render it in a custom empty state for the plugin
list.

* refactor(ui): address review feedback for plugin empty state

- Pass label translation key directly to RA Button (auto-translates)
- Use within() from Testing Library instead of querySelector for
  scoped queries with better error messages
This commit is contained in:
Deluan Quintão 2026-05-05 18:49:24 -04:00 committed by GitHub
parent 5b85b2839a
commit 39f8eec8d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 75 additions and 18 deletions

View file

@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react'
import { import {
Button, Button,
Datagrid, Datagrid,
Empty,
TextField, TextField,
TopToolbar, TopToolbar,
useNotify, useNotify,
@ -10,7 +11,13 @@ import {
useTranslate, useTranslate,
} from 'react-admin' } from 'react-admin'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core' import {
useMediaQuery,
Tooltip,
Chip,
Typography,
Box,
} from '@material-ui/core'
import { MdError, MdRefresh } from 'react-icons/md' import { MdError, MdRefresh } from 'react-icons/md'
import { List, DateField, SimpleList, useResourceRefresh } from '../common' import { List, DateField, SimpleList, useResourceRefresh } from '../common'
import { httpClient } from '../dataProvider' import { httpClient } from '../dataProvider'
@ -72,8 +79,7 @@ const ManifestField = ({ source }) => {
return <Typography variant="body2">{manifest[source] || '-'}</Typography> return <Typography variant="body2">{manifest[source] || '-'}</Typography>
} }
const PluginListActions = () => { const RescanButton = () => {
const translate = useTranslate()
const notify = useNotify() const notify = useNotify()
const refresh = useRefresh() const refresh = useRefresh()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -92,20 +98,37 @@ const PluginListActions = () => {
}) })
}, [notify, refresh]) }, [notify, refresh])
return (
<Button
onClick={handleRescan}
disabled={loading}
label="resources.plugin.actions.rescan"
data-testid="rescan-button"
>
<MdRefresh />
</Button>
)
}
const PluginListActions = () => {
return ( return (
<TopToolbar> <TopToolbar>
<Button <RescanButton />
onClick={handleRescan}
disabled={loading}
label={translate('resources.plugin.actions.rescan')}
data-testid="rescan-button"
>
<MdRefresh />
</Button>
</TopToolbar> </TopToolbar>
) )
} }
const PluginEmpty = () => {
return (
<>
<Empty />
<Box textAlign="center" mt={2}>
<RescanButton />
</Box>
</>
)
}
const PluginList = (props) => { const PluginList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate() const translate = useTranslate()
@ -118,6 +141,7 @@ const PluginList = (props) => {
exporter={false} exporter={false}
bulkActionButtons={false} bulkActionButtons={false}
actions={<PluginListActions />} actions={<PluginListActions />}
empty={<PluginEmpty />}
> >
{isXsmall ? ( {isXsmall ? (
<SimpleList <SimpleList

View file

@ -1,5 +1,11 @@
import React from 'react' import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import {
render,
screen,
fireEvent,
waitFor,
within,
} from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockNotify = vi.fn() const mockNotify = vi.fn()
@ -34,6 +40,7 @@ vi.mock('react-admin', async () => {
TopToolbar: ({ children }) => ( TopToolbar: ({ children }) => (
<div data-testid="top-toolbar">{children}</div> <div data-testid="top-toolbar">{children}</div>
), ),
Empty: () => <div data-testid="ra-empty">No resources</div>,
Datagrid: ({ children }) => <div data-testid="datagrid">{children}</div>, Datagrid: ({ children }) => <div data-testid="datagrid">{children}</div>,
TextField: ({ source }) => <span data-testid={`text-${source}`} />, TextField: ({ source }) => <span data-testid={`text-${source}`} />,
} }
@ -42,9 +49,10 @@ vi.mock('react-admin', async () => {
// Mock common components // Mock common components
vi.mock('../common', async () => { vi.mock('../common', async () => {
return { return {
List: ({ children, actions, ...props }) => ( List: ({ children, actions, empty, ...props }) => (
<div data-testid="list"> <div data-testid="list">
{actions} {actions}
{empty && <div data-testid="empty-state">{empty}</div>}
{children} {children}
</div> </div>
), ),
@ -94,14 +102,16 @@ describe('PluginList', () => {
expect(screen.getByTestId('datagrid')).toBeInTheDocument() expect(screen.getByTestId('datagrid')).toBeInTheDocument()
}) })
it('renders the rescan button', () => { it('renders the rescan button in the toolbar', () => {
render(<PluginList />) render(<PluginList />)
expect(screen.getByTestId('rescan-button')).toBeInTheDocument() const toolbar = screen.getByTestId('top-toolbar')
expect(within(toolbar).getByTestId('rescan-button')).toBeInTheDocument()
}) })
it('calls rescan endpoint when rescan button is clicked', async () => { it('calls rescan endpoint when rescan button is clicked', async () => {
render(<PluginList />) render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button') const toolbar = screen.getByTestId('top-toolbar')
const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton) fireEvent.click(rescanButton)
@ -114,7 +124,8 @@ describe('PluginList', () => {
it('calls refresh after successful rescan', async () => { it('calls refresh after successful rescan', async () => {
render(<PluginList />) render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button') const toolbar = screen.getByTestId('top-toolbar')
const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton) fireEvent.click(rescanButton)
@ -127,7 +138,8 @@ describe('PluginList', () => {
mockHttpClient.mockRejectedValue(new Error('Network error')) mockHttpClient.mockRejectedValue(new Error('Network error'))
render(<PluginList />) render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button') const toolbar = screen.getByTestId('top-toolbar')
const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton) fireEvent.click(rescanButton)
@ -137,4 +149,25 @@ describe('PluginList', () => {
}) })
}) })
}) })
it('renders a rescan button in the empty state', () => {
render(<PluginList />)
const emptyState = screen.getByTestId('empty-state')
expect(emptyState).toBeInTheDocument()
expect(within(emptyState).getByTestId('rescan-button')).toBeInTheDocument()
})
it('empty state rescan button triggers rescan', async () => {
render(<PluginList />)
const emptyState = screen.getByTestId('empty-state')
const rescanButton = within(emptyState).getByTestId('rescan-button')
fireEvent.click(rescanButton)
await waitFor(() => {
expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', {
method: 'POST',
})
})
})
}) })