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:
@@ -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',
|
||||
+ },
|
||||
```
|
||||
@@ -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';
|
||||
+50
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+55
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user