scaffolder: improve actions page UX with pagination and card detail view

Replace the unpaginated action list with a paginated table showing 20
items per page, giving users a clear count and navigation controls.
Consolidate the two-column layout into a single column with the
description shown as subtitle text in each row.

Revamp the action detail view using Backstage UI Card and Accordion
components for a structured, collapsible layout of input/output schemas
and usage examples.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-28 16:03:46 +01:00
parent 31c460da83
commit dae4e6ef09
2 changed files with 82 additions and 81 deletions
@@ -94,8 +94,8 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(screen.getByText('Test title')).toBeVisible();
expect(screen.getByText('foobar')).toBeVisible();
expect(await screen.findByText('Test title')).toBeInTheDocument();
expect(screen.getByText('foobar')).toBeInTheDocument();
});
it('renders action with input and output on row click', async () => {
@@ -16,10 +16,8 @@
import { useMemo, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import { Action, scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import Box from '@material-ui/core/Box';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import LinkIcon from '@material-ui/icons/Link';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
@@ -27,14 +25,21 @@ import {
EmptyState,
ErrorPanel,
Header,
Link,
MarkdownContent,
Page,
} from '@backstage/core-components';
import {
Accordion,
AccordionGroup,
AccordionPanel,
AccordionTrigger,
Card,
CardBody,
CardHeader,
CellText,
Flex,
SearchField,
Table,
Text,
useTable,
type ColumnConfig,
type TableItem,
@@ -76,9 +81,6 @@ const useStyles = makeStyles(theme => ({
color: theme.palette.error.light,
},
},
link: {
paddingLeft: theme.spacing(1),
},
}));
interface ActionTableItem extends TableItem {
@@ -96,64 +98,69 @@ function ActionDetail({ action }: { action: Action }) {
headings: [<Typography variant="h6" component="h4" />],
};
const hasInput = !!action.schema?.input;
const hasOutput = !!action.schema?.output;
const hasExamples = !!action.examples;
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>
<Card>
<CardHeader>
<Flex direction="column" gap="1">
<Text as="h2" variant="title-medium" weight="bold">
{action.id}
</Text>
{action.description && (
<Text as="p" variant="body-medium" color="secondary">
{action.description}
</Text>
)}
</Flex>
</CardHeader>
{(hasInput || hasOutput || hasExamples) && (
<CardBody>
<AccordionGroup allowsMultiple>
{hasInput && (
<Accordion defaultExpanded>
<AccordionTrigger title={t('actionsPage.action.input')} />
<AccordionPanel>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</AccordionPanel>
</Accordion>
)}
{hasOutput && (
<Accordion>
<AccordionTrigger title={t('actionsPage.action.output')} />
<AccordionPanel>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</AccordionPanel>
</Accordion>
)}
{hasExamples && (
<Accordion>
<AccordionTrigger title={t('actionsPage.action.examples')} />
<AccordionPanel>
<ScaffolderUsageExamplesTable examples={action.examples!} />
</AccordionPanel>
</Accordion>
)}
</AccordionGroup>
</CardBody>
)}
{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>
</Card>
);
}
@@ -163,13 +170,12 @@ const columnConfig: ColumnConfig<ActionTableItem>[] = [
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 ?? ''} />,
cell: item => (
<CellText
title={item.action.id}
description={item.action.description ?? undefined}
/>
),
},
];
@@ -203,7 +209,7 @@ export const ActionPageContent = () => {
const { tableProps, search } = useTable({
mode: 'complete',
data: tableData,
paginationOptions: { type: 'none' },
paginationOptions: { pageSize: 20 },
searchFn: (items, query) => {
const lowerQuery = query.toLowerCase();
return items.filter(
@@ -228,11 +234,10 @@ export const ActionPageContent = () => {
}
return (
<>
<Flex direction="column" gap="4">
<SearchField
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
style={{ marginBottom: 24 }}
{...search}
/>
<Table
@@ -254,12 +259,8 @@ export const ActionPageContent = () => {
},
}}
/>
{selectedAction && (
<Box mt={2}>
<ActionDetail action={selectedAction} />
</Box>
)}
</>
{selectedAction && <ActionDetail action={selectedAction} />}
</Flex>
);
};