scaffolder: migrate RenderSchema and ActionsPage to @backstage/ui components

Replace all @material-ui imports in RenderSchema with @backstage/ui
equivalents (TableRoot, TableHeader, TableBody, Row, Column, Cell,
CellText, Button, Text, Flex, Tooltip). Simplify SchemaRenderContext
by removing the classes and headings fields that were only needed
for @material-ui styling.

Redesign the ActionsPage to use a List with selectable rows instead
of a Table with a separate detail Card. When an action is selected,
all descriptions are hidden from the list and the action detail is
shown below with input/output schemas and examples in accordions.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-28 16:43:22 +01:00
parent dae4e6ef09
commit 52c18c1f9a
7 changed files with 455 additions and 567 deletions
@@ -95,7 +95,7 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test title')).toBeInTheDocument();
expect(screen.getByText('foobar')).toBeInTheDocument();
expect(screen.getByText('foobar *')).toBeInTheDocument();
});
it('renders action with input and output on row click', async () => {
@@ -140,7 +140,7 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test title')).toBeInTheDocument();
expect(screen.getByText('foobar')).toBeInTheDocument();
expect(screen.getByText('foobar *')).toBeInTheDocument();
expect(screen.getByText('Test output')).toBeInTheDocument();
});
@@ -347,20 +347,20 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test object')).toBeInTheDocument();
const objectChip = screen.getByText('object');
expect(objectChip).toBeInTheDocument();
const objectButton = screen.getByRole('button', { name: /object/ });
expect(objectButton).toBeInTheDocument();
expect(screen.queryByText('nested prop a')).not.toBeInTheDocument();
expect(screen.queryByText('string')).not.toBeInTheDocument();
expect(screen.queryByText(/^string$/)).not.toBeInTheDocument();
expect(screen.queryByText('nested prop b')).not.toBeInTheDocument();
expect(screen.queryByText('number')).not.toBeInTheDocument();
expect(screen.queryByText(/^number$/)).not.toBeInTheDocument();
await userEvent.click(objectChip);
await userEvent.click(objectButton);
expect(screen.queryByText('nested prop a')).toBeInTheDocument();
expect(screen.queryByText('string')).toBeInTheDocument();
expect(screen.queryByText(/^string$/)).toBeInTheDocument();
expect(screen.queryByText('nested prop b')).toBeInTheDocument();
expect(screen.queryByText('number')).toBeInTheDocument();
expect(screen.queryByText(/^number$/)).toBeInTheDocument();
});
it('renders action with nested object input type', async () => {
@@ -412,22 +412,22 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test object')).toBeInTheDocument();
const objectChip = screen.getByText('object');
expect(objectChip).toBeInTheDocument();
const objectButton = screen.getByRole('button', { name: /object/ });
expect(objectButton).toBeInTheDocument();
expect(screen.queryByText('nested object a')).not.toBeInTheDocument();
expect(screen.queryByText('nested prop b')).not.toBeInTheDocument();
expect(screen.queryByText('nested object c')).not.toBeInTheDocument();
await userEvent.click(objectChip);
await userEvent.click(objectButton);
expect(screen.queryByText('nested object a')).toBeInTheDocument();
expect(screen.queryByText('nested prop b')).toBeInTheDocument();
expect(screen.queryByText('nested object c')).not.toBeInTheDocument();
const allObjectChips = screen.getAllByText('object');
expect(allObjectChips.length).toBe(2);
await userEvent.click(allObjectChips[1]);
const allObjectButtons = screen.getAllByRole('button', { name: /object/ });
expect(allObjectButtons.length).toBe(2);
await userEvent.click(allObjectButtons[1]);
expect(screen.queryByText('nested object a')).toBeInTheDocument();
expect(screen.queryByText('nested prop b')).toBeInTheDocument();
@@ -467,12 +467,12 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test object')).toBeInTheDocument();
const objectChip = screen.getByText('object');
expect(objectChip).toBeInTheDocument();
const objectButton = screen.getByRole('button', { name: /object/ });
expect(objectButton).toBeInTheDocument();
expect(screen.queryByText('No schema defined')).not.toBeInTheDocument();
await userEvent.click(objectChip);
await userEvent.click(objectButton);
expect(screen.queryByText('No schema defined')).toBeInTheDocument();
});
@@ -567,13 +567,15 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test array')).toBeInTheDocument();
const objectChip = screen.getByText('array(object)');
expect(objectChip).toBeInTheDocument();
const arrayButton = screen.getByRole('button', {
name: /array\(object\)/,
});
expect(arrayButton).toBeInTheDocument();
expect(screen.queryByText('nested object a')).not.toBeInTheDocument();
expect(screen.queryByText('nested prop b')).not.toBeInTheDocument();
await userEvent.click(objectChip);
await userEvent.click(arrayButton);
expect(screen.queryByText('nested object a')).toBeInTheDocument();
expect(screen.queryByText('nested prop b')).toBeInTheDocument();
@@ -16,8 +16,6 @@
import { useMemo, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import { Action, scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
@@ -32,17 +30,11 @@ import {
AccordionGroup,
AccordionPanel,
AccordionTrigger,
Card,
CardBody,
CardHeader,
CellText,
Flex,
List,
ListRow,
SearchField,
Table,
Text,
useTable,
type ColumnConfig,
type TableItem,
} from '@backstage/ui';
import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha';
import { useNavigate } from 'react-router-dom';
@@ -57,128 +49,66 @@ import { scaffolderTranslationRef } from '../../translation';
import { Expanded, RenderSchema, SchemaRenderContext } from '../RenderSchema';
import { ScaffolderUsageExamplesTable } from '../ScaffolderUsageExamplesTable';
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,
},
},
}));
interface ActionTableItem extends TableItem {
action: Action;
}
function ActionDetail({ action }: { action: Action }) {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const expanded = useState<Expanded>({});
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
const hasInput = !!action.schema?.input;
const hasOutput = !!action.schema?.output;
const hasExamples = !!action.examples;
if (!hasInput && !hasOutput && !hasExamples) {
return null;
}
return (
<Card>
<CardHeader>
<Flex direction="column" gap="1">
<Text as="h2" variant="title-medium" weight="bold">
{action.id}
</Text>
{action.description && (
<Text as="p" variant="body-medium" color="secondary">
{action.description}
</Text>
)}
</Flex>
</CardHeader>
{(hasInput || hasOutput || hasExamples) && (
<CardBody>
<AccordionGroup allowsMultiple>
{hasInput && (
<Accordion defaultExpanded>
<AccordionTrigger title={t('actionsPage.action.input')} />
<AccordionPanel>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</AccordionPanel>
</Accordion>
)}
{hasOutput && (
<Accordion>
<AccordionTrigger title={t('actionsPage.action.output')} />
<AccordionPanel>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</AccordionPanel>
</Accordion>
)}
{hasExamples && (
<Accordion>
<AccordionTrigger title={t('actionsPage.action.examples')} />
<AccordionPanel>
<ScaffolderUsageExamplesTable examples={action.examples!} />
</AccordionPanel>
</Accordion>
)}
</AccordionGroup>
</CardBody>
<AccordionGroup allowsMultiple defaultExpandedKeys={['input']}>
{hasInput && (
<Accordion id="input">
<AccordionTrigger title={t('actionsPage.action.input')} />
<AccordionPanel>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</AccordionPanel>
</Accordion>
)}
</Card>
{hasOutput && (
<Accordion id="output">
<AccordionTrigger title={t('actionsPage.action.output')} />
<AccordionPanel>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</AccordionPanel>
</Accordion>
)}
{hasExamples && (
<Accordion id="examples">
<AccordionTrigger title={t('actionsPage.action.examples')} />
<AccordionPanel>
<ScaffolderUsageExamplesTable examples={action.examples!} />
</AccordionPanel>
</Accordion>
)}
</AccordionGroup>
);
}
const columnConfig: ColumnConfig<ActionTableItem>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
defaultWidth: '1fr',
cell: item => (
<CellText
title={item.action.id}
description={item.action.description ?? undefined}
/>
),
},
];
export const ActionPageContent = () => {
const api = useApi(scaffolderApiRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
@@ -191,35 +121,30 @@ export const ActionPageContent = () => {
return api.listActions();
}, [api]);
const [selectedAction, setSelectedAction] = useState<Action | undefined>();
const [selectedActionId, setSelectedActionId] = useState<
string | undefined
>();
const [searchQuery, setSearchQuery] = useState('');
const tableData = useMemo(
() =>
actions
?.filter(action => !action.id.startsWith('legacy:'))
.map(
(action): ActionTableItem => ({
id: action.id,
action,
}),
),
[actions],
const filteredActions = useMemo(() => {
const nonLegacy =
actions?.filter(action => !action.id.startsWith('legacy:')) ?? [];
if (!searchQuery) {
return nonLegacy;
}
const lowerQuery = searchQuery.toLowerCase();
return nonLegacy.filter(
action =>
action.id.toLowerCase().includes(lowerQuery) ||
action.description?.toLowerCase().includes(lowerQuery),
);
}, [actions, searchQuery]);
const selectedAction = useMemo(
() => filteredActions.find(a => a.id === selectedActionId),
[filteredActions, selectedActionId],
);
const { tableProps, search } = useTable({
mode: 'complete',
data: tableData,
paginationOptions: { pageSize: 20 },
searchFn: (items, query) => {
const lowerQuery = query.toLowerCase();
return items.filter(
item =>
item.action.id.toLowerCase().includes(lowerQuery) ||
item.action.description?.toLowerCase().includes(lowerQuery),
);
},
});
if (error) {
return (
<>
@@ -233,33 +158,75 @@ export const ActionPageContent = () => {
);
}
if (!loading && !filteredActions.length) {
return (
<Flex direction="column" gap="4">
<SearchField
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
value={searchQuery}
onChange={setSearchQuery}
/>
<EmptyState
missing="info"
title={t('actionsPage.content.emptyState.title')}
description={t('actionsPage.content.emptyState.description')}
/>
</Flex>
);
}
return (
<Flex direction="column" gap="4">
<SearchField
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
{...search}
value={searchQuery}
onChange={setSearchQuery}
/>
<Table
columnConfig={columnConfig}
{...tableProps}
loading={loading}
emptyState={
<EmptyState
missing="info"
title={t('actionsPage.content.emptyState.title')}
description={t('actionsPage.content.emptyState.description')}
/>
}
rowConfig={{
onClick: item => {
setSelectedAction(prev =>
prev?.id === item.action.id ? undefined : item.action,
);
},
<List
aria-label={t('actionsPage.title')}
selectionMode="single"
selectionBehavior="toggle"
selectedKeys={selectedActionId ? [selectedActionId] : []}
onSelectionChange={selection => {
if (selection === 'all') {
return;
}
const selected = [...selection][0] as string | undefined;
setSelectedActionId(prev =>
prev === selected ? undefined : selected,
);
}}
/>
{selectedAction && <ActionDetail action={selectedAction} />}
>
{filteredActions.map(action => (
<ListRow
key={action.id}
id={action.id}
textValue={action.id}
description={
selectedAction ? undefined : action.description ?? undefined
}
>
{action.id}
</ListRow>
))}
</List>
{selectedAction && (
<Flex direction="column" gap="3">
<Flex direction="column" gap="1">
<Text as="h2" variant="title-medium" weight="bold">
{selectedAction.id}
</Text>
{selectedAction.description && (
<Text as="p" variant="body-medium" color="secondary">
{selectedAction.description}
</Text>
)}
</Flex>
<ActionDetail action={selectedAction} />
</Flex>
)}
</Flex>
);
};
@@ -14,8 +14,6 @@
* 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,
@@ -35,35 +33,8 @@ 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 }} />;
return <RenderEnum {...{ e }} />;
};
const LocalRenderSchema = ({
@@ -73,7 +44,6 @@ const LocalRenderSchema = ({
strategy: SchemaRenderStrategy;
schema?: JSONSchema7Definition;
}) => {
const classes = useStyles();
const expanded = useState<Expanded>({});
return (
<RenderSchema
@@ -82,9 +52,7 @@ const LocalRenderSchema = ({
schema,
context: {
parentId: 'test',
classes,
expanded,
headings: [<Typography component="h1" />],
},
}}
/>
@@ -119,19 +87,18 @@ it('enum rendering', async () => {
expect(el).toBeInTheDocument();
expect(el).toHaveTextContent(JSON.stringify(each.value));
const wrapTextIcon = getByTestId(`wrap-text_${each.index}`);
expect(wrapTextIcon).toBeInTheDocument();
const wrapTextButton = getByTestId(`wrap-text_${each.index}`);
expect(wrapTextButton).toBeInTheDocument();
expect(queryByTestId(`pretty_${each.index}`)).toBeNull();
await userEvent.hover(wrapTextIcon);
wrapTextButton.focus();
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);
wrapTextButton.blur();
await waitFor(() =>
expect(queryByTestId(`pretty_${each.index}`)).toBeNull(),
@@ -171,27 +138,29 @@ describe('JSON schema UI rendering', () => {
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;
const headerCells = within(tr).queryAllByRole('rowheader');
const dataCells = within(tr).getAllByRole('gridcell');
const allCells = [...headerCells, ...dataCells];
const cellTexts = allCells.map(c => c.textContent);
expect(Array.from(tr.querySelectorAll('td'), n => n.textContent)).toEqual(
[p, capitalize(p), `the ${pt}`, pt],
expect(cellTexts).toEqual(
expect.arrayContaining([
expect.stringContaining(p),
expect.stringContaining(capitalize(p)),
expect.stringContaining(`the ${pt}`),
expect.stringContaining(String(pt)),
]),
);
expect(
Array.from(within(tr).getByText(p).classList).some(c =>
c.includes('codeRequired'),
),
).toBe(basic.required?.includes(p));
if (basic.required?.includes(p)) {
expect(cellTexts[0]).toContain('*');
}
expect(queryByTestId(`expand_${id}.${p}`)).not.toBeInTheDocument();
}
@@ -241,11 +210,6 @@ describe('JSON schema UI rendering', () => {
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;
@@ -254,7 +218,6 @@ describe('JSON schema UI rendering', () => {
}> = async (k: string) => {
const r = getByTestId(`properties-row_${id}.${k}`);
expect(r).toBeInTheDocument();
expect(r.tagName).toBe('TR');
const expansionId = `expansion_${id}.${k}`;
@@ -311,19 +274,6 @@ describe('JSON schema UI rendering', () => {
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();
@@ -349,18 +299,6 @@ describe('JSON schema UI rendering', () => {
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();
@@ -385,18 +323,7 @@ describe('JSON schema UI rendering', () => {
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');
@@ -478,11 +405,10 @@ describe('JSON schema UI rendering', () => {
const sub = getByTestId(`properties-row_test_oneOf${i}.${k}`);
expect(sub).toBeInTheDocument();
expect(
Array.from(within(sub).getByText(k).classList).some(c =>
c.includes('codeRequired'),
),
).toBe(true);
const nameCell =
within(sub).queryAllByRole('rowheader')[0] ??
within(sub).getAllByRole('gridcell')[0];
expect(nameCell.textContent).toContain('*');
expect(
getByTestId(`properties-row_test_oneOf${i}.${k}Flag`),
@@ -15,36 +15,28 @@
*/
import { MarkdownContent } from '@backstage/core-components';
import { 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 {
Button,
Cell,
CellText,
Column,
Flex,
Row,
TableBody,
TableHeader,
TableRoot,
Text,
Tooltip,
TooltipTrigger,
} from '@backstage/ui';
import {
JSONSchema7,
JSONSchema7Definition,
JSONSchema7Type,
} from 'json-schema';
import { FC, JSX, cloneElement, Fragment, ReactElement } from 'react';
import { FC, Fragment, JSX } from 'react';
import { scaffolderTranslationRef } from '../../translation';
import { SchemaRenderContext, SchemaRenderStrategy } from './types';
import { TranslationMessages } from '../TemplatingExtensionsPage/types';
const compositeSchemaProperties = ['allOf', 'anyOf', 'not', 'oneOf'] as const;
@@ -131,20 +123,6 @@ const getSubschemas = (schema: JSONSchema7Definition): subSchemasType => {
);
};
const useColumnStyles = makeStyles({
description: {
width: '40%',
whiteSpace: 'normal',
wordWrap: 'break-word',
'&.MuiTableCell-root': {
whiteSpace: 'normal',
},
},
standard: {
whiteSpace: 'normal',
},
});
type SchemaRenderElement = {
schema: JSONSchema7Definition;
key?: string;
@@ -156,11 +134,11 @@ type RenderColumn = (
context: SchemaRenderContext,
) => JSX.Element;
type Column = {
type ColumnDef = {
key: string;
title: (t: TranslationMessages<typeof scaffolderTranslationRef>) => string;
title: string;
render: RenderColumn;
className?: keyof ReturnType<typeof useColumnStyles>;
width?: `${number}fr`;
};
const generateId = (
@@ -170,41 +148,6 @@ const generateId = (
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') {
@@ -242,107 +185,57 @@ const inspectSchema = (
};
};
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) =>
info.canSubschema || (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: FC<{
e: JSONSchema7Type[];
classes: ClassNameMap;
[key: string]: any;
}> = ({
e,
classes,
...props
}: {
e: JSONSchema7Type[];
classes: ClassNameMap;
}) => {
}> = ({ e, ...props }: { e: JSONSchema7Type[] }) => {
return (
<List {...props}>
<ul {...props} style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{e.map((v, i) => {
let inner: 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>
}
const text = JSON.stringify(v);
const isComplex = v !== null && ['object', 'array'].includes(typeof v);
return (
<li key={i} style={{ padding: '2px 0' }}>
<Flex gap="2" align="center">
<Text
as="span"
variant="body-small"
data-testid={`enum_el${i}`}
style={{ fontFamily: 'monospace' }}
>
<IconButton data-testid={`wrap-text_${i}`}>
<WrapText />
</IconButton>
</Tooltip>
</>
);
}
return <ListItem key={i}>{inner}</ListItem>;
{text}
</Text>
{isComplex && (
<TooltipTrigger>
<Button
data-testid={`wrap-text_${i}`}
variant="tertiary"
size="small"
>
</Button>
<Tooltip>
<Text
as="span"
data-testid={`pretty_${i}`}
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
}}
>
{JSON.stringify(v, undefined, 2)}
</Text>
</Tooltip>
</TooltipTrigger>
)}
</Flex>
</li>
);
})}
</List>
</ul>
);
};
const useTableStyles = makeStyles({
schema: {
width: '100%',
overflowX: 'hidden',
'& table': {
width: '100%',
tableLayout: 'fixed',
},
},
});
export const RenderSchema = ({
strategy,
context,
@@ -353,26 +246,62 @@ export const RenderSchema = ({
schema?: JSONSchema7Definition;
}) => {
const { t } = useTranslationRef(scaffolderTranslationRef);
const tableStyles = useTableStyles();
const columnStyles = useColumnStyles();
const result = (() => {
if (typeof schema === 'object') {
const subschemas = getSubschemas(schema);
let columns: Column[] | undefined;
let columns: ColumnDef[] | undefined;
let elements: SchemaRenderElement[] | undefined;
if (strategy === 'root') {
if ('type' in schema || !Object.keys(subschemas).length) {
elements = [{ schema }];
columns = [typeColumn];
columns = [
{
key: 'type',
title: t('renderSchema.tableCell.type'),
render: renderTypeCell,
},
];
if (schema.description) {
columns.unshift(descriptionColumn);
columns.unshift({
key: 'description',
title: t('renderSchema.tableCell.description'),
render: renderDescriptionCell,
width: '1fr',
});
}
if (schema.title) {
columns.unshift(titleColumn);
columns.unshift({
key: 'title',
title: t('renderSchema.tableCell.title'),
render: renderTitleCell,
});
}
}
} else if (schema.properties) {
columns = [nameColumn, titleColumn, descriptionColumn, typeColumn];
columns = [
{
key: 'name',
title: t('renderSchema.tableCell.name'),
render: renderNameCell,
},
{
key: 'title',
title: t('renderSchema.tableCell.title'),
render: renderTitleCell,
},
{
key: 'description',
title: t('renderSchema.tableCell.description'),
render: renderDescriptionCell,
width: '1fr',
},
{
key: 'type',
title: t('renderSchema.tableCell.type'),
render: renderTypeCell,
},
];
elements = Object.entries(schema.properties!).map(([key, v]) => ({
schema: v,
key,
@@ -386,109 +315,112 @@ export const RenderSchema = ({
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']}
<TableRoot
data-testid={`${strategy}_${context.parentId}`}
aria-label={`${strategy} schema`}
>
<TableHeader>
{columns.map(col => (
<Column
key={col.key}
id={col.key}
isRowHeader={col.key === 'name'}
defaultWidth={col.width ?? undefined}
>
{col.title}
</Column>
))}
</TableHeader>
<TableBody>
{elements.map(el => {
const id = generateId(el, context);
const info = inspectSchema(el.schema);
const hasDetails =
typeof el.schema !== 'boolean' &&
(info.canSubschema || info.hasEnum);
return (
<Fragment key={id}>
<Row
id={`${strategy}-row_${id}`}
data-testid={`${strategy}-row_${id}`}
columns={columns}
>
{col.title(t)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{elements.map(el => {
const id = generateId(el, context);
const info = inspectSchema(el.schema);
const rows = [
<TableRow data-testid={`${strategy}-row_${id}`}>
{columns!.map(col => (
<TableCell
key={col.key}
className={
columnStyles[col.className ?? 'standard']
}
>
{col.render(el, context)}
</TableCell>
))}
</TableRow>,
];
if (
typeof el.schema !== 'boolean' &&
(info.canSubschema || info.hasEnum)
) {
let details: ReactElement = (
<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
{col => col.render(el, context)}
</Row>
{hasDetails &&
typeof el.schema !== 'boolean' &&
(() => {
const s = el.schema as JSONSchema7;
const isOpen = !getTypes(s) || isExpanded[id];
if (!isOpen) {
return null;
}
return (
<Row
id={`${strategy}-detail_${id}`}
columns={columns}
>
{(col: ColumnDef) =>
col.key === columns![0].key ? (
<Cell
data-testid={`expansion_${id}`}
style={{
gridColumn: `1 / -1`,
padding: '8px 16px',
}}
>
{info.canSubschema && (
<RenderSchema
strategy="properties"
context={{
...context,
parentId: id,
parent: context,
}}
schema={
s.type === 'array'
? (s.items as
| JSONSchema7
| undefined)
: s
}
/>
)}
{info.hasEnum && (
<>
<Text
as="h4"
variant="title-small"
weight="bold"
>
Valid values:
</Text>
<RenderEnum
data-testid={`enum_${id}`}
e={enumFrom(s)!}
/>
</>
)}
</Cell>
) : (
<Cell style={{ display: 'none' }} />
)
}
/>
)}
{info.hasEnum && (
<>
{cloneElement(
context.headings[0],
{},
'Valid values:',
)}
<RenderEnum
data-testid={`enum_${id}`}
e={enumFrom(el.schema)!}
classes={context.classes}
/>
</>
)}
</Box>
);
if (getTypes(el.schema)) {
details = (
<Collapse
in={isExpanded[id]}
timeout="auto"
unmountOnExit
>
{details}
</Collapse>
);
}
rows.push(
<TableRow>
<TableCell
style={{ paddingBottom: 0, paddingTop: 0 }}
colSpan={columns!.length}
>
{details}
</TableCell>
</TableRow>,
);
}
return <Fragment key={id}>{rows}</Fragment>;
})}
</TableBody>
</Table>
</TableContainer>
</Row>
);
})()}
</Fragment>
);
})}
</TableBody>
</TableRoot>
)}
{(Object.keys(subschemas) as Array<keyof subSchemasType>).map(sk => (
<Fragment key={sk}>
{cloneElement(context.headings[0], {}, sk)}
<Text as="h4" variant="title-small" weight="bold">
{sk}
</Text>
{subschemas[sk]!.map((sub, index) => (
<RenderSchema
key={index}
@@ -497,13 +429,11 @@ export const RenderSchema = ({
? strategy
: 'root'
}
{...{
context: {
...context,
parentId: `${context.parentId}_${sk}${index}`,
},
schema: sub,
context={{
...context,
parentId: `${context.parentId}_${sk}${index}`,
}}
schema={sub}
/>
))}
</Fragment>
@@ -513,5 +443,77 @@ export const RenderSchema = ({
}
return undefined;
})();
return result ?? <Typography>No schema defined</Typography>;
return result ?? <Text as="p">No schema defined</Text>;
};
function renderNameCell(
element: SchemaRenderElement,
_context: SchemaRenderContext,
) {
const name = element.key ?? '';
return <CellText title={element.required ? `${name} *` : name} />;
}
function renderTitleCell(element: SchemaRenderElement) {
return (
<Cell>
<MarkdownContent content={(element.schema as JSONSchema7).title ?? ''} />
</Cell>
);
}
function renderDescriptionCell(element: SchemaRenderElement) {
return (
<Cell>
<MarkdownContent
content={(element.schema as JSONSchema7).description ?? ''}
/>
</Cell>
);
}
function renderTypeCell(
element: SchemaRenderElement,
context: SchemaRenderContext,
) {
if (typeof element.schema === 'boolean') {
return <CellText title={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 (
<Cell>
<Flex gap="1" align="center">
{types?.map((type, index) =>
(info.canSubschema || info.hasEnum) && index === 0 ? (
<Button
key={type}
data-testid={`expand_${id}`}
variant="tertiary"
size="small"
onPress={() =>
setIsExpanded(prevState => ({
...prevState,
[id]: !prevState[id],
}))
}
>
{isExpanded[id] ? '▴' : '▾'} {type}
</Button>
) : (
<Text
key={type}
as="span"
variant="body-small"
style={{ fontFamily: 'monospace' }}
>
{type}
</Text>
),
)}
</Flex>
</Cell>
);
}
@@ -13,16 +13,13 @@
* 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';
@@ -55,9 +55,7 @@ const FilterDetailContent = ({
}
const schema = filter.schema;
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Fragment key={`${name}.detail`}>
@@ -97,7 +95,6 @@ const FilterDetailContent = ({
context={{
parentId: `${name}.arg${i}`,
...partialSchemaRenderContext,
headings: [<Typography variant="h6" component="h5" />],
}}
schema={argSchema}
/>
@@ -58,9 +58,7 @@ const FunctionDetailContent = ({
}
const schema = fn.schema;
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Fragment key={`${name}.detail`}>
@@ -88,7 +86,6 @@ const FunctionDetailContent = ({
context={{
parentId: `${name}.arg${i}`,
...partialSchemaRenderContext,
headings: [<Typography variant="h6" component="h5" />],
}}
schema={argSchema}
/>