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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user