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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-28 14:18:30 +01:00
parent 781815fd25
commit 5d8112e8f1
3 changed files with 258 additions and 211 deletions
@@ -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.
@@ -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<ScaffolderApi> = {
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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();
});
});
@@ -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<Expanded>({});
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Box pb={1}>
<Box display="flex" alignItems="center" pb={1}>
<Typography
id={action.id.replaceAll(':', '-')}
variant="h5"
component="h2"
className={classes.code}
>
{action.id}
</Typography>
<Link
className={classes.link}
to={`#${action.id.replaceAll(':', '-')}`}
>
<LinkIcon />
</Link>
</Box>
{action.description && <MarkdownContent content={action.description} />}
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.input')}
</Typography>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</Box>
)}
{action.examples && (
<Box pb={2}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.examples')}
</Typography>
<ScaffolderUsageExamplesTable examples={action.examples} />
</Box>
)}
</Box>
);
}
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<Action | null>(null);
const expanded = useState<Expanded>({});
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 <Progress />;
@@ -123,114 +201,34 @@ export const ActionPageContent = () => {
return (
<>
<Box pb={3}>
<Autocomplete
id="actions-autocomplete"
options={value}
loading={loading}
getOptionLabel={option => option.id}
renderInput={params => (
<TextField
{...params}
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
setSelectedAction(option);
}}
fullWidth
<SearchField
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
style={{ marginBottom: 24 }}
value={search}
onChange={setSearch}
/>
{filteredActions.length === 0 ? (
<EmptyState
missing="info"
title={t('actionsPage.content.emptyState.title')}
description={t('actionsPage.content.emptyState.description')}
/>
</Box>
{(selectedAction ? [selectedAction] : value).map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const partialSchemaRenderContext: Omit<
SchemaRenderContext,
'parentId'
> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Box pb={3} key={action.id}>
<Box display="flex" alignItems="center">
<Typography
id={action.id.replaceAll(':', '-')}
variant="h5"
component="h2"
className={classes.code}
>
{action.id}
</Typography>
<Link
className={classes.link}
to={`#${action.id.replaceAll(':', '-')}`}
>
<LinkIcon />
</Link>
</Box>
{action.description && (
<MarkdownContent content={action.description} />
)}
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.input')}
</Typography>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</Box>
)}
{action.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.examples')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ScaffolderUsageExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
</Box>
);
})}
) : (
<AccordionGroup allowsMultiple>
{filteredActions.map(action => (
<Accordion key={action.id} id={action.id.replaceAll(':', '-')}>
<AccordionTrigger
title={action.id}
subtitle={action.description}
/>
<AccordionPanel>
<ActionDetail action={action} />
</AccordionPanel>
</Accordion>
))}
</AccordionGroup>
)}
</>
);
};