Merge pull request #29383 from mbenson/scaffolder-template-extensions-stage6

factor JSON schema rendering and scaffolder usage examples out of Act…
This commit is contained in:
Ben Lambert
2025-04-03 15:07:58 +01:00
committed by GitHub
11 changed files with 1273 additions and 222 deletions
+26
View File
@@ -0,0 +1,26 @@
---
'@backstage/plugin-scaffolder': minor
---
**BREAKING ALPHA**: Extract out schema rendering components into their own Component. This means that the translation keys have changed for `actionsPage.content.tableCell.*`. They have moved to their own root key `renderSchema.*` instead.
```diff
...
- tableCell: {
- name: 'Name',
- title: 'Title',
- description: 'Description',
- type: 'Type',
- },
- noRowsDescription: 'No schema defined',
...
+ renderSchema: {
+ tableCell: {
+ name: 'Name',
+ title: 'Title',
+ description: 'Description',
+ type: 'Type',
+ },
+ undefined: 'No schema defined',
+ },
```
+5 -5
View File
@@ -324,12 +324,7 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'fields.repoUrlPicker.repository.inputTitle': 'Repository';
readonly 'actionsPage.content.emptyState.title': 'No information to display';
readonly 'actionsPage.content.emptyState.description': 'There are no actions installed or there was an issue communicating with backend.';
readonly 'actionsPage.content.tableCell.name': 'Name';
readonly 'actionsPage.content.tableCell.type': 'Type';
readonly 'actionsPage.content.tableCell.title': 'Title';
readonly 'actionsPage.content.tableCell.description': 'Description';
readonly 'actionsPage.content.searchFieldPlaceholder': 'Search for an action';
readonly 'actionsPage.content.noRowsDescription': 'No schema defined';
readonly 'actionsPage.title': 'Installed actions';
readonly 'actionsPage.action.input': 'Input';
readonly 'actionsPage.action.output': 'Output';
@@ -367,6 +362,11 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'ongoingTask.hideLogsButtonTitle': 'Hide Logs';
readonly 'ongoingTask.showLogsButtonTitle': 'Show Logs';
readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.';
readonly 'renderSchema.undefined': 'No schema defined';
readonly 'renderSchema.tableCell.name': 'Name';
readonly 'renderSchema.tableCell.type': 'Type';
readonly 'renderSchema.tableCell.title': 'Title';
readonly 'renderSchema.tableCell.description': 'Description';
readonly 'templateTypePicker.title': 'Categories';
readonly 'templateIntroPage.title': 'Manage Templates';
readonly 'templateIntroPage.subtitle': 'Edit, preview, and try out templates, forms, and custom fields';
@@ -13,32 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { Fragment, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import {
Action,
ActionExample,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { Action, scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import Accordion from '@material-ui/core/Accordion';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import Box from '@material-ui/core/Box';
import Collapse from '@material-ui/core/Collapse';
import Grid from '@material-ui/core/Grid';
import Paper from '@material-ui/core/Paper';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
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 LinkIcon from '@material-ui/icons/Link';
import Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
@@ -47,7 +31,6 @@ import SearchIcon from '@material-ui/icons/Search';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
CodeSnippet,
Content,
EmptyState,
ErrorPanel,
@@ -57,7 +40,6 @@ import {
Page,
Progress,
} from '@backstage/core-components';
import Chip from '@material-ui/core/Chip';
import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha';
import { useNavigate } from 'react-router-dom';
import {
@@ -67,6 +49,8 @@ import {
} from '../../routes';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
import { Expanded, RenderSchema, SchemaRenderContext } from '../RenderSchema';
import { ScaffolderUsageExamplesTable } from '../ScaffolderUsageExamplesTable';
const useStyles = makeStyles(theme => ({
code: {
@@ -97,34 +81,6 @@ const useStyles = makeStyles(theme => ({
},
}));
const ExamplesTable = (props: { examples: ActionExample[] }) => {
return (
<Grid container>
{props.examples.map((example, index) => {
return (
<Fragment key={`example-${index}`}>
<Grid item lg={3}>
<Box padding={4}>
<Typography>{example.description}</Typography>
</Box>
</Grid>
<Grid item lg={9}>
<Box padding={1}>
<CodeSnippet
text={example.example}
showLineNumbers
showCopyCodeButton
language="yaml"
/>
</Box>
</Grid>
</Fragment>
);
})}
</Grid>
);
};
export const ActionPageContent = () => {
const api = useApi(scaffolderApiRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
@@ -139,7 +95,7 @@ export const ActionPageContent = () => {
}, [api]);
const [selectedAction, setSelectedAction] = useState<Action | null>(null);
const [isExpanded, setIsExpanded] = useState<{ [key: string]: boolean }>({});
const expanded = useState<Expanded>({});
useEffect(() => {
if (value.length && window.location.hash) {
@@ -164,147 +120,6 @@ export const ActionPageContent = () => {
);
}
const renderTable = (rows?: JSX.Element[]) => {
if (!rows || rows.length < 1) {
return (
<Typography>{t('actionsPage.content.noRowsDescription')}</Typography>
);
}
return (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t('actionsPage.content.tableCell.name')}</TableCell>
<TableCell>{t('actionsPage.content.tableCell.title')}</TableCell>
<TableCell>
{t('actionsPage.content.tableCell.description')}
</TableCell>
<TableCell>{t('actionsPage.content.tableCell.type')}</TableCell>
</TableRow>
</TableHead>
<TableBody>{rows}</TableBody>
</Table>
</TableContainer>
);
};
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;
}
return (
<>
<Typography variant="h6" component="h4">
{name}
</Typography>
{input.map((i, index) => (
<div key={index}>
{renderTable(
formatRows(`${id}.${index}`, i as unknown as JSONSchema7),
)}
</div>
))}
</>
);
};
return (
<>
<Box pb={3}>
@@ -339,17 +154,14 @@ export const ActionPageContent = () => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const oneOfInput = renderTables(
'oneOf',
`${action.id}.input`,
action.schema?.input?.oneOf,
);
const oneOfOutput = renderTables(
'oneOf',
`${action.id}.output`,
action.schema?.output?.oneOf,
);
const partialSchemaRenderContext: Omit<
SchemaRenderContext,
'parentId'
> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Box pb={3} key={action.id}>
<Box display="flex" alignItems="center">
@@ -376,10 +188,14 @@ export const ActionPageContent = () => {
<Typography variant="h6" component="h3">
{t('actionsPage.action.input')}
</Typography>
{renderTable(
formatRows(`${action.id}.input`, action?.schema?.input),
)}
{oneOfInput}
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</Box>
)}
{action.schema?.output && (
@@ -387,10 +203,14 @@ export const ActionPageContent = () => {
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
{renderTable(
formatRows(`${action.id}.output`, action?.schema?.output),
)}
{oneOfOutput}
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</Box>
)}
{action.examples && (
@@ -402,7 +222,7 @@ export const ActionPageContent = () => {
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ExamplesTable examples={action.examples} />
<ScaffolderUsageExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
@@ -0,0 +1,537 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import {
BoundFunction,
queries,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
JSONSchema7,
JSONSchema7Definition,
JSONSchema7Type,
} from 'json-schema';
import { capitalize } from 'lodash';
import React, { useState } from 'react';
import { Expanded, SchemaRenderStrategy } from '.';
import { RenderEnum, RenderSchema } from './RenderSchema';
/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "assert*"] }] */
const useStyles = makeStyles(theme => ({
code: {
fontFamily: 'Menlo, monospace',
padding: theme.spacing(1),
backgroundColor:
theme.palette.type === 'dark'
? theme.palette.grey[700]
: theme.palette.grey[300],
display: 'inline-block',
borderRadius: 5,
border: `1px solid ${theme.palette.grey[500]}`,
position: 'relative',
},
codeRequired: {
'&::after': {
position: 'absolute',
content: '"*"',
top: 0,
right: theme.spacing(0.5),
fontWeight: 'bolder',
color: theme.palette.error.light,
},
},
}));
const LocalRenderEnum = ({ e }: { e: JSONSchema7Type[] }) => {
const classes = useStyles();
return <RenderEnum {...{ classes, e }} />;
};
const LocalRenderSchema = ({
strategy,
schema,
}: {
strategy: SchemaRenderStrategy;
schema?: JSONSchema7Definition;
}) => {
const classes = useStyles();
const expanded = useState<Expanded>({});
return (
<RenderSchema
{...{
strategy,
schema,
context: {
parentId: 'test',
classes,
expanded,
headings: [<Typography component="h1" />],
},
}}
/>
);
};
it('enum rendering', async () => {
const enums = [
'foo',
123,
null,
['bar', 'baz'],
{ what: 'ever' },
[{ fo: 'real?' }],
];
const rendered = await renderInTestApp(<LocalRenderEnum {...{ e: enums }} />);
const { getByTestId, findByTestId, queryByTestId } = rendered;
const elements = enums.map((value, index) => ({
index,
value,
complex: value !== null && ['array', 'object'].includes(typeof value),
}));
for (const each of elements.filter(e => !e.complex)) {
const el = getByTestId(`enum_el${each.index}`);
expect(el).toBeInTheDocument();
expect(el).toHaveTextContent(JSON.stringify(each.value));
expect(queryByTestId(`wrap-text_${each.index}`)).toBeNull();
}
for (const each of elements.filter(e => e.complex)) {
const el = getByTestId(`enum_el${each.index}`);
expect(el).toBeInTheDocument();
expect(el).toHaveTextContent(JSON.stringify(each.value));
const wrapTextIcon = getByTestId(`wrap-text_${each.index}`);
expect(wrapTextIcon).toBeInTheDocument();
expect(queryByTestId(`pretty_${each.index}`)).toBeNull();
await userEvent.hover(wrapTextIcon);
const pretty = await findByTestId(`pretty_${each.index}`);
expect(pretty).toBeInTheDocument();
// test textContent directly to avoid ws normalization:
expect(pretty.textContent).toBe(JSON.stringify(each.value, null, 2));
await userEvent.unhover(wrapTextIcon);
await waitFor(() =>
expect(queryByTestId(`pretty_${each.index}`)).toBeNull(),
);
}
});
describe('JSON schema UI rendering', () => {
const basic: JSONSchema7 = {
type: 'object',
required: ['a'],
properties: {
a: {
title: 'A',
description: 'the string',
type: 'string',
},
b: {
title: 'B',
description: 'the number',
type: 'number',
},
c: {
title: 'C',
description: 'the boolean',
type: 'boolean',
},
},
};
type qt = typeof queries;
const assertBasicSchemaProperties = (
q: {
[P in keyof qt]: BoundFunction<qt[P]>;
},
id: string,
) => {
const { getByTestId, queryByTestId } = q;
const t = getByTestId(`properties_${id}`);
expect(t).toBeInTheDocument();
expect(t.tagName).toBe('TABLE');
expect(
Array.from(t.querySelectorAll('thead > tr > th'), e => e.textContent),
).toEqual(['Name', 'Title', 'Description', 'Type']);
for (const p of Object.keys(basic.properties!)) {
const tr = getByTestId(`properties-row_${id}.${p}`);
expect(tr).toBeInTheDocument();
expect(tr.tagName).toBe('TR');
const pt = (basic.properties![p] as JSONSchema7).type;
expect(Array.from(tr.querySelectorAll('td'), n => n.textContent)).toEqual(
[p, capitalize(p), `the ${pt}`, pt],
);
expect(
Array.from(within(tr).getByText(p).classList).some(c =>
c.includes('codeRequired'),
),
).toBe(basic.required?.includes(p));
expect(queryByTestId(`expand_${id}.${p}`)).not.toBeInTheDocument();
}
};
const msv: JSONSchema7 = {
title: 'MSV',
description: 'metasyntactic variable',
type: 'string',
enum: ['foo', 'bar', 'baz'],
};
const assertMsv = (
q: {
[P in keyof qt]: BoundFunction<qt[P]>;
},
id: string,
) => {
const { getByTestId } = q;
const e = getByTestId(`enum_${id}`);
expect(e).toBeInTheDocument();
expect(e.tagName).toBe('UL');
expect(e.querySelectorAll('li')).toHaveLength(msv.enum!.length);
};
const deep: JSONSchema7 = {
title: 'Deep',
description: 'object with deeply nested properties',
type: 'object',
properties: {
basic,
msvs: {
title: 'MSVs',
description: 'metasyntactic variable array',
type: 'array',
items: msv,
},
},
};
const assertDeepSchemaProperties = async (
q: {
[P in keyof qt]: BoundFunction<qt[P]>;
},
id: string,
) => {
const { findByTestId, getByTestId, queryByTestId } = q;
const t = getByTestId(`properties_${id}`);
expect(t).toBeInTheDocument();
expect(t.tagName).toBe('TABLE');
expect(
Array.from(t.querySelectorAll('thead > tr > th'), n => n.textContent),
).toEqual(['Name', 'Title', 'Description', 'Type']);
const xp: (k: string) => Promise<{
expander: HTMLElement;
expansionId: string;
expansion: HTMLElement;
}> = async (k: string) => {
const r = getByTestId(`properties-row_${id}.${k}`);
expect(r).toBeInTheDocument();
expect(r.tagName).toBe('TR');
const expansionId = `expansion_${id}.${k}`;
expect(queryByTestId(expansionId)).not.toBeInTheDocument();
const expander = getByTestId(`expand_${id}.${k}`);
expect(expander).toBeInTheDocument();
await userEvent.click(expander);
const expansion = await findByTestId(expansionId);
expect(expansion).toBeInTheDocument();
return { expander, expansionId, expansion };
};
const b = await xp('basic');
const m = await xp('msvs');
assertBasicSchemaProperties(within(b.expansion), `${id}.basic`);
assertMsv(within(m.expansion), `${id}.msvs`);
await userEvent.click(b.expander);
await userEvent.click(m.expander);
await waitFor(() => {
expect(queryByTestId(b.expansionId)).toBeNull();
expect(queryByTestId(m.expansionId)).toBeNull();
});
};
describe('root strategy', () => {
it('undefined', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="root" schema={undefined} />,
);
expect(rendered.getByText('No schema defined')).toBeInTheDocument();
});
it('true', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="root" schema />,
);
expect(rendered.getByText('No schema defined')).toBeInTheDocument();
});
it('false', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="root" schema={false} />,
);
expect(rendered.getByText('No schema defined')).toBeInTheDocument();
});
it('basic', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="root" schema={basic} />,
);
const { findByTestId, getByTestId, queryByTestId } = rendered;
const t = getByTestId('root_test');
expect(t).toBeInTheDocument();
expect(t.tagName).toBe('TABLE');
expect(
Array.from(t.querySelectorAll('thead > tr > th'), n => n.textContent),
).toEqual(['Type']);
const tr = getByTestId('root-row_test');
expect(tr).toBeInTheDocument();
expect(tr.tagName).toBe('TR');
expect(Array.from(tr.querySelectorAll('td'), n => n.textContent)).toEqual(
['object'],
);
expect(queryByTestId('expansion_test')).not.toBeInTheDocument();
const expand = getByTestId('expand_test');
expect(expand).toBeInTheDocument();
await userEvent.click(expand);
const expanded = await findByTestId('expansion_test');
expect(expanded).toBeInTheDocument();
assertBasicSchemaProperties(within(expanded), 'test');
await userEvent.click(expand);
await waitFor(() => expect(queryByTestId('expansion_test')).toBeNull());
});
it('enum', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="root" schema={msv} />,
);
const { findByTestId, getByTestId, queryByTestId } = rendered;
const t = getByTestId('root_test');
expect(t).toBeInTheDocument();
expect(t.tagName).toBe('TABLE');
expect(
Array.from(t.querySelectorAll('thead > tr > th'), n => n.textContent),
).toEqual(['Title', 'Description', 'Type']);
const tr = getByTestId('root-row_test');
expect(tr).toBeInTheDocument();
expect(tr.tagName).toBe('TR');
expect(Array.from(tr.querySelectorAll('td'), n => n.textContent)).toEqual(
[msv.title, msv.description, 'string'],
);
expect(queryByTestId('expansion_test')).not.toBeInTheDocument();
const expand = getByTestId('expand_test');
expect(expand).toBeInTheDocument();
await userEvent.click(expand);
const expansion = await findByTestId('expansion_test');
expect(expansion).toBeInTheDocument();
assertMsv(within(expansion), 'test');
await userEvent.click(expand);
await waitFor(() => expect(queryByTestId('expansion_test')).toBeNull());
});
it('deep', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="root" schema={deep} />,
);
const { findByTestId, getByTestId, queryByTestId } = rendered;
const t = getByTestId('root_test');
expect(t).toBeInTheDocument();
expect(t.tagName).toBe('TABLE');
expect(
Array.from(t.querySelectorAll('thead > tr > th'), n => n.textContent),
).toEqual(['Title', 'Description', 'Type']);
const tr = getByTestId('root-row_test');
expect(tr).toBeInTheDocument();
expect(tr.tagName).toBe('TR');
expect(Array.from(tr.querySelectorAll('td'), n => n.textContent)).toEqual(
[deep.title, deep.description, 'object'],
);
expect(queryByTestId('expansion_test')).not.toBeInTheDocument();
const expand = getByTestId('expand_test');
expect(expand).toBeInTheDocument();
await userEvent.click(expand);
const expansion = await findByTestId('expansion_test');
expect(expansion).toBeInTheDocument();
await assertDeepSchemaProperties(within(expansion), 'test');
await userEvent.click(expand);
await waitFor(() => expect(queryByTestId('expansion_test')).toBeNull());
});
});
describe('properties strategy', () => {
it('undefined', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" schema={undefined} />,
);
expect(rendered.getByText('No schema defined')).toBeInTheDocument();
});
it('true', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" schema />,
);
expect(rendered.getByText('No schema defined')).toBeInTheDocument();
});
it('false', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" schema={false} />,
);
expect(rendered.getByText('No schema defined')).toBeInTheDocument();
});
it('basic', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" schema={basic} />,
);
assertBasicSchemaProperties(rendered, 'test');
});
it('enum', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" schema={msv} />,
);
expect(rendered.getByText('No schema defined')).toBeInTheDocument();
});
it('deep', async () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" schema={deep} />,
);
assertDeepSchemaProperties(rendered, 'test');
});
it('require oneOf', async () => {
const schema: JSONSchema7 = {
properties: {
foo: {
type: 'string',
},
bar: {
type: 'string',
},
},
oneOf: [
{ required: ['foo'], properties: { fooFlag: { type: 'boolean' } } },
{ required: ['bar'], properties: { barFlag: { type: 'boolean' } } },
],
};
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
const { getByTestId } = rendered;
for (const [i, k] of Object.keys(schema.properties!).entries()) {
const tr = getByTestId(`properties-row_test.${k}`);
expect(tr).toBeInTheDocument();
const sub = getByTestId(`properties-row_test_sub${i}.${k}`);
expect(sub).toBeInTheDocument();
expect(
Array.from(within(sub).getByText(k).classList).some(c =>
c.includes('codeRequired'),
),
).toBe(true);
expect(
getByTestId(`properties-row_test_sub${i}.${k}Flag`),
).toBeInTheDocument();
}
});
it('full oneOf', async () => {
const schema: JSONSchema7 = {
oneOf: [
{
properties: {
guitar: {
type: 'boolean',
},
string_gauge: {
oneOf: [
{
type: 'number',
},
{
type: 'string',
},
],
},
},
},
{
properties: {
drum: {
type: 'boolean',
},
stick_size: {
type: 'string',
},
},
},
],
};
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
const subs = schema.oneOf as JSONSchema7[];
for (const i of subs.keys()) {
for (const k of Object.keys(subs[i].properties!)) {
expect(
rendered.getByTestId(`properties-row_test_sub${i}.${k}`),
).toBeInTheDocument();
}
}
});
});
});
@@ -0,0 +1,500 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MarkdownContent } from '@backstage/core-components';
import {
TranslationFunction,
TranslationRef,
useTranslationRef,
} from '@backstage/core-plugin-api/alpha';
import Box from '@material-ui/core/Box';
import Chip from '@material-ui/core/Chip';
import Collapse from '@material-ui/core/Collapse';
import IconButton from '@material-ui/core/IconButton';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import Paper from '@material-ui/core/Paper';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import WrapText from '@material-ui/icons/WrapText';
import classNames from 'classnames';
import {
JSONSchema7,
JSONSchema7Definition,
JSONSchema7Type,
} from 'json-schema';
import React from 'react';
import { scaffolderTranslationRef } from '../../translation';
import { SchemaRenderContext, SchemaRenderStrategy } from './types';
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 getSubschemas = (
schema: JSONSchema7Definition,
): Record<string, JSONSchema7Definition[]> => {
if (typeof schema === 'boolean') {
return {};
}
const base: Omit<JSONSchema7, keyof subSchemasType> = {};
const compositeSchemaProperties = ['allOf', 'anyOf', 'not', 'oneOf'] as const;
type subSchemasType = {
[K in (typeof compositeSchemaProperties)[number]]?: JSONSchema7Definition[];
};
const subschemas: subSchemasType = {};
for (const [key, value] of Object.entries(schema) as [
keyof JSONSchema7,
any,
][]) {
if (compositeSchemaProperties.includes(key as keyof subSchemasType)) {
let v;
if (Array.isArray(value)) {
if (!value.length) {
continue;
}
v = value;
} else if (value) {
v = [value];
} else {
continue;
}
subschemas[key as keyof subSchemasType] = v as any;
} else {
base[key as Exclude<keyof JSONSchema7, keyof subSchemasType>] = value;
}
}
return Object.fromEntries(
Object.entries(subschemas).map(([key, sub]) => {
const mergedSubschema = sub.map(alt => {
if (typeof alt !== 'boolean' && alt.required) {
const properties: JSONSchema7['properties'] = {};
if (schema.properties) {
for (const k of alt.required) {
if (k in schema.properties) {
properties[k] = schema.properties[k];
}
}
}
Object.assign(properties, alt.properties);
return {
...base,
...alt,
properties,
};
}
return alt;
});
return [key, mergedSubschema];
}),
);
};
const useColumnStyles = makeStyles({
description: {
width: '40%',
whiteSpace: 'normal',
wordWrap: 'break-word',
'&.MuiTableCell-root': {
whiteSpace: 'normal',
},
},
standard: {
whiteSpace: 'normal',
},
});
type SchemaRenderElement = {
schema: JSONSchema7Definition;
key?: string;
required?: boolean;
};
type RenderColumn = (
element: SchemaRenderElement,
context: SchemaRenderContext,
) => JSX.Element;
type Xlate<R> = R extends TranslationRef<any, infer M>
? TranslationFunction<M>
: never;
type Column = {
key: string;
title: (t: Xlate<typeof scaffolderTranslationRef>) => string;
render: RenderColumn;
className?: keyof ReturnType<typeof useColumnStyles>;
};
const generateId = (
element: SchemaRenderElement,
context: SchemaRenderContext,
) => {
return element.key ? `${context.parentId}.${element.key}` : context.parentId;
};
const nameColumn = {
key: 'name',
title: t => t('renderSchema.tableCell.name'),
render: (element: SchemaRenderElement, context: SchemaRenderContext) => {
return (
<div
className={classNames(context.classes.code, {
[context.classes.codeRequired]: element.required,
})}
>
{element.key}
</div>
);
},
} as Column;
const titleColumn = {
key: 'title',
title: t => t('renderSchema.tableCell.title'),
render: (element: SchemaRenderElement) => (
<MarkdownContent content={(element.schema as JSONSchema7).title ?? ''} />
),
} as Column;
const descriptionColumn = {
key: 'description',
title: t => t('renderSchema.tableCell.description'),
render: (element: SchemaRenderElement) => (
<MarkdownContent
content={(element.schema as JSONSchema7).description ?? ''}
/>
),
className: 'description',
} as Column;
const enumFrom = (schema: JSONSchema7) => {
if (schema.type === 'array') {
if (schema.items && typeof schema.items !== 'boolean') {
if (Array.isArray(schema.items)) {
const itemsWithEnum = schema.items
.filter(e => typeof e === 'object' && 'enum' in e)
.map(e => e as JSONSchema7);
if (itemsWithEnum.length) {
return itemsWithEnum[0].enum;
}
} else {
return schema.items?.enum;
}
}
return undefined;
}
return schema.enum;
};
const inspectSchema = (
schema: JSONSchema7Definition,
): {
canSubschema: boolean;
hasEnum: boolean;
} => {
if (typeof schema === 'boolean') {
return { canSubschema: false, hasEnum: false };
}
return {
canSubschema: getTypes(schema).some(t => t.includes('object')),
hasEnum: !!enumFrom(schema),
};
};
const typeColumn = {
key: 'type',
title: t => t('renderSchema.tableCell.type'),
render: (element: SchemaRenderElement, context: SchemaRenderContext) => {
if (typeof element.schema === 'boolean') {
return <Typography>{element.schema ? 'any' : 'none'}</Typography>;
}
const types = getTypes(element.schema);
const [isExpanded, setIsExpanded] = context.expanded;
const id = generateId(element, context);
const info = inspectSchema(element.schema);
return (
<>
{types.map((type, index) =>
type.includes('object') || (info.hasEnum && index === 0) ? (
<Chip
data-testid={`expand_${id}`}
label={type}
key={type}
icon={isExpanded[id] ? <ExpandLessIcon /> : <ExpandMoreIcon />}
variant="outlined"
onClick={() =>
setIsExpanded(prevState => {
return {
...prevState,
[id]: !!!prevState[id],
};
})
}
/>
) : (
<Chip label={type} key={type} variant="outlined" />
),
)}
</>
);
},
} as Column;
export const RenderEnum: React.FC<{
e: JSONSchema7Type[];
classes: ClassNameMap;
[key: string]: any;
}> = ({
e,
classes,
...props
}: {
e: JSONSchema7Type[];
classes: ClassNameMap;
}) => {
return (
<List {...props}>
{e.map((v, i) => {
let inner: React.JSX.Element = (
<Typography
data-testid={`enum_el${i}`}
className={classNames(classes.code)}
>
{JSON.stringify(v)}
</Typography>
);
if (v !== null && ['object', 'array'].includes(typeof v)) {
inner = (
<>
{inner}
<Tooltip
title={
<Typography
data-testid={`pretty_${i}`}
className={classNames(classes.code)}
style={{ whiteSpace: 'pre-wrap' }}
>
{JSON.stringify(v, undefined, 2)}
</Typography>
}
>
<IconButton data-testid={`wrap-text_${i}`}>
<WrapText />
</IconButton>
</Tooltip>
</>
);
}
return <ListItem key={i}>{inner}</ListItem>;
})}
</List>
);
};
const useTableStyles = makeStyles({
schema: {
width: '100%',
overflowX: 'hidden',
'& table': {
width: '100%',
tableLayout: 'fixed',
},
},
});
export const RenderSchema = ({
strategy,
context,
schema,
}: {
strategy: SchemaRenderStrategy;
context: SchemaRenderContext;
schema?: JSONSchema7Definition;
}) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
const tableStyles = useTableStyles();
const columnStyles = useColumnStyles();
const result = (() => {
if (typeof schema === 'object') {
const subschemas =
strategy === 'root' || !context.parent ? getSubschemas(schema) : {};
let columns: Column[] | undefined;
let elements: SchemaRenderElement[] | undefined;
if (strategy === 'root') {
elements = [{ schema }];
columns = [typeColumn];
if (schema.description) {
columns.unshift(descriptionColumn);
}
if (schema.title) {
columns.unshift(titleColumn);
}
} else if (schema.properties) {
columns = [nameColumn, titleColumn, descriptionColumn, typeColumn];
elements = Object.entries(schema.properties!).map(([key, v]) => ({
schema: v,
key,
required: schema.required?.includes(key),
}));
} else if (!Object.keys(subschemas).length) {
return undefined;
}
const [isExpanded] = context.expanded;
return (
<>
{columns && elements && (
<TableContainer component={Paper} className={tableStyles.schema}>
<Table
data-testid={`${strategy}_${context.parentId}`}
size="small"
>
<TableHead>
<TableRow>
{columns.map((col, index) => (
<TableCell
key={index}
className={columnStyles[col.className ?? 'standard']}
>
{col.title(t)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{elements.map(el => {
const id = generateId(el, context);
const info = inspectSchema(el.schema);
return (
<React.Fragment key={id}>
<TableRow data-testid={`${strategy}-row_${id}`}>
{columns!.map(col => (
<TableCell
key={col.key}
className={
columnStyles[col.className ?? 'standard']
}
>
{col.render(el, context)}
</TableCell>
))}
</TableRow>
{typeof el.schema !== 'boolean' &&
(info.canSubschema || info.hasEnum) && (
<TableRow>
<TableCell
style={{ paddingBottom: 0, paddingTop: 0 }}
colSpan={columns!.length}
>
<Collapse
in={isExpanded[id]}
timeout="auto"
unmountOnExit
>
<Box
data-testid={`expansion_${id}`}
sx={{ margin: 1 }}
>
{info.canSubschema && (
<RenderSchema
strategy="properties"
context={{
...context,
parentId: id,
parent: context,
}}
schema={
el.schema.type === 'array'
? (el.schema.items as
| JSONSchema7
| undefined)
: el.schema
}
/>
)}
{info.hasEnum && (
<>
{React.cloneElement(
context.headings[0],
{},
'Valid values:',
)}
<RenderEnum
data-testid={`enum_${id}`}
e={enumFrom(el.schema)!}
classes={context.classes}
/>
</>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
{Object.keys(subschemas).map(sk => (
<React.Fragment key={sk}>
{React.cloneElement(context.headings[0], {}, sk)}
{subschemas[sk].map((sub, index) => (
<RenderSchema
key={index}
{...{
strategy,
context: {
...context,
parentId: `${context.parentId}_sub${index}`,
},
schema: sub,
}}
/>
))}
</React.Fragment>
))}
</>
);
}
return undefined;
})();
return result ?? <Typography>No schema defined</Typography>;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types';
export { RenderSchema } from './RenderSchema';
@@ -0,0 +1,28 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
export type Expanded = { [key: string]: boolean };
export type SchemaRenderContext = {
parent?: SchemaRenderContext;
parentId: string;
classes: ClassNameMap;
expanded: [Expanded, React.Dispatch<React.SetStateAction<Expanded>>];
headings: [React.JSX.Element, ...React.JSX.Element[]];
};
export type SchemaRenderStrategy = 'root' | 'properties';
@@ -0,0 +1,50 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ScaffolderUsageExample } from '@backstage/plugin-scaffolder-react';
import { renderInTestApp } from '@backstage/test-utils';
import { within } from '@testing-library/react';
import React from 'react';
import { ScaffolderUsageExamplesTable } from './ScaffolderUsageExamplesTable';
describe('examples', () => {
it('renders component', async () => {
const examples: ScaffolderUsageExample[] = [
{
description: 'foo',
example: 'bar',
},
{
description: 'baz',
example: 'blah',
},
];
const { getByTestId } = await renderInTestApp(
<ScaffolderUsageExamplesTable {...{ examples }} />,
);
const x = getByTestId('examples');
expect(x).toBeInTheDocument();
const wx = within(x);
for (const [index, value] of examples.entries()) {
const d = wx.getByTestId(`example_desc${index}`);
expect(d).toBeInTheDocument();
expect(d.textContent).toBe(value.description);
const c = wx.getByTestId(`example_code${index}`);
expect(c).toBeInTheDocument();
expect(within(c).getByText(value.example)).toBeInTheDocument();
}
});
});
@@ -0,0 +1,55 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CodeSnippet, MarkdownContent } from '@backstage/core-components';
import { ScaffolderUsageExample } from '@backstage/plugin-scaffolder-react';
import Box from '@material-ui/core/Box';
import Grid from '@material-ui/core/Grid';
import React, { Fragment } from 'react';
export const ScaffolderUsageExamplesTable = (props: {
examples: ScaffolderUsageExample[];
}) => {
return (
<Grid data-testid="examples" container>
{props.examples.map((example, index) => {
return (
<Fragment key={`example-${index}`}>
<Grid data-testid={`example_desc${index}`} item lg={3}>
<Box padding={1} style={{ overflowX: 'auto' }}>
{example.description && (
<MarkdownContent content={example.description} />
)}
{example.notes?.length && (
<MarkdownContent content={example.notes} />
)}
</Box>
</Grid>
<Grid data-testid={`example_code${index}`} item lg={9}>
<Box padding={1}>
<CodeSnippet
text={example.example?.trim()}
showLineNumbers
showCopyCodeButton
language="yaml"
/>
</Box>
</Grid>
</Fragment>
);
})}
</Grid>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ScaffolderUsageExamplesTable } from './ScaffolderUsageExamplesTable';
+9 -7
View File
@@ -30,13 +30,6 @@ export const scaffolderTranslationRef = createTranslationRef({
'There are no actions installed or there was an issue communicating with backend.',
},
searchFieldPlaceholder: 'Search for an action',
tableCell: {
name: 'Name',
title: 'Title',
description: 'Description',
type: 'Type',
},
noRowsDescription: 'No schema defined',
},
action: {
input: 'Input',
@@ -192,6 +185,15 @@ export const scaffolderTranslationRef = createTranslationRef({
emptyText: 'There are no spec parameters in the template to preview.',
},
},
renderSchema: {
tableCell: {
name: 'Name',
title: 'Title',
description: 'Description',
type: 'Type',
},
undefined: 'No schema defined',
},
templateTypePicker: {
title: 'Categories',
},