diff --git a/.changeset/slimy-houses-chew.md b/.changeset/slimy-houses-chew.md new file mode 100644 index 0000000000..964f1a0cb5 --- /dev/null +++ b/.changeset/slimy-houses-chew.md @@ -0,0 +1,26 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +**BREAKING ALPHA**: Extract out schema rendering components into their own Component. This means that the translation keys have changed for `actionsPage.content.tableCell.*`. They have moved to their own root key `renderSchema.*` instead. + +```diff +... +- tableCell: { +- name: 'Name', +- title: 'Title', +- description: 'Description', +- type: 'Type', +- }, +- noRowsDescription: 'No schema defined', +... ++ renderSchema: { ++ tableCell: { ++ name: 'Name', ++ title: 'Title', ++ description: 'Description', ++ type: 'Type', ++ }, ++ undefined: 'No schema defined', ++ }, +``` diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 6c4ab666c4..024b0e064d 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -324,12 +324,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'fields.repoUrlPicker.repository.inputTitle': 'Repository'; readonly 'actionsPage.content.emptyState.title': 'No information to display'; readonly 'actionsPage.content.emptyState.description': 'There are no actions installed or there was an issue communicating with backend.'; - readonly 'actionsPage.content.tableCell.name': 'Name'; - readonly 'actionsPage.content.tableCell.type': 'Type'; - readonly 'actionsPage.content.tableCell.title': 'Title'; - readonly 'actionsPage.content.tableCell.description': 'Description'; readonly 'actionsPage.content.searchFieldPlaceholder': 'Search for an action'; - readonly 'actionsPage.content.noRowsDescription': 'No schema defined'; readonly 'actionsPage.title': 'Installed actions'; readonly 'actionsPage.action.input': 'Input'; readonly 'actionsPage.action.output': 'Output'; @@ -367,6 +362,11 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'ongoingTask.hideLogsButtonTitle': 'Hide Logs'; readonly 'ongoingTask.showLogsButtonTitle': 'Show Logs'; readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.'; + readonly 'renderSchema.undefined': 'No schema defined'; + readonly 'renderSchema.tableCell.name': 'Name'; + readonly 'renderSchema.tableCell.type': 'Type'; + readonly 'renderSchema.tableCell.title': 'Title'; + readonly 'renderSchema.tableCell.description': 'Description'; readonly 'templateTypePicker.title': 'Categories'; readonly 'templateIntroPage.title': 'Manage Templates'; readonly 'templateIntroPage.subtitle': 'Edit, preview, and try out templates, forms, and custom fields'; diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 50f7726202..92d6d2ea01 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -13,32 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import useAsync from 'react-use/esm/useAsync'; -import { - Action, - ActionExample, - scaffolderApiRef, -} from '@backstage/plugin-scaffolder-react'; +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 Collapse from '@material-ui/core/Collapse'; -import Grid from '@material-ui/core/Grid'; -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 Typography from '@material-ui/core/Typography'; import { makeStyles } from '@material-ui/core/styles'; -import { JSONSchema7, JSONSchema7Definition } from 'json-schema'; -import classNames from 'classnames'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import ExpandLessIcon from '@material-ui/icons/ExpandLess'; import LinkIcon from '@material-ui/icons/Link'; import Autocomplete from '@material-ui/lab/Autocomplete'; import TextField from '@material-ui/core/TextField'; @@ -47,7 +31,6 @@ import SearchIcon from '@material-ui/icons/Search'; import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { - CodeSnippet, Content, EmptyState, ErrorPanel, @@ -57,7 +40,6 @@ import { Page, Progress, } from '@backstage/core-components'; -import Chip from '@material-ui/core/Chip'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; import { @@ -67,6 +49,8 @@ import { } from '../../routes'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../translation'; +import { Expanded, RenderSchema, SchemaRenderContext } from '../RenderSchema'; +import { ScaffolderUsageExamplesTable } from '../ScaffolderUsageExamplesTable'; const useStyles = makeStyles(theme => ({ code: { @@ -97,34 +81,6 @@ const useStyles = makeStyles(theme => ({ }, })); -const ExamplesTable = (props: { examples: ActionExample[] }) => { - return ( - - {props.examples.map((example, index) => { - return ( - - - - {example.description} - - - - - - - - - ); - })} - - ); -}; - export const ActionPageContent = () => { const api = useApi(scaffolderApiRef); const { t } = useTranslationRef(scaffolderTranslationRef); @@ -139,7 +95,7 @@ export const ActionPageContent = () => { }, [api]); const [selectedAction, setSelectedAction] = useState(null); - const [isExpanded, setIsExpanded] = useState<{ [key: string]: boolean }>({}); + const expanded = useState({}); useEffect(() => { if (value.length && window.location.hash) { @@ -164,147 +120,6 @@ export const ActionPageContent = () => { ); } - const renderTable = (rows?: JSX.Element[]) => { - if (!rows || rows.length < 1) { - return ( - {t('actionsPage.content.noRowsDescription')} - ); - } - return ( - - - - - {t('actionsPage.content.tableCell.name')} - {t('actionsPage.content.tableCell.title')} - - {t('actionsPage.content.tableCell.description')} - - {t('actionsPage.content.tableCell.type')} - - - {rows} -
-
- ); - }; - - const getTypes = (properties: JSONSchema7) => { - if (!properties.type) { - return ['unknown']; - } - - if (properties.type !== 'array') { - return [properties.type].flat(); - } - - return [ - `${properties.type}(${ - (properties.items as JSONSchema7 | undefined)?.type ?? 'unknown' - })`, - ]; - }; - - const formatRows = (parentId: string, input?: JSONSchema7) => { - const properties = input?.properties; - if (!properties) { - return undefined; - } - - return Object.entries(properties).map(entry => { - const [key] = entry; - const id = `${parentId}.${key}`; - const props = entry[1] as unknown as JSONSchema7; - const codeClassname = classNames(classes.code, { - [classes.codeRequired]: input.required?.includes(key), - }); - const types = getTypes(props); - - return ( - - - -
{key}
-
- {props.title} - {props.description} - - {types.map(type => - type.includes('object') ? ( - : - } - variant="outlined" - onClick={() => - setIsExpanded(prevState => { - const state = { ...prevState }; - state[id] = !prevState[id]; - return state; - }) - } - /> - ) : ( - - ), - )} - -
- - - - - - {key} - - {renderTable( - formatRows( - id, - props.type === 'array' - ? ({ - properties: - (props.items as JSONSchema7 | undefined) - ?.properties ?? {}, - } as unknown as JSONSchema7 | undefined) - : props, - ), - )} - - - - -
- ); - }); - }; - - const renderTables = ( - name: string, - id: string, - input?: JSONSchema7Definition[], - ) => { - if (!input) { - return undefined; - } - - return ( - <> - - {name} - - {input.map((i, index) => ( -
- {renderTable( - formatRows(`${id}.${index}`, i as unknown as JSONSchema7), - )} -
- ))} - - ); - }; - return ( <> @@ -339,17 +154,14 @@ export const ActionPageContent = () => { if (action.id.startsWith('legacy:')) { return undefined; } - - const oneOfInput = renderTables( - 'oneOf', - `${action.id}.input`, - action.schema?.input?.oneOf, - ); - const oneOfOutput = renderTables( - 'oneOf', - `${action.id}.output`, - action.schema?.output?.oneOf, - ); + const partialSchemaRenderContext: Omit< + SchemaRenderContext, + 'parentId' + > = { + classes, + expanded, + headings: [], + }; return ( @@ -376,10 +188,14 @@ export const ActionPageContent = () => { {t('actionsPage.action.input')} - {renderTable( - formatRows(`${action.id}.input`, action?.schema?.input), - )} - {oneOfInput} + )} {action.schema?.output && ( @@ -387,10 +203,14 @@ export const ActionPageContent = () => { {t('actionsPage.action.output')} - {renderTable( - formatRows(`${action.id}.output`, action?.schema?.output), - )} - {oneOfOutput} + )} {action.examples && ( @@ -402,7 +222,7 @@ export const ActionPageContent = () => { - + diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx new file mode 100644 index 0000000000..32d1e2e142 --- /dev/null +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx @@ -0,0 +1,537 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * 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, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + JSONSchema7, + JSONSchema7Definition, + JSONSchema7Type, +} from 'json-schema'; +import { capitalize } from 'lodash'; +import React, { useState } from 'react'; +import { Expanded, SchemaRenderStrategy } from '.'; +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 ; +}; + +const LocalRenderSchema = ({ + strategy, + schema, +}: { + strategy: SchemaRenderStrategy; + schema?: JSONSchema7Definition; +}) => { + const classes = useStyles(); + const expanded = useState({}); + return ( + ], + }, + }} + /> + ); +}; + +it('enum rendering', async () => { + const enums = [ + 'foo', + 123, + null, + ['bar', 'baz'], + { what: 'ever' }, + [{ fo: 'real?' }], + ]; + const rendered = await renderInTestApp(); + const { getByTestId, findByTestId, queryByTestId } = rendered; + + const elements = enums.map((value, index) => ({ + index, + value, + complex: value !== null && ['array', 'object'].includes(typeof value), + })); + for (const each of elements.filter(e => !e.complex)) { + const el = getByTestId(`enum_el${each.index}`); + expect(el).toBeInTheDocument(); + expect(el).toHaveTextContent(JSON.stringify(each.value)); + expect(queryByTestId(`wrap-text_${each.index}`)).toBeNull(); + } + for (const each of elements.filter(e => e.complex)) { + const el = getByTestId(`enum_el${each.index}`); + expect(el).toBeInTheDocument(); + expect(el).toHaveTextContent(JSON.stringify(each.value)); + + const wrapTextIcon = getByTestId(`wrap-text_${each.index}`); + expect(wrapTextIcon).toBeInTheDocument(); + + expect(queryByTestId(`pretty_${each.index}`)).toBeNull(); + + await userEvent.hover(wrapTextIcon); + + 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); + + await waitFor(() => + expect(queryByTestId(`pretty_${each.index}`)).toBeNull(), + ); + } +}); + +describe('JSON schema UI rendering', () => { + const basic: JSONSchema7 = { + type: 'object', + required: ['a'], + properties: { + a: { + title: 'A', + description: 'the string', + type: 'string', + }, + b: { + title: 'B', + description: 'the number', + type: 'number', + }, + c: { + title: 'C', + description: 'the boolean', + type: 'boolean', + }, + }, + }; + type qt = typeof queries; + const assertBasicSchemaProperties = ( + q: { + [P in keyof qt]: BoundFunction; + }, + id: string, + ) => { + 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; + + expect(Array.from(tr.querySelectorAll('td'), n => n.textContent)).toEqual( + [p, capitalize(p), `the ${pt}`, pt], + ); + expect( + Array.from(within(tr).getByText(p).classList).some(c => + c.includes('codeRequired'), + ), + ).toBe(basic.required?.includes(p)); + + expect(queryByTestId(`expand_${id}.${p}`)).not.toBeInTheDocument(); + } + }; + + const msv: JSONSchema7 = { + title: 'MSV', + description: 'metasyntactic variable', + type: 'string', + enum: ['foo', 'bar', 'baz'], + }; + + const assertMsv = ( + q: { + [P in keyof qt]: BoundFunction; + }, + id: string, + ) => { + const { getByTestId } = q; + const e = getByTestId(`enum_${id}`); + expect(e).toBeInTheDocument(); + expect(e.tagName).toBe('UL'); + expect(e.querySelectorAll('li')).toHaveLength(msv.enum!.length); + }; + + const deep: JSONSchema7 = { + title: 'Deep', + description: 'object with deeply nested properties', + type: 'object', + properties: { + basic, + msvs: { + title: 'MSVs', + description: 'metasyntactic variable array', + type: 'array', + items: msv, + }, + }, + }; + + const assertDeepSchemaProperties = async ( + q: { + [P in keyof qt]: BoundFunction; + }, + id: string, + ) => { + 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; + expansionId: string; + expansion: HTMLElement; + }> = async (k: string) => { + const r = getByTestId(`properties-row_${id}.${k}`); + expect(r).toBeInTheDocument(); + expect(r.tagName).toBe('TR'); + + const expansionId = `expansion_${id}.${k}`; + + expect(queryByTestId(expansionId)).not.toBeInTheDocument(); + + const expander = getByTestId(`expand_${id}.${k}`); + expect(expander).toBeInTheDocument(); + + await userEvent.click(expander); + + const expansion = await findByTestId(expansionId); + expect(expansion).toBeInTheDocument(); + return { expander, expansionId, expansion }; + }; + const b = await xp('basic'); + const m = await xp('msvs'); + + assertBasicSchemaProperties(within(b.expansion), `${id}.basic`); + assertMsv(within(m.expansion), `${id}.msvs`); + + await userEvent.click(b.expander); + await userEvent.click(m.expander); + + await waitFor(() => { + expect(queryByTestId(b.expansionId)).toBeNull(); + expect(queryByTestId(m.expansionId)).toBeNull(); + }); + }; + + describe('root strategy', () => { + it('undefined', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('No schema defined')).toBeInTheDocument(); + }); + it('true', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('No schema defined')).toBeInTheDocument(); + }); + it('false', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('No schema defined')).toBeInTheDocument(); + }); + it('basic', async () => { + const rendered = await renderInTestApp( + , + ); + const { findByTestId, getByTestId, queryByTestId } = rendered; + + 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(); + + const expand = getByTestId('expand_test'); + expect(expand).toBeInTheDocument(); + + await userEvent.click(expand); + + const expanded = await findByTestId('expansion_test'); + expect(expanded).toBeInTheDocument(); + + assertBasicSchemaProperties(within(expanded), 'test'); + + await userEvent.click(expand); + + await waitFor(() => expect(queryByTestId('expansion_test')).toBeNull()); + }); + it('enum', async () => { + const rendered = await renderInTestApp( + , + ); + const { findByTestId, getByTestId, queryByTestId } = rendered; + + 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(); + + const expand = getByTestId('expand_test'); + expect(expand).toBeInTheDocument(); + + await userEvent.click(expand); + + const expansion = await findByTestId('expansion_test'); + expect(expansion).toBeInTheDocument(); + assertMsv(within(expansion), 'test'); + + await userEvent.click(expand); + + await waitFor(() => expect(queryByTestId('expansion_test')).toBeNull()); + }); + it('deep', async () => { + const rendered = await renderInTestApp( + , + ); + const { findByTestId, getByTestId, queryByTestId } = rendered; + + 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'); + expect(expand).toBeInTheDocument(); + + await userEvent.click(expand); + + const expansion = await findByTestId('expansion_test'); + expect(expansion).toBeInTheDocument(); + + await assertDeepSchemaProperties(within(expansion), 'test'); + + await userEvent.click(expand); + + await waitFor(() => expect(queryByTestId('expansion_test')).toBeNull()); + }); + }); + describe('properties strategy', () => { + it('undefined', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('No schema defined')).toBeInTheDocument(); + }); + it('true', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('No schema defined')).toBeInTheDocument(); + }); + it('false', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('No schema defined')).toBeInTheDocument(); + }); + it('basic', async () => { + const rendered = await renderInTestApp( + , + ); + assertBasicSchemaProperties(rendered, 'test'); + }); + it('enum', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText('No schema defined')).toBeInTheDocument(); + }); + it('deep', async () => { + const rendered = await renderInTestApp( + , + ); + assertDeepSchemaProperties(rendered, 'test'); + }); + it('require oneOf', async () => { + const schema: JSONSchema7 = { + properties: { + foo: { + type: 'string', + }, + bar: { + type: 'string', + }, + }, + oneOf: [ + { required: ['foo'], properties: { fooFlag: { type: 'boolean' } } }, + { required: ['bar'], properties: { barFlag: { type: 'boolean' } } }, + ], + }; + const rendered = await renderInTestApp( + , + ); + const { getByTestId } = rendered; + + for (const [i, k] of Object.keys(schema.properties!).entries()) { + const tr = getByTestId(`properties-row_test.${k}`); + expect(tr).toBeInTheDocument(); + + const sub = getByTestId(`properties-row_test_sub${i}.${k}`); + expect(sub).toBeInTheDocument(); + + expect( + Array.from(within(sub).getByText(k).classList).some(c => + c.includes('codeRequired'), + ), + ).toBe(true); + + expect( + getByTestId(`properties-row_test_sub${i}.${k}Flag`), + ).toBeInTheDocument(); + } + }); + it('full oneOf', async () => { + const schema: JSONSchema7 = { + oneOf: [ + { + properties: { + guitar: { + type: 'boolean', + }, + string_gauge: { + oneOf: [ + { + type: 'number', + }, + { + type: 'string', + }, + ], + }, + }, + }, + { + properties: { + drum: { + type: 'boolean', + }, + stick_size: { + type: 'string', + }, + }, + }, + ], + }; + const rendered = await renderInTestApp( + , + ); + 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_sub${i}.${k}`), + ).toBeInTheDocument(); + } + } + }); + }); +}); diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx new file mode 100644 index 0000000000..011d9e3559 --- /dev/null +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -0,0 +1,500 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { MarkdownContent } from '@backstage/core-components'; +import { + TranslationFunction, + TranslationRef, + 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 { + JSONSchema7, + JSONSchema7Definition, + JSONSchema7Type, +} from 'json-schema'; +import React from 'react'; +import { scaffolderTranslationRef } from '../../translation'; +import { SchemaRenderContext, SchemaRenderStrategy } from './types'; + +const getTypes = (properties: JSONSchema7) => { + if (!properties.type) { + return ['unknown']; + } + if (properties.type !== 'array') { + return [properties.type].flat(); + } + return [ + `${properties.type}(${ + (properties.items as JSONSchema7 | undefined)?.type ?? 'unknown' + })`, + ]; +}; + +const getSubschemas = ( + schema: JSONSchema7Definition, +): Record => { + if (typeof schema === 'boolean') { + return {}; + } + const base: Omit = {}; + + const compositeSchemaProperties = ['allOf', 'anyOf', 'not', 'oneOf'] as const; + type subSchemasType = { + [K in (typeof compositeSchemaProperties)[number]]?: JSONSchema7Definition[]; + }; + const subschemas: subSchemasType = {}; + + for (const [key, value] of Object.entries(schema) as [ + keyof JSONSchema7, + any, + ][]) { + if (compositeSchemaProperties.includes(key as keyof subSchemasType)) { + let v; + if (Array.isArray(value)) { + if (!value.length) { + continue; + } + v = value; + } else if (value) { + v = [value]; + } else { + continue; + } + subschemas[key as keyof subSchemasType] = v as any; + } else { + base[key as Exclude] = value; + } + } + return Object.fromEntries( + Object.entries(subschemas).map(([key, sub]) => { + const mergedSubschema = sub.map(alt => { + if (typeof alt !== 'boolean' && alt.required) { + const properties: JSONSchema7['properties'] = {}; + if (schema.properties) { + for (const k of alt.required) { + if (k in schema.properties) { + properties[k] = schema.properties[k]; + } + } + } + Object.assign(properties, alt.properties); + return { + ...base, + ...alt, + properties, + }; + } + return alt; + }); + return [key, mergedSubschema]; + }), + ); +}; + +const useColumnStyles = makeStyles({ + description: { + width: '40%', + whiteSpace: 'normal', + wordWrap: 'break-word', + '&.MuiTableCell-root': { + whiteSpace: 'normal', + }, + }, + standard: { + whiteSpace: 'normal', + }, +}); + +type SchemaRenderElement = { + schema: JSONSchema7Definition; + key?: string; + required?: boolean; +}; + +type RenderColumn = ( + element: SchemaRenderElement, + context: SchemaRenderContext, +) => JSX.Element; + +type Xlate = R extends TranslationRef + ? TranslationFunction + : never; + +type Column = { + key: string; + title: (t: Xlate) => string; + render: RenderColumn; + className?: keyof ReturnType; +}; + +const generateId = ( + element: SchemaRenderElement, + context: SchemaRenderContext, +) => { + 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') { + if (Array.isArray(schema.items)) { + const itemsWithEnum = schema.items + .filter(e => typeof e === 'object' && 'enum' in e) + .map(e => e as JSONSchema7); + if (itemsWithEnum.length) { + return itemsWithEnum[0].enum; + } + } else { + return schema.items?.enum; + } + } + return undefined; + } + return schema.enum; +}; + +const inspectSchema = ( + schema: JSONSchema7Definition, +): { + canSubschema: boolean; + hasEnum: boolean; +} => { + if (typeof schema === 'boolean') { + return { canSubschema: false, hasEnum: false }; + } + return { + canSubschema: getTypes(schema).some(t => t.includes('object')), + hasEnum: !!enumFrom(schema), + }; +}; + +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) => + type.includes('object') || (info.hasEnum && index === 0) ? ( + : } + variant="outlined" + onClick={() => + setIsExpanded(prevState => { + return { + ...prevState, + [id]: !!!prevState[id], + }; + }) + } + /> + ) : ( + + ), + )} + + ); + }, +} as Column; + +export const RenderEnum: React.FC<{ + e: JSONSchema7Type[]; + classes: ClassNameMap; + [key: string]: any; +}> = ({ + e, + classes, + ...props +}: { + e: JSONSchema7Type[]; + classes: ClassNameMap; +}) => { + return ( + + {e.map((v, i) => { + let inner: React.JSX.Element = ( + + {JSON.stringify(v)} + + ); + if (v !== null && ['object', 'array'].includes(typeof v)) { + inner = ( + <> + {inner} + + {JSON.stringify(v, undefined, 2)} +
+ } + > + + + + + + ); + } + return {inner}; + })} + + ); +}; + +const useTableStyles = makeStyles({ + schema: { + width: '100%', + overflowX: 'hidden', + '& table': { + width: '100%', + tableLayout: 'fixed', + }, + }, +}); + +export const RenderSchema = ({ + strategy, + context, + schema, +}: { + strategy: SchemaRenderStrategy; + context: SchemaRenderContext; + schema?: JSONSchema7Definition; +}) => { + const { t } = useTranslationRef(scaffolderTranslationRef); + const tableStyles = useTableStyles(); + const columnStyles = useColumnStyles(); + const result = (() => { + if (typeof schema === 'object') { + const subschemas = + strategy === 'root' || !context.parent ? getSubschemas(schema) : {}; + let columns: Column[] | undefined; + let elements: SchemaRenderElement[] | undefined; + if (strategy === 'root') { + elements = [{ schema }]; + columns = [typeColumn]; + if (schema.description) { + columns.unshift(descriptionColumn); + } + if (schema.title) { + columns.unshift(titleColumn); + } + } else if (schema.properties) { + columns = [nameColumn, titleColumn, descriptionColumn, typeColumn]; + elements = Object.entries(schema.properties!).map(([key, v]) => ({ + schema: v, + key, + required: schema.required?.includes(key), + })); + } else if (!Object.keys(subschemas).length) { + return undefined; + } + const [isExpanded] = context.expanded; + + return ( + <> + {columns && elements && ( + + + + + {columns.map((col, index) => ( + + {col.title(t)} + + ))} + + + + {elements.map(el => { + const id = generateId(el, context); + const info = inspectSchema(el.schema); + return ( + + + {columns!.map(col => ( + + {col.render(el, context)} + + ))} + + {typeof el.schema !== 'boolean' && + (info.canSubschema || info.hasEnum) && ( + + + + + {info.canSubschema && ( + + )} + {info.hasEnum && ( + <> + {React.cloneElement( + context.headings[0], + {}, + 'Valid values:', + )} + + + )} + + + + + )} + + ); + })} + +
+
+ )} + {Object.keys(subschemas).map(sk => ( + + {React.cloneElement(context.headings[0], {}, sk)} + {subschemas[sk].map((sub, index) => ( + + ))} + + ))} + + ); + } + return undefined; + })(); + return result ?? No schema defined; +}; diff --git a/plugins/scaffolder/src/components/RenderSchema/index.ts b/plugins/scaffolder/src/components/RenderSchema/index.ts new file mode 100644 index 0000000000..6a9395fc0e --- /dev/null +++ b/plugins/scaffolder/src/components/RenderSchema/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './types'; +export { RenderSchema } from './RenderSchema'; diff --git a/plugins/scaffolder/src/components/RenderSchema/types.ts b/plugins/scaffolder/src/components/RenderSchema/types.ts new file mode 100644 index 0000000000..70f3c27dfd --- /dev/null +++ b/plugins/scaffolder/src/components/RenderSchema/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * 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/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.test.tsx b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.test.tsx new file mode 100644 index 0000000000..3d96b7107b --- /dev/null +++ b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ScaffolderUsageExample } from '@backstage/plugin-scaffolder-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { within } from '@testing-library/react'; +import React from 'react'; +import { ScaffolderUsageExamplesTable } from './ScaffolderUsageExamplesTable'; + +describe('examples', () => { + it('renders component', async () => { + const examples: ScaffolderUsageExample[] = [ + { + description: 'foo', + example: 'bar', + }, + { + description: 'baz', + example: 'blah', + }, + ]; + const { getByTestId } = await renderInTestApp( + , + ); + const x = getByTestId('examples'); + expect(x).toBeInTheDocument(); + const wx = within(x); + + for (const [index, value] of examples.entries()) { + const d = wx.getByTestId(`example_desc${index}`); + expect(d).toBeInTheDocument(); + expect(d.textContent).toBe(value.description); + const c = wx.getByTestId(`example_code${index}`); + expect(c).toBeInTheDocument(); + expect(within(c).getByText(value.example)).toBeInTheDocument(); + } + }); +}); diff --git a/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.tsx b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.tsx new file mode 100644 index 0000000000..758fa90ec4 --- /dev/null +++ b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/ScaffolderUsageExamplesTable.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CodeSnippet, MarkdownContent } from '@backstage/core-components'; +import { ScaffolderUsageExample } from '@backstage/plugin-scaffolder-react'; +import Box from '@material-ui/core/Box'; +import Grid from '@material-ui/core/Grid'; +import React, { Fragment } from 'react'; + +export const ScaffolderUsageExamplesTable = (props: { + examples: ScaffolderUsageExample[]; +}) => { + return ( + + {props.examples.map((example, index) => { + return ( + + + + {example.description && ( + + )} + {example.notes?.length && ( + + )} + + + + + + + + + ); + })} + + ); +}; diff --git a/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/index.ts b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/index.ts new file mode 100644 index 0000000000..9d73e1cb81 --- /dev/null +++ b/plugins/scaffolder/src/components/ScaffolderUsageExamplesTable/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { ScaffolderUsageExamplesTable } from './ScaffolderUsageExamplesTable'; diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts index 7299d3245a..fc20649a7b 100644 --- a/plugins/scaffolder/src/translation.ts +++ b/plugins/scaffolder/src/translation.ts @@ -30,13 +30,6 @@ export const scaffolderTranslationRef = createTranslationRef({ 'There are no actions installed or there was an issue communicating with backend.', }, searchFieldPlaceholder: 'Search for an action', - tableCell: { - name: 'Name', - title: 'Title', - description: 'Description', - type: 'Type', - }, - noRowsDescription: 'No schema defined', }, action: { input: 'Input', @@ -192,6 +185,15 @@ export const scaffolderTranslationRef = createTranslationRef({ emptyText: 'There are no spec parameters in the template to preview.', }, }, + renderSchema: { + tableCell: { + name: 'Name', + title: 'Title', + description: 'Description', + type: 'Type', + }, + undefined: 'No schema defined', + }, templateTypePicker: { title: 'Categories', },