From 5d8112e8f19df61c4d8cd25640a36ec3db3390d6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 14:18:30 +0100 Subject: [PATCH] scaffolder: migrate actions page to accordion layout Replace the table-based actions page with an accordion layout using @backstage/ui components. Each action is listed as an expandable accordion, letting users browse the full list while expanding individual actions to see their schema details inline. This avoids the need to scroll between a table and a detail section. The search field is kept for filtering actions by name or description. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-actions-page-table.md | 5 + .../ActionsPage/ActionsPage.test.tsx | 218 +++++++++------- .../components/ActionsPage/ActionsPage.tsx | 246 +++++++++--------- 3 files changed, 258 insertions(+), 211 deletions(-) create mode 100644 .changeset/scaffolder-actions-page-table.md 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 => ( + + + + + + + ))} + + )} ); };