feat: add a search box for filtering actions

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-10-01 12:08:05 +02:00
parent 41f8c8e2f3
commit 3ac4766ca7
3 changed files with 194 additions and 66 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Add a actions filter on the list actions page and drawer.
@@ -509,4 +509,81 @@ describe('TemplatePage', () => {
expect(rendered.getByText('array(unknown)')).toBeInTheDocument();
});
it('should filter an action', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'githut:repo:create',
description: 'Create a new Github repository',
schema: {
input: {
type: 'object',
required: ['name'],
properties: {
name: {
title: 'Repository name',
type: 'string',
},
},
},
},
},
{
id: 'githut:repo:push',
description: 'Push to a Github repository',
schema: {
input: {
type: 'object',
required: ['url'],
properties: {
url: {
title: 'Repository url',
type: 'string',
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(
rendered.getByRole('heading', { name: 'githut:repo:create' }),
).toBeInTheDocument();
expect(
rendered.getByRole('heading', { name: 'githut:repo:push' }),
).toBeInTheDocument();
// should filter actions when searching
await userEvent.type(
rendered.getByPlaceholderText('Search for an action'),
'create',
);
await userEvent.keyboard('[ArrowDown][Enter]');
expect(
rendered.getByRole('heading', { name: 'githut:repo:create' }),
).toBeInTheDocument();
expect(
rendered.queryByRole('heading', { name: 'githut:repo:push' }),
).not.toBeInTheDocument();
// should show all actions when clearing the search
await userEvent.click(rendered.getByTitle('Clear'));
expect(
rendered.getByRole('heading', { name: 'githut:repo:create' }),
).toBeInTheDocument();
expect(
rendered.getByRole('heading', { name: 'githut:repo:push' }),
).toBeInTheDocument();
});
});
@@ -16,6 +16,7 @@
import React, { Fragment, useEffect, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import {
Action,
ActionExample,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
@@ -39,6 +40,10 @@ import classNames from 'classnames';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
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 {
@@ -125,14 +130,19 @@ export const ActionPageContent = () => {
const { t } = useTranslationRef(scaffolderTranslationRef);
const classes = useStyles();
const { loading, value, error } = useAsync(async () => {
const {
loading,
value = [],
error,
} = useAsync(async () => {
return api.listActions();
}, [api]);
const [selectedAction, setSelectedAction] = useState<Action | null>(null);
const [isExpanded, setIsExpanded] = useState<{ [key: string]: boolean }>({});
useEffect(() => {
if (value && window.location.hash) {
if (value.length && window.location.hash) {
document.querySelector(window.location.hash)?.scrollIntoView();
}
}, [value]);
@@ -295,71 +305,107 @@ export const ActionPageContent = () => {
);
};
return value?.map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const oneOf = renderTables(
'oneOf',
`${action.id}.input`,
action.schema?.input?.oneOf,
);
return (
<Box pb={4} key={action.id}>
<Typography
id={action.id.replaceAll(':', '-')}
variant="h4"
component="h2"
className={classes.code}
>
{action.id}
</Typography>
<Link
className={classes.link}
to={`#${action.id.replaceAll(':', '-')}`}
>
<LinkIcon />
</Link>
{action.description && <MarkdownContent content={action.description} />}
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.input')}
</Typography>
{renderTable(
formatRows(`${action.id}.input`, action?.schema?.input),
)}
{oneOf}
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
{renderTable(
formatRows(`${action.id}.output`, action?.schema?.output),
)}
</Box>
)}
{action.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.examples')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
return (
<>
<Box pb={3}>
<Autocomplete
id="actions-autocomplete"
options={value}
loading={loading}
getOptionLabel={option => option.id}
renderInput={params => (
<TextField
{...params}
aria-label="Actions"
placeholder="Search for an action"
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
setSelectedAction(option);
}}
fullWidth
/>
</Box>
);
});
{(selectedAction ? [selectedAction] : value).map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const oneOf = renderTables(
'oneOf',
`${action.id}.input`,
action.schema?.input?.oneOf,
);
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>
{renderTable(
formatRows(`${action.id}.input`, action?.schema?.input),
)}
{oneOf}
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
{renderTable(
formatRows(`${action.id}.output`, 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}>
<ExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
</Box>
);
})}
</>
);
};
export const ActionsPage = () => {
const navigate = useNavigate();