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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user