diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md new file mode 100644 index 0000000000..0b392401d3 --- /dev/null +++ b/.changeset/scaffolder-actions-page-table.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Migrated the actions page to use `@backstage/ui` accordion and search components, replacing the previous layout where all actions were rendered at once. Actions are now listed as expandable accordions with built-in search filtering. diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 201073e8a9..189e4f6bf3 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -22,6 +22,7 @@ import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { ApiProvider } from '@backstage/core-app-api'; import { rootRouteRef } from '../../routes'; import { userEvent } from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import { permissionApiRef } from '@backstage/plugin-permission-react'; const scaffolderApiMock: jest.Mocked = { @@ -45,10 +46,17 @@ const apis = TestApiRegistry.from( [permissionApiRef, mockPermissionApi], ); -describe('TemplatePage', () => { +async function expandAction(actionId: string) { + const button = await screen.findByRole('button', { + name: new RegExp(actionId), + }); + await userEvent.click(button); +} + +describe('ActionsPage', () => { beforeEach(() => jest.resetAllMocks()); - it('renders action with input', async () => { + it('renders actions as accordions and shows detail on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -67,7 +75,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -77,13 +85,27 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('Test title')).toBeInTheDocument(); - expect(rendered.getByText('example description')).toBeInTheDocument(); - expect(rendered.getByText('foobar')).toBeInTheDocument(); - expect(rendered.queryByText('output')).not.toBeInTheDocument(); + + expect( + await screen.findByRole('button', { name: /test/ }), + ).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: /test/ })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + + await expandAction('test'); + + expect(screen.getByRole('button', { name: /test/ })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + expect(screen.getByText('Test title')).toBeVisible(); + expect(screen.getByText('foobar')).toBeVisible(); }); - it('renders action with input and output', async () => { + it('renders action with input and output on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -111,7 +133,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -121,13 +143,15 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('Test title')).toBeInTheDocument(); - expect(rendered.getByText('example description')).toBeInTheDocument(); - expect(rendered.getByText('foobar')).toBeInTheDocument(); - expect(rendered.getByText('Test output')).toBeInTheDocument(); + + await expandAction('test'); + + expect(await screen.findByText('Test title')).toBeInTheDocument(); + expect(screen.getByText('foobar')).toBeInTheDocument(); + expect(screen.getByText('Test output')).toBeInTheDocument(); }); - it('renders action with oneOf output', async () => { + it('renders action with oneOf output on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -168,7 +192,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -178,13 +202,16 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('oneOf')).toBeInTheDocument(); - expect(rendered.getByText('Test title')).toBeInTheDocument(); - expect(rendered.getByText('Test output1')).toBeInTheDocument(); - expect(rendered.getByText('Test output2')).toBeInTheDocument(); + + await expandAction('test'); + + expect(await screen.findByText('oneOf')).toBeInTheDocument(); + expect(screen.getByText('Test title')).toBeInTheDocument(); + expect(screen.getByText('Test output1')).toBeInTheDocument(); + expect(screen.getByText('Test output2')).toBeInTheDocument(); }); - it('renders action with multiple input types', async () => { + it('renders action with multiple input types on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -212,7 +239,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -222,11 +249,14 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('array')).toBeInTheDocument(); - expect(rendered.getByText('number')).toBeInTheDocument(); + + await expandAction('test'); + + expect(await screen.findByText('array')).toBeInTheDocument(); + expect(screen.getByText('number')).toBeInTheDocument(); }); - it('renders action with oneOf input', async () => { + it('renders action with oneOf input on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -261,7 +291,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -271,14 +301,17 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('oneOf')).toBeInTheDocument(); - expect(rendered.getByText('Foo title')).toBeInTheDocument(); - expect(rendered.getByText('Foo description')).toBeInTheDocument(); - expect(rendered.getByText('Bar title')).toBeInTheDocument(); - expect(rendered.getByText('Bar description')).toBeInTheDocument(); + + await expandAction('test'); + + expect(await screen.findByText('oneOf')).toBeInTheDocument(); + expect(screen.getByText('Foo title')).toBeInTheDocument(); + expect(screen.getByText('Foo description')).toBeInTheDocument(); + expect(screen.getByText('Bar title')).toBeInTheDocument(); + expect(screen.getByText('Bar description')).toBeInTheDocument(); }); - it('renders action with object input type', async () => { + it('renders action with expandable object input type', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -307,7 +340,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -318,21 +351,23 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); + await expandAction('test'); + + expect(await screen.findByText('Test object')).toBeInTheDocument(); + const objectChip = screen.getByText('object'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('nested prop a')).not.toBeInTheDocument(); - expect(rendered.queryByText('string')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); - expect(rendered.queryByText('number')).not.toBeInTheDocument(); + expect(screen.queryByText('nested prop a')).not.toBeInTheDocument(); + expect(screen.queryByText('string')).not.toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).not.toBeInTheDocument(); + expect(screen.queryByText('number')).not.toBeInTheDocument(); await userEvent.click(objectChip); - expect(rendered.queryByText('nested prop a')).toBeInTheDocument(); - expect(rendered.queryByText('string')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('number')).toBeInTheDocument(); + expect(screen.queryByText('nested prop a')).toBeInTheDocument(); + expect(screen.queryByText('string')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('number')).toBeInTheDocument(); }); it('renders action with nested object input type', async () => { @@ -370,7 +405,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -381,27 +416,29 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); + await expandAction('test'); + + expect(await screen.findByText('Test object')).toBeInTheDocument(); + const objectChip = screen.getByText('object'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('nested object a')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).not.toBeInTheDocument(); + expect(screen.queryByText('nested object a')).not.toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).not.toBeInTheDocument(); + expect(screen.queryByText('nested object c')).not.toBeInTheDocument(); await userEvent.click(objectChip); - expect(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).not.toBeInTheDocument(); + expect(screen.queryByText('nested object a')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('nested object c')).not.toBeInTheDocument(); - const allObjectChips = rendered.getAllByText('object'); + const allObjectChips = screen.getAllByText('object'); expect(allObjectChips.length).toBe(2); await userEvent.click(allObjectChips[1]); - expect(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).toBeInTheDocument(); + expect(screen.queryByText('nested object a')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('nested object c')).toBeInTheDocument(); }); it('renders action with object input type and no properties', async () => { @@ -423,7 +460,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -434,15 +471,17 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); + await expandAction('test'); + + expect(await screen.findByText('Test object')).toBeInTheDocument(); + const objectChip = screen.getByText('object'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('No schema defined')).not.toBeInTheDocument(); + expect(screen.queryByText('No schema defined')).not.toBeInTheDocument(); await userEvent.click(objectChip); - expect(rendered.queryByText('No schema defined')).toBeInTheDocument(); + expect(screen.queryByText('No schema defined')).toBeInTheDocument(); }); it('renders action with array(string) input type', async () => { @@ -466,7 +505,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -477,8 +516,10 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test array')).toBeInTheDocument(); - expect(rendered.getByText('array(string)')).toBeInTheDocument(); + await expandAction('test'); + + expect(await screen.findByText('Test array')).toBeInTheDocument(); + expect(screen.getByText('array(string)')).toBeInTheDocument(); }); it('renders action with array(object) input type', async () => { @@ -519,7 +560,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -530,17 +571,19 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test array')).toBeInTheDocument(); - const objectChip = rendered.getByText('array(object)'); + await expandAction('test'); + + expect(await screen.findByText('Test array')).toBeInTheDocument(); + const objectChip = screen.getByText('array(object)'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('nested object a')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); + expect(screen.queryByText('nested object a')).not.toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).not.toBeInTheDocument(); await userEvent.click(objectChip); - expect(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('nested object a')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); }); it('renders action with array input type and no items', async () => { @@ -560,7 +603,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -571,13 +614,15 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('array(unknown)')).toBeInTheDocument(); + await expandAction('test'); + + expect(await screen.findByText('array(unknown)')).toBeInTheDocument(); }); - it('should filter an action', async () => { + it('should filter actions via the search field', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { - id: 'githut:repo:create', + id: 'github:repo:create', description: 'Create a new Github repository', schema: { input: { @@ -593,7 +638,7 @@ describe('TemplatePage', () => { }, }, { - id: 'githut:repo:push', + id: 'github:repo:push', description: 'Push to a Github repository', schema: { input: { @@ -610,7 +655,7 @@ describe('TemplatePage', () => { }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -622,32 +667,31 @@ describe('TemplatePage', () => { ); expect( - rendered.getByRole('heading', { name: 'githut:repo:create' }), + await screen.findByRole('button', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.getByRole('heading', { name: 'githut:repo:push' }), + screen.getByRole('button', { name: /github:repo:push/ }), ).toBeInTheDocument(); - // should filter actions when searching await userEvent.type( - rendered.getByPlaceholderText('Search for an action'), + screen.getByPlaceholderText('Search for an action'), 'create', ); - await userEvent.keyboard('[ArrowDown][Enter]'); + expect( - rendered.getByRole('heading', { name: 'githut:repo:create' }), + await screen.findByRole('button', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.queryByRole('heading', { name: 'githut:repo:push' }), + screen.queryByRole('button', { name: /github:repo:push/ }), ).not.toBeInTheDocument(); - // should show all actions when clearing the search - await userEvent.click(rendered.getByTitle('Clear')); + await userEvent.click(screen.getByLabelText('Clear search')); + expect( - rendered.getByRole('heading', { name: 'githut:repo:create' }), + await screen.findByRole('button', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.getByRole('heading', { name: 'githut:repo:push' }), + screen.getByRole('button', { name: /github:repo:push/ }), ).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 6be502659a..bfa09e0010 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -13,21 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useMemo, useState } from 'react'; import useAsync from 'react-use/esm/useAsync'; import { Action, scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import Accordion from '@material-ui/core/Accordion'; -import AccordionDetails from '@material-ui/core/AccordionDetails'; -import AccordionSummary from '@material-ui/core/AccordionSummary'; import Box from '@material-ui/core/Box'; import Typography from '@material-ui/core/Typography'; import { makeStyles } from '@material-ui/core/styles'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import LinkIcon from '@material-ui/icons/Link'; -import Autocomplete from '@material-ui/lab/Autocomplete'; -import TextField from '@material-ui/core/TextField'; -import InputAdornment from '@material-ui/core/InputAdornment'; -import SearchIcon from '@material-ui/icons/Search'; import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { @@ -40,6 +32,13 @@ import { Page, Progress, } from '@backstage/core-components'; +import { + Accordion, + AccordionGroup, + AccordionPanel, + AccordionTrigger, + SearchField, +} from '@backstage/ui'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; import { @@ -82,27 +81,106 @@ const useStyles = makeStyles(theme => ({ }, })); +function ActionDetail({ action }: { action: Action }) { + const classes = useStyles(); + const { t } = useTranslationRef(scaffolderTranslationRef); + const expanded = useState({}); + + const partialSchemaRenderContext: Omit = { + classes, + expanded, + headings: [], + }; + + return ( + + + + {action.id} + + + + + + {action.description && } + {action.schema?.input && ( + + + {t('actionsPage.action.input')} + + + + )} + {action.schema?.output && ( + + + {t('actionsPage.action.output')} + + + + )} + {action.examples && ( + + + {t('actionsPage.action.examples')} + + + + )} + + ); +} + export const ActionPageContent = () => { const api = useApi(scaffolderApiRef); const { t } = useTranslationRef(scaffolderTranslationRef); - const classes = useStyles(); const { loading, - value = [], + value: actions = [], error, } = useAsync(async () => { return api.listActions(); }, [api]); - const [selectedAction, setSelectedAction] = useState(null); - const expanded = useState({}); + const [search, setSearch] = useState(''); - useEffect(() => { - if (value.length && window.location.hash) { - document.querySelector(window.location.hash)?.scrollIntoView(); + const filteredActions = useMemo(() => { + const nonLegacy = actions.filter( + action => !action.id.startsWith('legacy:'), + ); + if (!search) { + return nonLegacy; } - }, [value]); + const lowerQuery = search.toLowerCase(); + return nonLegacy.filter( + action => + action.id.toLowerCase().includes(lowerQuery) || + action.description?.toLowerCase().includes(lowerQuery), + ); + }, [actions, search]); if (loading) { return ; @@ -123,114 +201,34 @@ export const ActionPageContent = () => { return ( <> - - option.id} - renderInput={params => ( - - - - ), - }} - /> - )} - onChange={(_event, option) => { - setSelectedAction(option); - }} - fullWidth + + {filteredActions.length === 0 ? ( + - - {(selectedAction ? [selectedAction] : value).map(action => { - if (action.id.startsWith('legacy:')) { - return undefined; - } - const partialSchemaRenderContext: Omit< - SchemaRenderContext, - 'parentId' - > = { - classes, - expanded, - headings: [], - }; - return ( - - - - {action.id} - - - - - - {action.description && ( - - )} - {action.schema?.input && ( - - - {t('actionsPage.action.input')} - - - - )} - {action.schema?.output && ( - - - {t('actionsPage.action.output')} - - - - )} - {action.examples && ( - - }> - - {t('actionsPage.action.examples')} - - - - - - - - - )} - - ); - })} + ) : ( + + {filteredActions.map(action => ( + + + + + + + ))} + + )} ); };