Merge remote-tracking branch 'upstream/master' into mcalus3/enable-pending-locations-for-repo-importing

This commit is contained in:
Marek Calus
2020-10-20 22:43:10 +02:00
64 changed files with 808 additions and 947 deletions
+1 -1
View File
@@ -35,7 +35,7 @@
"@material-ui/icons": "^4.9.1",
"@octokit/rest": "^18.0.0",
"@roadiehq/backstage-plugin-github-insights": "^0.2.7",
"@roadiehq/backstage-plugin-github-pull-requests": "^0.5.1",
"@roadiehq/backstage-plugin-github-pull-requests": "^0.5.2",
"@roadiehq/backstage-plugin-travis-ci": "^0.2.3",
"dayjs": "^1.9.1",
"history": "^5.0.0",
@@ -105,10 +105,12 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
let content: ReactNode;
switch (true) {
case isJenkinsAvailable(entity):
content = <JenkinsLatestRunCard branch="master" />;
content = <JenkinsLatestRunCard branch="master" variant="gridItem" />;
break;
case isGitHubActionsAvailable(entity):
content = <RecentWorkflowRunsCard entity={entity} />;
content = (
<RecentWorkflowRunsCard entity={entity} limit={4} variant="gridItem" />
);
break;
case isTravisCIAvailable(entity):
content = <RecentTravisCIBuildsWidget entity={entity} />;
@@ -127,9 +129,9 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
};
const OverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3}>
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<AboutCard entity={entity} />
<AboutCard entity={entity} variant="gridItem" />
</Grid>
<RecentCICDRunsSwitcher entity={entity} />
{isGitHubAvailable(entity) && (
@@ -145,7 +147,7 @@ const OverviewContent = ({ entity }: { entity: Entity }) => (
)}
{isLighthouseAvailable(entity) && (
<Grid item sm={4}>
<LastLighthouseAuditCard />
<LastLighthouseAuditCard variant="gridItem" />
</Grid>
)}
{isPullRequestsAvailable(entity) && (
@@ -1,72 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import yaml from 'yaml';
import { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
describe('ReservedFieldsEntityPolicy', () => {
let data: any;
let policy: ReservedFieldsEntityPolicy;
beforeEach(() => {
data = yaml.parse(`
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
uid: e01199ab-08cc-44c2-8e19-5c29ded82521
etag: lsndfkjsndfkjnsdfkjnsd==
generation: 13
name: my-component-yay
namespace: the-namespace
labels:
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
tags:
- java
- data
spec:
custom: stuff
`);
policy = new ReservedFieldsEntityPolicy();
});
it('works for the happy path', async () => {
await expect(policy.enforce(data)).resolves.toBe(data);
});
it('rejects reserved keys in the spec root', async () => {
data.spec.apiVersion = 'a/b';
await expect(policy.enforce(data)).rejects.toThrow(/spec.*apiVersion/i);
});
it('rejects reserved keys in labels', async () => {
data.metadata.labels.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(/label.*apiVersion/i);
});
it('rejects reserved keys in annotations', async () => {
data.metadata.annotations.apiVersion = 'a';
await expect(policy.enforce(data)).rejects.toThrow(
/annotation.*apiVersion/i,
);
});
it('rejects core fields mistakenly placed in metadata', async () => {
data.metadata.owner = 'emma';
await expect(policy.enforce(data)).rejects.toThrow(/owner/i);
});
});
@@ -1,85 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityPolicy } from '../../types';
import { Entity } from '../Entity';
const DEFAULT_RESERVED_ENTITY_FIELDS: string[] = [
'apiVersion',
'kind',
'spec',
'metadata.uid',
'metadata.etag',
'metadata.generation',
'metadata.name',
'metadata.namespace',
'metadata.description',
'metadata.labels',
'metadata.annotations',
'metadata.tags',
// The below items are known to appear in core kinds, and therefore should
// not be appearing in metadata (which would indicate that the user made a
// mistake in where to place them).
'spec.lifecycle',
'spec.owner',
];
/**
* Ensures that fields are not given certain reserved names.
*/
export class ReservedFieldsEntityPolicy implements EntityPolicy {
private readonly reservedFields: string[];
constructor(fields?: string[]) {
this.reservedFields = [
...(fields ?? []),
...DEFAULT_RESERVED_ENTITY_FIELDS,
];
}
async enforce(entity: Entity): Promise<Entity> {
for (const path of this.reservedFields) {
const [where, name] = path.includes('.')
? path.split('.')
: [undefined, path];
if (where !== 'metadata' && entity.metadata.hasOwnProperty(name)) {
throw new Error(
`The metadata may not contain the field ${name}, because it has reserved meaning`,
);
}
if (where !== 'spec' && entity.spec?.hasOwnProperty(name)) {
throw new Error(
`The spec may not contain the field ${name}, because it has reserved meaning`,
);
}
if (where !== 'labels' && entity.metadata.labels?.hasOwnProperty(name)) {
throw new Error(
`A label may not have the field ${name}, because it has reserved meaning`,
);
}
if (
where !== 'annotations' &&
entity.metadata.annotations?.hasOwnProperty(name)
) {
throw new Error(
`An annotation may not have the field ${name}, because it has reserved meaning`,
);
}
}
return entity;
}
}
@@ -17,5 +17,4 @@
export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
@@ -62,7 +62,7 @@ export const removePluginDirectory = async (destination: string) => {
export const removeSymLink = async (destination: string) => {
await Task.forItem('removing', 'symbolic link', async () => {
const symLinkExists = fse.pathExists(destination);
const symLinkExists = await fse.pathExists(destination);
if (symLinkExists) {
try {
await fse.remove(destination);
@@ -189,7 +189,7 @@ export default async () => {
return chalk.red('Please enter an ID for the plugin');
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return chalk.red(
'Plugin IDs must be kehbab-cased and contain only letters, digits and dashes.',
'Plugin IDs must be kebab-cased and contain only letters, digits and dashes.',
);
}
return true;
@@ -34,7 +34,9 @@ describe('<Button />', () => {
);
expect(() => getByText(testString)).toThrow();
await act(async () => fireEvent.click(getByText(buttonLabel)));
await act(async () => {
fireEvent.click(getByText(buttonLabel));
});
expect(getByText(testString)).toBeInTheDocument();
});
});
@@ -15,8 +15,9 @@
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { CodeSnippet } from './CodeSnippet';
@@ -32,24 +33,24 @@ const minProps = {
};
describe('<CodeSnippet />', () => {
it('renders text without exploding', () => {
const { getByText } = render(wrapInTestApp(<CodeSnippet {...minProps} />));
it('renders text without exploding', async () => {
const { getByText } = await renderInTestApp(<CodeSnippet {...minProps} />);
expect(getByText(/"Hello"/)).toBeInTheDocument();
expect(getByText(/"World"/)).toBeInTheDocument();
});
it('renders without line numbers', () => {
const { queryByText } = render(
wrapInTestApp(<CodeSnippet {...minProps} />),
it('renders without line numbers', async () => {
const { queryByText } = await renderInTestApp(
<CodeSnippet {...minProps} />,
);
expect(queryByText('1')).not.toBeInTheDocument();
expect(queryByText('2')).not.toBeInTheDocument();
expect(queryByText('3')).not.toBeInTheDocument();
});
it('renders with line numbers', () => {
const { getByText } = render(
wrapInTestApp(<CodeSnippet {...minProps} showLineNumbers />),
it('renders with line numbers', async () => {
const { getByText } = await renderInTestApp(
<CodeSnippet {...minProps} showLineNumbers />,
);
expect(getByText('1')).toBeInTheDocument();
expect(getByText('2')).toBeInTheDocument();
@@ -57,12 +58,17 @@ describe('<CodeSnippet />', () => {
});
it('copy code using button', async () => {
jest.useFakeTimers();
document.execCommand = jest.fn();
const { getByTitle } = render(
wrapInTestApp(<CodeSnippet {...minProps} showCopyCodeButton />),
const { getByTitle } = await renderInTestApp(
<CodeSnippet {...minProps} showCopyCodeButton />,
);
const button = getByTitle('Text copied to clipboard');
fireEvent.click(button);
act(() => {
jest.runAllTimers();
});
expect(document.execCommand).toHaveBeenCalled();
jest.useRealTimers();
});
});
@@ -15,8 +15,9 @@
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { CopyTextButton } from './CopyTextButton';
import {
ApiRegistry,
@@ -55,29 +56,30 @@ const apiRegistry = ApiRegistry.from([
]);
describe('<CopyTextButton />', () => {
it('renders without exploding', () => {
const { getByDisplayValue } = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
),
it('renders without exploding', async () => {
const { getByDisplayValue } = await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
);
getByDisplayValue('mockText');
});
it('displays tooltip on click', async () => {
jest.useFakeTimers();
document.execCommand = jest.fn();
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
),
const rendered = await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<CopyTextButton {...props} />
</ApiProvider>,
);
const button = rendered.getByTitle('mockTooltip');
fireEvent.click(button);
act(() => {
jest.runAllTimers();
});
expect(document.execCommand).toHaveBeenCalled();
rendered.getByText('mockTooltip');
jest.useRealTimers();
});
});
@@ -15,31 +15,30 @@
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { HorizontalScrollGrid } from './HorizontalScrollGrid';
import { Grid } from '@material-ui/core';
describe('<HorizontalScrollGrid />', () => {
beforeEach(() => {
jest.spyOn(window.performance, 'now').mockReturnValue(5);
jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation(cb => cb(20));
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
cb(20);
return 1;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders without exploding', () => {
const rendered = render(
wrapInTestApp(
<HorizontalScrollGrid>
<Grid item>item1</Grid>
<Grid item>item2</Grid>
</HorizontalScrollGrid>,
),
it('renders without exploding', async () => {
const rendered = await renderInTestApp(
<HorizontalScrollGrid>
<Grid item>item1</Grid>
<Grid item>item2</Grid>
</HorizontalScrollGrid>,
);
rendered.getByText('item1');
rendered.getByText('item2');
@@ -48,37 +47,31 @@ describe('<HorizontalScrollGrid />', () => {
});
it('should show scroll buttons', async () => {
Object.defineProperties(HTMLElement.prototype, {
scrollLeft: {
configurable: true,
value: 5,
},
offsetWidth: {
configurable: true,
value: 10,
},
scrollWidth: {
configurable: true,
value: 20,
},
});
jest
.spyOn(HTMLElement.prototype, 'scrollLeft', 'get')
.mockImplementation(() => 5);
jest
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockImplementation(() => 10);
jest
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockImplementation(() => 20);
let lastScroll = 0;
HTMLElement.prototype.scrollBy = ({ left }) => {
lastScroll = left;
};
const scrollBy = HTMLElement.prototype.scrollBy;
HTMLElement.prototype.scrollBy = (({ left }: ScrollToOptions): void => {
lastScroll = left || 0;
}) as any;
const rendered = await renderWithEffects(
wrapInTestApp(
<HorizontalScrollGrid style={{ maxWidth: 300 }}>
<Grid item style={{ minWidth: 200 }}>
item1
</Grid>
<Grid item style={{ minWidth: 200 }}>
item2
</Grid>
</HorizontalScrollGrid>,
),
const rendered = await renderInTestApp(
<HorizontalScrollGrid>
<Grid item style={{ minWidth: 200 }}>
item1
</Grid>
<Grid item style={{ minWidth: 200 }}>
item2
</Grid>
</HorizontalScrollGrid>,
);
rendered.getByTitle('Scroll Left');
@@ -89,9 +82,6 @@ describe('<HorizontalScrollGrid />', () => {
fireEvent.click(rendered.getByTitle('Scroll Left'));
expect(lastScroll).toBeLessThan(0);
delete HTMLElement.prototype.scrollLeft;
delete HTMLElement.prototype.offsetWidth;
delete HTMLElement.prototype.scrollWidth;
delete HTMLElement.prototype.scrollBy;
HTMLElement.prototype.scrollBy = scrollBy;
});
});
@@ -15,28 +15,27 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { Lifecycle } from './Lifecycle';
describe('<Lifecycle />', () => {
it('renders Alpha with shorthand', async () => {
const { getByText } = render(wrapInTestApp(<Lifecycle alpha shorthand />));
const { getByText } = await renderInTestApp(<Lifecycle alpha shorthand />);
expect(getByText('α')).toBeInTheDocument();
});
it('renders Alpha without shorthand', async () => {
const { getByText } = render(wrapInTestApp(<Lifecycle alpha />));
const { getByText } = await renderInTestApp(<Lifecycle alpha />);
expect(getByText('Alpha')).toBeInTheDocument();
});
it('renders Beta with shorthand', async () => {
const { getByText } = render(wrapInTestApp(<Lifecycle shorthand />));
const { getByText } = await renderInTestApp(<Lifecycle shorthand />);
expect(getByText('β')).toBeInTheDocument();
});
it('renders Beta without shorthand', async () => {
const { getByText } = render(wrapInTestApp(<Lifecycle />));
const { getByText } = await renderInTestApp(<Lifecycle />);
expect(getByText('Beta')).toBeInTheDocument();
});
});
@@ -34,7 +34,9 @@ describe('<Link />', () => {
),
);
expect(() => getByText(testString)).toThrow();
await act(async () => fireEvent.click(getByText(linkText)));
await act(async () => {
fireEvent.click(getByText(linkText));
});
expect(getByText(testString)).toBeInTheDocument();
});
});
@@ -15,34 +15,34 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { Gauge, getProgressColor } from './Gauge';
import * as theme from '@backstage/theme';
describe('<Gauge />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInTestApp(<Gauge value={10} fractional={false} />),
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(
<Gauge value={10} fractional={false} />,
);
getByText('10%');
});
it('handles fractional prop', () => {
const { getByText } = render(
wrapInTestApp(<Gauge value={0.1} fractional />),
it('handles fractional prop', async () => {
const { getByText } = await renderInTestApp(
<Gauge value={0.1} fractional />,
);
getByText('10%');
});
it('handles max prop', () => {
const { getByText } = render(
wrapInTestApp(<Gauge value={1} max={10} fractional={false} />),
it('handles max prop', async () => {
const { getByText } = await renderInTestApp(
<Gauge value={1} max={10} fractional={false} />,
);
getByText('1%');
});
it('handles unit prop', () => {
const { getByText } = render(
wrapInTestApp(<Gauge value={10} fractional={false} unit="m" />),
it('handles unit prop', async () => {
const { getByText } = await renderInTestApp(
<Gauge value={10} fractional={false} unit="m" />,
);
getByText('10m');
});
@@ -50,17 +50,20 @@ describe('<Gauge />', () => {
const ok = '#111';
const warning = '#222';
const error = '#333';
const palette = { status: { ok, warning, error } };
const palette = {
...theme.lightTheme.palette,
status: { ...theme.lightTheme.palette.status, ok, warning, error },
};
it('colors the progress correctly', () => {
expect(getProgressColor(palette)).toBe('#ddd');
expect(getProgressColor(palette, 'Not a Number' as any)).toBe('#ddd');
expect(getProgressColor(palette, 10)).toBe(error);
expect(getProgressColor(palette, 50)).toBe(warning);
expect(getProgressColor(palette, 90)).toBe(ok);
});
it('colors the inverse progress correctly', () => {
expect(getProgressColor(palette)).toBe('#ddd');
expect(getProgressColor(palette, 'Not a Number' as any)).toBe('#ddd');
expect(getProgressColor(palette, 10, true)).toBe(ok);
expect(getProgressColor(palette, 50, true)).toBe(warning);
expect(getProgressColor(palette, 90, true)).toBe(error);
@@ -15,33 +15,32 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { GaugeCard } from './GaugeCard';
const minProps = { title: 'Tingle upgrade', progress: 0.12 };
describe('<GaugeCard />', () => {
it('renders without exploding', () => {
const { getByText } = render(wrapInTestApp(<GaugeCard {...minProps} />));
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(<GaugeCard {...minProps} />);
expect(getByText(/Tingle.*/)).toBeInTheDocument();
});
it('renders progress and title', () => {
const { getByText } = render(wrapInTestApp(<GaugeCard {...minProps} />));
it('renders progress and title', async () => {
const { getByText } = await renderInTestApp(<GaugeCard {...minProps} />);
expect(getByText(/Tingle.*/)).toBeInTheDocument();
expect(getByText(/12%.*/)).toBeInTheDocument();
});
it('does not render deepLink', () => {
const { queryByText } = render(wrapInTestApp(<GaugeCard {...minProps} />));
it('does not render deepLink', async () => {
const { queryByText } = await renderInTestApp(<GaugeCard {...minProps} />);
expect(queryByText('View more')).not.toBeInTheDocument();
});
it('handles invalid numbers', () => {
const badProps = { title: 'Tingle upgrade', progress: 'hejjo' };
const { getByText } = render(wrapInTestApp(<GaugeCard {...badProps} />));
it('handles invalid numbers', async () => {
const badProps = { title: 'Tingle upgrade', progress: 'hejjo' } as any;
const { getByText } = await renderInTestApp(<GaugeCard {...badProps} />);
expect(getByText(/N\/A.*/)).toBeInTheDocument();
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React from 'react';
import { render, fireEvent, within } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, within } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { SimpleStepper as Stepper } from './SimpleStepper';
import { SimpleStepperStep as Step } from './SimpleStepperStep';
@@ -24,20 +24,18 @@ const getTextInSlide = (rendered: any, index: number) =>
describe('Stepper', () => {
it('Maintains state history', async () => {
const rendered = render(
wrapInTestApp(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step title="Step 1" data-testid="step1">
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
</Stepper>,
),
const rendered = await renderInTestApp(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step title="Step 1" data-testid="step1">
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
</Stepper>,
);
fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
@@ -51,27 +49,25 @@ describe('Stepper', () => {
});
it('Handles nextStep property', async () => {
const rendered = render(
wrapInTestApp(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{ nextStep: () => 3 }}
data-testid="step1"
>
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
<Step title="Step 3" data-testid="step3">
<div>step3</div>
</Step>
</Stepper>,
),
const rendered = await renderInTestApp(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{ nextStep: () => 3 }}
data-testid="step1"
>
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
<Step title="Step 3" data-testid="step3">
<div>step3</div>
</Step>
</Stepper>,
);
fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
@@ -84,28 +80,26 @@ describe('Stepper', () => {
expect(rendered.getByText('step1')).toBeInTheDocument();
});
it('Shows controls and content when going back to first step', () => {
const rendered = render(
wrapInTestApp(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{ nextStep: () => 3 }}
data-testid="step1"
>
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
<Step title="Step 3" data-testid="step3">
<div>step3</div>
</Step>
</Stepper>,
),
it('Shows controls and content when going back to first step', async () => {
const rendered = await renderInTestApp(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{ nextStep: () => 3 }}
data-testid="step1"
>
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
<Step title="Step 3" data-testid="step3">
<div>step3</div>
</Step>
</Stepper>,
);
fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
@@ -123,30 +117,28 @@ describe('Stepper', () => {
expect(getTextInSlide(rendered, 0)('Next')).toBeInTheDocument();
});
it('uses nextText if specified in all steps', () => {
const rendered = render(
wrapInTestApp(
it('uses nextText if specified in all steps', async () => {
const rendered = await renderInTestApp(
<Stepper>
<Stepper>
<Stepper>
<Step
title="Step 0"
actions={{
nextText: 'Step0Next',
}}
>
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{
nextText: 'FinalStepNext',
}}
>
<div>final step</div>
</Step>
</Stepper>
</Stepper>,
),
<Step
title="Step 0"
actions={{
nextText: 'Step0Next',
}}
>
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{
nextText: 'FinalStepNext',
}}
>
<div>final step</div>
</Step>
</Stepper>
</Stepper>,
);
expect(rendered.getByText('Step0Next')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Step0Next'));
@@ -14,11 +14,10 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { StructuredMetadataTable } from './StructuredMetadataTable';
import { startCase } from 'lodash';
import React from 'react';
import { StructuredMetadataTable } from './StructuredMetadataTable';
describe('<StructuredMetadataTable />', () => {
it('renders without exploding', () => {
@@ -31,32 +30,34 @@ describe('<StructuredMetadataTable />', () => {
describe('Item Mappings', () => {
it('Iterates over and displays every field in the map', () => {
const metadata = { field1: 'one', field2: 'two', field3: 'three' };
const metadata = {
field1: 'one',
field2: 'two',
field3: 'three',
} as const;
const { getByText } = render(
<StructuredMetadataTable metadata={metadata} />,
);
const keys = Object.keys(metadata);
keys.forEach(value => {
expect(getByText(startCase(value))).toBeInTheDocument();
expect(getByText(metadata[value])).toBeInTheDocument();
});
for (const [key, value] of Object.entries(metadata)) {
expect(getByText(startCase(key))).toBeInTheDocument();
expect(getByText(value)).toBeInTheDocument();
}
});
it('Supports primative value fields', () => {
const metadata = { strField: 'my field', intField: 1 };
it('Supports primitive value fields', () => {
const metadata = { strField: 'my field', intField: 1 } as const;
const { getByText } = render(
<StructuredMetadataTable metadata={metadata} />,
);
const keys = Object.keys(metadata);
keys.forEach(value => {
expect(getByText(startCase(value))).toBeInTheDocument();
expect(getByText(metadata[value].toString())).toBeInTheDocument();
});
for (const [key, value] of Object.entries(metadata)) {
expect(getByText(startCase(key))).toBeInTheDocument();
expect(getByText(value.toString())).toBeInTheDocument();
}
});
it('Supports array fields', () => {
const metadata = { arrayField: ['arrVal1', 'arrVal2'] };
const metadata = { arrayField: ['arrVal1', 'arrVal2'] } as const;
const { getByText } = render(
<StructuredMetadataTable metadata={metadata} />,
);
@@ -79,20 +80,19 @@ describe('<StructuredMetadataTable />', () => {
});
it('Supports object elements', () => {
const metadata = { config: { a: 1, b: 2 } };
const metadata = {
config: { a: 1, b: 2 },
} as const;
const { getByText } = render(
<StructuredMetadataTable metadata={metadata} />,
);
const keys = Object.keys(metadata.config);
keys.forEach(value => {
for (const [key, value] of Object.entries(metadata.config)) {
expect(getByText(startCase(key), { exact: false })).toBeInTheDocument();
expect(
getByText(startCase(value), { exact: false }),
getByText(value.toString(), { exact: false }),
).toBeInTheDocument();
expect(
getByText(metadata.config[value].toString(), { exact: false }),
).toBeInTheDocument();
});
}
});
});
});
@@ -15,8 +15,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { Table } from './Table';
const minProps = {
@@ -43,14 +42,14 @@ const minProps = {
};
describe('<Table />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<Table {...minProps} />));
it('renders without exploding', async () => {
const rendered = await renderInTestApp(<Table {...minProps} />);
expect(rendered.getByText('second value, second row')).toBeInTheDocument();
});
it('renders with subtitle', () => {
const rendered = render(
wrapInTestApp(<Table subtitle="subtitle" {...minProps} />),
it('renders with subtitle', async () => {
const rendered = await renderInTestApp(
<Table subtitle="subtitle" {...minProps} />,
);
expect(rendered.getByText('subtitle')).toBeInTheDocument();
});
@@ -15,13 +15,12 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { StyledTab } from './Tab';
describe('<Tab />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<StyledTab label="test" />));
it('renders without exploding', async () => {
const rendered = await renderInTestApp(<StyledTab label="test" />);
expect(rendered.getByText('test')).toBeInTheDocument();
});
});
@@ -17,24 +17,24 @@
/* eslint-disable jest/no-disabled-tests */
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp, wrapInTestApp } from '@backstage/test-utils';
import { TrendLine } from './TrendLine';
describe('TrendLine', () => {
describe('when no data is present', () => {
it('renders null without throwing', () => {
const rendered = render(
wrapInTestApp(<TrendLine data={[]} title="sparkline" />),
it('renders null without throwing', async () => {
const rendered = await renderInTestApp(
<TrendLine data={[]} title="sparkline" />,
);
expect(rendered.queryByTitle('sparkline')).not.toBeInTheDocument();
});
});
describe('when one datapoint is present', () => {
it('renders as a straight line', () => {
const rendered = render(
wrapInTestApp(<TrendLine data={[0.5]} title="sparkline" />),
it('renders as a straight line', async () => {
const rendered = await renderInTestApp(
<TrendLine data={[0.5]} title="sparkline" />,
);
expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
});
@@ -15,22 +15,21 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { WarningPanel } from './WarningPanel';
const minProps = { title: 'Mock title', message: 'Some more info' };
describe('<WarningPanel />', () => {
it('renders without exploding', () => {
const { getByText } = render(wrapInTestApp(<WarningPanel {...minProps} />));
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(<WarningPanel {...minProps} />);
expect(getByText('Mock title')).toBeInTheDocument();
});
it('renders message and children', () => {
const { getByText } = render(
wrapInTestApp(<WarningPanel {...minProps}>children</WarningPanel>),
it('renders message and children', async () => {
const { getByText } = await renderInTestApp(
<WarningPanel {...minProps}>children</WarningPanel>,
);
expect(getByText('Some more info')).toBeInTheDocument();
expect(getByText('children')).toBeInTheDocument();
@@ -15,8 +15,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { BottomLink } from './BottomLink';
const minProps = {
@@ -25,8 +24,8 @@ const minProps = {
};
describe('<BottomLink />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<BottomLink {...minProps} />));
it('renders without exploding', async () => {
const rendered = await renderInTestApp(<BottomLink {...minProps} />);
expect(rendered.getByText('A deepLink title')).toBeInTheDocument();
});
});
@@ -15,9 +15,8 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ContentHeader } from './ContentHeader';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
jest.mock('react-helmet', () => {
return {
@@ -26,23 +25,23 @@ jest.mock('react-helmet', () => {
});
describe('<ContentHeader/>', () => {
it('should render with title', () => {
const rendered = render(wrapInTestApp(<ContentHeader title="Title" />));
it('should render with title', async () => {
const rendered = await renderInTestApp(<ContentHeader title="Title" />);
rendered.getByText('Title');
});
it('should render with titleComponent', () => {
it('should render with titleComponent', async () => {
const title = 'Custom title';
const titleComponent = () => <h1>{title}</h1>;
const rendered = render(
wrapInTestApp(<ContentHeader titleComponent={titleComponent} />),
const rendered = await renderInTestApp(
<ContentHeader titleComponent={titleComponent} />,
);
rendered.getByText(title);
});
it('should render with description', () => {
const rendered = render(
wrapInTestApp(<ContentHeader description="description" />),
it('should render with description', async () => {
const rendered = await renderInTestApp(
<ContentHeader description="description" />,
);
rendered.getByText('description');
});
@@ -15,14 +15,13 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ErrorPage } from './ErrorPage';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
describe('<ErrorPage/>', () => {
it('should render with status code, status message and go back link', () => {
const rendered = render(
wrapInTestApp(<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />),
it('should render with status code, status message and go back link', async () => {
const rendered = await renderInTestApp(
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />,
);
rendered.getByText(/page not found/i);
rendered.getByText(/404/i);
+17 -18
View File
@@ -15,8 +15,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { Header } from './Header';
jest.mock('react-helmet', () => {
@@ -26,42 +25,42 @@ jest.mock('react-helmet', () => {
});
describe('<Header/>', () => {
it('should render with title', () => {
const rendered = render(wrapInTestApp(<Header title="Title" />));
it('should render with title', async () => {
const rendered = await renderInTestApp(<Header title="Title" />);
rendered.getByText('Title');
});
it('should set document title', () => {
const rendered = render(wrapInTestApp(<Header title="Title1" />));
it('should set document title', async () => {
const rendered = await renderInTestApp(<Header title="Title1" />);
rendered.getByText('Title1');
rendered.getByText('defaultTitle: Title1 | Backstage');
});
it('should override document title', () => {
const rendered = render(
wrapInTestApp(<Header title="Title1" pageTitleOverride="Title2" />),
it('should override document title', async () => {
const rendered = await renderInTestApp(
<Header title="Title1" pageTitleOverride="Title2" />,
);
rendered.getByText('Title1');
rendered.getByText('defaultTitle: Title2 | Backstage');
});
it('should have subtitle', () => {
const rendered = render(
wrapInTestApp(<Header title="Title" subtitle="Subtitle" />),
it('should have subtitle', async () => {
const rendered = await renderInTestApp(
<Header title="Title" subtitle="Subtitle" />,
);
rendered.getByText('Subtitle');
});
it('should have type rendered', () => {
const rendered = render(
wrapInTestApp(<Header title="Title" type="tool" />),
it('should have type rendered', async () => {
const rendered = await renderInTestApp(
<Header title="Title" type="tool" />,
);
rendered.getByText('tool');
});
it('should have breadcrumb rendered', () => {
const rendered = render(
wrapInTestApp(<Header title="Title" type="tool" typeLink="/tool" />),
it('should have breadcrumb rendered', async () => {
const rendered = await renderInTestApp(
<Header title="Title" type="tool" typeLink="/tool" />,
);
rendered.getAllByText('Title');
});
@@ -16,22 +16,24 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp, Keyboard } from '@backstage/test-utils';
import {
wrapInTestApp,
Keyboard,
renderInTestApp,
} from '@backstage/test-utils';
import { HeaderActionMenu } from './HeaderActionMenu';
describe('<ComponentContextMenu />', () => {
it('renders without any items and without exploding', () => {
render(wrapInTestApp(<HeaderActionMenu actionItems={[]} />));
it('renders without any items and without exploding', async () => {
await renderInTestApp(<HeaderActionMenu actionItems={[]} />);
});
it('can open the menu and click menu items', () => {
it('can open the menu and click menu items', async () => {
const onClickFunction = jest.fn();
const rendered = render(
wrapInTestApp(
<HeaderActionMenu
actionItems={[{ label: 'Some label', onClick: onClickFunction }]}
/>,
),
const rendered = await renderInTestApp(
<HeaderActionMenu
actionItems={[{ label: 'Some label', onClick: onClickFunction }]}
/>,
);
expect(rendered.queryByText('Some label')).not.toBeInTheDocument();
expect(onClickFunction).not.toHaveBeenCalled();
@@ -48,12 +50,10 @@ describe('<ComponentContextMenu />', () => {
});
it('Disabled', async () => {
const rendered = render(
wrapInTestApp(
<HeaderActionMenu
actionItems={[{ label: 'Some label', disabled: true }]}
/>,
),
const rendered = await renderInTestApp(
<HeaderActionMenu
actionItems={[{ label: 'Some label', disabled: true }]}
/>,
);
fireEvent.click(rendered.getByTestId('header-action-menu'));
@@ -63,22 +63,20 @@ describe('<ComponentContextMenu />', () => {
);
});
it('Test wrapper, and secondary label', () => {
it('Test wrapper, and secondary label', async () => {
const onClickFunction = jest.fn();
const rendered = render(
wrapInTestApp(
<HeaderActionMenu
actionItems={[
{
label: 'Some label',
secondaryLabel: 'Secondary label',
WrapperComponent: ({ children }) => (
<button onClick={onClickFunction}>{children}</button>
),
},
]}
/>,
),
const rendered = await renderInTestApp(
<HeaderActionMenu
actionItems={[
{
label: 'Some label',
secondaryLabel: 'Secondary label',
WrapperComponent: ({ children }) => (
<button onClick={onClickFunction}>{children}</button>
),
},
]}
/>,
);
expect(onClickFunction).not.toHaveBeenCalled();
@@ -15,38 +15,37 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { HeaderLabel } from './HeaderLabel';
describe('<HeaderLabel />', () => {
it('should have a label', () => {
const rendered = render(wrapInTestApp(<HeaderLabel label="Label" />));
it('should have a label', async () => {
const rendered = await renderInTestApp(<HeaderLabel label="Label" />);
expect(rendered.getByText('Label')).toBeInTheDocument();
});
it('should say unknown', () => {
const rendered = render(wrapInTestApp(<HeaderLabel label="Label" />));
it('should say unknown', async () => {
const rendered = await renderInTestApp(<HeaderLabel label="Label" />);
expect(rendered.getByText('<Unknown>')).toBeInTheDocument();
});
it('should say unknown when passing null as value prop', () => {
const rendered = render(
wrapInTestApp(<HeaderLabel label="Label" value={null} />),
it('should say unknown when passing null as value prop', async () => {
const rendered = await renderInTestApp(
<HeaderLabel label="Label" value={null} />,
);
expect(rendered.getByText('<Unknown>')).toBeInTheDocument();
});
it('should have value', () => {
const rendered = render(
wrapInTestApp(<HeaderLabel label="Label" value="Value" />),
it('should have value', async () => {
const rendered = await renderInTestApp(
<HeaderLabel label="Label" value="Value" />,
);
expect(rendered.getByText('Value')).toBeInTheDocument();
});
it('should have a link', () => {
const rendered = render(
wrapInTestApp(<HeaderLabel label="Label" value="Value" url="/test" />),
it('should have a link', async () => {
const rendered = await renderInTestApp(
<HeaderLabel label="Label" value="Value" url="/test" />,
);
const anchor = rendered.container.querySelector('a') as HTMLAnchorElement;
expect(rendered.getByText('Value')).toBeInTheDocument();
@@ -15,8 +15,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { HeaderTabs } from './';
const mockTabs = [
@@ -25,15 +24,15 @@ const mockTabs = [
];
describe('<HeaderTabs />', () => {
it('should render tabs', () => {
const rendered = render(wrapInTestApp(<HeaderTabs tabs={mockTabs} />));
it('should render tabs', async () => {
const rendered = await renderInTestApp(<HeaderTabs tabs={mockTabs} />);
expect(rendered.getByText('Overview')).toBeInTheDocument();
expect(rendered.getByText('Docs')).toBeInTheDocument();
});
it('should render correct selected tab', () => {
const rendered = render(wrapInTestApp(<HeaderTabs tabs={mockTabs} />));
it('should render correct selected tab', async () => {
const rendered = await renderInTestApp(<HeaderTabs tabs={mockTabs} />);
expect(rendered.getByText('Docs').parentElement).toHaveAttribute(
'aria-selected',
@@ -15,8 +15,7 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import { InfoCard } from './InfoCard';
const minProps = {
@@ -28,13 +27,13 @@ const minProps = {
};
describe('<InfoCard />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<InfoCard {...minProps} />));
it('renders without exploding', async () => {
const rendered = await renderInTestApp(<InfoCard {...minProps} />);
expect(rendered.getByText('Some title')).toBeInTheDocument();
});
it('renders a deepLink when prop is set', () => {
const rendered = render(wrapInTestApp(<InfoCard {...minProps} />));
it('renders a deepLink when prop is set', async () => {
const rendered = await renderInTestApp(<InfoCard {...minProps} />);
expect(rendered.getByText('A deepLink title')).toBeInTheDocument();
});
});
+24 -4
View File
@@ -70,6 +70,15 @@ const VARIANT_STYLES = {
flexDirection: 'column',
height: '100%',
},
gridItem: {
display: 'flex',
flexDirection: 'column',
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
},
/**
* @deprecated This variant is replaced by 'gridItem'.
*/
height100: {
display: 'flex',
flexDirection: 'column',
@@ -81,9 +90,15 @@ const VARIANT_STYLES = {
fullHeight: {
flex: 1,
},
/**
* @deprecated This variant is replaced by 'gridItem'.
*/
height100: {
flex: 1,
},
gridItem: {
flex: 1,
},
},
};
@@ -100,9 +115,10 @@ const VARIANT_STYLES = {
* By default the InfoCard has no custom layout of its children, but is treated as a block element. A
* couple common variants are provided and can be specified via the variant property:
*
* Display the card full height suitable for DataGrid:
* When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items.
* Set to the 'gridItem' variant to display the InfoCard with full height suitable for Grid:
*
* <InfoCard variant="height100">...</InfoCard>
* <InfoCard variant="gridItem">...</InfoCard>
*/
type Props = {
title?: ReactNode;
@@ -142,17 +158,21 @@ export const InfoCard = ({
noPadding,
}: Props): JSX.Element => {
const classes = useStyles();
/**
* If variant is specified, we build up styles for that particular variant for both
* the Card and the CardContent (since these need to be synced)
*/
let calculatedStyle = {};
let calculatedCardStyle = {};
if (variant) {
const variants = variant.split(/[\s]+/g);
variants.forEach(name => {
if (name === 'height100') {
// eslint-disable-next-line no-console
console.warn(
"Variant 'height100' of InfoCard is deprecated. Use variant 'gridItem' instead.",
);
}
calculatedStyle = {
...calculatedStyle,
...VARIANT_STYLES.card[name as keyof typeof VARIANT_STYLES['card']],
@@ -16,7 +16,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderInTestApp, wrapInTestApp } from '@backstage/test-utils';
import { TabbedCard, CardTab } from '.';
const minProps = {
@@ -28,38 +28,32 @@ const minProps = {
};
describe('<TabbedCard />', () => {
it('renders without exploding', () => {
const rendered = render(
wrapInTestApp(
<TabbedCard title={minProps.title}>
<CardTab label="Test 1">Test Content</CardTab>
<CardTab label="Test 2">Test Content</CardTab>
</TabbedCard>,
),
it('renders without exploding', async () => {
const rendered = await renderInTestApp(
<TabbedCard title={minProps.title}>
<CardTab label="Test 1">Test Content</CardTab>
<CardTab label="Test 2">Test Content</CardTab>
</TabbedCard>,
);
expect(rendered.getByText('Some title')).toBeInTheDocument();
});
it('renders a deepLink when prop is set', () => {
const rendered = render(
wrapInTestApp(
<TabbedCard deepLink={minProps.deepLink}>
<CardTab label="Test 1">Test Content</CardTab>
<CardTab label="Test 2">Test Content</CardTab>
</TabbedCard>,
),
it('renders a deepLink when prop is set', async () => {
const rendered = await renderInTestApp(
<TabbedCard deepLink={minProps.deepLink}>
<CardTab label="Test 1">Test Content</CardTab>
<CardTab label="Test 2">Test Content</CardTab>
</TabbedCard>,
);
expect(rendered.getByText('A deepLink title')).toBeInTheDocument();
});
it('switches tabs when clicking', () => {
const rendered = render(
wrapInTestApp(
<TabbedCard>
<CardTab label="Test 1">Test Content 1</CardTab>
<CardTab label="Test 2">Test Content 2</CardTab>
</TabbedCard>,
),
it('switches tabs when clicking', async () => {
const rendered = await renderInTestApp(
<TabbedCard>
<CardTab label="Test 1">Test Content 1</CardTab>
<CardTab label="Test 2">Test Content 2</CardTab>
</TabbedCard>,
);
expect(rendered.getByText('Test Content 1')).toBeInTheDocument();
@@ -53,9 +53,9 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => {
};
const OverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3}>
<Grid container spacing={3} alignItems="stretch">
<Grid item>
<AboutCard entity={entity} />
<AboutCard entity={entity} variant="gridItem" />
</Grid>
</Grid>
);
@@ -77,7 +77,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
title="API"
element={<ApiDocsRouter entity={entity} />}
/>
<EntityPageLayout.Content
<EntityPageLayout.Content
path="/docs/*"
title="Docs"
element={<DocsRouter entity={entity} />}
@@ -97,7 +97,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
title="CI/CD"
element={<CICDSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
<EntityPageLayout.Content
path="/docs/*"
title="Docs"
element={<DocsRouter entity={entity} />}
@@ -112,7 +112,7 @@ const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
title="Overview"
element={<OverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
<EntityPageLayout.Content
path="/docs/*"
title="Docs"
element={<DocsRouter entity={entity} />}