({});
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 (
- {t('actionsPage.content.noRowsDescription')}
- );
- }
- return (
-
-
-
-
- {t('actionsPage.content.tableCell.name')}
- {t('actionsPage.content.tableCell.title')}
-
- {t('actionsPage.content.tableCell.description')}
-
- {t('actionsPage.content.tableCell.type')}
-
-
- {rows}
-
-
- );
- };
-
- 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;
- }
-
- return (
- <>
-
- {name}
-
- {input.map((i, index) => (
-
- {renderTable(
- formatRows(`${id}.${index}`, i as unknown as JSONSchema7),
- )}
-
- ))}
- >
- );
- };
-
return (
<>
@@ -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: [],
+ };
return (
@@ -376,10 +188,14 @@ export const ActionPageContent = () => {
{t('actionsPage.action.input')}
- {renderTable(
- formatRows(`${action.id}.input`, action?.schema?.input),
- )}
- {oneOfInput}
+
)}
{action.schema?.output && (
@@ -387,10 +203,14 @@ export const ActionPageContent = () => {
{t('actionsPage.action.output')}
- {renderTable(
- formatRows(`${action.id}.output`, action?.schema?.output),
- )}
- {oneOfOutput}
+
)}
{action.examples && (
@@ -402,7 +222,7 @@ export const ActionPageContent = () => {
-
+
diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx
new file mode 100644
index 0000000000..32d1e2e142
--- /dev/null
+++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx
@@ -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 ;
+};
+
+const LocalRenderSchema = ({
+ strategy,
+ schema,
+}: {
+ strategy: SchemaRenderStrategy;
+ schema?: JSONSchema7Definition;
+}) => {
+ const classes = useStyles();
+ const expanded = useState({});
+ return (
+ ],
+ },
+ }}
+ />
+ );
+};
+
+it('enum rendering', async () => {
+ const enums = [
+ 'foo',
+ 123,
+ null,
+ ['bar', 'baz'],
+ { what: 'ever' },
+ [{ fo: 'real?' }],
+ ];
+ const rendered = await renderInTestApp();
+ 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;
+ },
+ 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;
+ },
+ 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;
+ },
+ 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(
+ ,
+ );
+ expect(rendered.getByText('No schema defined')).toBeInTheDocument();
+ });
+ it('true', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('No schema defined')).toBeInTheDocument();
+ });
+ it('false', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('No schema defined')).toBeInTheDocument();
+ });
+ it('basic', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(rendered.getByText('No schema defined')).toBeInTheDocument();
+ });
+ it('true', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('No schema defined')).toBeInTheDocument();
+ });
+ it('false', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('No schema defined')).toBeInTheDocument();
+ });
+ it('basic', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ assertBasicSchemaProperties(rendered, 'test');
+ });
+ it('enum', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('No schema defined')).toBeInTheDocument();
+ });
+ it('deep', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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();
+ }
+ }
+ });
+ });
+});
diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx
new file mode 100644
index 0000000000..011d9e3559
--- /dev/null
+++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx
@@ -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 => {
+ if (typeof schema === 'boolean') {
+ return {};
+ }
+ const base: Omit = {};
+
+ 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] = 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 extends TranslationRef
+ ? TranslationFunction
+ : never;
+
+type Column = {
+ key: string;
+ title: (t: Xlate) => string;
+ render: RenderColumn;
+ className?: keyof ReturnType;
+};
+
+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 (
+
+ {element.key}
+
+ );
+ },
+} as Column;
+
+const titleColumn = {
+ key: 'title',
+ title: t => t('renderSchema.tableCell.title'),
+ render: (element: SchemaRenderElement) => (
+
+ ),
+} as Column;
+
+const descriptionColumn = {
+ key: 'description',
+ title: t => t('renderSchema.tableCell.description'),
+ render: (element: SchemaRenderElement) => (
+
+ ),
+ 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 {element.schema ? 'any' : 'none'};
+ }
+ 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) ? (
+ : }
+ variant="outlined"
+ onClick={() =>
+ setIsExpanded(prevState => {
+ return {
+ ...prevState,
+ [id]: !!!prevState[id],
+ };
+ })
+ }
+ />
+ ) : (
+
+ ),
+ )}
+ >
+ );
+ },
+} as Column;
+
+export const RenderEnum: React.FC<{
+ e: JSONSchema7Type[];
+ classes: ClassNameMap;
+ [key: string]: any;
+}> = ({
+ e,
+ classes,
+ ...props
+}: {
+ e: JSONSchema7Type[];
+ classes: ClassNameMap;
+}) => {
+ return (
+
+ {e.map((v, i) => {
+ let inner: React.JSX.Element = (
+
+ {JSON.stringify(v)}
+
+ );
+ if (v !== null && ['object', 'array'].includes(typeof v)) {
+ inner = (
+ <>
+ {inner}
+
+ {JSON.stringify(v, undefined, 2)}
+
+ }
+ >
+
+
+
+
+ >
+ );
+ }
+ return {inner};
+ })}
+
+ );
+};
+
+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 && (
+
+
+
+
+ {columns.map((col, index) => (
+
+ {col.title(t)}
+
+ ))}
+
+
+
+ {elements.map(el => {
+ const id = generateId(el, context);
+ const info = inspectSchema(el.schema);
+ return (
+
+
+ {columns!.map(col => (
+
+ {col.render(el, context)}
+
+ ))}
+
+ {typeof el.schema !== 'boolean' &&
+ (info.canSubschema || info.hasEnum) && (
+
+
+
+
+ {info.canSubschema && (
+
+ )}
+ {info.hasEnum && (
+ <>
+ {React.cloneElement(
+ context.headings[0],
+ {},
+ 'Valid values:',
+ )}
+
+ >
+ )}
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+ )}
+ {Object.keys(subschemas).map(sk => (
+
+ {React.cloneElement(context.headings[0], {}, sk)}
+ {subschemas[sk].map((sub, index) => (
+
+ ))}
+
+ ))}
+ >
+ );
+ }
+ return undefined;
+ })();
+ return result ?? No schema defined;
+};
diff --git a/plugins/scaffolder/src/components/RenderSchema/index.ts b/plugins/scaffolder/src/components/RenderSchema/index.ts
new file mode 100644
index 0000000000..6a9395fc0e
--- /dev/null
+++ b/plugins/scaffolder/src/components/RenderSchema/index.ts
@@ -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';
diff --git a/plugins/scaffolder/src/components/RenderSchema/types.ts b/plugins/scaffolder/src/components/RenderSchema/types.ts
new file mode 100644
index 0000000000..70f3c27dfd
--- /dev/null
+++ b/plugins/scaffolder/src/components/RenderSchema/types.ts
@@ -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>];
+ headings: [React.JSX.Element, ...React.JSX.Element[]];
+};
+
+export type SchemaRenderStrategy = 'root' | 'properties';
diff --git a/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.test.tsx b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.test.tsx
new file mode 100644
index 0000000000..3d96b7107b
--- /dev/null
+++ b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.test.tsx
@@ -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(
+ ,
+ );
+ 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();
+ }
+ });
+});
diff --git a/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.tsx b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.tsx
new file mode 100644
index 0000000000..758fa90ec4
--- /dev/null
+++ b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.tsx
@@ -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 (
+
+ {props.examples.map((example, index) => {
+ return (
+
+
+
+ {example.description && (
+
+ )}
+ {example.notes?.length && (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/index.ts b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/index.ts
new file mode 100644
index 0000000000..9d73e1cb81
--- /dev/null
+++ b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/index.ts
@@ -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';
diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts
index 7299d3245a..fc20649a7b 100644
--- a/plugins/scaffolder/src/translation.ts
+++ b/plugins/scaffolder/src/translation.ts
@@ -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',
},