diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md new file mode 100644 index 0000000000..c0443a2ab3 --- /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` 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/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/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/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index 201073e8a9..9c004ab8a6 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,20 @@ const apis = TestApiRegistry.from( [permissionApiRef, mockPermissionApi], ); -describe('TemplatePage', () => { - beforeEach(() => jest.resetAllMocks()); +async function selectAction(actionId: string) { + const row = await screen.findByRole('row', { + name: new RegExp(actionId), + }); + await userEvent.click(row); +} - it('renders action with input', async () => { +describe('ActionsPage', () => { + beforeEach(() => { + jest.resetAllMocks(); + window.location.hash = ''; + }); + + it('renders actions in a table and shows detail on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -67,7 +78,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -77,13 +88,17 @@ 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('row', { name: /test/ }), + ).toBeInTheDocument(); + + await selectAction('test'); + + expect(await screen.findByText('foobar *')).toBeInTheDocument(); }); - it('renders action with input and output', async () => { + it('renders action with input and output on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -111,7 +126,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -121,13 +136,14 @@ 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 selectAction('test'); + + expect(await screen.findByText('foobar *')).toBeInTheDocument(); + expect(screen.getByText('buzz')).toBeInTheDocument(); }); - it('renders action with oneOf output', async () => { + it('renders action with oneOf output on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -168,7 +184,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -178,13 +194,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 selectAction('test'); + + expect( + await screen.findByRole('button', { name: /oneOf/ }), + ).toBeInTheDocument(); + expect(screen.getByText('foobar *')).toBeInTheDocument(); }); - it('renders action with multiple input types', async () => { + it('renders action with multiple input types on row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -212,7 +231,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -222,11 +241,14 @@ describe('TemplatePage', () => { }, }, ); - expect(rendered.getByText('array')).toBeInTheDocument(); - expect(rendered.getByText('number')).toBeInTheDocument(); + + await selectAction('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 row click', async () => { scaffolderApiMock.listActions.mockResolvedValue([ { id: 'test', @@ -261,7 +283,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -271,14 +293,23 @@ 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 selectAction('test'); + + 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 *')).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 +338,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -318,21 +349,20 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); - expect(objectChip).toBeInTheDocument(); + await selectAction('test'); - 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(); + const objectButton = await screen.findByRole('button', { name: /object/ }); + expect(objectButton).toBeInTheDocument(); - await userEvent.click(objectChip); + expect(screen.queryByText(/^string$/)).not.toBeInTheDocument(); + expect(screen.queryByText(/^number$/)).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop a')).toBeInTheDocument(); - expect(rendered.queryByText('string')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('number')).toBeInTheDocument(); + await userEvent.click(objectButton); + + expect(screen.queryByText('a')).toBeInTheDocument(); + expect(screen.queryByText(/^string$/)).toBeInTheDocument(); + expect(screen.queryByText('b')).toBeInTheDocument(); + expect(screen.queryByText(/^number$/)).toBeInTheDocument(); }); it('renders action with nested object input type', async () => { @@ -370,7 +400,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -381,27 +411,22 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); - expect(objectChip).toBeInTheDocument(); + await selectAction('test'); - expect(rendered.queryByText('nested object a')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).not.toBeInTheDocument(); + const objectButton = await screen.findByRole('button', { name: /object/ }); + expect(objectButton).toBeInTheDocument(); - await userEvent.click(objectChip); + await userEvent.click(objectButton); - 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('a')).toBeInTheDocument(); + expect(screen.queryByText('b')).toBeInTheDocument(); + expect(screen.queryByText('c')).not.toBeInTheDocument(); - const allObjectChips = rendered.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(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); - expect(rendered.queryByText('nested object c')).toBeInTheDocument(); + expect(screen.queryByText('c')).toBeInTheDocument(); }); it('renders action with object input type and no properties', async () => { @@ -423,7 +448,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -434,15 +459,16 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test object')).toBeInTheDocument(); - const objectChip = rendered.getByText('object'); - expect(objectChip).toBeInTheDocument(); + await selectAction('test'); - expect(rendered.queryByText('No schema defined')).not.toBeInTheDocument(); + const objectButton = await screen.findByRole('button', { name: /object/ }); + expect(objectButton).toBeInTheDocument(); - await userEvent.click(objectChip); + expect(screen.queryByText('No schema defined')).not.toBeInTheDocument(); - expect(rendered.queryByText('No schema defined')).toBeInTheDocument(); + await userEvent.click(objectButton); + + expect(screen.queryByText('No schema defined')).toBeInTheDocument(); }); it('renders action with array(string) input type', async () => { @@ -466,7 +492,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -477,8 +503,9 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test array')).toBeInTheDocument(); - expect(rendered.getByText('array(string)')).toBeInTheDocument(); + await selectAction('test'); + + expect(await screen.findByText('array(string)')).toBeInTheDocument(); }); it('renders action with array(object) input type', async () => { @@ -519,7 +546,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -530,17 +557,19 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('Test array')).toBeInTheDocument(); - const objectChip = rendered.getByText('array(object)'); - expect(objectChip).toBeInTheDocument(); + await selectAction('test'); - expect(rendered.queryByText('nested object a')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).not.toBeInTheDocument(); + const arrayButton = await screen.findByRole('button', { + name: /array\(object\)/, + }); + expect(arrayButton).toBeInTheDocument(); - await userEvent.click(objectChip); + expect(screen.queryByText('b')).not.toBeInTheDocument(); - expect(rendered.queryByText('nested object a')).toBeInTheDocument(); - expect(rendered.queryByText('nested prop b')).toBeInTheDocument(); + await userEvent.click(arrayButton); + + expect(screen.queryByText('a')).toBeInTheDocument(); + expect(screen.queryByText('b')).toBeInTheDocument(); }); it('renders action with array input type and no items', async () => { @@ -560,7 +589,7 @@ describe('TemplatePage', () => { }, }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -571,14 +600,16 @@ describe('TemplatePage', () => { }, ); - expect(rendered.getByText('array(unknown)')).toBeInTheDocument(); + await selectAction('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', - description: 'Create a new Github repository', + id: 'github:repo:create', + description: 'Create a new GitHub repository', schema: { input: { type: 'object', @@ -593,8 +624,8 @@ describe('TemplatePage', () => { }, }, { - id: 'githut:repo:push', - description: 'Push to a Github repository', + id: 'github:repo:push', + description: 'Push to a GitHub repository', schema: { input: { type: 'object', @@ -610,7 +641,7 @@ describe('TemplatePage', () => { }, ]); - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -622,32 +653,141 @@ describe('TemplatePage', () => { ); expect( - rendered.getByRole('heading', { name: 'githut:repo:create' }), + await screen.findByRole('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.getByRole('heading', { name: 'githut:repo:push' }), + screen.getByRole('row', { 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('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.queryByRole('heading', { name: 'githut:repo:push' }), + screen.queryByRole('row', { 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('row', { name: /github:repo:create/ }), ).toBeInTheDocument(); expect( - rendered.getByRole('heading', { name: 'githut:repo:push' }), + screen.getByRole('row', { name: /github:repo:push/ }), + ).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([ + { + 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 6be502659a..227943e467 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -13,21 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEffect, 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'; -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 { @@ -35,11 +23,10 @@ import { EmptyState, ErrorPanel, Header, - Link, MarkdownContent, Page, - Progress, } from '@backstage/core-components'; +import { Flex, List, ListRow, SearchField, Text } from '@backstage/ui'; import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha'; import { useNavigate } from 'react-router-dom'; import { @@ -53,60 +40,119 @@ 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', - }, +function ActionDetail({ action }: { action: Action }) { + const { t } = useTranslationRef(scaffolderTranslationRef); + const expanded = useState({}); - codeRequired: { - '&::after': { - position: 'absolute', - content: '"*"', - top: 0, - right: theme.spacing(0.5), - fontWeight: 'bolder', - color: theme.palette.error.light, - }, - }, - link: { - paddingLeft: theme.spacing(1), - }, -})); + const partialSchemaRenderContext: Omit = { + expanded, + }; + + const hasInput = !!action.schema?.input; + const hasOutput = !!action.schema?.output; + const hasExamples = !!action.examples; + + if (!hasInput && !hasOutput && !hasExamples) { + return null; + } + + return ( + + {hasInput && ( + + + {t('actionsPage.action.input')} + + + + )} + {hasOutput && ( + + + {t('actionsPage.action.output')} + + + + )} + {hasExamples && ( + + + {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 [selectedActionId, setSelectedActionId] = useState< + string | undefined + >(); + const [searchQuery, setSearchQuery] = useState(''); + const initialHashHandled = useRef(false); useEffect(() => { - if (value.length && window.location.hash) { - document.querySelector(window.location.hash)?.scrollIntoView(); + if (initialHashHandled.current || !actions) { + return; } - }, [value]); + 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]); - if (loading) { - return ; - } + 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], + ); if (error) { return ( @@ -122,116 +168,80 @@ export const ActionPageContent = () => { } return ( - <> - - option.id} - renderInput={params => ( - - - - ), - }} - /> - )} - onChange={(_event, option) => { - setSelectedAction(option); - }} - fullWidth +
+ + {!loading && !filteredActions.length ? ( + - - {(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 && ( - + ) : ( + { + if (selection === 'all') { + return; + } + const selected = [...selection][0] as string | undefined; + 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 => ( + + {action.id} + + ))} + + )} + {selectedAction && ( + + + + {selectedAction.id} + + {selectedAction.description && ( + )} - {action.schema?.input && ( - - - {t('actionsPage.action.input')} - - - - )} - {action.schema?.output && ( - - - {t('actionsPage.action.output')} - - - - )} - {action.examples && ( - - }> - - {t('actionsPage.action.examples')} - - - - - - - - - )} - - ); - })} - + + + + )} +
); }; diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx index ec49f414f9..4c91befb55 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, @@ -28,42 +26,14 @@ import { JSONSchema7Definition, JSONSchema7Type, } from 'json-schema'; -import { capitalize } from 'lodash'; import { 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 ; + return ; }; const LocalRenderSchema = ({ @@ -73,7 +43,6 @@ const LocalRenderSchema = ({ strategy: SchemaRenderStrategy; schema?: JSONSchema7Definition; }) => { - const classes = useStyles(); const expanded = useState({}); return ( ], }, }} /> @@ -119,19 +86,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(), @@ -162,36 +128,55 @@ 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 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']); + 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(); - 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( - Array.from(within(tr).getByText(p).classList).some(c => - c.includes('codeRequired'), - ), - ).toBe(basic.required?.includes(p)); + 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('*'); + } expect(queryByTestId(`expand_${id}.${p}`)).not.toBeInTheDocument(); } @@ -235,26 +220,18 @@ describe('JSON schema UI rendering', () => { const assertDeepSchemaProperties = async ( q: { [P in keyof qt]: BoundFunction; - }, + } & { container: HTMLElement }, 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 { 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(); - expect(r.tagName).toBe('TR'); const expansionId = `expansion_${id}.${k}`; @@ -272,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); @@ -311,19 +291,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(); @@ -335,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); @@ -349,18 +319,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 +343,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'); @@ -407,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); @@ -469,25 +419,44 @@ describe('JSON schema UI rendering', () => { const rendered = await renderInTestApp( , ); - const { getByTestId } = rendered; + const { container, getByTestId } = rendered; + + for (const k of Object.keys(schema.properties!)) { + const tr = getRowById(container, `properties-row_test.${k}`); + expect(tr).toBeInTheDocument(); + } + + expect( + 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 tr = getByTestId(`properties-row_test.${k}`); - expect(tr).toBeInTheDocument(); - - const sub = getByTestId(`properties-row_test_oneOf${i}.${k}`); + const sub = await findRowById( + container, + `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`), + getRowById(container, `properties-row_test_oneOf${i}.${k}Flag`), ).toBeInTheDocument(); } + + await userEvent.click(expandOneOf); + + await waitFor(() => { + expect( + queryRowById(container, 'properties-row_test_oneOf0.foo'), + ).not.toBeInTheDocument(); + }); }); it('full oneOf', async () => { const schema: JSONSchema7 = { @@ -524,11 +493,18 @@ 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 findRowById( + rendered.container, + `properties-row_test_oneOf${i}.${k}`, + ), ).toBeInTheDocument(); } } @@ -551,11 +527,19 @@ describe('JSON schema UI rendering', () => { const rendered = await renderInTestApp( , ); + expect( - rendered.getByTestId(`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 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 2d4ebd6fca..0383d46c81 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -15,36 +15,25 @@ */ 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, + ColumnConfig, + Flex, + Table, + Text, + Tooltip, + TooltipTrigger, +} from '@backstage/ui'; import { JSONSchema7, JSONSchema7Definition, JSONSchema7Type, } from 'json-schema'; -import { FC, JSX, cloneElement, Fragment, ReactElement } from 'react'; +import { FC } 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,80 +120,22 @@ 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 = { +interface SchemaTableItem { + id: string; schema: JSONSchema7Definition; - key?: string; + propKey?: string; required?: boolean; -}; - -type RenderColumn = ( - element: SchemaRenderElement, - context: SchemaRenderContext, -) => JSX.Element; - -type Column = { - key: string; - title: (t: TranslationMessages) => string; - render: RenderColumn; - className?: keyof ReturnType; -}; +} 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 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 +173,58 @@ 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,165 +235,228 @@ export const RenderSchema = ({ schema?: JSONSchema7Definition; }) => { const { t } = useTranslationRef(scaffolderTranslationRef); - const tableStyles = useTableStyles(); - const columnStyles = useColumnStyles(); + + const columnConfig: ColumnConfig[] = + strategy === 'root' + ? [ + { + id: 'value', + label: t('renderSchema.tableCell.value'), + isRowHeader: true, + defaultWidth: '3fr', + cell: item => , + }, + ] + : [ + { + id: 'name', + label: t('renderSchema.tableCell.name'), + isRowHeader: true, + defaultWidth: 300, + cell: item => { + const name = item.propKey ?? ''; + return ( + + + {item.required ? `${name} *` : name} + + + ); + }, + }, + { + id: 'value', + label: t('renderSchema.tableCell.value'), + defaultWidth: '1fr', + cell: item => , + }, + ]; + const result = (() => { if (typeof schema === 'object') { const subschemas = getSubschemas(schema); - let columns: Column[] | undefined; - let elements: SchemaRenderElement[] | undefined; + let data: SchemaTableItem[] | undefined; if (strategy === 'root') { if ('type' in schema || !Object.keys(subschemas).length) { - elements = [{ schema }]; - columns = [typeColumn]; - if (schema.description) { - columns.unshift(descriptionColumn); - } - if (schema.title) { - columns.unshift(titleColumn); - } + data = [{ id: `root-row_${context.parentId}`, schema }]; } } else if (schema.properties) { - columns = [nameColumn, titleColumn, descriptionColumn, typeColumn]; - 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) { return undefined; } - const [isExpanded] = context.expanded; + const [isExpanded, setIsExpanded] = context.expanded; return ( - <> - {columns && elements && ( - + + {data && ( +
- - - {columns.map((col, index) => ( - - {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 && ( - - )} - {info.hasEnum && ( - <> - {cloneElement( - context.headings[0], - {}, - 'Valid values:', - )} - - - )} - - ); - if (getTypes(el.schema)) { - details = ( - - {details} - - ); - } - rows.push( - - - {details} - - , - ); - } - return {rows}; - })} - -
- + columnConfig={columnConfig} + data={data} + pagination={{ type: 'none' }} + /> +
)} - {(Object.keys(subschemas) as Array).map(sk => ( - - {cloneElement(context.headings[0], {}, sk)} - {subschemas[sk]!.map((sub, index) => ( - ).map(sk => { + const subId = `${context.parentId}_${sk}`; + const isSubOpen = isExpanded[subId]; + return ( + + + {isSubOpen && + subschemas[sk]!.map((sub, index) => ( + + ))} + + ); + })} +
); } return undefined; })(); - return result ?? No schema defined; + return result ?? {t('renderSchema.undefined')}; }; + +function RenderExpansion({ + item, + context, +}: { + item: SchemaTableItem; + context: SchemaRenderContext; +}) { + const id = generateId(item, context); + const info = inspectSchema(item.schema); + const hasDetails = + typeof item.schema !== 'boolean' && (info.canSubschema || info.hasEnum); + const s = + typeof item.schema !== 'boolean' ? (item.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 ValueCell({ + item, + context, +}: { + item: SchemaTableItem; + context: SchemaRenderContext; +}) { + if (typeof item.schema === 'boolean') { + return ; + } + const types = getTypes(item.schema); + const [isExpanded, setIsExpanded] = context.expanded; + const id = generateId(item, context); + const info = inspectSchema(item.schema); + const { title, description } = item.schema; + return ( + + + + {types?.map((type, index) => + (info.canSubschema || info.hasEnum) && index === 0 ? ( + + ) : ( + + {type} + + ), + )} + + {title && } + {description && } + + + + ); +} 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} /> 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', },