From 22c00fd01127ea9f0a8a6199af647bbc2967dca4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 18:47:57 +0100 Subject: [PATCH] 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 Made-with: Cursor --- .../ActionsPage/ActionsPage.test.tsx | 72 +++--- .../RenderSchema/RenderSchema.test.tsx | 47 +++- .../components/RenderSchema/RenderSchema.tsx | 230 +++++++++--------- 3 files changed, 176 insertions(+), 173 deletions(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index cc789d8ea9..462c162970 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -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 () => { diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx index a5d7915482..0e13858d8c 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx @@ -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( , ); - 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( , ); + + 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( , ); + 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(); }); }); diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 4d5511876a..86da40d5e6 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -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 ( - {columns && elements && ( - <> - - - {columns.map((col, index) => ( - - {col.title} - - ))} - - - {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 ( + + + + {columns.map((col, index) => ( + + {col.title} + + ))} + + {col => col.render(el, context)} - ); - })} - - - {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 ( -
- {info.canSubschema && ( - - )} - {info.hasEnum && ( - <> - - Valid values: - - + + {isOpen && ( +
+ {info.canSubschema && ( + - - )} -
- ); - })} - - )} - {(Object.keys(subschemas) as Array).map(sk => ( - - - {sk} - - {subschemas[sk]!.map((sub, index) => ( - + + Valid values: + + + + )} +
+ )} +
+ ); + })} + {(Object.keys(subschemas) as Array).map(sk => { + const subId = `${context.parentId}_${sk}`; + const isSubOpen = isExpanded[subId]; + return ( + + + {isSubOpen && + subschemas[sk]!.map((sub, index) => ( + + ))} + + ); + })}
); } @@ -437,14 +437,6 @@ function renderNameCell( return ; } -function renderTitleCell(element: SchemaRenderElement) { - return ( - - - - ); -} - function renderDescriptionCell(element: SchemaRenderElement) { return (