Merge pull request #33664 from backstage/scaffolder-actions-single-table

scaffolder: migrate actions page to @backstage/ui components
This commit is contained in:
Patrik Oldsberg
2026-04-09 20:25:49 +02:00
committed by GitHub
12 changed files with 792 additions and 712 deletions
@@ -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.
+1
View File
@@ -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';
+1
View File
@@ -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';
@@ -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 () => {
@@ -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<ScaffolderApi> = {
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
@@ -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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
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(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
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();
});
});
@@ -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<Expanded>({});
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<SchemaRenderContext, 'parentId'> = {
expanded,
};
const hasInput = !!action.schema?.input;
const hasOutput = !!action.schema?.output;
const hasExamples = !!action.examples;
if (!hasInput && !hasOutput && !hasExamples) {
return null;
}
return (
<Flex direction="column" gap="6">
{hasInput && (
<Flex direction="column" gap="2">
<Text as="h3" variant="title-small" weight="bold">
{t('actionsPage.action.input')}
</Text>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</Flex>
)}
{hasOutput && (
<Flex direction="column" gap="2">
<Text as="h3" variant="title-small" weight="bold">
{t('actionsPage.action.output')}
</Text>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</Flex>
)}
{hasExamples && (
<Flex direction="column" gap="2">
<Text as="h3" variant="title-small" weight="bold">
{t('actionsPage.action.examples')}
</Text>
<ScaffolderUsageExamplesTable examples={action.examples!} />
</Flex>
)}
</Flex>
);
}
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<Action | null>(null);
const expanded = useState<Expanded>({});
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 <Progress />;
}
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 (
<>
<Box pb={3}>
<Autocomplete
id="actions-autocomplete"
options={value}
loading={loading}
getOptionLabel={option => option.id}
renderInput={params => (
<TextField
{...params}
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
setSelectedAction(option);
}}
fullWidth
<div
style={{
display: 'grid',
gridTemplateColumns: selectedAction ? '320px 1fr' : '1fr',
gridTemplateRows: 'auto 1fr',
gap: 24,
}}
>
<SearchField
aria-label={t('actionsPage.content.searchFieldPlaceholder')}
placeholder={t('actionsPage.content.searchFieldPlaceholder')}
value={searchQuery}
onChange={setSearchQuery}
/>
{!loading && !filteredActions.length ? (
<EmptyState
missing="info"
title={t('actionsPage.content.emptyState.title')}
description={t('actionsPage.content.emptyState.description')}
/>
</Box>
{(selectedAction ? [selectedAction] : value).map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const partialSchemaRenderContext: Omit<
SchemaRenderContext,
'parentId'
> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Box pb={3} key={action.id}>
<Box display="flex" alignItems="center">
<Typography
id={action.id.replaceAll(':', '-')}
variant="h5"
component="h2"
className={classes.code}
>
{action.id}
</Typography>
<Link
className={classes.link}
to={`#${action.id.replaceAll(':', '-')}`}
>
<LinkIcon />
</Link>
</Box>
{action.description && (
<MarkdownContent content={action.description} />
) : (
<List
aria-label={t('actionsPage.title')}
selectionMode="single"
selectionBehavior="toggle"
selectedKeys={selectedActionId ? [selectedActionId] : []}
style={{ minWidth: 0, overflow: 'hidden' }}
onSelectionChange={selection => {
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 => (
<ListRow
key={action.id}
id={action.id}
textValue={action.id}
description={action.description ?? undefined}
>
{action.id}
</ListRow>
))}
</List>
)}
{selectedAction && (
<Flex
direction="column"
gap="3"
style={{ gridColumn: 2, gridRow: '1 / -1', minWidth: 0 }}
>
<Flex direction="column" gap="1">
<Text as="h2" variant="title-medium" weight="bold">
{selectedAction.id}
</Text>
{selectedAction.description && (
<MarkdownContent content={selectedAction.description} />
)}
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.input')}
</Typography>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.input`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.input}
/>
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('actionsPage.action.output')}
</Typography>
<RenderSchema
strategy="properties"
context={{
parentId: `${action.id}.output`,
...partialSchemaRenderContext,
}}
schema={action?.schema?.output}
/>
</Box>
)}
{action.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6" component="h3">
{t('actionsPage.action.examples')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ScaffolderUsageExamplesTable examples={action.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
</Box>
);
})}
</>
</Flex>
<ActionDetail action={selectedAction} />
</Flex>
)}
</div>
);
};
@@ -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 <RenderEnum {...{ classes, e }} />;
return <RenderEnum {...{ e }} />;
};
const LocalRenderSchema = ({
@@ -73,7 +43,6 @@ const LocalRenderSchema = ({
strategy: SchemaRenderStrategy;
schema?: JSONSchema7Definition;
}) => {
const classes = useStyles();
const expanded = useState<Expanded>({});
return (
<RenderSchema
@@ -82,9 +51,7 @@ const LocalRenderSchema = ({
schema,
context: {
parentId: 'test',
classes,
expanded,
headings: [<Typography component="h1" />],
},
}}
/>
@@ -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<HTMLElement> => {
return waitFor(() => getRowById(container, rowId));
};
const assertBasicSchemaProperties = (
q: {
[P in keyof qt]: BoundFunction<qt[P]>;
},
} & { 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<qt[P]>;
},
} & { 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(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
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(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
const expandOneOf = rendered.getByTestId('expand_test_oneOf');
await userEvent.click(expandOneOf);
const subs = schema.oneOf as JSONSchema7[];
for (const i of subs.keys()) {
for (const k of Object.keys(subs[i].properties!)) {
expect(
rendered.getByTestId(`properties-row_test_oneOf${i}.${k}`),
await findRowById(
rendered.container,
`properties-row_test_oneOf${i}.${k}`,
),
).toBeInTheDocument();
}
}
@@ -551,11 +527,19 @@ describe('JSON schema UI rendering', () => {
const rendered = await renderInTestApp(
<LocalRenderSchema strategy="properties" {...{ schema }} />,
);
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();
});
});
@@ -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<typeof scaffolderTranslationRef>) => string;
render: RenderColumn;
className?: keyof ReturnType<typeof useColumnStyles>;
};
}
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 (
<div
className={classNames(context.classes.code, {
[context.classes.codeRequired]: element.required,
})}
>
{element.key}
</div>
);
},
} as Column;
const titleColumn = {
key: 'title',
title: t => t('renderSchema.tableCell.title'),
render: (element: SchemaRenderElement) => (
<MarkdownContent content={(element.schema as JSONSchema7).title ?? ''} />
),
} as Column;
const descriptionColumn = {
key: 'description',
title: t => t('renderSchema.tableCell.description'),
render: (element: SchemaRenderElement) => (
<MarkdownContent
content={(element.schema as JSONSchema7).description ?? ''}
/>
),
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 <Typography>{element.schema ? 'any' : 'none'}</Typography>;
}
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) ? (
<Chip
data-testid={`expand_${id}`}
label={type}
key={type}
icon={isExpanded[id] ? <ExpandLessIcon /> : <ExpandMoreIcon />}
variant="outlined"
onClick={() =>
setIsExpanded(prevState => {
return {
...prevState,
[id]: !!!prevState[id],
};
})
}
/>
) : (
<Chip label={type} key={type} variant="outlined" />
),
)}
</>
);
},
} 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 (
<List {...props}>
<ul {...props} style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{e.map((v, i) => {
let inner: JSX.Element = (
<Typography
data-testid={`enum_el${i}`}
className={classNames(classes.code)}
>
{JSON.stringify(v)}
</Typography>
);
if (v !== null && ['object', 'array'].includes(typeof v)) {
inner = (
<>
{inner}
<Tooltip
title={
<Typography
data-testid={`pretty_${i}`}
className={classNames(classes.code)}
style={{ whiteSpace: 'pre-wrap' }}
>
{JSON.stringify(v, undefined, 2)}
</Typography>
}
const text = JSON.stringify(v);
const isComplex = v !== null && ['object', 'array'].includes(typeof v);
return (
<li key={i} style={{ padding: '2px 0' }}>
<Flex gap="2" align="center">
<Text
as="span"
variant="body-small"
data-testid={`enum_el${i}`}
style={{ fontFamily: 'monospace' }}
>
<IconButton data-testid={`wrap-text_${i}`}>
<WrapText />
</IconButton>
</Tooltip>
</>
);
}
return <ListItem key={i}>{inner}</ListItem>;
{text}
</Text>
{isComplex && (
<TooltipTrigger>
<Button
data-testid={`wrap-text_${i}`}
variant="tertiary"
size="small"
aria-label="Show formatted value"
>
</Button>
<Tooltip>
<Text
as="span"
data-testid={`pretty_${i}`}
style={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
}}
>
{JSON.stringify(v, undefined, 2)}
</Text>
</Tooltip>
</TooltipTrigger>
)}
</Flex>
</li>
);
})}
</List>
</ul>
);
};
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<SchemaTableItem>[] =
strategy === 'root'
? [
{
id: 'value',
label: t('renderSchema.tableCell.value'),
isRowHeader: true,
defaultWidth: '3fr',
cell: item => <ValueCell item={item} context={context} />,
},
]
: [
{
id: 'name',
label: t('renderSchema.tableCell.name'),
isRowHeader: true,
defaultWidth: 300,
cell: item => {
const name = item.propKey ?? '';
return (
<Cell>
<Text
as="span"
variant="body-medium"
style={{ fontFamily: 'monospace' }}
>
{item.required ? `${name} *` : name}
</Text>
</Cell>
);
},
},
{
id: 'value',
label: t('renderSchema.tableCell.value'),
defaultWidth: '1fr',
cell: item => <ValueCell item={item} context={context} />,
},
];
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 && (
<TableContainer component={Paper} className={tableStyles.schema}>
<Flex direction="column" gap="2">
{data && (
<div data-testid={`${strategy}_${context.parentId}`}>
<Table
data-testid={`${strategy}_${context.parentId}`}
size="small"
>
<TableHead>
<TableRow>
{columns.map((col, index) => (
<TableCell
key={index}
className={columnStyles[col.className ?? 'standard']}
>
{col.title(t)}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{elements.map(el => {
const id = generateId(el, context);
const info = inspectSchema(el.schema);
const rows = [
<TableRow data-testid={`${strategy}-row_${id}`}>
{columns!.map(col => (
<TableCell
key={col.key}
className={
columnStyles[col.className ?? 'standard']
}
>
{col.render(el, context)}
</TableCell>
))}
</TableRow>,
];
if (
typeof el.schema !== 'boolean' &&
(info.canSubschema || info.hasEnum)
) {
let details: ReactElement = (
<Box data-testid={`expansion_${id}`} sx={{ margin: 1 }}>
{info.canSubschema && (
<RenderSchema
strategy="properties"
context={{
...context,
parentId: id,
parent: context,
}}
schema={
el.schema.type === 'array'
? (el.schema.items as JSONSchema7 | undefined)
: el.schema
}
/>
)}
{info.hasEnum && (
<>
{cloneElement(
context.headings[0],
{},
'Valid values:',
)}
<RenderEnum
data-testid={`enum_${id}`}
e={enumFrom(el.schema)!}
classes={context.classes}
/>
</>
)}
</Box>
);
if (getTypes(el.schema)) {
details = (
<Collapse
in={isExpanded[id]}
timeout="auto"
unmountOnExit
>
{details}
</Collapse>
);
}
rows.push(
<TableRow>
<TableCell
style={{ paddingBottom: 0, paddingTop: 0 }}
colSpan={columns!.length}
>
{details}
</TableCell>
</TableRow>,
);
}
return <Fragment key={id}>{rows}</Fragment>;
})}
</TableBody>
</Table>
</TableContainer>
columnConfig={columnConfig}
data={data}
pagination={{ type: 'none' }}
/>
</div>
)}
{(Object.keys(subschemas) as Array<keyof subSchemasType>).map(sk => (
<Fragment key={sk}>
{cloneElement(context.headings[0], {}, sk)}
{subschemas[sk]!.map((sub, index) => (
<RenderSchema
key={index}
strategy={
typeof sub !== 'boolean' && 'properties' in sub
? strategy
: 'root'
{(Object.keys(subschemas) as Array<keyof subSchemasType>).map(sk => {
const subId = `${context.parentId}_${sk}`;
const isSubOpen = isExpanded[subId];
return (
<Flex key={sk} direction="column" gap="2">
<Button
data-testid={`expand_${subId}`}
variant="tertiary"
size="small"
onPress={() =>
setIsExpanded(prevState => ({
...prevState,
[subId]: !prevState[subId],
}))
}
{...{
context: {
...context,
parentId: `${context.parentId}_${sk}${index}`,
},
schema: sub,
}}
/>
))}
</Fragment>
))}
</>
style={{ alignSelf: 'flex-start' }}
>
{isSubOpen ? '▴' : '▾'} {sk}
</Button>
{isSubOpen &&
subschemas[sk]!.map((sub, index) => (
<RenderSchema
key={index}
strategy={
typeof sub !== 'boolean' && 'properties' in sub
? strategy
: 'root'
}
context={{
...context,
parentId: `${context.parentId}_${sk}${index}`,
}}
schema={sub}
/>
))}
</Flex>
);
})}
</Flex>
);
}
return undefined;
})();
return result ?? <Typography>No schema defined</Typography>;
return result ?? <Text as="p">{t('renderSchema.undefined')}</Text>;
};
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 (
<div data-testid={`expansion_${id}`} style={{ paddingLeft: 16 }}>
{info.canSubschema && (
<RenderSchema
strategy="properties"
context={{
...context,
parentId: id,
parent: context,
}}
schema={
s!.type === 'array' ? (s!.items as JSONSchema7 | undefined) : s
}
/>
)}
{info.hasEnum && (
<>
<Text as="h4" variant="title-small" weight="bold">
Valid values:
</Text>
<RenderEnum data-testid={`enum_${id}`} e={enumFrom(s!)!} />
</>
)}
</div>
);
}
function ValueCell({
item,
context,
}: {
item: SchemaTableItem;
context: SchemaRenderContext;
}) {
if (typeof item.schema === 'boolean') {
return <CellText title={item.schema ? 'any' : 'none'} />;
}
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 (
<Cell>
<Flex direction="column" gap="1">
<Flex gap="1" align="center">
{types?.map((type, index) =>
(info.canSubschema || info.hasEnum) && index === 0 ? (
<Button
key={type}
data-testid={`expand_${id}`}
variant="tertiary"
size="small"
onPress={() =>
setIsExpanded(prevState => ({
...prevState,
[id]: !prevState[id],
}))
}
>
{isExpanded[id] ? '▴' : '▾'} {type}
</Button>
) : (
<Text
key={type}
as="span"
variant="body-small"
color="secondary"
style={{
fontFamily: 'monospace',
background: 'var(--bui-bg-neutral-2)',
padding: '1px 6px',
borderRadius: 4,
}}
>
{type}
</Text>
),
)}
</Flex>
{title && <MarkdownContent content={title} />}
{description && <MarkdownContent content={description} />}
<RenderExpansion item={item} context={context} />
</Flex>
</Cell>
);
}
@@ -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<React.SetStateAction<Expanded>>];
headings: [React.JSX.Element, ...React.JSX.Element[]];
};
export type SchemaRenderStrategy = 'root' | 'properties';
@@ -55,9 +55,7 @@ const FilterDetailContent = ({
}
const schema = filter.schema;
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Fragment key={`${name}.detail`}>
@@ -97,7 +95,6 @@ const FilterDetailContent = ({
context={{
parentId: `${name}.arg${i}`,
...partialSchemaRenderContext,
headings: [<Typography variant="h6" component="h5" />],
}}
schema={argSchema}
/>
@@ -58,9 +58,7 @@ const FunctionDetailContent = ({
}
const schema = fn.schema;
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<Fragment key={`${name}.detail`}>
@@ -88,7 +86,6 @@ const FunctionDetailContent = ({
context={{
parentId: `${name}.arg${i}`,
...partialSchemaRenderContext,
headings: [<Typography variant="h6" component="h5" />],
}}
schema={argSchema}
/>
+1
View File
@@ -203,6 +203,7 @@ export const scaffolderTranslationRef = createTranslationRef({
title: 'Title',
description: 'Description',
type: 'Type',
value: 'Value',
},
undefined: 'No schema defined',
},