From 5d8112e8f19df61c4d8cd25640a36ec3db3390d6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 14:18:30 +0100 Subject: [PATCH 01/21] scaffolder: migrate actions page to accordion layout Replace the table-based actions page with an accordion layout using @backstage/ui components. Each action is listed as an expandable accordion, letting users browse the full list while expanding individual actions to see their schema details inline. This avoids the need to scroll between a table and a detail section. The search field is kept for filtering actions by name or description. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-actions-page-table.md | 5 + .../ActionsPage/ActionsPage.test.tsx | 218 +++++++++------- .../components/ActionsPage/ActionsPage.tsx | 246 +++++++++--------- 3 files changed, 258 insertions(+), 211 deletions(-) create mode 100644 .changeset/scaffolder-actions-page-table.md diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md new file mode 100644 index 0000000000..0b392401d3 --- /dev/null +++ b/.changeset/scaffolder-actions-page-table.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Migrated the actions page to use `@backstage/ui` accordion and search components, replacing the previous layout where all actions were rendered at once. Actions are now listed as expandable accordions with built-in search filtering. diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 201073e8a9..189e4f6bf3 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -22,6 +22,7 @@ import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { ApiProvider } from '@backstage/core-app-api'; import { rootRouteRef } from '../../routes'; import { userEvent } from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import { permissionApiRef } from '@backstage/plugin-permission-react'; const scaffolderApiMock: jest.Mocked = { @@ -45,10 +46,17 @@ const apis = TestApiRegistry.from( [permissionApiRef, mockPermissionApi], ); -describe('TemplatePage', () => { +async function expandAction(actionId: string) { + const button = await screen.findByRole('button', { + name: new RegExp(actionId), + }); + await userEvent.click(button); +} + +describe('ActionsPage', () => { beforeEach(() => jest.resetAllMocks()); - it('renders action with input', async () => { + it('renders actions as accordions and shows detail on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -67,7 +75,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -77,13 +85,27 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('Test title')).toBeInTheDocument(); - expect(rendered.getByText('example description')).toBeInTheDocument(); - expect(rendered.getByText('foobar')).toBeInTheDocument(); - expect(rendered.queryByText('output')).not.toBeInTheDocument(); + + expect( + await screen.findByRole('button', { name: /test/ }), + ).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: /test/ })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + + await expandAction('test'); + + expect(screen.getByRole('button', { name: /test/ })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + expect(screen.getByText('Test title')).toBeVisible(); + expect(screen.getByText('foobar')).toBeVisible(); }); - it('renders action with input and output', async () => { + it('renders action with input and output on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -111,7 +133,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -121,13 +143,15 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('Test title')).toBeInTheDocument(); - expect(rendered.getByText('example description')).toBeInTheDocument(); - expect(rendered.getByText('foobar')).toBeInTheDocument(); - expect(rendered.getByText('Test output')).toBeInTheDocument(); + + await expandAction('test'); + + expect(await screen.findByText('Test title')).toBeInTheDocument(); + expect(screen.getByText('foobar')).toBeInTheDocument(); + expect(screen.getByText('Test output')).toBeInTheDocument(); }); - it('renders action with oneOf output', async () => { + it('renders action with oneOf output on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -168,7 +192,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -178,13 +202,16 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('oneOf')).toBeInTheDocument(); - expect(rendered.getByText('Test title')).toBeInTheDocument(); - expect(rendered.getByText('Test output1')).toBeInTheDocument(); - expect(rendered.getByText('Test output2')).toBeInTheDocument(); + + await expandAction('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(); }); - it('renders action with multiple input types', async () => { + it('renders action with multiple input types on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -212,7 +239,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -222,11 +249,14 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('array')).toBeInTheDocument(); - expect(rendered.getByText('number')).toBeInTheDocument(); + + await expandAction('test'); + + expect(await screen.findByText('array')).toBeInTheDocument(); + expect(screen.getByText('number')).toBeInTheDocument(); }); - it('renders action with oneOf input', async () => { + it('renders action with oneOf input on expand', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -261,7 +291,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -271,14 +301,17 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('oneOf')).toBeInTheDocument(); - expect(rendered.getByText('Foo title')).toBeInTheDocument(); - expect(rendered.getByText('Foo description')).toBeInTheDocument(); - expect(rendered.getByText('Bar title')).toBeInTheDocument(); - expect(rendered.getByText('Bar description')).toBeInTheDocument(); + + await expandAction('test'); + + expect(await screen.findByText('oneOf')).toBeInTheDocument(); + expect(screen.getByText('Foo title')).toBeInTheDocument(); + expect(screen.getByText('Foo description')).toBeInTheDocument(); + expect(screen.getByText('Bar title')).toBeInTheDocument(); + expect(screen.getByText('Bar description')).toBeInTheDocument(); }); - it('renders action with object input type', async () => { + it('renders action with expandable object input type', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -307,7 +340,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -318,21 +351,23 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); + await expandAction('test'); + + expect(await screen.findByText('Test object')).toBeInTheDocument(); + const objectChip = screen.getByText('object'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('nested prop a')).not.toBeInTheDocument(); - expect(rendered.queryByText('string')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); - expect(rendered.queryByText('number')).not.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(objectChip); - expect(rendered.queryByText('nested prop a')).toBeInTheDocument(); - expect(rendered.queryByText('string')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('number')).toBeInTheDocument(); + expect(screen.queryByText('nested prop a')).toBeInTheDocument(); + expect(screen.queryByText('string')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('number')).toBeInTheDocument(); }); it('renders action with nested object input type', async () => { @@ -370,7 +405,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -381,27 +416,29 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); + await expandAction('test'); + + expect(await screen.findByText('Test object')).toBeInTheDocument(); + const objectChip = screen.getByText('object'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('nested object a')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).not.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); - expect(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).not.toBeInTheDocument(); + expect(screen.queryByText('nested object a')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('nested object c')).not.toBeInTheDocument(); - const allObjectChips = rendered.getAllByText('object'); + const allObjectChips = screen.getAllByText('object'); expect(allObjectChips.length).toBe(2); await userEvent.click(allObjectChips[1]); - expect(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).toBeInTheDocument(); + expect(screen.queryByText('nested object a')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('nested object c')).toBeInTheDocument(); }); it('renders action with object input type and no properties', async () => { @@ -423,7 +460,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -434,15 +471,17 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); + await expandAction('test'); + + expect(await screen.findByText('Test object')).toBeInTheDocument(); + const objectChip = screen.getByText('object'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('No schema defined')).not.toBeInTheDocument(); + expect(screen.queryByText('No schema defined')).not.toBeInTheDocument(); await userEvent.click(objectChip); - expect(rendered.queryByText('No schema defined')).toBeInTheDocument(); + expect(screen.queryByText('No schema defined')).toBeInTheDocument(); }); it('renders action with array(string) input type', async () => { @@ -466,7 +505,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -477,8 +516,10 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test array')).toBeInTheDocument(); - expect(rendered.getByText('array(string)')).toBeInTheDocument(); + await expandAction('test'); + + expect(await screen.findByText('Test array')).toBeInTheDocument(); + expect(screen.getByText('array(string)')).toBeInTheDocument(); }); it('renders action with array(object) input type', async () => { @@ -519,7 +560,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -530,17 +571,19 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test array')).toBeInTheDocument(); - const objectChip = rendered.getByText('array(object)'); + await expandAction('test'); + + expect(await screen.findByText('Test array')).toBeInTheDocument(); + const objectChip = screen.getByText('array(object)'); expect(objectChip).toBeInTheDocument(); - expect(rendered.queryByText('nested object a')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); + expect(screen.queryByText('nested object a')).not.toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).not.toBeInTheDocument(); await userEvent.click(objectChip); - expect(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); + expect(screen.queryByText('nested object a')).toBeInTheDocument(); + expect(screen.queryByText('nested prop b')).toBeInTheDocument(); }); it('renders action with array input type and no items', async () => { @@ -560,7 +603,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -571,13 +614,15 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('array(unknown)')).toBeInTheDocument(); + await expandAction('test'); + + expect(await screen.findByText('array(unknown)')).toBeInTheDocument(); }); - it('should filter an action', async () => { + it('should filter actions via the search field', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { - id: 'githut:repo:create', + id: 'github:repo:create', description: 'Create a new Github repository', schema: { input: { @@ -593,7 +638,7 @@ describe('TemplatePage', () => { }, }, { - id: 'githut:repo:push', + id: 'github:repo:push', description: 'Push to a Github repository', schema: { input: { @@ -610,7 +655,7 @@ describe('TemplatePage', () => { }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -622,32 +667,31 @@ describe('TemplatePage', () => { ); expect( - rendered.getByRole('heading', { name: 'githut:repo:create' }), + await screen.findByRole('button', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.getByRole('heading', { name: 'githut:repo:push' }), + screen.getByRole('button', { name: /github:repo:push/ }), ).toBeInTheDocument(); - // should filter actions when searching await userEvent.type( - rendered.getByPlaceholderText('Search for an action'), + screen.getByPlaceholderText('Search for an action'), 'create', ); - await userEvent.keyboard('[ArrowDown][Enter]'); + expect( - rendered.getByRole('heading', { name: 'githut:repo:create' }), + await screen.findByRole('button', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.queryByRole('heading', { name: 'githut:repo:push' }), + screen.queryByRole('button', { name: /github:repo:push/ }), ).not.toBeInTheDocument(); - // should show all actions when clearing the search - await userEvent.click(rendered.getByTitle('Clear')); + await userEvent.click(screen.getByLabelText('Clear search')); + expect( - rendered.getByRole('heading', { name: 'githut:repo:create' }), + await screen.findByRole('button', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.getByRole('heading', { name: 'githut:repo:push' }), + screen.getByRole('button', { name: /github:repo:push/ }), ).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 6be502659a..bfa09e0010 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -13,21 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useMemo, useState } from 'react'; import useAsync from 'react-use/esm/useAsync'; 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 Typography from '@material-ui/core/Typography'; import { makeStyles } from '@material-ui/core/styles'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import LinkIcon from '@material-ui/icons/Link'; -import Autocomplete from '@material-ui/lab/Autocomplete'; -import TextField from '@material-ui/core/TextField'; -import InputAdornment from '@material-ui/core/InputAdornment'; -import SearchIcon from '@material-ui/icons/Search'; import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { @@ -40,6 +32,13 @@ import { Page, Progress, } from '@backstage/core-components'; +import { + Accordion, + AccordionGroup, + AccordionPanel, + AccordionTrigger, + SearchField, +} from '@backstage/ui'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; import { @@ -82,27 +81,106 @@ const useStyles = makeStyles(theme => ({ }, })); +function ActionDetail({ action }: { action: Action }) { + const classes = useStyles(); + const { t } = useTranslationRef(scaffolderTranslationRef); + const expanded = useState({}); + + const partialSchemaRenderContext: Omit = { + classes, + expanded, + headings: [], + }; + + return ( + + + + {action.id} + + + + + + {action.description && } + {action.schema?.input && ( + + + {t('actionsPage.action.input')} + + + + )} + {action.schema?.output && ( + + + {t('actionsPage.action.output')} + + + + )} + {action.examples && ( + + + {t('actionsPage.action.examples')} + + + + )} + + ); +} + export const ActionPageContent = () => { const api = useApi(scaffolderApiRef); const { t } = useTranslationRef(scaffolderTranslationRef); - const classes = useStyles(); const { loading, - value = [], + value: actions = [], error, } = useAsync(async () => { return api.listActions(); }, [api]); - const [selectedAction, setSelectedAction] = useState(null); - const expanded = useState({}); + const [search, setSearch] = useState(''); - useEffect(() => { - if (value.length && window.location.hash) { - document.querySelector(window.location.hash)?.scrollIntoView(); + const filteredActions = useMemo(() => { + const nonLegacy = actions.filter( + action => !action.id.startsWith('legacy:'), + ); + if (!search) { + return nonLegacy; } - }, [value]); + const lowerQuery = search.toLowerCase(); + return nonLegacy.filter( + action => + action.id.toLowerCase().includes(lowerQuery) || + action.description?.toLowerCase().includes(lowerQuery), + ); + }, [actions, search]); if (loading) { return ; @@ -123,114 +201,34 @@ export const ActionPageContent = () => { return ( <> - - option.id} - renderInput={params => ( - - - - ), - }} - /> - )} - onChange={(_event, option) => { - setSelectedAction(option); - }} - fullWidth + + {filteredActions.length === 0 ? ( + - - {(selectedAction ? [selectedAction] : value).map(action => { - if (action.id.startsWith('legacy:')) { - return undefined; - } - const partialSchemaRenderContext: Omit< - SchemaRenderContext, - 'parentId' - > = { - classes, - expanded, - headings: [], - }; - return ( - - - - {action.id} - - - - - - {action.description && ( - - )} - {action.schema?.input && ( - - - {t('actionsPage.action.input')} - - - - )} - {action.schema?.output && ( - - - {t('actionsPage.action.output')} - - - - )} - {action.examples && ( - - }> - - {t('actionsPage.action.examples')} - - - - - - - - - )} - - ); - })} + ) : ( + + {filteredActions.map(action => ( + + + + + + + ))} + + )} ); }; From 31c460da83b7702cb43acca5240156c4f888f6a8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 14:50:00 +0100 Subject: [PATCH 02/21] scaffolder: use table layout for actions page Replace the accordion-based actions list with a Table component from @backstage/ui, providing aligned columns for action name and description with click-to-expand detail view. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-actions-page-table.md | 2 +- .../ActionsPage/ActionsPage.test.tsx | 61 ++++----- .../components/ActionsPage/ActionsPage.tsx | 122 +++++++++++------- 3 files changed, 104 insertions(+), 81 deletions(-) diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md index 0b392401d3..a53b7fa8ce 100644 --- a/.changeset/scaffolder-actions-page-table.md +++ b/.changeset/scaffolder-actions-page-table.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -Migrated the actions page to use `@backstage/ui` accordion and search components, replacing the previous layout where all actions were rendered at once. Actions are now listed as expandable accordions with built-in search filtering. +Migrated the actions page to use `@backstage/ui` table and search components. Actions are now listed in a table with aligned name and description columns, with click-to-expand detail view and built-in search filtering. diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 189e4f6bf3..9f91cd6e1c 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -46,17 +46,17 @@ const apis = TestApiRegistry.from( [permissionApiRef, mockPermissionApi], ); -async function expandAction(actionId: string) { - const button = await screen.findByRole('button', { +async function selectAction(actionId: string) { + const row = await screen.findByRole('row', { name: new RegExp(actionId), }); - await userEvent.click(button); + await userEvent.click(row); } describe('ActionsPage', () => { beforeEach(() => jest.resetAllMocks()); - it('renders actions as accordions and shows detail on expand', async () => { + it('renders actions in a table and shows detail on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -87,25 +87,18 @@ describe('ActionsPage', () => { ); expect( - await screen.findByRole('button', { name: /test/ }), + await screen.findByRole('row', { name: /test/ }), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /test/ })).toHaveAttribute( - 'aria-expanded', - 'false', - ); + expect(screen.queryByText('Test title')).not.toBeInTheDocument(); - await expandAction('test'); + await selectAction('test'); - expect(screen.getByRole('button', { name: /test/ })).toHaveAttribute( - 'aria-expanded', - 'true', - ); expect(screen.getByText('Test title')).toBeVisible(); expect(screen.getByText('foobar')).toBeVisible(); }); - it('renders action with input and output on expand', async () => { + it('renders action with input and output on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -144,14 +137,14 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test title')).toBeInTheDocument(); expect(screen.getByText('foobar')).toBeInTheDocument(); expect(screen.getByText('Test output')).toBeInTheDocument(); }); - it('renders action with oneOf output on expand', async () => { + it('renders action with oneOf output on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -203,7 +196,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('oneOf')).toBeInTheDocument(); expect(screen.getByText('Test title')).toBeInTheDocument(); @@ -211,7 +204,7 @@ describe('ActionsPage', () => { expect(screen.getByText('Test output2')).toBeInTheDocument(); }); - it('renders action with multiple input types on expand', async () => { + it('renders action with multiple input types on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -250,13 +243,13 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('array')).toBeInTheDocument(); expect(screen.getByText('number')).toBeInTheDocument(); }); - it('renders action with oneOf input on expand', async () => { + it('renders action with oneOf input on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -302,7 +295,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('oneOf')).toBeInTheDocument(); expect(screen.getByText('Foo title')).toBeInTheDocument(); @@ -351,7 +344,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test object')).toBeInTheDocument(); const objectChip = screen.getByText('object'); @@ -416,7 +409,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test object')).toBeInTheDocument(); const objectChip = screen.getByText('object'); @@ -471,7 +464,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test object')).toBeInTheDocument(); const objectChip = screen.getByText('object'); @@ -516,7 +509,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test array')).toBeInTheDocument(); expect(screen.getByText('array(string)')).toBeInTheDocument(); @@ -571,7 +564,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('Test array')).toBeInTheDocument(); const objectChip = screen.getByText('array(object)'); @@ -614,7 +607,7 @@ describe('ActionsPage', () => { }, ); - await expandAction('test'); + await selectAction('test'); expect(await screen.findByText('array(unknown)')).toBeInTheDocument(); }); @@ -667,10 +660,10 @@ describe('ActionsPage', () => { ); expect( - await screen.findByRole('button', { name: /github:repo:create/ }), + await screen.findByRole('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: /github:repo:push/ }), + screen.getByRole('row', { name: /github:repo:push/ }), ).toBeInTheDocument(); await userEvent.type( @@ -679,19 +672,19 @@ describe('ActionsPage', () => { ); expect( - await screen.findByRole('button', { name: /github:repo:create/ }), + await screen.findByRole('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: /github:repo:push/ }), + screen.queryByRole('row', { name: /github:repo:push/ }), ).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText('Clear search')); expect( - await screen.findByRole('button', { name: /github:repo:create/ }), + await screen.findByRole('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - screen.getByRole('button', { name: /github:repo:push/ }), + screen.getByRole('row', { name: /github:repo:push/ }), ).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index bfa09e0010..22c74c2556 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -30,14 +30,14 @@ import { Link, MarkdownContent, Page, - Progress, } from '@backstage/core-components'; import { - Accordion, - AccordionGroup, - AccordionPanel, - AccordionTrigger, + CellText, SearchField, + Table, + useTable, + type ColumnConfig, + type TableItem, } from '@backstage/ui'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; @@ -81,6 +81,10 @@ const useStyles = makeStyles(theme => ({ }, })); +interface ActionTableItem extends TableItem { + action: Action; +} + function ActionDetail({ action }: { action: Action }) { const classes = useStyles(); const { t } = useTranslationRef(scaffolderTranslationRef); @@ -153,38 +157,62 @@ function ActionDetail({ action }: { action: Action }) { ); } +const columnConfig: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + defaultWidth: '1fr', + cell: item => , + }, + { + id: 'description', + label: 'Description', + defaultWidth: '2fr', + cell: item => , + }, +]; + export const ActionPageContent = () => { const api = useApi(scaffolderApiRef); const { t } = useTranslationRef(scaffolderTranslationRef); const { loading, - value: actions = [], + value: actions, error, } = useAsync(async () => { return api.listActions(); }, [api]); - const [search, setSearch] = useState(''); + const [selectedAction, setSelectedAction] = useState(); - const filteredActions = useMemo(() => { - const nonLegacy = actions.filter( - action => !action.id.startsWith('legacy:'), - ); - if (!search) { - return nonLegacy; - } - const lowerQuery = search.toLowerCase(); - return nonLegacy.filter( - action => - action.id.toLowerCase().includes(lowerQuery) || - action.description?.toLowerCase().includes(lowerQuery), - ); - }, [actions, search]); + const tableData = useMemo( + () => + actions + ?.filter(action => !action.id.startsWith('legacy:')) + .map( + (action): ActionTableItem => ({ + id: action.id, + action, + }), + ), + [actions], + ); - if (loading) { - return ; - } + const { tableProps, search } = useTable({ + mode: 'complete', + data: tableData, + paginationOptions: { type: 'none' }, + 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 ( @@ -205,29 +233,31 @@ export const ActionPageContent = () => { aria-label={t('actionsPage.content.searchFieldPlaceholder')} placeholder={t('actionsPage.content.searchFieldPlaceholder')} style={{ marginBottom: 24 }} - value={search} - onChange={setSearch} + {...search} /> - {filteredActions.length === 0 ? ( - - ) : ( - - {filteredActions.map(action => ( - - - - - - - ))} - + + } + rowConfig={{ + onClick: item => { + setSelectedAction(prev => + prev?.id === item.action.id ? undefined : item.action, + ); + }, + }} + /> + {selectedAction && ( + + + )} ); From dae4e6ef09b7527e32bdf129af11c6d1fb434804 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 16:03:46 +0100 Subject: [PATCH 03/21] scaffolder: improve actions page UX with pagination and card detail view Replace the unpaginated action list with a paginated table showing 20 items per page, giving users a clear count and navigation controls. Consolidate the two-column layout into a single column with the description shown as subtitle text in each row. Revamp the action detail view using Backstage UI Card and Accordion components for a structured, collapsible layout of input/output schemas and usage examples. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../ActionsPage/ActionsPage.test.tsx | 4 +- .../components/ActionsPage/ActionsPage.tsx | 159 +++++++++--------- 2 files changed, 82 insertions(+), 81 deletions(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 9f91cd6e1c..d11b47498d 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -94,8 +94,8 @@ describe('ActionsPage', () => { await selectAction('test'); - expect(screen.getByText('Test title')).toBeVisible(); - expect(screen.getByText('foobar')).toBeVisible(); + expect(await screen.findByText('Test title')).toBeInTheDocument(); + expect(screen.getByText('foobar')).toBeInTheDocument(); }); it('renders action with input and output on row click', async () => { diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 22c74c2556..1088a44708 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -16,10 +16,8 @@ import { useMemo, useState } from 'react'; import useAsync from 'react-use/esm/useAsync'; import { Action, scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import Box from '@material-ui/core/Box'; import Typography from '@material-ui/core/Typography'; import { makeStyles } from '@material-ui/core/styles'; -import LinkIcon from '@material-ui/icons/Link'; import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { @@ -27,14 +25,21 @@ import { EmptyState, ErrorPanel, Header, - Link, - MarkdownContent, Page, } from '@backstage/core-components'; import { + Accordion, + AccordionGroup, + AccordionPanel, + AccordionTrigger, + Card, + CardBody, + CardHeader, CellText, + Flex, SearchField, Table, + Text, useTable, type ColumnConfig, type TableItem, @@ -76,9 +81,6 @@ const useStyles = makeStyles(theme => ({ color: theme.palette.error.light, }, }, - link: { - paddingLeft: theme.spacing(1), - }, })); interface ActionTableItem extends TableItem { @@ -96,64 +98,69 @@ function ActionDetail({ action }: { action: Action }) { headings: [], }; + const hasInput = !!action.schema?.input; + const hasOutput = !!action.schema?.output; + const hasExamples = !!action.examples; + return ( - - - - {action.id} - - - - - - {action.description && } - {action.schema?.input && ( - - - {t('actionsPage.action.input')} - - - + + + + + {action.id} + + {action.description && ( + + {action.description} + + )} + + + {(hasInput || hasOutput || hasExamples) && ( + + + {hasInput && ( + + + + + + + )} + {hasOutput && ( + + + + + + + )} + {hasExamples && ( + + + + + + + )} + + )} - {action.schema?.output && ( - - - {t('actionsPage.action.output')} - - - - )} - {action.examples && ( - - - {t('actionsPage.action.examples')} - - - - )} - + ); } @@ -163,13 +170,12 @@ const columnConfig: ColumnConfig[] = [ label: 'Name', isRowHeader: true, defaultWidth: '1fr', - cell: item => , - }, - { - id: 'description', - label: 'Description', - defaultWidth: '2fr', - cell: item => , + cell: item => ( + + ), }, ]; @@ -203,7 +209,7 @@ export const ActionPageContent = () => { const { tableProps, search } = useTable({ mode: 'complete', data: tableData, - paginationOptions: { type: 'none' }, + paginationOptions: { pageSize: 20 }, searchFn: (items, query) => { const lowerQuery = query.toLowerCase(); return items.filter( @@ -228,11 +234,10 @@ export const ActionPageContent = () => { } return ( - <> +
{ }, }} /> - {selectedAction && ( - - - - )} - + {selectedAction && } + ); }; From 52c18c1f9a414fc00da519cd87938108386c373a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 16:43:22 +0100 Subject: [PATCH 04/21] 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 Made-with: Cursor --- .../ActionsPage/ActionsPage.test.tsx | 44 +- .../components/ActionsPage/ActionsPage.tsx | 289 ++++----- .../RenderSchema/RenderSchema.test.tsx | 122 +--- .../components/RenderSchema/RenderSchema.tsx | 558 +++++++++--------- .../src/components/RenderSchema/types.ts | 3 - .../TemplateFilters.tsx | 3 - .../TemplateGlobals.tsx | 3 - 7 files changed, 455 insertions(+), 567 deletions(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index d11b47498d..b56d652a83 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -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(); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 1088a44708..98beeaa997 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -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({}); const partialSchemaRenderContext: Omit = { - classes, expanded, - headings: [], }; const hasInput = !!action.schema?.input; const hasOutput = !!action.schema?.output; const hasExamples = !!action.examples; + if (!hasInput && !hasOutput && !hasExamples) { + return null; + } + return ( - - - - - {action.id} - - {action.description && ( - - {action.description} - - )} - - - {(hasInput || hasOutput || hasExamples) && ( - - - {hasInput && ( - - - - - - - )} - {hasOutput && ( - - - - - - - )} - {hasExamples && ( - - - - - - - )} - - + + {hasInput && ( + + + + + + )} - + {hasOutput && ( + + + + + + + )} + {hasExamples && ( + + + + + + + )} + ); } -const columnConfig: ColumnConfig[] = [ - { - id: 'name', - label: 'Name', - isRowHeader: true, - defaultWidth: '1fr', - cell: item => ( - - ), - }, -]; - 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(); + 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 ( + + + + + ); + } + return ( -
- } - rowConfig={{ - onClick: item => { - setSelectedAction(prev => - prev?.id === item.action.id ? undefined : item.action, - ); - }, + { + if (selection === 'all') { + return; + } + const selected = [...selection][0] as string | undefined; + setSelectedActionId(prev => + prev === selected ? undefined : selected, + ); }} - /> - {selectedAction && } + > + {filteredActions.map(action => ( + + {action.id} + + ))} + + {selectedAction && ( + + + + {selectedAction.id} + + {selectedAction.description && ( + + {selectedAction.description} + + )} + + + + )} ); }; diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx index ec49f414f9..a5d7915482 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx @@ -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 ; + return ; }; const LocalRenderSchema = ({ @@ -73,7 +44,6 @@ const LocalRenderSchema = ({ strategy: SchemaRenderStrategy; schema?: JSONSchema7Definition; }) => { - const classes = useStyles(); const expanded = useState({}); return ( ], }, }} /> @@ -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`), diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 2d4ebd6fca..a195da7b60 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -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) => string; + title: string; render: RenderColumn; - className?: keyof ReturnType; + 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 ( -
- {element.key} -
- ); - }, -} as Column; - -const titleColumn = { - key: 'title', - title: t => t('renderSchema.tableCell.title'), - render: (element: SchemaRenderElement) => ( - - ), -} as Column; - -const descriptionColumn = { - key: 'description', - title: t => t('renderSchema.tableCell.description'), - render: (element: SchemaRenderElement) => ( - - ), - 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 {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 ( - <> - {types?.map((type, index) => - info.canSubschema || (info.hasEnum && index === 0) ? ( - : } - variant="outlined" - onClick={() => - setIsExpanded(prevState => { - return { - ...prevState, - [id]: !!!prevState[id], - }; - }) - } - /> - ) : ( - - ), - )} - - ); - }, -} 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 ( - +
    {e.map((v, i) => { - let inner: JSX.Element = ( - - {JSON.stringify(v)} - - ); - if (v !== null && ['object', 'array'].includes(typeof v)) { - inner = ( - <> - {inner} - - {JSON.stringify(v, undefined, 2)} - - } + const text = JSON.stringify(v); + const isComplex = v !== null && ['object', 'array'].includes(typeof v); + return ( +
  • + + - - - - - - ); - } - return {inner}; + {text} + + {isComplex && ( + + + + + {JSON.stringify(v, undefined, 2)} + + + + )} + +
  • + ); })} - +
); }; -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 && ( - -
- - - {columns.map((col, index) => ( - + + {columns.map(col => ( + + {col.title} + + ))} + + + {elements.map(el => { + const id = generateId(el, context); + const info = inspectSchema(el.schema); + const hasDetails = + typeof el.schema !== 'boolean' && + (info.canSubschema || info.hasEnum); + + return ( + + - {col.title(t)} - - ))} - - - - {elements.map(el => { - const id = generateId(el, context); - const info = inspectSchema(el.schema); - const rows = [ - - {columns!.map(col => ( - - {col.render(el, context)} - - ))} - , - ]; - if ( - typeof el.schema !== 'boolean' && - (info.canSubschema || info.hasEnum) - ) { - let details: ReactElement = ( - - {info.canSubschema && ( - col.render(el, context)} + + {hasDetails && + typeof el.schema !== 'boolean' && + (() => { + const s = el.schema as JSONSchema7; + const isOpen = !getTypes(s) || isExpanded[id]; + if (!isOpen) { + return null; + } + return ( + + {(col: ColumnDef) => + col.key === columns![0].key ? ( + + {info.canSubschema && ( + + )} + {info.hasEnum && ( + <> + + Valid values: + + + + )} + + ) : ( + + ) } - /> - )} - {info.hasEnum && ( - <> - {cloneElement( - context.headings[0], - {}, - 'Valid values:', - )} - - - )} - - ); - if (getTypes(el.schema)) { - details = ( - - {details} - - ); - } - rows.push( - - - {details} - - , - ); - } - return {rows}; - })} - -
- + + ); + })()} + + ); + })} + + )} {(Object.keys(subschemas) as Array).map(sk => ( - {cloneElement(context.headings[0], {}, sk)} + + {sk} + {subschemas[sk]!.map((sub, index) => ( ))} @@ -513,5 +443,77 @@ export const RenderSchema = ({ } return undefined; })(); - return result ?? No schema defined; + return result ?? No schema defined; }; + +function renderNameCell( + element: SchemaRenderElement, + _context: SchemaRenderContext, +) { + const name = element.key ?? ''; + return ; +} + +function renderTitleCell(element: SchemaRenderElement) { + return ( + + + + ); +} + +function renderDescriptionCell(element: SchemaRenderElement) { + return ( + + + + ); +} + +function renderTypeCell( + element: SchemaRenderElement, + context: SchemaRenderContext, +) { + if (typeof element.schema === 'boolean') { + return ; + } + 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 ? ( + + ) : ( + + {type} + + ), + )} + + + ); +} diff --git a/plugins/scaffolder/src/components/RenderSchema/types.ts b/plugins/scaffolder/src/components/RenderSchema/types.ts index 70f3c27dfd..f12f0765ea 100644 --- a/plugins/scaffolder/src/components/RenderSchema/types.ts +++ b/plugins/scaffolder/src/components/RenderSchema/types.ts @@ -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>]; - headings: [React.JSX.Element, ...React.JSX.Element[]]; }; export type SchemaRenderStrategy = 'root' | 'properties'; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx index 05f3043dd1..c3d425ae44 100644 --- a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx @@ -55,9 +55,7 @@ const FilterDetailContent = ({ } const schema = filter.schema; const partialSchemaRenderContext: Omit = { - classes, expanded, - headings: [], }; return ( @@ -97,7 +95,6 @@ const FilterDetailContent = ({ context={{ parentId: `${name}.arg${i}`, ...partialSchemaRenderContext, - headings: [], }} schema={argSchema} /> diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx index 249e2a581d..e274b11944 100644 --- a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx @@ -58,9 +58,7 @@ const FunctionDetailContent = ({ } const schema = fn.schema; const partialSchemaRenderContext: Omit = { - classes, expanded, - headings: [], }; return ( @@ -88,7 +86,6 @@ const FunctionDetailContent = ({ context={{ parentId: `${name}.arg${i}`, ...partialSchemaRenderContext, - headings: [], }} schema={argSchema} /> From 338f7e01f4625dce62c7783e6a0eccf94de675d8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 17:32:57 +0100 Subject: [PATCH 05/21] scaffolder: use sidebar layout for actions page detail view Replace accordion groups with plain titled sections for action input/output/examples, and switch to a sidebar layout where the action list remains visible when viewing action details. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../components/ActionsPage/ActionsPage.tsx | 196 +++++++++--------- 1 file changed, 102 insertions(+), 94 deletions(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 98beeaa997..df8ca2d760 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -25,17 +25,7 @@ import { Header, Page, } from '@backstage/core-components'; -import { - Accordion, - AccordionGroup, - AccordionPanel, - AccordionTrigger, - Flex, - List, - ListRow, - SearchField, - Text, -} from '@backstage/ui'; +import { Box, Flex, List, ListRow, SearchField, Text } from '@backstage/ui'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; import { @@ -66,46 +56,46 @@ function ActionDetail({ action }: { action: Action }) { } return ( - + {hasInput && ( - - - - - - + + + {t('actionsPage.action.input')} + + + )} {hasOutput && ( - - - - - - + + + {t('actionsPage.action.output')} + + + )} {hasExamples && ( - - - - - - + + + {t('actionsPage.action.examples')} + + + )} - + ); } @@ -176,57 +166,75 @@ export const ActionPageContent = () => { ); } - return ( - - - { - if (selection === 'all') { - return; + const listElement = ( + { + if (selection === 'all') { + return; + } + const selected = [...selection][0] as string | undefined; + setSelectedActionId(prev => (prev === selected ? undefined : selected)); + }} + > + {filteredActions.map(action => ( + - prev === selected ? undefined : selected, - ); - }} - > - {filteredActions.map(action => ( - - {action.id} - - ))} - - {selectedAction && ( - - - - {selectedAction.id} - - {selectedAction.description && ( - - {selectedAction.description} - - )} - - + > + {action.id} + + ))} + + ); + + if (!selectedAction) { + return ( + + + {listElement} + + ); + } + + return ( + + + + + {listElement} - )} + + + + + {selectedAction.id} + + {selectedAction.description && ( + + {selectedAction.description} + + )} + + + ); }; From 16a15f276e6207566f7cb7e78311ca36180149d9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 17:42:02 +0100 Subject: [PATCH 06/21] scaffolder: fix actions page list and schema rendering issues Always show descriptions in the action list regardless of selection state, use MarkdownContent for the detail description, fix the isRowHeader error by always marking the first column, and move schema expansion content outside of the table to avoid broken nesting and scroll-jump issues. Subschemas (anyOf/oneOf) now render at full width inside a Flex column layout. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../components/ActionsPage/ActionsPage.tsx | 55 ++---- .../components/RenderSchema/RenderSchema.tsx | 179 ++++++++---------- 2 files changed, 102 insertions(+), 132 deletions(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index df8ca2d760..dd7bf334a1 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -23,9 +23,10 @@ import { EmptyState, ErrorPanel, Header, + MarkdownContent, Page, } from '@backstage/core-components'; -import { Box, Flex, List, ListRow, SearchField, Text } from '@backstage/ui'; +import { Flex, List, ListRow, SearchField, Text } from '@backstage/ui'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; import { @@ -185,9 +186,7 @@ export const ActionPageContent = () => { key={action.id} id={action.id} textValue={action.id} - description={ - selectedAction ? undefined : action.description ?? undefined - } + description={action.description ?? undefined} > {action.id} @@ -195,9 +194,13 @@ export const ActionPageContent = () => { ); - if (!selectedAction) { - return ( - + return ( + + { /> {listElement} - ); - } - - return ( - - - - - {listElement} - - - - - - {selectedAction.id} - - {selectedAction.description && ( - - {selectedAction.description} + {selectedAction && ( + + + + {selectedAction.id} - )} + {selectedAction.description && ( + + )} + + - - + )} ); }; diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index a195da7b60..4d5511876a 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -34,7 +34,7 @@ import { JSONSchema7Definition, JSONSchema7Type, } from 'json-schema'; -import { FC, Fragment, JSX } from 'react'; +import { FC, JSX } from 'react'; import { scaffolderTranslationRef } from '../../translation'; import { SchemaRenderContext, SchemaRenderStrategy } from './types'; @@ -313,111 +313,94 @@ export const RenderSchema = ({ const [isExpanded] = context.expanded; return ( - <> + {columns && elements && ( - - - {columns.map(col => ( - - {col.title} - - ))} - - - {elements.map(el => { - const id = generateId(el, context); - const info = inspectSchema(el.schema); - const hasDetails = - typeof el.schema !== 'boolean' && - (info.canSubschema || info.hasEnum); - - return ( - + <> + + + {columns.map((col, index) => ( + + {col.title} + + ))} + + + {elements.map(el => { + const id = generateId(el, context); + return ( {col => col.render(el, context)} - {hasDetails && - typeof el.schema !== 'boolean' && - (() => { - const s = el.schema as JSONSchema7; - const isOpen = !getTypes(s) || isExpanded[id]; - if (!isOpen) { - return null; - } - return ( - - {(col: ColumnDef) => - col.key === columns![0].key ? ( - - {info.canSubschema && ( - - )} - {info.hasEnum && ( - <> - - Valid values: - - - - )} - - ) : ( - - ) - } - - ); - })()} - - ); - })} - - + ); + })} + + + {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: + + + + )} +
+ ); + })} + )} {(Object.keys(subschemas) as Array).map(sk => ( - + {sk} @@ -436,9 +419,9 @@ export const RenderSchema = ({ schema={sub} /> ))} - +
))} - +
); } return undefined; From 8d0304030691ab8443276566fb71e88bfd9a1b27 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 18:21:56 +0100 Subject: [PATCH 07/21] scaffolder: fix actions page layout alignment Add `alignItems="start"` to the top-level Flex container so the sidebar and detail panels align to the top rather than stretching to equal height. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index dd7bf334a1..caf14c520b 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -195,7 +195,7 @@ export const ActionPageContent = () => { ); return ( - + Date: Sat, 28 Mar 2026 18:28:41 +0100 Subject: [PATCH 08/21] scaffolder: use CSS grid layout to fix search field focus loss The search field was being rendered in two separate conditional branches (empty state vs list state), causing React to remount it when toggling between them and losing focus. Restructure to a CSS grid layout where the search field is always in a stable tree position. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../ActionsPage/ActionsPage.test.tsx | 38 +++++++ .../components/ActionsPage/ActionsPage.tsx | 105 ++++++++---------- 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index b56d652a83..cc789d8ea9 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -689,4 +689,42 @@ describe('ActionsPage', () => { screen.getByRole('row', { name: /github:repo:push/ }), ).toBeInTheDocument(); }); + + it('should keep search field focused when filtering causes empty then non-empty results', async () => { + scaffolderApiMock.listActions.mockResolvedValue([ + { + id: 'github:repo:create', + description: 'Create a new Github repository', + schema: {}, + }, + ]); + + await renderInTestApp( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + }, + ); + + const searchField = screen.getByPlaceholderText('Search for an action'); + await userEvent.click(searchField); + expect(searchField).toHaveFocus(); + + await userEvent.type(searchField, 'zzz-no-match'); + expect(searchField).toHaveFocus(); + expect( + screen.queryByRole('row', { name: /github:repo:create/ }), + ).not.toBeInTheDocument(); + + await userEvent.clear(searchField); + await userEvent.type(searchField, 'create'); + expect(searchField).toHaveFocus(); + expect( + await screen.findByRole('row', { name: /github:repo:create/ }), + ).toBeInTheDocument(); + }); }); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index caf14c520b..342d3e377d 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -149,68 +149,61 @@ export const ActionPageContent = () => { ); } - if (!loading && !filteredActions.length) { - return ( - - + return ( +
+ + {!loading && !filteredActions.length ? ( - - ); - } - - const listElement = ( - { - if (selection === 'all') { - return; - } - const selected = [...selection][0] as string | undefined; - setSelectedActionId(prev => (prev === selected ? undefined : selected)); - }} - > - {filteredActions.map(action => ( - { + if (selection === 'all') { + return; + } + const selected = [...selection][0] as string | undefined; + setSelectedActionId(prev => + prev === selected ? undefined : selected, + ); + }} > - {action.id} - - ))} - - ); - - return ( - - - - {listElement} - + {filteredActions.map(action => ( + + {action.id} + + ))} + + )} {selectedAction && ( - + {selectedAction.id} @@ -222,7 +215,7 @@ export const ActionPageContent = () => { )} - +
); }; From 80d4b26a29f93d94eff2e7e08fd3d97ee7b1138b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 18:32:25 +0100 Subject: [PATCH 09/21] scaffolder: fix detail panel grid column placement Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 342d3e377d..560ffd7136 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -202,7 +202,7 @@ export const ActionPageContent = () => { From 22c00fd01127ea9f0a8a6199af647bbc2967dca4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 18:47:57 +0100 Subject: [PATCH 10/21] 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 ( From 7936e5c917968cebff5f2e2aba209a7b300761b9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 19:08:03 +0100 Subject: [PATCH 11/21] scaffolder: use single table for schema properties and widen description column Consolidate the per-property tables into a single table with one header row, eliminating the repeated table headers for each property. Give the description column 3x the width of name/type columns so longer descriptions get more room. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../components/RenderSchema/RenderSchema.tsx | 166 +++++++++--------- 1 file changed, 85 insertions(+), 81 deletions(-) diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 86da40d5e6..5b8a84c49f 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -267,7 +267,7 @@ export const RenderSchema = ({ key: 'description', title: t('renderSchema.tableCell.description'), render: renderDescriptionCell, - width: '1fr', + width: '3fr', }); } } @@ -282,7 +282,7 @@ export const RenderSchema = ({ key: 'description', title: t('renderSchema.tableCell.description'), render: renderDescriptionCell, - width: '1fr', + width: '3fr', }, { key: 'type', @@ -302,87 +302,91 @@ export const RenderSchema = ({ 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)} - - - - {isOpen && ( -
+ + + {columns.map((col, index) => ( + - {info.canSubschema && ( - + ))} + + + {elements.map(el => ( + + {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); + const s = + typeof el.schema !== 'boolean' + ? (el.schema as JSONSchema7) + : undefined; + const isOpen = + hasDetails && s && (!getTypes(s) || isExpanded[id]); + + if (!isOpen) { + return null; + } + + return ( +
+ {info.canSubschema && ( + + )} + {info.hasEnum && ( + <> + + Valid values: + + - )} - {info.hasEnum && ( - <> - - Valid values: - - - - )} -
- )} - - ); - })} + + )} +
+ ); + })} + + )} {(Object.keys(subschemas) as Array).map(sk => { const subId = `${context.parentId}_${sk}`; const isSubOpen = isExpanded[subId]; From 7daa71fdc661c6a8c14ac237496b974b298eaf79 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 19:20:55 +0100 Subject: [PATCH 12/21] scaffolder: merge description and type into a single value column Combine the description and type columns into a single "Value" column that shows the type, description, and nested content inline. This avoids the broken layout where expanded rows were rendered as siblings below the table, decoupled from their row context. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/report-alpha.api.md | 1 + plugins/scaffolder/report.api.md | 1 + .../RenderSchema/RenderSchema.test.tsx | 11 +- .../components/RenderSchema/RenderSchema.tsx | 258 ++++++++---------- plugins/scaffolder/src/translation.ts | 1 + 5 files changed, 125 insertions(+), 147 deletions(-) diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 4031f295f1..32a6a98658 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -806,6 +806,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'renderSchema.undefined': 'No schema defined'; readonly 'renderSchema.tableCell.name': 'Name'; readonly 'renderSchema.tableCell.type': 'Type'; + readonly 'renderSchema.tableCell.value': 'Value'; readonly 'renderSchema.tableCell.title': 'Title'; readonly 'renderSchema.tableCell.description': 'Description'; readonly 'templatingExtensions.content.values.title': 'Values'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 545c69f0ff..2101501bff 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -708,6 +708,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'renderSchema.undefined': 'No schema defined'; readonly 'renderSchema.tableCell.name': 'Name'; readonly 'renderSchema.tableCell.type': 'Type'; + readonly 'renderSchema.tableCell.value': 'Value'; readonly 'renderSchema.tableCell.title': 'Title'; readonly 'renderSchema.tableCell.description': 'Description'; readonly 'templatingExtensions.content.values.title': 'Values'; diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx index 0e13858d8c..68615426ea 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx @@ -146,13 +146,10 @@ describe('JSON schema UI rendering', () => { const allCells = [...headerCells, ...dataCells]; const cellTexts = allCells.map(c => c.textContent); - expect(cellTexts).toEqual( - expect.arrayContaining([ - expect.stringContaining(p), - expect.stringContaining(`the ${pt}`), - expect.stringContaining(String(pt)), - ]), - ); + expect(cellTexts[0]).toContain(p); + const valueCell = cellTexts[1]; + expect(valueCell).toContain(String(pt)); + expect(valueCell).toContain(`the ${pt}`); if (basic.required?.includes(p)) { expect(cellTexts[0]).toContain('*'); diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 5b8a84c49f..4c4c977065 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -257,19 +257,12 @@ export const RenderSchema = ({ elements = [{ schema }]; columns = [ { - key: 'type', - title: t('renderSchema.tableCell.type'), - render: renderTypeCell, + key: 'value', + title: t('renderSchema.tableCell.value'), + render: renderValueCell, + width: '3fr', }, ]; - if (schema.description) { - columns.unshift({ - key: 'description', - title: t('renderSchema.tableCell.description'), - render: renderDescriptionCell, - width: '3fr', - }); - } } } else if (schema.properties) { columns = [ @@ -279,16 +272,11 @@ export const RenderSchema = ({ render: renderNameCell, }, { - key: 'description', - title: t('renderSchema.tableCell.description'), - render: renderDescriptionCell, + key: 'value', + title: t('renderSchema.tableCell.value'), + render: renderValueCell, width: '3fr', }, - { - key: 'type', - title: t('renderSchema.tableCell.type'), - render: renderTypeCell, - }, ]; elements = Object.entries(schema.properties!).map(([key, v]) => ({ schema: v, @@ -303,89 +291,35 @@ export const RenderSchema = ({ return ( {columns && elements && ( - <> - - - {columns.map((col, index) => ( - - {col.title} - - ))} - - - {elements.map(el => ( - - {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); - const s = - typeof el.schema !== 'boolean' - ? (el.schema as JSONSchema7) - : undefined; - const isOpen = - hasDetails && s && (!getTypes(s) || isExpanded[id]); - - if (!isOpen) { - return null; - } - - return ( -
+ + {columns.map((col, index) => ( + - {info.canSubschema && ( - - )} - {info.hasEnum && ( - <> - - Valid values: - - - - )} -
- ); - })} - + {col.title} + + ))} + + + {elements.map(el => ( + + {col => col.render(el, context)} + + ))} + + )} {(Object.keys(subschemas) as Array).map(sk => { const subId = `${context.parentId}_${sk}`; @@ -433,6 +367,55 @@ export const RenderSchema = ({ return result ?? No schema defined; }; +function RenderExpansion({ + element, + context, +}: { + element: SchemaRenderElement; + context: SchemaRenderContext; +}) { + const id = generateId(element, context); + const info = inspectSchema(element.schema); + const hasDetails = + typeof element.schema !== 'boolean' && (info.canSubschema || info.hasEnum); + const s = + typeof element.schema !== 'boolean' + ? (element.schema as JSONSchema7) + : undefined; + const [isExpanded] = context.expanded; + const isOpen = hasDetails && s && (!getTypes(s) || isExpanded[id]); + + if (!isOpen) { + return null; + } + + return ( +
+ {info.canSubschema && ( + + )} + {info.hasEnum && ( + <> + + Valid values: + + + + )} +
+ ); +} + function renderNameCell( element: SchemaRenderElement, _context: SchemaRenderContext, @@ -441,17 +424,7 @@ function renderNameCell( return ; } -function renderDescriptionCell(element: SchemaRenderElement) { - return ( - - - - ); -} - -function renderTypeCell( +function renderValueCell( element: SchemaRenderElement, context: SchemaRenderContext, ) { @@ -462,36 +435,41 @@ function renderTypeCell( const [isExpanded, setIsExpanded] = context.expanded; const id = generateId(element, context); const info = inspectSchema(element.schema); + const description = element.schema.description; return ( - - {types?.map((type, index) => - (info.canSubschema || info.hasEnum) && index === 0 ? ( - - ) : ( - - {type} - - ), - )} + + + {types?.map((type, index) => + (info.canSubschema || info.hasEnum) && index === 0 ? ( + + ) : ( + + {type} + + ), + )} + + {description && } + ); diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts index 0b19082d8f..ea934e9ffd 100644 --- a/plugins/scaffolder/src/translation.ts +++ b/plugins/scaffolder/src/translation.ts @@ -203,6 +203,7 @@ export const scaffolderTranslationRef = createTranslationRef({ title: 'Title', description: 'Description', type: 'Type', + value: 'Value', }, undefined: 'No schema defined', }, From daa7bdc7b0a3e598ea140156121cf6e96853afc0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 20:55:56 +0100 Subject: [PATCH 13/21] scaffolder: adjust table column widths for better proportions Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../scaffolder/src/components/RenderSchema/RenderSchema.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 4c4c977065..39e22c3aea 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -270,12 +270,13 @@ export const RenderSchema = ({ key: 'name', title: t('renderSchema.tableCell.name'), render: renderNameCell, + width: '1fr', }, { key: 'value', title: t('renderSchema.tableCell.value'), render: renderValueCell, - width: '3fr', + width: '4fr', }, ]; elements = Object.entries(schema.properties!).map(([key, v]) => ({ From 648d2b9e51eed515e5da383c90a0904dbaa4372a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 21:05:26 +0100 Subject: [PATCH 14/21] scaffolder: use fixed width for name column in schema table Use a fixed pixel width for the name column instead of a fractional unit, preventing it from growing too wide with longer property names. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../scaffolder/src/components/RenderSchema/RenderSchema.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 39e22c3aea..6387739ebd 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -138,7 +138,7 @@ type ColumnDef = { key: string; title: string; render: RenderColumn; - width?: `${number}fr`; + width?: number | `${number}fr`; }; const generateId = ( @@ -270,13 +270,13 @@ export const RenderSchema = ({ key: 'name', title: t('renderSchema.tableCell.name'), render: renderNameCell, - width: '1fr', + width: 200, }, { key: 'value', title: t('renderSchema.tableCell.value'), render: renderValueCell, - width: '4fr', + width: '1fr', }, ]; elements = Object.entries(schema.properties!).map(([key, v]) => ({ From 881d586722627e49b5903d98a0580893714d4d25 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 21:20:06 +0100 Subject: [PATCH 15/21] scaffolder: use high-level Table component with column configs Replace manual composition of TableRoot/TableHeader/TableBody/Row/Column primitives with the top-level Table component and ColumnConfig-based column definitions. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../RenderSchema/RenderSchema.test.tsx | 76 ++++++-- .../components/RenderSchema/RenderSchema.tsx | 180 +++++++----------- 2 files changed, 130 insertions(+), 126 deletions(-) diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx index 68615426ea..4c91befb55 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx @@ -128,16 +128,39 @@ describe('JSON schema UI rendering', () => { }, }; type qt = typeof queries; + + const getRowById = (container: HTMLElement, rowId: string): HTMLElement => { + const el = container.querySelector(`[data-key="${rowId}"]`); + if (!el) { + throw new Error(`Could not find row with data-key="${rowId}"`); + } + return el as HTMLElement; + }; + + const queryRowById = ( + container: HTMLElement, + rowId: string, + ): HTMLElement | null => { + return container.querySelector(`[data-key="${rowId}"]`); + }; + + const findRowById = ( + container: HTMLElement, + rowId: string, + ): Promise => { + return waitFor(() => getRowById(container, rowId)); + }; + const assertBasicSchemaProperties = ( q: { [P in keyof qt]: BoundFunction; - }, + } & { container: HTMLElement }, id: string, ) => { - const { getByTestId, queryByTestId } = q; + const { container, queryByTestId } = q; for (const p of Object.keys(basic.properties!)) { - const tr = getByTestId(`properties-row_${id}.${p}`); + const tr = getRowById(container, `properties-row_${id}.${p}`); expect(tr).toBeInTheDocument(); const pt = (basic.properties![p] as JSONSchema7).type; @@ -197,17 +220,17 @@ describe('JSON schema UI rendering', () => { const assertDeepSchemaProperties = async ( q: { [P in keyof qt]: BoundFunction; - }, + } & { container: HTMLElement }, id: string, ) => { - const { findByTestId, getByTestId, queryByTestId } = q; + const { container, findByTestId, getByTestId, queryByTestId } = q; const xp: (k: string) => Promise<{ expander: HTMLElement; expansionId: string; expansion: HTMLElement; }> = async (k: string) => { - const r = getByTestId(`properties-row_${id}.${k}`); + const r = getRowById(container, `properties-row_${id}.${k}`); expect(r).toBeInTheDocument(); const expansionId = `expansion_${id}.${k}`; @@ -226,7 +249,10 @@ describe('JSON schema UI rendering', () => { const b = await xp('basic'); const m = await xp('msvs'); - assertBasicSchemaProperties(within(b.expansion), `${id}.basic`); + assertBasicSchemaProperties( + { ...within(b.expansion), container: b.expansion }, + `${id}.basic`, + ); assertMsv(within(m.expansion), `${id}.msvs`); await userEvent.click(b.expander); @@ -276,7 +302,10 @@ describe('JSON schema UI rendering', () => { const expanded = await findByTestId('expansion_test'); expect(expanded).toBeInTheDocument(); - assertBasicSchemaProperties(within(expanded), 'test'); + assertBasicSchemaProperties( + { ...within(expanded), container: expanded }, + 'test', + ); await userEvent.click(expand); @@ -325,7 +354,10 @@ describe('JSON schema UI rendering', () => { const expansion = await findByTestId('expansion_test'); expect(expansion).toBeInTheDocument(); - await assertDeepSchemaProperties(within(expansion), 'test'); + await assertDeepSchemaProperties( + { ...within(expansion), container: expansion }, + 'test', + ); await userEvent.click(expand); @@ -387,22 +419,25 @@ describe('JSON schema UI rendering', () => { const rendered = await renderInTestApp( , ); - const { findByTestId, getByTestId, queryByTestId } = rendered; + const { container, getByTestId } = rendered; for (const k of Object.keys(schema.properties!)) { - const tr = getByTestId(`properties-row_test.${k}`); + const tr = getRowById(container, `properties-row_test.${k}`); expect(tr).toBeInTheDocument(); } expect( - queryByTestId('properties-row_test_oneOf0.foo'), + queryRowById(container, '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}`); + const sub = await findRowById( + container, + `properties-row_test_oneOf${i}.${k}`, + ); expect(sub).toBeInTheDocument(); const nameCell = @@ -411,7 +446,7 @@ describe('JSON schema UI rendering', () => { expect(nameCell.textContent).toContain('*'); expect( - getByTestId(`properties-row_test_oneOf${i}.${k}Flag`), + getRowById(container, `properties-row_test_oneOf${i}.${k}Flag`), ).toBeInTheDocument(); } @@ -419,7 +454,7 @@ describe('JSON schema UI rendering', () => { await waitFor(() => { expect( - queryByTestId('properties-row_test_oneOf0.foo'), + queryRowById(container, 'properties-row_test_oneOf0.foo'), ).not.toBeInTheDocument(); }); }); @@ -466,7 +501,10 @@ describe('JSON schema UI rendering', () => { for (const i of subs.keys()) { for (const k of Object.keys(subs[i].properties!)) { expect( - await rendered.findByTestId(`properties-row_test_oneOf${i}.${k}`), + await findRowById( + rendered.container, + `properties-row_test_oneOf${i}.${k}`, + ), ).toBeInTheDocument(); } } @@ -491,17 +529,17 @@ describe('JSON schema UI rendering', () => { ); expect( - rendered.queryByTestId('root-row_test.bs_anyOf0'), + queryRowById(rendered.container, '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'), + await findRowById(rendered.container, 'root-row_test.bs_anyOf0'), ).toBeInTheDocument(); expect( - rendered.getByTestId('root-row_test.bs_anyOf1'), + getRowById(rendered.container, '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 6387739ebd..45a195e0d3 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -19,12 +19,9 @@ import { Button, Cell, CellText, - Column, + ColumnConfig, Flex, - Row, - TableBody, - TableHeader, - TableRoot, + Table, Text, Tooltip, TooltipTrigger, @@ -34,7 +31,7 @@ import { JSONSchema7Definition, JSONSchema7Type, } from 'json-schema'; -import { FC, JSX } from 'react'; +import { FC } from 'react'; import { scaffolderTranslationRef } from '../../translation'; import { SchemaRenderContext, SchemaRenderStrategy } from './types'; @@ -123,29 +120,20 @@ const getSubschemas = (schema: JSONSchema7Definition): subSchemasType => { ); }; -type SchemaRenderElement = { +interface SchemaTableItem { + id: string; schema: JSONSchema7Definition; - key?: string; + propKey?: string; required?: boolean; -}; - -type RenderColumn = ( - element: SchemaRenderElement, - context: SchemaRenderContext, -) => JSX.Element; - -type ColumnDef = { - key: string; - title: string; - render: RenderColumn; - width?: number | `${number}fr`; -}; +} const generateId = ( - element: SchemaRenderElement, + item: { propKey?: string }, context: SchemaRenderContext, ) => { - return element.key ? `${context.parentId}.${element.key}` : context.parentId; + return item.propKey + ? `${context.parentId}.${item.propKey}` + : context.parentId; }; const enumFrom = (schema: JSONSchema7) => { @@ -247,41 +235,48 @@ export const RenderSchema = ({ }) => { const { t } = useTranslationRef(scaffolderTranslationRef); + const columnConfig: ColumnConfig[] = + strategy === 'root' + ? [ + { + id: 'value', + label: t('renderSchema.tableCell.value'), + defaultWidth: '3fr' as any, + cell: item => , + }, + ] + : [ + { + id: 'name', + label: t('renderSchema.tableCell.name'), + isRowHeader: true, + defaultWidth: 200, + cell: item => { + const name = item.propKey ?? ''; + return ; + }, + }, + { + id: 'value', + label: t('renderSchema.tableCell.value'), + defaultWidth: '1fr' as any, + cell: item => , + }, + ]; + const result = (() => { if (typeof schema === 'object') { const subschemas = getSubschemas(schema); - let columns: ColumnDef[] | undefined; - let elements: SchemaRenderElement[] | undefined; + let data: SchemaTableItem[] | undefined; if (strategy === 'root') { if ('type' in schema || !Object.keys(subschemas).length) { - elements = [{ schema }]; - columns = [ - { - key: 'value', - title: t('renderSchema.tableCell.value'), - render: renderValueCell, - width: '3fr', - }, - ]; + data = [{ id: `root-row_${context.parentId}`, schema }]; } } else if (schema.properties) { - columns = [ - { - key: 'name', - title: t('renderSchema.tableCell.name'), - render: renderNameCell, - width: 200, - }, - { - key: 'value', - title: t('renderSchema.tableCell.value'), - render: renderValueCell, - width: '1fr', - }, - ]; - elements = Object.entries(schema.properties!).map(([key, v]) => ({ + data = Object.entries(schema.properties).map(([key, v]) => ({ + id: `${strategy}-row_${context.parentId}.${key}`, schema: v, - key, + propKey: key, required: schema.required?.includes(key), })); } else if (!Object.keys(subschemas).length) { @@ -291,36 +286,14 @@ export const RenderSchema = ({ return ( - {columns && elements && ( - - - {columns.map((col, index) => ( - - {col.title} - - ))} - - - {elements.map(el => ( - - {col => col.render(el, context)} - - ))} - - + {data && ( +
+ + )} {(Object.keys(subschemas) as Array).map(sk => { const subId = `${context.parentId}_${sk}`; @@ -369,20 +342,18 @@ export const RenderSchema = ({ }; function RenderExpansion({ - element, + item, context, }: { - element: SchemaRenderElement; + item: SchemaTableItem; context: SchemaRenderContext; }) { - const id = generateId(element, context); - const info = inspectSchema(element.schema); + const id = generateId(item, context); + const info = inspectSchema(item.schema); const hasDetails = - typeof element.schema !== 'boolean' && (info.canSubschema || info.hasEnum); + typeof item.schema !== 'boolean' && (info.canSubschema || info.hasEnum); const s = - typeof element.schema !== 'boolean' - ? (element.schema as JSONSchema7) - : undefined; + typeof item.schema !== 'boolean' ? (item.schema as JSONSchema7) : undefined; const [isExpanded] = context.expanded; const isOpen = hasDetails && s && (!getTypes(s) || isExpanded[id]); @@ -417,26 +388,21 @@ function RenderExpansion({ ); } -function renderNameCell( - element: SchemaRenderElement, - _context: SchemaRenderContext, -) { - const name = element.key ?? ''; - return ; -} - -function renderValueCell( - element: SchemaRenderElement, - context: SchemaRenderContext, -) { - if (typeof element.schema === 'boolean') { - return ; +function ValueCell({ + item, + context, +}: { + item: SchemaTableItem; + context: SchemaRenderContext; +}) { + if (typeof item.schema === 'boolean') { + return ; } - const types = getTypes(element.schema); + const types = getTypes(item.schema); const [isExpanded, setIsExpanded] = context.expanded; - const id = generateId(element, context); - const info = inspectSchema(element.schema); - const description = element.schema.description; + const id = generateId(item, context); + const info = inspectSchema(item.schema); + const description = item.schema.description; return ( @@ -470,7 +436,7 @@ function renderValueCell( )} {description && } - + ); From 693c6e8277c55006a8984f5858cc036b68172040 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Mar 2026 21:26:57 +0100 Subject: [PATCH 16/21] scaffolder: refine schema table cell styling Style the name column with monospace text and widen it slightly. Add a neutral background badge style to type labels in the value column. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../components/RenderSchema/RenderSchema.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 45a195e0d3..64be2dc71f 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -241,6 +241,7 @@ export const RenderSchema = ({ { id: 'value', label: t('renderSchema.tableCell.value'), + isRowHeader: true, defaultWidth: '3fr' as any, cell: item => , }, @@ -250,10 +251,20 @@ export const RenderSchema = ({ id: 'name', label: t('renderSchema.tableCell.name'), isRowHeader: true, - defaultWidth: 200, + defaultWidth: 220, cell: item => { const name = item.propKey ?? ''; - return ; + return ( + + + {item.required ? `${name} *` : name} + + + ); }, }, { @@ -428,7 +439,13 @@ function ValueCell({ key={type} as="span" variant="body-small" - style={{ fontFamily: 'monospace' }} + color="secondary" + style={{ + fontFamily: 'monospace', + background: 'var(--bui-bg-neutral-2)', + padding: '1px 6px', + borderRadius: 4, + }} > {type} From 1d94f0f9f797232d098fb2ca5ff0023412c28f97 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 29 Mar 2026 11:57:22 +0200 Subject: [PATCH 17/21] fix: prevent actions list from expanding beyond screen edge Add minWidth and overflow constraints to the List component so long descriptions are truncated with ellipsis instead of overflowing the grid column. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 560ffd7136..3cfd4a1873 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -176,6 +176,7 @@ export const ActionPageContent = () => { selectionMode="single" selectionBehavior="toggle" selectedKeys={selectedActionId ? [selectedActionId] : []} + style={{ minWidth: 0, overflow: 'hidden' }} onSelectionChange={selection => { if (selection === 'all') { return; From 0a7ec8e7c95b864cbbec2befb8b7bf0f9653d470 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 29 Mar 2026 22:41:15 +0200 Subject: [PATCH 18/21] scaffolder: widen schema name column to 300px Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 64be2dc71f..3325e2dc13 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -251,7 +251,7 @@ export const RenderSchema = ({ id: 'name', label: t('renderSchema.tableCell.name'), isRowHeader: true, - defaultWidth: 220, + defaultWidth: 300, cell: item => { const name = item.propKey ?? ''; return ( From a7aedb4f0a3472f8de1a37bf91eefbb1be97e18c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 29 Mar 2026 22:49:04 +0200 Subject: [PATCH 19/21] scaffolder: restore schema title rendering and fix toolbar test The migration to the table-based RenderSchema dropped rendering of the JSON Schema `title` field. Restore it in the ValueCell. Also update the TemplateEditorToolbar test to select an action before asserting on its schema details, matching the new list+detail UI. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../TemplateEditorPage/TemplateEditorToolbar.test.tsx | 6 ++++-- .../scaffolder/src/components/RenderSchema/RenderSchema.tsx | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx index 883aab8754..2f09a79317 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx @@ -114,8 +114,10 @@ describe('TemplateEditorToolbar', () => { expect(screen.getByLabelText('Search for an action')).toBeInTheDocument(); expect(screen.getByText('action:example')).toBeInTheDocument(); - expect(screen.getByText('Example description')).toBeInTheDocument(); - expect(screen.getByText('Inform the title')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('action:example')); + + expect(await screen.findByText('Inform the title')).toBeInTheDocument(); }); it('should accept custom toolbar actions', async () => { diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 3325e2dc13..d343a8bc10 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -413,7 +413,7 @@ function ValueCell({ const [isExpanded, setIsExpanded] = context.expanded; const id = generateId(item, context); const info = inspectSchema(item.schema); - const description = item.schema.description; + const { title, description } = item.schema; return ( @@ -452,6 +452,7 @@ function ValueCell({ ), )} + {title && } {description && } From 8f93ed9e12805c50cc37abb6b1a1d4c10129cc1f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 1 Apr 2026 14:32:38 +0200 Subject: [PATCH 20/21] scaffolder: address copilot review feedback - Use translation key for "No schema defined" fallback instead of hardcoded string - Add aria-label to the enum format button for accessibility - Remove unnecessary `as any` casts on defaultWidth column config - Fix "Github" -> "GitHub" capitalization in test data - Update changeset wording to match actual sidebar list UI Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-actions-page-table.md | 2 +- .../src/components/ActionsPage/ActionsPage.test.tsx | 6 +++--- .../src/components/RenderSchema/RenderSchema.tsx | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md index a53b7fa8ce..ec6c7ad335 100644 --- a/.changeset/scaffolder-actions-page-table.md +++ b/.changeset/scaffolder-actions-page-table.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -Migrated the actions page to use `@backstage/ui` table and search components. Actions are now listed in a table with aligned name and description columns, with click-to-expand detail view and built-in search filtering. +Migrated the actions page to use `@backstage/ui` list and search components. Actions are now presented in a sidebar list with a separate detail panel for the selected action, along with built-in search filtering. diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 462c162970..b40e514f88 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -606,7 +606,7 @@ describe('ActionsPage', () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'github:repo:create', - description: 'Create a new Github repository', + description: 'Create a new GitHub repository', schema: { input: { type: 'object', @@ -622,7 +622,7 @@ describe('ActionsPage', () => { }, { id: 'github:repo:push', - description: 'Push to a Github repository', + description: 'Push to a GitHub repository', schema: { input: { type: 'object', @@ -682,7 +682,7 @@ describe('ActionsPage', () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'github:repo:create', - description: 'Create a new Github repository', + description: 'Create a new GitHub repository', schema: {}, }, ]); diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index d343a8bc10..0383d46c81 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -199,6 +199,7 @@ export const RenderEnum: FC<{ data-testid={`wrap-text_${i}`} variant="tertiary" size="small" + aria-label="Show formatted value" > ↵ @@ -242,7 +243,7 @@ export const RenderSchema = ({ id: 'value', label: t('renderSchema.tableCell.value'), isRowHeader: true, - defaultWidth: '3fr' as any, + defaultWidth: '3fr', cell: item => , }, ] @@ -270,7 +271,7 @@ export const RenderSchema = ({ { id: 'value', label: t('renderSchema.tableCell.value'), - defaultWidth: '1fr' as any, + defaultWidth: '1fr', cell: item => , }, ]; @@ -349,7 +350,7 @@ export const RenderSchema = ({ } return undefined; })(); - return result ?? No schema defined; + return result ?? {t('renderSchema.undefined')}; }; function RenderExpansion({ From 67fcc5a689accb0eed17dadaaff7277bbb970a98 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 4 Apr 2026 11:28:47 +0200 Subject: [PATCH 21/21] scaffolder: sync selected action with URL hash Reflect the currently selected action in the URL hash so that users can deep-link to a specific action on the actions page. On load the hash is read to pre-select and scroll to the matching action. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-actions-page-table.md | 2 +- .../ActionsPage/ActionsPage.test.tsx | 77 ++++++++++++++++++- .../components/ActionsPage/ActionsPage.tsx | 33 +++++++- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md index ec6c7ad335..c0443a2ab3 100644 --- a/.changeset/scaffolder-actions-page-table.md +++ b/.changeset/scaffolder-actions-page-table.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -Migrated the actions page to use `@backstage/ui` list and search components. Actions are now presented in a sidebar list with a separate detail panel for the selected action, along with built-in search filtering. +Migrated the actions page to use `@backstage/ui` list and search components. Actions are now presented in a sidebar list with a separate detail panel for the selected action, along with built-in search filtering. The selected action is also reflected in the URL hash, allowing deep-linking to a specific action. diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index b40e514f88..9c004ab8a6 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -54,7 +54,10 @@ async function selectAction(actionId: string) { } describe('ActionsPage', () => { - beforeEach(() => jest.resetAllMocks()); + beforeEach(() => { + jest.resetAllMocks(); + window.location.hash = ''; + }); it('renders actions in a table and shows detail on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ @@ -678,6 +681,78 @@ describe('ActionsPage', () => { ).toBeInTheDocument(); }); + it('should pre-select the action matching the URL hash on load', async () => { + scaffolderApiMock.listActions.mockResolvedValue([ + { + id: 'publish:github', + description: 'Publish to GitHub', + schema: { + input: { + type: 'object', + properties: { + repo: { title: 'Repo name', type: 'string' }, + }, + }, + }, + }, + { + id: 'fetch:plain', + description: 'Fetch plain content', + schema: {}, + }, + ]); + + window.location.hash = '#publish:github'; + + await renderInTestApp( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + routeEntries: ['/create/actions#publish:github'], + }, + ); + + expect( + await screen.findByRole('heading', { name: 'publish:github' }), + ).toBeInTheDocument(); + }); + + it('should update the URL hash when selecting and deselecting actions', async () => { + scaffolderApiMock.listActions.mockResolvedValue([ + { + id: 'publish:github', + description: 'Publish to GitHub', + schema: {}, + }, + { + id: 'fetch:plain', + description: 'Fetch plain content', + schema: {}, + }, + ]); + + await renderInTestApp( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + }, + ); + + await selectAction('publish:github'); + expect(window.location.hash).toBe('#publish:github'); + + await selectAction('publish:github'); + expect(window.location.hash).toBe(''); + }); + it('should keep search field focused when filtering causes empty then non-empty results', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 3cfd4a1873..227943e467 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import useAsync from 'react-use/esm/useAsync'; import { Action, scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; @@ -116,6 +116,24 @@ export const ActionPageContent = () => { string | undefined >(); const [searchQuery, setSearchQuery] = useState(''); + const initialHashHandled = useRef(false); + + useEffect(() => { + if (initialHashHandled.current || !actions) { + return; + } + const hash = window.location.hash.slice(1); + if (hash && actions.some(a => a.id === hash)) { + initialHashHandled.current = true; + setSelectedActionId(hash); + requestAnimationFrame(() => { + const row = document.querySelector(`[data-key="${CSS.escape(hash)}"]`); + if (row && typeof row.scrollIntoView === 'function') { + row.scrollIntoView({ block: 'nearest' }); + } + }); + } + }, [actions]); const filteredActions = useMemo(() => { const nonLegacy = @@ -182,9 +200,16 @@ export const ActionPageContent = () => { return; } const selected = [...selection][0] as string | undefined; - setSelectedActionId(prev => - prev === selected ? undefined : selected, - ); + setSelectedActionId(prev => { + const next = prev === selected ? undefined : selected; + const hash = next ? `#${next}` : ''; + window.history.replaceState( + null, + '', + `${window.location.pathname}${window.location.search}${hash}`, + ); + return next; + }); }} > {filteredActions.map(action => (