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

View File

@ -1,5 +1,11 @@
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'
const mockNotify = vi.fn()
@ -34,6 +40,7 @@ vi.mock('react-admin', async () => {
TopToolbar: ({ children }) => (
<div data-testid="top-toolbar">{children}</div>
),
Empty: () => <div data-testid="ra-empty">No resources</div>,
Datagrid: ({ children }) => <div data-testid="datagrid">{children}</div>,
TextField: ({ source }) => <span data-testid={`text-${source}`} />,
}
@ -42,9 +49,10 @@ vi.mock('react-admin', async () => {
// Mock common components
vi.mock('../common', async () => {
return {
List: ({ children, actions, ...props }) => (
List: ({ children, actions, empty, ...props }) => (
<div data-testid="list">
{actions}
{empty && <div data-testid="empty-state">{empty}</div>}
{children}
</div>
),
@ -94,14 +102,16 @@ describe('PluginList', () => {
expect(screen.getByTestId('datagrid')).toBeInTheDocument()
})
it('renders the rescan button', () => {
it('renders the rescan button in the toolbar', () => {
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 () => {
render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button')
const toolbar = screen.getByTestId('top-toolbar')
const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton)
@ -114,7 +124,8 @@ describe('PluginList', () => {
it('calls refresh after successful rescan', async () => {
render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button')
const toolbar = screen.getByTestId('top-toolbar')
const rescanButton = within(toolbar).getByTestId('rescan-button')
fireEvent.click(rescanButton)
@ -127,7 +138,8 @@ describe('PluginList', () => {
mockHttpClient.mockRejectedValue(new Error('Network error'))
render(<PluginList />)
const rescanButton = screen.getByTestId('rescan-button')
const toolbar = screen.getByTestId('top-toolbar')
const rescanButton = within(toolbar).getByTestId('rescan-button')
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',
})
})
})
})