From 31c460da83b7702cb43acca5240156c4f888f6a8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 14:50:00 +0100 Subject: [PATCH] scaffolder: use table layout for actions page Replace the accordion-based actions list with a Table component from @backstage/ui, providing aligned columns for action name and description with click-to-expand detail view. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-actions-page-table.md | 2 +- .../ActionsPage/ActionsPage.test.tsx | 61 ++++----- .../components/ActionsPage/ActionsPage.tsx | 122 +++++++++++------- 3 files changed, 104 insertions(+), 81 deletions(-) diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md index 0b392401d3..a53b7fa8ce 100644 --- a/.changeset/scaffolder-actions-page-table.md +++ b/.changeset/scaffolder-actions-page-table.md @@ -2,4 +2,4 @@ '@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. +Migrated the actions page to use `@backstage/ui` table and search components. Actions are now listed in a table with aligned name and description columns, with click-to-expand detail view and 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 189e4f6bf3..9f91cd6e1c 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -46,17 +46,17 @@ const apis = TestApiRegistry.from( [permissionApiRef, mockPermissionApi], ); -async function expandAction(actionId: string) { - const button = await screen.findByRole('button', { +async function selectAction(actionId: string) { + const row = await screen.findByRole('row', { name: new RegExp(actionId), }); - await userEvent.click(button); + await userEvent.click(row); } describe('ActionsPage', () => { beforeEach(() => jest.resetAllMocks()); - it('renders actions as accordions and shows detail on expand', async () => { + it('renders actions in a table and shows detail on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -87,25 +87,18 @@ describe('ActionsPage', () => { ); expect( - await screen.findByRole('button', { name: /test/ }), + await screen.findByRole('row', { name: /test/ }), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /test/ })).toHaveAttribute( - 'aria-expanded', - 'false', - ); + expect(screen.queryByText('Test title')).not.toBeInTheDocument(); - await expandAction('test'); + await selectAction('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 on expand', async () => { + it('renders action with input and output on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -144,14 +137,14 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('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 on expand', async () => { + it('renders action with oneOf output on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -203,7 +196,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('oneOf')).toBeInTheDocument(); expect(screen.getByText('Test title')).toBeInTheDocument(); @@ -211,7 +204,7 @@ describe('ActionsPage', () => { expect(screen.getByText('Test output2')).toBeInTheDocument(); }); - it('renders action with multiple input types on expand', async () => { + it('renders action with multiple input types on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -250,13 +243,13 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('array')).toBeInTheDocument(); expect(screen.getByText('number')).toBeInTheDocument(); }); - it('renders action with oneOf input on expand', async () => { + it('renders action with oneOf input on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -302,7 +295,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('oneOf')).toBeInTheDocument(); expect(screen.getByText('Foo title')).toBeInTheDocument(); @@ -351,7 +344,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test object')).toBeInTheDocument(); const objectChip = screen.getByText('object'); @@ -416,7 +409,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test object')).toBeInTheDocument(); const objectChip = screen.getByText('object'); @@ -471,7 +464,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test object')).toBeInTheDocument(); const objectChip = screen.getByText('object'); @@ -516,7 +509,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test array')).toBeInTheDocument(); expect(screen.getByText('array(string)')).toBeInTheDocument(); @@ -571,7 +564,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test array')).toBeInTheDocument(); const objectChip = screen.getByText('array(object)'); @@ -614,7 +607,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('array(unknown)')).toBeInTheDocument(); }); @@ -667,10 +660,10 @@ describe('ActionsPage', () => { ); expect( - await screen.findByRole('button', { name: /github:repo:create/ }), + await screen.findByRole('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: /github:repo:push/ }), + screen.getByRole('row', { name: /github:repo:push/ }), ).toBeInTheDocument(); await userEvent.type( @@ -679,19 +672,19 @@ describe('ActionsPage', () => { ); expect( - await screen.findByRole('button', { name: /github:repo:create/ }), + await screen.findByRole('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /github:repo:push/ }), + screen.queryByRole('row', { name: /github:repo:push/ }), ).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText('Clear search')); expect( - await screen.findByRole('button', { name: /github:repo:create/ }), + await screen.findByRole('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: /github:repo:push/ }), + screen.getByRole('row', { name: /github:repo:push/ }), ).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index bfa09e0010..22c74c2556 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -30,14 +30,14 @@ import { Link, MarkdownContent, Page, - Progress, } from '@backstage/core-components'; import { - Accordion, - AccordionGroup, - AccordionPanel, - AccordionTrigger, + CellText, SearchField, + Table, + useTable, + type ColumnConfig, + type TableItem, } from '@backstage/ui'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; @@ -81,6 +81,10 @@ const useStyles = makeStyles(theme => ({ }, })); +interface ActionTableItem extends TableItem { + action: Action; +} + function ActionDetail({ action }: { action: Action }) { const classes = useStyles(); const { t } = useTranslationRef(scaffolderTranslationRef); @@ -153,38 +157,62 @@ function ActionDetail({ action }: { action: Action }) { ); } +const columnConfig: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + defaultWidth: '1fr', + cell: item => , + }, + { + id: 'description', + label: 'Description', + defaultWidth: '2fr', + cell: item => , + }, +]; + export const ActionPageContent = () => { const api = useApi(scaffolderApiRef); const { t } = useTranslationRef(scaffolderTranslationRef); const { loading, - value: actions = [], + value: actions, error, } = useAsync(async () => { return api.listActions(); }, [api]); - const [search, setSearch] = useState(''); + const [selectedAction, setSelectedAction] = useState(); - const filteredActions = useMemo(() => { - const nonLegacy = actions.filter( - action => !action.id.startsWith('legacy:'), - ); - if (!search) { - return nonLegacy; - } - const lowerQuery = search.toLowerCase(); - return nonLegacy.filter( - action => - action.id.toLowerCase().includes(lowerQuery) || - action.description?.toLowerCase().includes(lowerQuery), - ); - }, [actions, search]); + const tableData = useMemo( + () => + actions + ?.filter(action => !action.id.startsWith('legacy:')) + .map( + (action): ActionTableItem => ({ + id: action.id, + action, + }), + ), + [actions], + ); - if (loading) { - return ; - } + const { tableProps, search } = useTable({ + mode: 'complete', + data: tableData, + paginationOptions: { type: 'none' }, + searchFn: (items, query) => { + const lowerQuery = query.toLowerCase(); + return items.filter( + item => + item.action.id.toLowerCase().includes(lowerQuery) || + item.action.description?.toLowerCase().includes(lowerQuery), + ); + }, + }); if (error) { return ( @@ -205,29 +233,31 @@ export const ActionPageContent = () => { aria-label={t('actionsPage.content.searchFieldPlaceholder')} placeholder={t('actionsPage.content.searchFieldPlaceholder')} style={{ marginBottom: 24 }} - value={search} - onChange={setSearch} + {...search} /> - {filteredActions.length === 0 ? ( - - ) : ( - - {filteredActions.map(action => ( - - - - - - - ))} - + + } + rowConfig={{ + onClick: item => { + setSelectedAction(prev => + prev?.id === item.action.id ? undefined : item.action, + ); + }, + }} + /> + {selectedAction && ( + + + )} );