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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-28 14:50:00 +01:00
parent 5d8112e8f1
commit 31c460da83
3 changed files with 104 additions and 81 deletions
+1 -1
View File
@@ -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.
@@ -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();
});
});
@@ -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<ActionTableItem>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
defaultWidth: '1fr',
cell: item => <CellText title={item.action.id} />,
},
{
id: 'description',
label: 'Description',
defaultWidth: '2fr',
cell: item => <CellText title={item.action.description ?? ''} />,
},
];
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<Action | undefined>();
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 <Progress />;
}
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 ? (
<EmptyState
missing="info"
title={t('actionsPage.content.emptyState.title')}
description={t('actionsPage.content.emptyState.description')}
/>
) : (
<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>
<Table
columnConfig={columnConfig}
{...tableProps}
loading={loading}
emptyState={
<EmptyState
missing="info"
title={t('actionsPage.content.emptyState.title')}
description={t('actionsPage.content.emptyState.description')}
/>
}
rowConfig={{
onClick: item => {
setSelectedAction(prev =>
prev?.id === item.action.id ? undefined : item.action,
);
},
}}
/>
{selectedAction && (
<Box mt={2}>
<ActionDetail action={selectedAction} />
</Box>
)}
</>
);