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
+20
View File
@@ -0,0 +1,20 @@
---
'example-app': minor
'@backstage/core': minor
'@backstage/create-app': minor
'@backstage/plugin-catalog': minor
'@backstage/plugin-github-actions': minor
'@backstage/plugin-jenkins': minor
'@backstage/plugin-lighthouse': minor
---
The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead.
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="gridItem">...</InfoCard>`
Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse'
to pass an optional variant to the corresponding card of the plugin.
As a result the overview content of the EntityPage shows cards with full height suitable for Grid.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-catalog-backend': minor
---
- The `CatalogProcessor` API was updated to have `preProcessEntity` and
`postProcessEntity` methods, instead of just one `processEntity`. This makes
it easier to make processors that have several stages in one, and to make
different processors more position independent in the list of processors.
- The `EntityPolicy` is now given directly to the `LocationReaders`, instead of
being enforced inside a policy. We have decided to separate out the act of
validating an entity to be outside of the processing flow, to make it
possible to apply more liberally and to evolve it as a separate concept.
- Because of the above, the `EntityPolicyProcessor` has been removed.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
fix the warning of all the core components test cases
+9
View File
@@ -0,0 +1,9 @@
---
title: AWS Lambda
author: roadie.io
authorUrl: https://roadie.io
category: Monitoring
description: View AWS Lambda functions for your components in Backstage.
documentation: https://roadie.io/backstage/plugins/aws-lambda
iconUrl: https://roadie.io/static/77f62f79e27ae8565496e4df7eef8be5/45f2b/logo.png
npmPackageName: '@roadiehq/backstage-plugin-aws-lambda'
-4
View File
@@ -15,9 +15,6 @@ table tr:nth-child(2n) {
background-color: #2b2b2b;
}
@media only screen and (min-device-width: 360px) and (max-device-width: 736px) {
}
@media only screen and (min-width: 1024px) and (max-width: 1500px) {
/* Experience */
.content-experience {
@@ -857,7 +854,6 @@ code {
position: relative;
padding-top: 75%;
width: 100%;
/* border: 1px dotted #f00; */
}
.Block__Graphic {
+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} />}
@@ -17,6 +17,7 @@
import { UrlReader } from '@backstage/backend-common';
import {
Entity,
EntityPolicy,
ENTITY_DEFAULT_NAMESPACE,
LocationSpec,
} from '@backstage/catalog-model';
@@ -44,6 +45,7 @@ type Options = {
config: Config;
processors: CatalogProcessor[];
rulesEnforcer: CatalogRulesEnforcer;
policy: EntityPolicy;
};
/**
@@ -74,10 +76,12 @@ export class LocationReaders implements LocationReader {
} else if (item.type === 'entity') {
if (rulesEnforcer.isAllowed(item.entity, item.location)) {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
if (entity) {
output.entities.push({
entity,
location: item.location,
});
}
} else {
output.errors.push({
location: item.location,
@@ -162,25 +166,65 @@ export class LocationReaders implements LocationReader {
private async handleEntity(
item: CatalogProcessorEntityResult,
emit: CatalogProcessorEmit,
): Promise<Entity> {
): Promise<Entity | undefined> {
const { processors, logger } = this.options;
let current = item.entity;
// Construct the name carefully, this happens before validation below
// so we do not want to crash here due to missing metadata or so
const kind = current.kind || '';
const namespace = !current.metadata
? ''
: current.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE;
const name = !current.metadata ? '' : current.metadata.name;
for (const processor of processors) {
if (processor.processEntity) {
if (processor.preProcessEntity) {
try {
current = await processor.processEntity(current, item.location, emit);
current = await processor.preProcessEntity(
current,
item.location,
emit,
);
} catch (e) {
// Construct the name carefully, if we got validation errors we do
// not want to crash here due to missing metadata or so
const namespace = !current.metadata
? ''
: current.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE;
const name = !current.metadata ? '' : current.metadata.name;
const message = `Processor ${processor.constructor.name} threw an error while processing entity ${current.kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
}
}
}
try {
const next = await this.options.policy.enforce(current);
if (!next) {
const message = `Policy unexpectedly returned no data while analyzing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
}
current = next;
} catch (e) {
const message = `Policy check failed while analyzing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
emit(result.inputError(item.location, message));
logger.warn(message);
return undefined;
}
for (const processor of processors) {
if (processor.postProcessEntity) {
try {
current = await processor.postProcessEntity(
current,
item.location,
emit,
);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while postprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
}
}
}
@@ -19,7 +19,10 @@ import lodash from 'lodash';
import { CatalogProcessor } from './types';
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
): Promise<Entity> {
return lodash.merge(
{
metadata: {
@@ -224,7 +224,7 @@ describe('CodeOwnersProcessor', () => {
spec: { owner: '@acme/foo-team' },
});
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation(),
);
@@ -235,7 +235,7 @@ describe('CodeOwnersProcessor', () => {
it('should ignore url locations', async () => {
const { entity, processor } = setupTest();
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation({ type: 'url' }),
);
@@ -246,7 +246,7 @@ describe('CodeOwnersProcessor', () => {
it('should ignore invalid kinds', async () => {
const { entity, processor } = setupTest({ kind: 'Group' });
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation(),
);
@@ -257,7 +257,7 @@ describe('CodeOwnersProcessor', () => {
it('should set owner from codeowner', async () => {
const { entity, processor } = setupTest();
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation(),
);
@@ -40,7 +40,10 @@ type Options = {
export class CodeOwnersProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
): Promise<Entity> {
// Only continue if the owner is not set
if (
!entity ||
@@ -1,34 +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 { Entity, EntityPolicy } from '@backstage/catalog-model';
import { CatalogProcessor } from './types';
export class EntityPolicyProcessor implements CatalogProcessor {
private readonly policy: EntityPolicy;
constructor(policy: EntityPolicy) {
this.policy = policy;
}
async processEntity(entity: Entity): Promise<Entity> {
const output = await this.policy.enforce(entity);
if (!output) {
throw new Error(`Entity did not match any known schema`);
}
return output;
}
}
@@ -19,7 +19,7 @@ import * as result from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
export class LocationRefProcessor implements CatalogProcessor {
async processEntity(
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
@@ -46,7 +46,7 @@ describe('PlaceholderProcessor', () => {
reader,
});
await expect(
processor.processEntity(input, { type: 't', target: 'l' }),
processor.preProcessEntity(input, { type: 't', target: 'l' }),
).resolves.toBe(input);
});
@@ -62,7 +62,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -98,7 +98,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -122,7 +122,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -143,7 +143,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -177,7 +177,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -209,7 +209,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -241,7 +241,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -277,7 +277,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -316,7 +316,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -45,7 +45,10 @@ type Options = {
export class PlaceholderProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
): Promise<Entity> {
const process = async (data: any): Promise<[any, boolean]> => {
if (!data || !(data instanceof Object)) {
// Scalars can't have placeholders
@@ -23,7 +23,6 @@ export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcess
export { AzureApiReaderProcessor } from './AzureApiReaderProcessor';
export { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor';
export { CodeOwnersProcessor } from './CodeOwnersProcessor';
export { EntityPolicyProcessor } from './EntityPolicyProcessor';
export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { GithubReaderProcessor } from './GithubReaderProcessor';
@@ -46,15 +46,33 @@ export type CatalogProcessor = {
): Promise<boolean>;
/**
* Processes an emitted entity, e.g. by validating or modifying it.
* Pre-processes an emitted entity, after it has been emitted but before it
* has been validated.
*
* @param entity The entity to process
* This type of processing usually involves enriching the entity with
* additional data, and the input entity may actually still be incomplete
* when the processor is invoked.
*
* @param entity The (possibly partial) entity to process
* @param location The location that the entity came from
* @param read Reads the contents of a location
* @param emit A sink for auxiliary items resulting from the processing
* @returns The same entity or a modified version of it
*/
processEntity?(
preProcessEntity?(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity>;
/**
* Post-processes an emitted entity, after it has been validated.
*
* @param entity The entity to process
* @param location The location that the entity came from
* @param emit A sink for auxiliary items resulting from the processing
* @returns The same entity or a modified version of it
*/
postProcessEntity?(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
@@ -49,49 +49,6 @@ describe('CatalogBuilder', () => {
reader.read.mockResolvedValue(Buffer.from('junk'));
const builder = new CatalogBuilder(env)
.replaceReaderProcessors([
{
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
) {
expect(location.type).toBe('test');
emit(result.data(location, await reader.read('ignored')));
return true;
},
},
])
.replaceParserProcessors([
{
async parseData(
data: Buffer,
location: LocationSpec,
emit: CatalogProcessorEmit,
) {
expect(data.toString()).toEqual('junk');
emit(
result.entity(location, {
apiVersion: 'av',
kind: 'Component',
metadata: { name: 'n' },
}),
);
return true;
},
},
])
.replacePreProcessors([
{
async processEntity(entity: Entity) {
expect(entity.apiVersion).toBe('av');
return {
...entity,
metadata: { ...entity.metadata, namespace: 'ns' },
};
},
},
])
.replaceEntityPolicies([
{
async enforce(entity: Entity) {
@@ -108,9 +65,51 @@ describe('CatalogBuilder', () => {
},
},
])
.replacePostProcessors([
.setPlaceholderResolver('t', async ({ value }) => {
expect(value).toBe('tt');
return 'tt2';
})
.setFieldFormatValidators({
isValidEntityName: n => {
expect(n).toBe('n');
return true;
},
})
.replaceProcessors([
{
async processEntity(entity: Entity) {
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
) {
expect(location.type).toBe('test');
emit(result.data(location, await reader.read('ignored')));
return true;
},
async parseData(
data: Buffer,
location: LocationSpec,
emit: CatalogProcessorEmit,
) {
expect(data.toString()).toEqual('junk');
emit(
result.entity(location, {
apiVersion: 'av',
kind: 'Component',
metadata: { name: 'n', replaced: { $t: 'tt' } },
}),
);
return true;
},
async preProcessEntity(entity: Entity) {
expect(entity.apiVersion).toBe('av');
return {
...entity,
metadata: { ...entity.metadata, namespace: 'ns' },
};
},
async postProcessEntity(entity: Entity) {
expect(entity.metadata.namespace).toBe('ns');
return {
...entity,
metadata: { ...entity.metadata, post: 'p' },
@@ -124,6 +123,7 @@ describe('CatalogBuilder', () => {
type: 'test',
target: '',
});
expect.assertions(8);
expect(added.entities).toEqual([
{
apiVersion: 'av',
@@ -132,6 +132,7 @@ describe('CatalogBuilder', () => {
name: 'n',
namespace: 'ns',
post: 'p',
replaced: 'tt2',
}),
},
]);
@@ -26,7 +26,6 @@ import {
locationEntityV1alpha1Policy,
makeValidator,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
templateEntityV1alpha1Policy,
userEntityV1alpha1Policy,
@@ -48,7 +47,6 @@ import {
BitbucketApiReaderProcessor,
CatalogProcessor,
CodeOwnersProcessor,
EntityPolicyProcessor,
FileReaderProcessor,
GithubOrgReaderProcessor,
GithubReaderProcessor,
@@ -84,36 +82,24 @@ export type CatalogEnvironment = {
*
* The touch points where you can replace or extend behavior are as follows:
*
* - Reader processors can be added or replaced. These implement the
* functionality of reading raw data from a location, in the form of an
* entity definition file.
* - Parser processors can be added or replaced. These accept the raw data as
* read by the previous processors and parse it into raw structured data
* (for example, from a binary buffer containing yaml text, to a JS object
* structure).
* - Entity policies can be added or replaced. These are automatically run
* after the processors' pre-processing steps. All policies are given the
* chance to inspect the entity, and all of them have to pass in order for
* the entity to be considered valid from an overall point of view.
* - Entity kinds can be added or replaced. These are the second line of
* validation that is applied after the entity policies, which adds
* additional kind-specific validation (usually based on a schema). Only one
* of the entity kinds has to accept the entity, but if none of them do, the
* entity is rejected as a whole.
* - Placeholder resolvers can be replaced or added. These run on the raw
* structured data between the parsing and pre-processing steps, to replace
* dollar-prefixed entries with their actual values (like $file).
* - Pre-processors can be added or replaced. These take the raw unvalidated
* data from the parser processors and can enrich or extend it before
* validation. This is the place where for example codeowners data can be
* injected into partial entity definitions.
* - Entity policies can be added or replaced. These are the first line of
* validation from the output of the pre-processing step. All policies are
* given the chance to inspect the entity, and all of them have to pass in
* order for the entity to be considered valid from an overall point of
* view.
* - Field format validators can be replaced. These check the format of
* individual core fields such as metadata.name, such that they adhere to
* certain rules.
* - Entity kinds can be added or replaced. These are the second line of
* validation that is applied after the entity policies, which add additional
* kind-specific validation (usually based on a schema). Only one of the
* entity kinds has to accept the entity, but if none of them do, the
* entity is rejected as a whole.
* - Post-processors can be added or replaced. These take the validated
* entities out of the validation step and can perform additional actions on
* them.
* individual core fields such as metadata.name, to ensure that they adhere
* to certain rules.
* - Processors can be added or replaced. These implement the functionality of
* reading, parsing and processing the entity data before it is persisted in
* the catalog.
*/
export class CatalogBuilder {
private readonly env: CatalogEnvironment;
@@ -121,16 +107,10 @@ export class CatalogBuilder {
private entityPoliciesReplace: boolean;
private entityKinds: EntityPolicy[];
private entityKindsReplace: boolean;
private readerProcessors: CatalogProcessor[];
private readerProcessorsReplace: boolean;
private parserProcessors: CatalogProcessor[];
private parserProcessorsReplace: boolean;
private preProcessors: CatalogProcessor[];
private preProcessorsReplace: boolean;
private postProcessors: CatalogProcessor[];
private postProcessorsReplace: boolean;
private placeholderResolvers: Record<string, PlaceholderResolver>;
private fieldFormatValidators: Partial<Validators>;
private processors: CatalogProcessor[];
private processorsReplace: boolean;
constructor(env: CatalogEnvironment) {
this.env = env;
@@ -138,16 +118,10 @@ export class CatalogBuilder {
this.entityPoliciesReplace = false;
this.entityKinds = [];
this.entityKindsReplace = false;
this.readerProcessors = [];
this.readerProcessorsReplace = false;
this.parserProcessors = [];
this.parserProcessorsReplace = false;
this.preProcessors = [];
this.preProcessorsReplace = false;
this.postProcessors = [];
this.postProcessorsReplace = false;
this.placeholderResolvers = {};
this.fieldFormatValidators = {};
this.processors = [];
this.processorsReplace = false;
}
/**
@@ -212,114 +186,6 @@ export class CatalogBuilder {
return this;
}
/**
* Adds processors that support reading of definition files. These are run
* before the entities are parsed, pre-processed, validated and post-
* processed.
*
* @param processors One or more processors
*/
addReaderProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.readerProcessors.push(...processors);
return this;
}
/**
* Sets what processors to use for the reading of definition files. These are
* run before the entities are parsed, pre-processed, validated and post-
* processed.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replaceReaderProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.readerProcessors = [...processors];
this.readerProcessorsReplace = true;
return this;
}
/**
* Adds processors that run after each definition file has been read, in
* order to parse the raw data. These are run before the entities are
* pre-processed, validated and post-processed.
*
* @param processors One or more processors
*/
addParserProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.parserProcessors.push(...processors);
return this;
}
/**
* Sets what processors to run after each definition file has been read, in
* order to parse the raw data. These are run before the entities are
* pre-processed, validated and post-processed.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replaceParserProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.parserProcessors = [...processors];
this.parserProcessorsReplace = true;
return this;
}
/**
* Adds processors that run after each entity has been read and parsed,
* but before being validated and post-processed.
*
* @param processors One or more processors
*/
addPreProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.preProcessors.push(...processors);
return this;
}
/**
* Sets what processors to run after each entity has been read and parsed,
* but before being validated and post-processed.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replacePreProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.preProcessors = [...processors];
this.preProcessorsReplace = true;
return this;
}
/**
* Adds processors that run after each entity has been read, parsed,
* run through the pre-processors, and validated.
*
* @param processors One or more processors
*/
addPostProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.postProcessors.push(...processors);
return this;
}
/**
* Sets what processors to run after each entity has been read, parsed,
* run through the pre-processors, and validated.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replacePostProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.postProcessors = [...processors];
this.postProcessorsReplace = true;
return this;
}
/**
* Adds, or overwrites, a handler for placeholders (e.g. $file) in entity
* definition files.
@@ -327,8 +193,12 @@ export class CatalogBuilder {
* @param key The key that identifies the placeholder, e.g. "file"
* @param resolver The resolver that gets values for this placeholder
*/
setPlaceholderResolver(key: string, resolver: PlaceholderResolver) {
setPlaceholderResolver(
key: string,
resolver: PlaceholderResolver,
): CatalogBuilder {
this.placeholderResolvers[key] = resolver;
return this;
}
/**
@@ -341,8 +211,34 @@ export class CatalogBuilder {
*
* @param validators The (subset of) validators to set
*/
setFieldFormatValidators(validators: Partial<Validators>) {
setFieldFormatValidators(validators: Partial<Validators>): CatalogBuilder {
lodash.merge(this.fieldFormatValidators, validators);
return this;
}
/**
* Adds entity processors. These are responsible for reading, parsing, and
* processing entities before they are persisted in the catalog.
*
* @param processors One or more processors
*/
addProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.processors.push(...processors);
return this;
}
/**
* Sets what entity processors to use. These are responsible for reading,
* parsing, and processing entities before they are persisted in the catalog.
*
* This function replaces the default set of processors; use with care.
*
* @param processors One or more processors
*/
replaceProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.processors = [...processors];
this.processorsReplace = true;
return this;
}
/**
@@ -355,14 +251,15 @@ export class CatalogBuilder {
}> {
const { config, database, logger } = this.env;
const entityPolicy = this.buildEntityPolicy();
const processors = this.buildProcessors(entityPolicy);
const policy = this.buildEntityPolicy();
const processors = this.buildProcessors();
const rulesEnforcer = CatalogRulesEnforcer.fromConfig(config);
const locationReader = new LocationReaders({
...this.env,
processors,
rulesEnforcer,
policy,
});
const db = await DatabaseManager.createDatabase(
@@ -396,7 +293,6 @@ export class CatalogBuilder {
new FieldFormatEntityPolicy(
makeValidator(this.fieldFormatValidators),
),
new ReservedFieldsEntityPolicy(),
...this.entityPolicies,
];
@@ -418,106 +314,74 @@ export class CatalogBuilder {
]);
}
private buildProcessors(entityPolicy: EntityPolicy): CatalogProcessor[] {
const { config, reader } = this.env;
private buildProcessors(): CatalogProcessor[] {
const { config, logger, reader } = this.env;
const placeholderResolvers = lodash.merge(
{
json: jsonPlaceholderResolver,
yaml: yamlPlaceholderResolver,
text: textPlaceholderResolver,
},
this.placeholderResolvers,
);
const placeholderResolvers: Record<string, PlaceholderResolver> = {
json: jsonPlaceholderResolver,
yaml: yamlPlaceholderResolver,
text: textPlaceholderResolver,
...this.placeholderResolvers,
};
const processors = this.processorsReplace
? this.processors
: [
new FileReaderProcessor(),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
LdapOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
new YamlProcessor(),
new CodeOwnersProcessor({ reader }),
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
];
return [
StaticLocationProcessor.fromConfig(config),
...this.buildReaderProcessors(),
...this.buildParserProcessors(),
new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }),
...this.buildPreProcessors(),
new EntityPolicyProcessor(entityPolicy),
...this.buildPostProcessors(),
...this.buildDeprecatedReaderProcessors(),
...processors,
];
}
private buildReaderProcessors(): CatalogProcessor[] {
const { config, logger, reader } = this.env;
// TODO(Rugvip): These are added for backwards compatibility if config exists
// The idea is to have everyone migrate from using the old processors to
// the new integration config driven UrlReaders. In an upcoming release we
// can then completely remove support for the old processors, but still
// keep handling the deprecated location types for a while, but with a
// warning.
private buildDeprecatedReaderProcessors(): CatalogProcessor[] {
const { config, logger } = this.env;
if (this.readerProcessorsReplace) {
return this.readerProcessors;
}
// TODO(Rugvip): These are added for backwards compatibility if config exists
// The idea is to have everyone migrate from using the old processors to the new
// integration config driven UrlReaders. In an upcoming release we can then completely
// remove support for the old processors, but still keep handling the deprecated location
// types for a while, but with a warning.
const oldProcessors = [];
const result = [];
const pc = config.getOptionalConfig('catalog.processors');
if (pc?.has('github')) {
logger.warn(
`Using deprecated configuration for catalog.processors.github, move to using integrations.github instead`,
);
oldProcessors.push(GithubReaderProcessor.fromConfig(config, logger));
result.push(GithubReaderProcessor.fromConfig(config, logger));
}
if (pc?.has('gitlabApi')) {
logger.warn(
`Using deprecated configuration for catalog.processors.gitlabApi, move to using integrations.gitlab instead`,
);
oldProcessors.push(new GitlabApiReaderProcessor(config));
oldProcessors.push(new GitlabReaderProcessor());
result.push(new GitlabApiReaderProcessor(config));
result.push(new GitlabReaderProcessor());
}
if (pc?.has('bitbucketApi')) {
logger.warn(
`Using deprecated configuration for catalog.processors.bitbucketApi, move to using integrations.bitbucket instead`,
);
oldProcessors.push(new BitbucketApiReaderProcessor(config));
result.push(new BitbucketApiReaderProcessor(config));
}
if (pc?.has('azureApi')) {
logger.warn(
`Using deprecated configuration for catalog.processors.azureApi, move to using integrations.azure instead`,
);
oldProcessors.push(new AzureApiReaderProcessor(config));
result.push(new AzureApiReaderProcessor(config));
}
return [
new FileReaderProcessor(),
...oldProcessors,
GithubOrgReaderProcessor.fromConfig(config, { logger }),
LdapOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
...this.readerProcessors,
];
}
private buildParserProcessors(): CatalogProcessor[] {
if (this.parserProcessorsReplace) {
return this.parserProcessors;
}
return [new YamlProcessor(), ...this.parserProcessors];
}
private buildPreProcessors(): CatalogProcessor[] {
const { reader } = this.env;
if (this.preProcessorsReplace) {
return this.preProcessors;
}
return [new CodeOwnersProcessor({ reader }), ...this.preProcessors];
}
private buildPostProcessors(): CatalogProcessor[] {
if (this.postProcessorsReplace) {
return this.postProcessors;
}
return [
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
...this.postProcessors,
];
return result;
}
}
@@ -14,24 +14,23 @@
* limitations under the License.
*/
import React from 'react';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
Grid,
Typography,
makeStyles,
Chip,
IconButton,
Card,
CardContent,
CardHeader,
Chip,
Divider,
Grid,
IconButton,
makeStyles,
Typography,
} from '@material-ui/core';
import { Entity } from '@backstage/catalog-model';
import GitHubIcon from '@material-ui/icons/GitHub';
import { IconLinkVertical } from './IconLinkVertical';
import EditIcon from '@material-ui/icons/Edit';
import DocsIcon from '@material-ui/icons/Description';
import EditIcon from '@material-ui/icons/Edit';
import GitHubIcon from '@material-ui/icons/GitHub';
import React from 'react';
import { IconLinkVertical } from './IconLinkVertical';
const useStyles = makeStyles(theme => ({
links: {
@@ -59,6 +58,15 @@ const useStyles = makeStyles(theme => ({
description: {
wordBreak: 'break-word',
},
gridItemCard: {
display: 'flex',
flexDirection: 'column',
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
},
gridItemCardContent: {
flex: 1,
},
}));
const iconMap: Record<string, React.ReactNode> = {
@@ -83,14 +91,15 @@ function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
type AboutCardProps = {
entity: Entity;
variant?: string;
};
export function AboutCard({ entity }: AboutCardProps) {
export function AboutCard({ entity, variant }: AboutCardProps) {
const classes = useStyles();
const codeLink = getCodeLinkInfo(entity);
return (
<Card>
<Card className={variant === 'gridItem' ? classes.gridItemCard : ''}>
<CardHeader
title="About"
action={
@@ -107,15 +116,17 @@ export function AboutCard({ entity }: AboutCardProps) {
}
label="View Techdocs"
icon={<DocsIcon />}
href={`/docs/${entity.kind}:${entity.metadata.namespace ?? ''}:${
entity.metadata.name
}`}
href={`/docs/${
entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE
}/${entity.kind}/${entity.metadata.name}`}
/>
</nav>
}
/>
<Divider />
<CardContent>
<CardContent
className={variant === 'gridItem' ? classes.gridItemCardContent : ''}
>
<Grid container>
<AboutField label="Description" gridSizes={{ xs: 12 }}>
<Typography
@@ -30,7 +30,7 @@ import {
import { Box, useTheme } from '@material-ui/core';
import BarChartTick from './BarChartTick';
import BarChartStepper from './BarChartStepper';
import Tooltip, { TooltipItemProps } from '../Tooltip';
import { Tooltip, TooltipItemProps } from '../Tooltip';
import { currencyFormatter } from '../../utils/formatters';
import {
@@ -16,7 +16,7 @@
import React from 'react';
import moment from 'moment';
import { TooltipPayload, TooltipProps } from 'recharts';
import Tooltip, { TooltipItemProps } from '../../components/Tooltip';
import { Tooltip, TooltipItemProps } from '../../components/Tooltip';
import { DEFAULT_DATE_FORMAT } from '../../types';
export type CostOverviewTooltipProps = TooltipProps & {
@@ -14,5 +14,7 @@
* limitations under the License.
*/
export { default } from './Tooltip';
export * from './TooltipItem';
export { default as Tooltip } from './Tooltip';
export type { TooltipProps } from './Tooltip';
export { default as TooltipItem } from './TooltipItem';
export type { TooltipItemProps } from './TooltipItem';
@@ -16,3 +16,4 @@
export { default as BarChart } from './BarChart';
export { default as LegendItem } from './LegendItem';
export * from './Tooltip';
+1
View File
@@ -17,4 +17,5 @@
export { plugin } from './plugin';
export * from './api';
export * from './components';
export { useCurrency } from './hooks';
export * from './types';
+1 -1
View File
@@ -17,7 +17,7 @@
export type Step = {
name: string;
status: string;
conclusion: string;
conclusion?: string;
number: number; // starts from 1
started_at: string;
completed_at: string;
@@ -81,10 +81,9 @@ const WidgetContent = ({
export const LatestWorkflowRunCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
// Display the card full height suitable for
variant,
}: Props) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/'
@@ -102,7 +101,7 @@ export const LatestWorkflowRunCard = ({
}, [error, errorApi]);
return (
<InfoCard title={`Last ${branch} build`}>
<InfoCard title={`Last ${branch} build`} variant={variant}>
<WidgetContent
error={error}
loading={loading}
@@ -113,14 +112,18 @@ export const LatestWorkflowRunCard = ({
);
};
type Props = {
entity: Entity;
branch: string;
variant?: string;
};
export const LatestWorkflowsForBranchCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => (
<InfoCard title={`Last ${branch} build`}>
variant,
}: Props) => (
<InfoCard title={`Last ${branch} build`} variant={variant}>
<WorkflowRunsTable branch={branch} entity={entity} />
</InfoCard>
);
@@ -18,9 +18,9 @@ import { errorApiRef, useApi } from '@backstage/core-api';
import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName';
import { useWorkflowRuns } from '../useWorkflowRuns';
import React, { useEffect } from 'react';
import { EmptyState, Table } from '@backstage/core';
import { EmptyState, InfoCard, Table } from '@backstage/core';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import { Button, Card, Link, TableContainer } from '@material-ui/core';
import { Button, Link } from '@material-ui/core';
import { generatePath, Link as RouterLink } from 'react-router-dom';
const firstLine = (message: string): string => message.split('\n')[0];
@@ -30,6 +30,7 @@ export type Props = {
branch?: string;
dense?: boolean;
limit?: number;
variant?: string;
};
export const RecentWorkflowRunsCard = ({
@@ -37,6 +38,7 @@ export const RecentWorkflowRunsCard = ({
branch,
dense = false,
limit = 5,
variant,
}: Props) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
@@ -70,15 +72,19 @@ export const RecentWorkflowRunsCard = ({
}
/>
) : (
<TableContainer component={Card}>
<InfoCard
title="Recent Workflow Runs"
subheader={branch ? `Branch: ${branch}` : 'All Branches'}
noPadding
variant={variant}
>
<Table
title="Recent Workflow Runs"
subtitle={branch ? `Branch: ${branch}` : 'All Branches'}
isLoading={loading}
options={{
search: false,
paging: false,
padding: dense ? 'dense' : 'default',
toolbar: false,
}}
columns={[
{
@@ -98,6 +104,6 @@ export const RecentWorkflowRunsCard = ({
]}
data={runs}
/>
</TableContainer>
</InfoCard>
);
};
@@ -77,8 +77,9 @@ const JobsList = ({ jobs, entity }: { jobs?: Jobs; entity: Entity }) => {
<Box>
{jobs &&
jobs.total_count > 0 &&
jobs.jobs.map((job: Job) => (
jobs.jobs.map(job => (
<JobListItem
key={job.id}
job={job}
className={
job.status !== 'success' ? classes.failed : classes.success
@@ -108,7 +109,7 @@ const StepView = ({ step }: { step: Step }) => {
<TableCell>
<WorkflowRunStatus
status={step.status.toUpperCase()}
conclusion={step.conclusion.toUpperCase()}
conclusion={step.conclusion?.toUpperCase()}
/>
</TableCell>
</TableRow>
@@ -142,8 +143,8 @@ const JobListItem = ({
<AccordionDetails className={classes.accordionDetails}>
<TableContainer>
<Table>
{job.steps.map((step: Step) => (
<StepView step={step} />
{job.steps.map(step => (
<StepView key={step.number} step={step} />
))}
</Table>
</TableContainer>
@@ -61,12 +61,18 @@ const WidgetContent = ({
);
};
export const LatestRunCard = ({ branch = 'master' }: { branch: string }) => {
export const LatestRunCard = ({
branch = 'master',
variant,
}: {
branch: string;
variant?: string;
}) => {
const { owner, repo } = useProjectSlugFromEntity();
const [{ builds, loading }] = useBuilds(owner, repo, branch);
const lastRun = builds ?? {};
return (
<InfoCard title={`Last ${branch} build`}>
<InfoCard title={`Last ${branch} build`} variant={variant}>
<WidgetContent loading={loading} branch={branch} lastRun={lastRun} />
</InfoCard>
);
@@ -88,9 +88,10 @@ const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({
return <StructuredMetadataTable metadata={tableData} dense={dense} />;
};
export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
dense = false,
}) => {
export const LastLighthouseAuditCard: FC<{
dense?: boolean;
variant?: string;
}> = ({ dense = false, variant }) => {
const { value: website, loading, error } = useWebsiteForEntity();
let content;
@@ -105,5 +106,9 @@ export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
<LighthouseAuditSummary audit={website.lastAudit} dense={dense} />
);
}
return <InfoCard title="Lighthouse Audit">{content}</InfoCard>;
return (
<InfoCard title="Lighthouse Audit" variant={variant}>
{content}
</InfoCard>
);
};
+2 -2
View File
@@ -28,8 +28,8 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@rjsf/core": "^2.1.0",
"@rjsf/material-ui": "^2.1.0",
"@rjsf/core": "^2.4.0",
"@rjsf/material-ui": "^2.4.0",
"classnames": "^2.2.6",
"moment": "^2.26.0",
"react": "^16.13.1",
+55 -15
View File
@@ -3644,15 +3644,10 @@
prop-types "^15.6.1"
react-lifecycles-compat "^3.0.4"
"@rehooks/local-storage@^2.4.0":
"@rjsf/core@^2.4.0":
version "2.4.0"
resolved "https://registry.npmjs.org/@rehooks/local-storage/-/local-storage-2.4.0.tgz#fb884b2b657cad5f77aa6ab60bb3532f0e0725d2"
integrity sha512-LoXDbEHsuIckVgBsFAv8SuU/M7memjyfWut9Zf36TQXqqCHBRFv8bweg9PymQCa1aWIMjNrZQflFdo55FDlXYg==
"@rjsf/core@^2.1.0":
version "2.3.0"
resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.3.0.tgz#334c73d2262ef1a8cda477e238067af7336c5599"
integrity sha512-OZKYHt9tjKhzOH4CvsPiCwepuIacqI++cNmnL2fsxh1IF+uEWGlo3NLDWhhSaBbOv9jps6a5YQcLbLtjNuSwug==
resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.4.0.tgz#c50bcff0d8178948ce08123177399d8816d51929"
integrity sha512-8zlydBkGldOxGXFEwNGFa1gzTxpcxaYn7ofegcu8XHJ7IKMCfpnU3ABg+H3eml1KZCX3FODmj1tHFJKuTmfynw==
dependencies:
"@babel/runtime-corejs2" "^7.8.7"
"@types/json-schema" "^7.0.4"
@@ -3666,10 +3661,10 @@
react-is "^16.9.0"
shortid "^2.2.14"
"@rjsf/material-ui@^2.1.0":
version "2.3.0"
resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.3.0.tgz#a051eb4db2ad778e39933a31ba806f415dd3ba90"
integrity sha512-v/xZ4Xk18ZgBARcCe99IDqdO97GU0UC784gG8PhGGFDdy5Rbdy7ixTjHkGYttkr43PB7SX6ttohNhkKSW4nATQ==
"@rjsf/material-ui@^2.4.0":
version "2.4.0"
resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.4.0.tgz#1b5859298bf3f61137d7b05084f058a775d6fd73"
integrity sha512-U8F/suzg4MuV+8mK1/ufs0Y6c3O8hc1wnuD2IKoOVJvegGfz5JCafyoyGAW6iyuT1DZBMPzVWEqfiuYPmoE7pw==
"@roadiehq/backstage-plugin-github-insights@^0.2.7":
version "0.2.7"
@@ -3691,6 +3686,51 @@
react-router "^6.0.0-beta.0"
react-use "^15.3.3"
"@roadiehq/backstage-plugin-github-pull-requests@^0.5.2":
version "0.5.2"
resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.5.2.tgz#455355fe5cdcc43c22b80a8ef3697378084680a1"
integrity sha512-GM+nWQZ+aehMclN70Pp35UQe3I46SQCjWiGZ6/0TfDzrueHln39L2UspUj1L/713xDDtDK8ZnTJdFn/qKx7j4A==
dependencies:
"@backstage/catalog-model" "^0.1.1-alpha.25"
"@backstage/core" "^0.1.1-alpha.25"
"@backstage/plugin-catalog" "^0.1.1-alpha.25"
"@backstage/theme" "^0.1.1-alpha.25"
"@material-ui/core" "^4.11.0"
"@material-ui/icons" "^4.9.1"
"@material-ui/lab" "^4.0.0-alpha.56"
"@octokit/rest" "^18.0.0"
"@octokit/types" "^5.0.1"
"@types/react-dom" "^16.9.8"
history "^5.0.0"
moment "^2.27.0"
react "^16.13.1"
react-dom "^16.13.1"
react-router "6.0.0-beta.0"
react-use "^15.3.3"
"@roadiehq/backstage-plugin-travis-ci@^0.2.3":
version "0.2.3"
resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.2.3.tgz#2d1c5a7ed3eab4fdcf95243b73dee44c0cff5a57"
integrity sha512-Xi8vZFbcm+D4ykyU3I9hImAd22F1v6M1F/H9CJanRHqj5esJxnuCS2+Kf+w09bwb9DuM8/c68PuMlf3PMIsOwg==
dependencies:
"@backstage/catalog-model" "^0.1.1-alpha.22"
"@backstage/core" "^0.1.1-alpha.22"
"@backstage/core-api" "^0.1.1-alpha.22"
"@backstage/plugin-catalog" "^0.1.1-alpha.22"
"@backstage/theme" "^0.1.1-alpha.22"
"@material-ui/core" "^4.9.1"
"@material-ui/icons" "^4.9.1"
"@material-ui/lab" "4.0.0-alpha.45"
date-fns "^2.15.0"
history "^5.0.0"
moment "^2.27.0"
react "^16.13.1"
react-dom "^16.13.1"
react-lazylog "^4.5.2"
react-router "6.0.0-beta.0"
react-router-dom "6.0.0-beta.0"
react-use "^15.3.3"
"@rollup/plugin-commonjs@^13.0.0":
version "13.0.0"
resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-13.0.0.tgz#8a1d684ba6848afe8b9e3d85649d4b2f6f7217ec"
@@ -17202,9 +17242,9 @@ object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
object-path@^0.11.4:
version "0.11.4"
resolved "https://registry.npmjs.org/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949"
integrity sha1-NwrnUvvzfePqcKhhwju6iRVpGUk=
version "0.11.5"
resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.5.tgz#d4e3cf19601a5140a55a16ad712019a9c50b577a"
integrity sha512-jgSbThcoR/s+XumvGMTMf81QVBmah+/Q7K7YduKeKVWL7N111unR2d6pZZarSk6kY/caeNxUDyxOvMWyzoU2eg==
object-visit@^1.0.0:
version "1.0.1"