feat: show details for nested objects and arrays in the Installed Actions page

Signed-off-by: Katharina Sick <katharina.sick@dynatrace.com>
This commit is contained in:
Katharina Sick
2023-03-24 14:14:17 +01:00
parent 740771b8ff
commit cf18c32934
3 changed files with 436 additions and 57 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
The Installed Actions page now shows details for nested objects and arrays
@@ -16,8 +16,8 @@
import React from 'react';
import { ActionsPage } from './ActionsPage';
import {
scaffolderApiRef,
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { ApiProvider } from '@backstage/core-app-api';
@@ -118,7 +118,7 @@ describe('TemplatePage', () => {
expect(rendered.getByText('Test output')).toBeInTheDocument();
});
it('renders action with multipel input types', async () => {
it('renders action with multiple input types', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
@@ -211,4 +211,300 @@ describe('TemplatePage', () => {
expect(rendered.getByText('Bar title')).toBeInTheDocument();
expect(rendered.getByText('Bar description')).toBeInTheDocument();
});
it('renders action with object input type', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test object',
type: ['object'],
properties: {
a: {
title: 'nested prop a',
type: 'string',
},
b: {
title: 'nested prop b',
type: 'number',
},
},
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.getByText('Test object')).toBeInTheDocument();
const objectChip = rendered.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();
objectChip.click();
expect(rendered.queryByText('nested prop a')).toBeInTheDocument();
expect(rendered.queryByText('string')).toBeInTheDocument();
expect(rendered.queryByText('nested prop b')).toBeInTheDocument();
expect(rendered.queryByText('number')).toBeInTheDocument();
});
it('renders action with nested object input type', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test object',
type: 'object',
properties: {
a: {
title: 'nested object a',
type: 'object',
properties: {
c: {
title: 'nested object c',
type: 'object',
},
},
},
b: {
title: 'nested prop b',
type: 'number',
},
},
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.getByText('Test object')).toBeInTheDocument();
const objectChip = rendered.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();
objectChip.click();
expect(rendered.queryByText('nested object a')).toBeInTheDocument();
expect(rendered.queryByText('nested prop b')).toBeInTheDocument();
expect(rendered.queryByText('nested object c')).not.toBeInTheDocument();
const allObjectChips = rendered.getAllByText('object');
expect(allObjectChips.length).toBe(2);
allObjectChips[1].click();
expect(rendered.queryByText('nested object a')).toBeInTheDocument();
expect(rendered.queryByText('nested prop b')).toBeInTheDocument();
expect(rendered.queryByText('nested object c')).toBeInTheDocument();
});
it('renders action with object input type and no properties', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test object',
type: ['object'],
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.getByText('Test object')).toBeInTheDocument();
const objectChip = rendered.getByText('object');
expect(objectChip).toBeInTheDocument();
expect(rendered.queryByText('No schema defined')).not.toBeInTheDocument();
objectChip.click();
expect(rendered.queryByText('No schema defined')).toBeInTheDocument();
});
it('renders action with array(string) input type', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
properties: {
foobar: {
title: 'Test array',
type: 'array',
items: {
type: 'string',
},
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.getByText('Test array')).toBeInTheDocument();
expect(rendered.getByText('array(string)')).toBeInTheDocument();
});
it('renders action with array(object) input type', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
properties: {
foobar: {
title: 'Test array',
type: 'array',
items: {
title: 'nested object',
type: 'object',
properties: {
a: {
title: 'nested object a',
type: 'object',
properties: {
c: {
title: 'nested object c',
type: 'object',
},
},
},
b: {
title: 'nested prop b',
type: 'number',
},
},
},
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.getByText('Test array')).toBeInTheDocument();
const objectChip = rendered.getByText('array(object)');
expect(objectChip).toBeInTheDocument();
expect(rendered.queryByText('nested object a')).not.toBeInTheDocument();
expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument();
objectChip.click();
expect(rendered.queryByText('nested object a')).toBeInTheDocument();
expect(rendered.queryByText('nested prop b')).toBeInTheDocument();
});
it('renders action with array input type and no items', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
properties: {
foo: {
type: 'array',
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.getByText('array(unknown)')).toBeInTheDocument();
});
});
@@ -13,43 +13,45 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { Fragment } from 'react';
import React, { Fragment, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import {
ActionExample,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import {
Typography,
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Collapse,
Grid,
makeStyles,
Paper,
Table,
TableBody,
Box,
Chip,
TableCell,
TableContainer,
TableHead,
TableRow,
Grid,
makeStyles,
Accordion,
AccordionSummary,
AccordionDetails,
Typography,
} from '@material-ui/core';
import { JSONSchema7, JSONSchema7Definition } from 'json-schema';
import classNames from 'classnames';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import { useApi } from '@backstage/core-plugin-api';
import {
Progress,
Content,
Header,
Page,
ErrorPage,
CodeSnippet,
Content,
ErrorPage,
Header,
MarkdownContent,
Page,
Progress,
} from '@backstage/core-components';
import Chip from '@material-ui/core/Chip';
const useStyles = makeStyles(theme => ({
code: {
@@ -111,6 +113,7 @@ export const ActionsPage = () => {
const { loading, value, error } = useAsync(async () => {
return api.listActions();
});
const [isExpanded, setIsExpanded] = useState<{ [key: string]: boolean }>({});
if (loading) {
return <Progress />;
@@ -125,41 +128,9 @@ export const ActionsPage = () => {
);
}
const formatRows = (input: JSONSchema7) => {
const properties = input.properties;
if (!properties) {
return undefined;
}
return Object.entries(properties).map(entry => {
const [key] = entry;
const props = entry[1] as unknown as JSONSchema7;
const codeClassname = classNames(classes.code, {
[classes.codeRequired]: input.required?.includes(key),
});
return (
<TableRow key={key}>
<TableCell>
<div className={codeClassname}>{key}</div>
</TableCell>
<TableCell>{props.title}</TableCell>
<TableCell>{props.description}</TableCell>
<TableCell>
<>
{[props.type].flat().map(type => (
<Chip label={type} key={type} />
))}
</>
</TableCell>
</TableRow>
);
});
};
const renderTable = (input: JSONSchema7) => {
if (!input.properties) {
return undefined;
const renderTable = (rows?: JSX.Element[]) => {
if (!rows || rows.length < 1) {
return <Typography>No schema defined</Typography>;
}
return (
<TableContainer component={Paper}>
@@ -172,13 +143,108 @@ export const ActionsPage = () => {
<TableCell>Type</TableCell>
</TableRow>
</TableHead>
<TableBody>{formatRows(input)}</TableBody>
<TableBody>{rows}</TableBody>
</Table>
</TableContainer>
);
};
const renderTables = (name: string, input?: JSONSchema7Definition[]) => {
const getTypes = (properties: JSONSchema7) => {
if (!properties.type) {
return ['unknown'];
}
if (properties.type !== 'array') {
return [properties.type].flat();
}
return [
`${properties.type}(${
(properties.items as JSONSchema7 | undefined)?.type ?? 'unknown'
})`,
];
};
const formatRows = (parentId: string, input?: JSONSchema7) => {
const properties = input?.properties;
if (!properties) {
return undefined;
}
return Object.entries(properties).map(entry => {
const [key] = entry;
const id = `${parentId}.${key}`;
const props = entry[1] as unknown as JSONSchema7;
const codeClassname = classNames(classes.code, {
[classes.codeRequired]: input.required?.includes(key),
});
const types = getTypes(props);
return (
<React.Fragment key={id}>
<TableRow key={id}>
<TableCell>
<div className={codeClassname}>{key}</div>
</TableCell>
<TableCell>{props.title}</TableCell>
<TableCell>{props.description}</TableCell>
<TableCell>
{types.map(type =>
type.includes('object') ? (
<Chip
label={type}
key={type}
icon={
isExpanded[id] ? <ExpandLessIcon /> : <ExpandMoreIcon />
}
variant="outlined"
onClick={() =>
setIsExpanded(prevState => {
const state = { ...prevState };
state[id] = !prevState[id];
return state;
})
}
/>
) : (
<Chip label={type} key={type} variant="outlined" />
),
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={isExpanded[id]} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1 }}>
<Typography variant="h6" component="div">
{key}
</Typography>
{renderTable(
formatRows(
id,
props.type === 'array'
? ({
properties:
(props.items as JSONSchema7 | undefined)
?.properties ?? {},
} as unknown as JSONSchema7 | undefined)
: props,
),
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
});
};
const renderTables = (
name: string,
id: string,
input?: JSONSchema7Definition[],
) => {
if (!input) {
return undefined;
}
@@ -187,7 +253,11 @@ export const ActionsPage = () => {
<>
<Typography variant="h6">{name}</Typography>
{input.map((i, index) => (
<div key={index}>{renderTable(i as unknown as JSONSchema7)}</div>
<div key={index}>
{renderTable(
formatRows(`${id}.${index}`, i as unknown as JSONSchema7),
)}
</div>
))}
</>
);
@@ -198,7 +268,11 @@ export const ActionsPage = () => {
return undefined;
}
const oneOf = renderTables('oneOf', action.schema?.input?.oneOf);
const oneOf = renderTables(
'oneOf',
`${action.id}.input`,
action.schema?.input?.oneOf,
);
return (
<Box pb={4} key={action.id}>
<Typography variant="h4" className={classes.code}>
@@ -208,14 +282,18 @@ export const ActionsPage = () => {
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h5">Input</Typography>
{renderTable(action.schema.input)}
{renderTable(
formatRows(`${action.id}.input`, action?.schema?.input),
)}
{oneOf}
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5">Output</Typography>
{renderTable(action.schema.output)}
{renderTable(
formatRows(`${action.id}.output`, action?.schema?.output),
)}
</Box>
)}
{action.examples && (