From cf18c32934afa755138459aefd9ee9b18149c271 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Fri, 24 Mar 2023 14:14:17 +0100 Subject: [PATCH] feat: show details for nested objects and arrays in the Installed Actions page Signed-off-by: Katharina Sick --- .changeset/tender-parrots-fry.md | 5 + .../ActionsPage/ActionsPage.test.tsx | 300 +++++++++++++++++- .../components/ActionsPage/ActionsPage.tsx | 188 +++++++---- 3 files changed, 436 insertions(+), 57 deletions(-) create mode 100644 .changeset/tender-parrots-fry.md diff --git a/.changeset/tender-parrots-fry.md b/.changeset/tender-parrots-fry.md new file mode 100644 index 0000000000..847bd18761 --- /dev/null +++ b/.changeset/tender-parrots-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +The Installed Actions page now shows details for nested objects and arrays diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 90f6f9b393..a402e078a8 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -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( + + + , + { + 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( + + + , + { + 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( + + + , + { + 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( + + + , + { + 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( + + + , + { + 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( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + }, + ); + + expect(rendered.getByText('array(unknown)')).toBeInTheDocument(); + }); }); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 10a272b3cf..3f21b75322 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -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 ; @@ -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 ( - - -
{key}
-
- {props.title} - {props.description} - - <> - {[props.type].flat().map(type => ( - - ))} - - -
- ); - }); - }; - - const renderTable = (input: JSONSchema7) => { - if (!input.properties) { - return undefined; + const renderTable = (rows?: JSX.Element[]) => { + if (!rows || rows.length < 1) { + return No schema defined; } return ( @@ -172,13 +143,108 @@ export const ActionsPage = () => { Type - {formatRows(input)} + {rows} ); }; - 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 ( + + + +
{key}
+
+ {props.title} + {props.description} + + {types.map(type => + type.includes('object') ? ( + : + } + variant="outlined" + onClick={() => + setIsExpanded(prevState => { + const state = { ...prevState }; + state[id] = !prevState[id]; + return state; + }) + } + /> + ) : ( + + ), + )} + +
+ + + + + + {key} + + {renderTable( + formatRows( + id, + props.type === 'array' + ? ({ + properties: + (props.items as JSONSchema7 | undefined) + ?.properties ?? {}, + } as unknown as JSONSchema7 | undefined) + : props, + ), + )} + + + + +
+ ); + }); + }; + + const renderTables = ( + name: string, + id: string, + input?: JSONSchema7Definition[], + ) => { if (!input) { return undefined; } @@ -187,7 +253,11 @@ export const ActionsPage = () => { <> {name} {input.map((i, index) => ( -
{renderTable(i as unknown as JSONSchema7)}
+
+ {renderTable( + formatRows(`${id}.${index}`, i as unknown as JSONSchema7), + )} +
))} ); @@ -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 ( @@ -208,14 +282,18 @@ export const ActionsPage = () => { {action.schema?.input && ( Input - {renderTable(action.schema.input)} + {renderTable( + formatRows(`${action.id}.input`, action?.schema?.input), + )} {oneOf} )} {action.schema?.output && ( Output - {renderTable(action.schema.output)} + {renderTable( + formatRows(`${action.id}.output`, action?.schema?.output), + )} )} {action.examples && (