scaffolder: render each property as its own table and make subschemas collapsible

Restructure schema rendering so each property row is wrapped in its own
table, enabling inline expansion of nested schemas. Remove the title
column since it duplicated the name. Make subschema sections (oneOf etc.)
collapsible behind toggle buttons instead of rendering them expanded.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-28 18:47:57 +01:00
parent 80d4b26a29
commit 22c00fd011
3 changed files with 176 additions and 173 deletions
@@ -90,12 +90,9 @@ describe('ActionsPage', () => {
await screen.findByRole('row', { name: /test/ }),
).toBeInTheDocument();
expect(screen.queryByText('Test title')).not.toBeInTheDocument();
await selectAction('test');
expect(await screen.findByText('Test title')).toBeInTheDocument();
expect(screen.getByText('foobar *')).toBeInTheDocument();
expect(await screen.findByText('foobar *')).toBeInTheDocument();
});
it('renders action with input and output on row click', async () => {
@@ -139,9 +136,8 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test title')).toBeInTheDocument();
expect(screen.getByText('foobar *')).toBeInTheDocument();
expect(screen.getByText('Test output')).toBeInTheDocument();
expect(await screen.findByText('foobar *')).toBeInTheDocument();
expect(screen.getByText('buzz')).toBeInTheDocument();
});
it('renders action with oneOf output on row click', async () => {
@@ -198,10 +194,10 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('oneOf')).toBeInTheDocument();
expect(screen.getByText('Test title')).toBeInTheDocument();
expect(screen.getByText('Test output1')).toBeInTheDocument();
expect(screen.getByText('Test output2')).toBeInTheDocument();
expect(
await screen.findByRole('button', { name: /oneOf/ }),
).toBeInTheDocument();
expect(screen.getByText('foobar *')).toBeInTheDocument();
});
it('renders action with multiple input types on row click', async () => {
@@ -297,10 +293,16 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('oneOf')).toBeInTheDocument();
expect(screen.getByText('Foo title')).toBeInTheDocument();
const oneOfButton = await screen.findByRole('button', { name: /oneOf/ });
expect(oneOfButton).toBeInTheDocument();
expect(screen.queryByText('foo *')).not.toBeInTheDocument();
await userEvent.click(oneOfButton);
expect(await screen.findByText('foo *')).toBeInTheDocument();
expect(screen.getByText('Foo description')).toBeInTheDocument();
expect(screen.getByText('Bar title')).toBeInTheDocument();
expect(screen.getByText('bar *')).toBeInTheDocument();
expect(screen.getByText('Bar description')).toBeInTheDocument();
});
@@ -346,20 +348,17 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test object')).toBeInTheDocument();
const objectButton = screen.getByRole('button', { name: /object/ });
const objectButton = await screen.findByRole('button', { name: /object/ });
expect(objectButton).toBeInTheDocument();
expect(screen.queryByText('nested prop a')).not.toBeInTheDocument();
expect(screen.queryByText(/^string$/)).not.toBeInTheDocument();
expect(screen.queryByText('nested prop b')).not.toBeInTheDocument();
expect(screen.queryByText(/^number$/)).not.toBeInTheDocument();
await userEvent.click(objectButton);
expect(screen.queryByText('nested prop a')).toBeInTheDocument();
expect(screen.queryByText('a')).toBeInTheDocument();
expect(screen.queryByText(/^string$/)).toBeInTheDocument();
expect(screen.queryByText('nested prop b')).toBeInTheDocument();
expect(screen.queryByText('b')).toBeInTheDocument();
expect(screen.queryByText(/^number$/)).toBeInTheDocument();
});
@@ -411,27 +410,20 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test object')).toBeInTheDocument();
const objectButton = screen.getByRole('button', { name: /object/ });
const objectButton = await screen.findByRole('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(objectButton);
expect(screen.queryByText('nested object a')).toBeInTheDocument();
expect(screen.queryByText('nested prop b')).toBeInTheDocument();
expect(screen.queryByText('nested object c')).not.toBeInTheDocument();
expect(screen.queryByText('a')).toBeInTheDocument();
expect(screen.queryByText('b')).toBeInTheDocument();
expect(screen.queryByText('c')).not.toBeInTheDocument();
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();
expect(screen.queryByText('nested object c')).toBeInTheDocument();
expect(screen.queryByText('c')).toBeInTheDocument();
});
it('renders action with object input type and no properties', async () => {
@@ -466,8 +458,7 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test object')).toBeInTheDocument();
const objectButton = screen.getByRole('button', { name: /object/ });
const objectButton = await screen.findByRole('button', { name: /object/ });
expect(objectButton).toBeInTheDocument();
expect(screen.queryByText('No schema defined')).not.toBeInTheDocument();
@@ -511,8 +502,7 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test array')).toBeInTheDocument();
expect(screen.getByText('array(string)')).toBeInTheDocument();
expect(await screen.findByText('array(string)')).toBeInTheDocument();
});
it('renders action with array(object) input type', async () => {
@@ -566,19 +556,17 @@ describe('ActionsPage', () => {
await selectAction('test');
expect(await screen.findByText('Test array')).toBeInTheDocument();
const arrayButton = screen.getByRole('button', {
const arrayButton = await screen.findByRole('button', {
name: /array\(object\)/,
});
expect(arrayButton).toBeInTheDocument();
expect(screen.queryByText('nested object a')).not.toBeInTheDocument();
expect(screen.queryByText('nested prop b')).not.toBeInTheDocument();
expect(screen.queryByText('b')).not.toBeInTheDocument();
await userEvent.click(arrayButton);
expect(screen.queryByText('nested object a')).toBeInTheDocument();
expect(screen.queryByText('nested prop b')).toBeInTheDocument();
expect(screen.queryByText('a')).toBeInTheDocument();
expect(screen.queryByText('b')).toBeInTheDocument();
});
it('renders action with array input type and no items', async () => {
@@ -26,7 +26,6 @@ import {
JSONSchema7Definition,
JSONSchema7Type,
} from 'json-schema';
import { capitalize } from 'lodash';
import { useState } from 'react';
import { Expanded, SchemaRenderStrategy } from '.';
import { RenderEnum, RenderSchema } from './RenderSchema';
@@ -136,8 +135,6 @@ describe('JSON schema UI rendering', () => {
id: string,
) => {
const { getByTestId, queryByTestId } = q;
const t = getByTestId(`properties_${id}`);
expect(t).toBeInTheDocument();
for (const p of Object.keys(basic.properties!)) {
const tr = getByTestId(`properties-row_${id}.${p}`);
@@ -152,7 +149,6 @@ describe('JSON schema UI rendering', () => {
expect(cellTexts).toEqual(
expect.arrayContaining([
expect.stringContaining(p),
expect.stringContaining(capitalize(p)),
expect.stringContaining(`the ${pt}`),
expect.stringContaining(String(pt)),
]),
@@ -208,8 +204,6 @@ describe('JSON schema UI rendering', () => {
id: string,
) => {
const { findByTestId, getByTestId, queryByTestId } = q;
const t = getByTestId(`properties_${id}`);
expect(t).toBeInTheDocument();
const xp: (k: string) => Promise<{
expander: HTMLElement;
@@ -396,13 +390,22 @@ describe('JSON schema UI rendering', () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
const { getByTestId } = rendered;
const { findByTestId, getByTestId, queryByTestId } = rendered;
for (const [i, k] of Object.keys(schema.properties!).entries()) {
for (const k of Object.keys(schema.properties!)) {
const tr = getByTestId(`properties-row_test.${k}`);
expect(tr).toBeInTheDocument();
}
const sub = getByTestId(`properties-row_test_oneOf${i}.${k}`);
expect(
queryByTestId('properties-row_test_oneOf0.foo'),
).not.toBeInTheDocument();
const expandOneOf = getByTestId('expand_test_oneOf');
await userEvent.click(expandOneOf);
for (const [i, k] of Object.keys(schema.properties!).entries()) {
const sub = await findByTestId(`properties-row_test_oneOf${i}.${k}`);
expect(sub).toBeInTheDocument();
const nameCell =
@@ -414,6 +417,14 @@ describe('JSON schema UI rendering', () => {
getByTestId(`properties-row_test_oneOf${i}.${k}Flag`),
).toBeInTheDocument();
}
await userEvent.click(expandOneOf);
await waitFor(() => {
expect(
queryByTestId('properties-row_test_oneOf0.foo'),
).not.toBeInTheDocument();
});
});
it('full oneOf', async () => {
const schema: JSONSchema7 = {
@@ -450,11 +461,15 @@ describe('JSON schema UI rendering', () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
const expandOneOf = rendered.getByTestId('expand_test_oneOf');
await userEvent.click(expandOneOf);
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_oneOf${i}.${k}`),
await rendered.findByTestId(`properties-row_test_oneOf${i}.${k}`),
).toBeInTheDocument();
}
}
@@ -477,11 +492,19 @@ describe('JSON schema UI rendering', () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
expect(
rendered.getByTestId(`root-row_test.bs_anyOf0`),
rendered.queryByTestId('root-row_test.bs_anyOf0'),
).not.toBeInTheDocument();
const expandAnyOf = rendered.getByTestId('expand_test.bs_anyOf');
await userEvent.click(expandAnyOf);
expect(
await rendered.findByTestId('root-row_test.bs_anyOf0'),
).toBeInTheDocument();
expect(
rendered.getByTestId(`root-row_test.bs_anyOf1`),
rendered.getByTestId('root-row_test.bs_anyOf1'),
).toBeInTheDocument();
});
});
@@ -270,13 +270,6 @@ export const RenderSchema = ({
width: '1fr',
});
}
if (schema.title) {
columns.unshift({
key: 'title',
title: t('renderSchema.tableCell.title'),
render: renderTitleCell,
});
}
}
} else if (schema.properties) {
columns = [
@@ -285,11 +278,6 @@ export const RenderSchema = ({
title: t('renderSchema.tableCell.name'),
render: renderNameCell,
},
{
key: 'title',
title: t('renderSchema.tableCell.title'),
render: renderTitleCell,
},
{
key: 'description',
title: t('renderSchema.tableCell.description'),
@@ -310,117 +298,129 @@ export const RenderSchema = ({
} else if (!Object.keys(subschemas).length) {
return undefined;
}
const [isExpanded] = context.expanded;
const [isExpanded, setIsExpanded] = context.expanded;
return (
<Flex direction="column" gap="2">
{columns && elements && (
<>
<TableRoot
data-testid={`${strategy}_${context.parentId}`}
aria-label={`${strategy} schema`}
>
<TableHeader>
{columns.map((col, index) => (
<Column
key={col.key}
id={col.key}
isRowHeader={index === 0}
defaultWidth={col.width ?? undefined}
>
{col.title}
</Column>
))}
</TableHeader>
<TableBody>
{elements.map(el => {
const id = generateId(el, context);
return (
{columns &&
elements &&
elements.map(el => {
const id = generateId(el, context);
const info = inspectSchema(el.schema);
const hasDetails =
typeof el.schema !== 'boolean' &&
(info.canSubschema || info.hasEnum);
const s =
typeof el.schema !== 'boolean'
? (el.schema as JSONSchema7)
: undefined;
const isOpen =
hasDetails && s && (!getTypes(s) || isExpanded[id]);
return (
<Flex key={id} direction="column" gap="2">
<TableRoot
data-testid={`${strategy}_${id}`}
aria-label={`${strategy} schema for ${
el.key ?? context.parentId
}`}
>
<TableHeader>
{columns.map((col, index) => (
<Column
key={col.key}
id={col.key}
isRowHeader={index === 0}
defaultWidth={col.width ?? undefined}
>
{col.title}
</Column>
))}
</TableHeader>
<TableBody>
<Row
key={id}
id={`${strategy}-row_${id}`}
data-testid={`${strategy}-row_${id}`}
columns={columns}
>
{col => col.render(el, context)}
</Row>
);
})}
</TableBody>
</TableRoot>
{elements.map(el => {
const id = generateId(el, context);
const info = inspectSchema(el.schema);
const hasDetails =
typeof el.schema !== 'boolean' &&
(info.canSubschema || info.hasEnum);
if (!hasDetails || typeof el.schema === 'boolean') {
return null;
}
const s = el.schema as JSONSchema7;
const isOpen = !getTypes(s) || isExpanded[id];
if (!isOpen) {
return null;
}
return (
<div
key={id}
data-testid={`expansion_${id}`}
style={{ paddingLeft: 16 }}
>
{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)!}
</TableBody>
</TableRoot>
{isOpen && (
<div
data-testid={`expansion_${id}`}
style={{ paddingLeft: 16 }}
>
{info.canSubschema && (
<RenderSchema
strategy="properties"
context={{
...context,
parentId: id,
parent: context,
}}
schema={
s!.type === 'array'
? (s!.items as JSONSchema7 | undefined)
: s
}
/>
</>
)}
</div>
);
})}
</>
)}
{(Object.keys(subschemas) as Array<keyof subSchemasType>).map(sk => (
<Flex key={sk} direction="column" gap="2">
<Text as="h4" variant="title-small" weight="bold">
{sk}
</Text>
{subschemas[sk]!.map((sub, index) => (
<RenderSchema
key={index}
strategy={
typeof sub !== 'boolean' && 'properties' in sub
? strategy
: 'root'
)}
{info.hasEnum && (
<>
<Text as="h4" variant="title-small" weight="bold">
Valid values:
</Text>
<RenderEnum
data-testid={`enum_${id}`}
e={enumFrom(s!)!}
/>
</>
)}
</div>
)}
</Flex>
);
})}
{(Object.keys(subschemas) as Array<keyof subSchemasType>).map(sk => {
const subId = `${context.parentId}_${sk}`;
const isSubOpen = isExpanded[subId];
return (
<Flex key={sk} direction="column" gap="2">
<Button
data-testid={`expand_${subId}`}
variant="tertiary"
size="small"
onPress={() =>
setIsExpanded(prevState => ({
...prevState,
[subId]: !prevState[subId],
}))
}
context={{
...context,
parentId: `${context.parentId}_${sk}${index}`,
}}
schema={sub}
/>
))}
</Flex>
))}
style={{ alignSelf: 'flex-start' }}
>
{isSubOpen ? '▴' : '▾'} {sk}
</Button>
{isSubOpen &&
subschemas[sk]!.map((sub, index) => (
<RenderSchema
key={index}
strategy={
typeof sub !== 'boolean' && 'properties' in sub
? strategy
: 'root'
}
context={{
...context,
parentId: `${context.parentId}_${sk}${index}`,
}}
schema={sub}
/>
))}
</Flex>
);
})}
</Flex>
);
}
@@ -437,14 +437,6 @@ function renderNameCell(
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>