Merge pull request #33664 from backstage/scaffolder-actions-single-table
scaffolder: migrate actions page to @backstage/ui components
This commit is contained in:
@@ -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.
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
+4
-2
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -203,6 +203,7 @@ export const scaffolderTranslationRef = createTranslationRef({
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
type: 'Type',
|
||||
value: 'Value',
|
||||
},
|
||||
undefined: 'No schema defined',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user