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 && (