Merge branch 'master' of github.com:spotify/backstage into migrate-to-msw

* 'master' of github.com:spotify/backstage: (34 commits)
  TechDocs Backend: Replace hardcoded github api by configuration value (#3004)
  fix: accordion details design for job stage failure  (#3014)
  fix: responsive of page example in storybook (#3005)
  fix: update the ItemCard component and it's story (#3007)
  rename stories folder to Chip (#3009)
  feat: Display the plugins InfoCards on EntityPage Overwiev suitable full height (#2826)
  fix(cost-insights): More exports to help custom alerting
  chore(scaffolder): bump rjsf
  remove unused CSS (#2999)
  feat: update github pull requests package version
  fix(github-actions): fix crash when viewing ongoing workflows
  fix(cli): removePlugin forgotten await
  catalog-backend: relations db review comments and more tests
  Feat/add aws lambda plugin (#2985)
  chore(deps): bump object-path from 0.11.4 to 0.11.5 (#2983)
  add changeset
  fix CodeSnippet test case warnings
  fix CopyTextButton test case warnings
  fix the warning by wrapping in act
  fix(catalog): fix link to techdocs
  ...
This commit is contained in:
blam
2020-10-21 11:27:18 +02:00
105 changed files with 1276 additions and 994 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/plugin-scaffolder': patch
---
fix the accordion details design when job stage fail
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
fix the warning of all the core components test cases
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
rename stories folder top Chip
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Replacing the hard coded `baseApiUrl` by reading the value from configuration to enable private GitHub setup for TechDocs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
update ItemCard component and it's story
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
fix the responsive of page story
+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'
+1 -1
View File
@@ -15,7 +15,7 @@
},
"devDependencies": {
"@spotify/prettier-config": "^8.0.0",
"docusaurus": "^2.0.0-alpha.65",
"docusaurus": "^2.0.0-alpha.66",
"js-yaml": "^3.14.0",
"prettier": "^2.0.5"
},
-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 {
+4 -4
View File
@@ -2233,10 +2233,10 @@ dir-glob@2.0.0:
arrify "^1.0.1"
path-type "^3.0.0"
docusaurus@^2.0.0-alpha.65:
version "2.0.0-alpha.65"
resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.65.tgz#e2b84985529deb79797aff52aa39ffbe03cd8a20"
integrity sha512-OEnKbXLPy3EpbQA/Dj0kakB6fg/Qni1LORTIero+5Q5G0QT5OH4FAbBQOy3L1qQH0S5Bv18zESxX9Nvclg20ag==
docusaurus@^2.0.0-alpha.66:
version "2.0.0-alpha.66"
resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.66.tgz#4dece48b838f773c4c973d9fe2546dd0bf2637cf"
integrity sha512-/HmRt3hEqpvZRdR2JRYKpwNUgalO14exxuwQu2vXW5eYzcKbQ922+3SxG7mF+LE8f7KOmgY+A3eS3SEyq+S3XA==
dependencies:
"@babel/core" "^7.9.0"
"@babel/plugin-proposal-class-properties" "^7.8.3"
+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) && (
@@ -6,7 +6,7 @@ metadata:
spec:
type: github
targets:
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/spotify-api.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/swapi-graphql.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/hello-world-api.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/streetlights-api.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/spotify-api.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/swapi-graphql.yaml
@@ -6,11 +6,11 @@ metadata:
spec:
type: github
targets:
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml
- https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml
@@ -15,6 +15,7 @@
*/
import { JsonObject } from '@backstage/config';
import { EntityName } from '../types';
/**
* The format envelope that's common to all versions/kinds of entity.
@@ -42,6 +43,11 @@ export type Entity = {
* The specification data describing the entity itself.
*/
spec?: JsonObject;
/**
* The relations that this entity has with other entities.
*/
relations?: EntityRelation[];
};
/**
@@ -120,3 +126,37 @@ export type EntityMeta = JsonObject & {
*/
tags?: string[];
};
/**
* A relation of a specific type to another entity in the catalog.
*/
export type EntityRelation = {
/**
* The type of the relation.
*/
type: string;
/**
* The target entity of this relation.
*/
target: EntityName;
};
/**
* Holds the relationship data for entities
*/
export type EntityRelationSpec = {
/**
* The source entity of this relation.
*/
source: EntityName;
/**
* The type of the relation.
*/
type: string;
/**
* The target entity of this relation.
*/
target: EntityName;
};
+6 -1
View File
@@ -18,7 +18,12 @@ export {
ENTITY_DEFAULT_NAMESPACE,
ENTITY_META_GENERATED_FIELDS,
} from './constants';
export type { Entity, EntityMeta } from './Entity';
export type {
Entity,
EntityMeta,
EntityRelation,
EntityRelationSpec,
} from './Entity';
export * from './policies';
export {
getEntityName,
@@ -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']],
@@ -24,7 +24,7 @@ export default {
export const Default = () => (
<Grid container spacing={4}>
<Grid item xs={3}>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card"
@@ -33,7 +33,7 @@ export const Default = () => (
onClick={() => {}}
/>
</Grid>
<Grid item xs={3}>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is the description of an Item Card"
@@ -47,18 +47,18 @@ export const Default = () => (
export const Tags = () => (
<Grid container spacing={4}>
<Grid item xs={3}>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is a Item Card"
tags={['one tag', 'two tag']}
description="This is the description of an Item Card with Tags"
tags={['one tag', 'one tag']}
label="Button"
/>
</Grid>
<Grid item xs={3}>
<Grid item xs={12} md={3}>
<ItemCard
title="Item Card"
description="This is a Item Card"
description="This is the description of an Item Card with Tags"
tags={['one tag', 'two tag']}
label="Button"
/>
@@ -0,0 +1,51 @@
/*
* 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 React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { ItemCard } from './ItemCard';
const minProps = {
description: 'This is the description of an Item Card',
label: 'Button',
title: 'Item Card',
type: 'Pretitle',
};
describe('<InfoCard />', () => {
it('renders default without exploding', async () => {
const { description, label, title, type } = minProps;
const { getByText } = await renderInTestApp(<ItemCard {...minProps} />);
expect(getByText(description)).toBeInTheDocument();
expect(getByText(title)).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument();
expect(getByText(type)).toBeInTheDocument();
});
it('renders with tags without exploding', async () => {
const { description, label, title } = minProps;
const tags = ['tag one', 'tag two'];
const { getByText } = await renderInTestApp(
<ItemCard {...minProps} tags={tags} />,
);
expect(getByText(description)).toBeInTheDocument();
expect(getByText(title)).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument();
tags.forEach(tag => {
expect(getByText(tag)).toBeInTheDocument();
});
});
});
@@ -61,8 +61,8 @@ export const ItemCard: FC<ItemCardProps> = ({
<Typography variant="h6">{title}</Typography>
</div>
<div className={classes.content}>
{tags?.map(tag => (
<Chip label={tag} key={tag} />
{tags?.map((tag, i) => (
<Chip label={tag} key={`tag-${i}`} />
))}
<Typography variant="body2" paragraph className={classes.description}>
{description}
@@ -118,14 +118,14 @@ const DataGrid = () => (
justify="space-between"
direction="row"
>
<Grid item xs={6}>
<Grid item xs={12} md={6}>
<GaugeCard
title="GKE Usage Score"
subheader="This should be above 75%"
progress={0.87}
/>
</Grid>
<Grid item xs={6}>
<Grid item xs={12} md={6}>
<GaugeCard
title="Deployment Score"
subheader="This should be above 40%"
@@ -174,7 +174,7 @@ const DataGrid = () => (
);
const ExampleHeader = () => (
<Header title="Example" subtitle="This an example plugin">
<Header title="Example" subtitle="This is an example plugin">
<HeaderLabel label="Owner" value="Owner" />
<HeaderLabel label="Lifecycle" value="Lifecycle" />
</Header>
@@ -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} />}
@@ -0,0 +1,54 @@
/*
* 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.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
await knex.schema.createTable('entities_relations', table => {
table.comment('All relations between entities in the catalog');
table
.uuid('originating_entity_id')
.references('id')
.inTable('entities')
.onDelete('CASCADE')
.notNullable()
.comment('The originating entity of the relation');
table
.string('source_full_name')
.notNullable()
.comment('The full name of the source entity of the relation');
table
.string('type')
.notNullable()
.comment('The type of the relation between the entities');
table
.string('target_full_name')
.notNullable()
.comment('The full name of the target entity of the relation');
table.primary(['source_full_name', 'type', 'target_full_name']);
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
await knex.schema.dropTable('entities_relations');
};
@@ -31,6 +31,7 @@ describe('DatabaseEntitiesCatalog', () => {
entityByName: jest.fn(),
entityByUid: jest.fn(),
removeEntityByUid: jest.fn(),
setRelations: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
location: jest.fn(),
@@ -15,13 +15,14 @@
*/
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import type { Entity } from '@backstage/catalog-model';
import {
entityHasChanges,
generateUpdatedEntity,
getEntityName,
LOCATION_ANNOTATION,
serializeEntityRef,
Entity,
EntityRelationSpec,
} from '@backstage/catalog-model';
import { chunk, groupBy } from 'lodash';
import limiterFactory from 'p-limit';
@@ -191,6 +192,16 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
await Promise.all(tasks);
}
// Set the relations originating from an entity using the DB layer
async setRelations(
originatingEntityId: string,
relations: EntityRelationSpec[],
): Promise<void> {
return await this.database.transaction(tx =>
this.database.setRelations(tx, originatingEntityId, relations),
);
}
// Given a batch of entities that were just read from a location, take them
// into consideration by comparing against the existing catalog entities and
// produce the list of entities to be added, and the list of entities to be
+7 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity, Location } from '@backstage/catalog-model';
import { Entity, Location, EntityRelationSpec } from '@backstage/catalog-model';
import type { EntityFilters } from '../database';
//
@@ -37,6 +37,12 @@ export type EntitiesCatalog = {
entities: Entity[],
locationId?: string,
): Promise<void>;
// Same as the DB layer
setRelations(
entityUid: string,
relations: EntityRelationSpec[],
): Promise<void>;
};
//
@@ -15,7 +15,7 @@
*/
import { ConflictError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import { Entity, Location, parseEntityRef } from '@backstage/catalog-model';
import { DatabaseManager } from './DatabaseManager';
import type {
DbEntityRequest,
@@ -494,6 +494,196 @@ describe('CommonDatabase', () => {
});
});
describe('setRelations', () => {
it('adds a relation for an entity', async () => {
const mockRelations = [
{
source: {
kind: entityRequest.entity.kind,
namespace: entityRequest.entity.metadata.namespace!,
name: entityRequest.entity.metadata.name,
},
target: {
kind: 'component',
namespace: 'asd',
name: 'bleb',
},
type: 'child',
},
];
const entityId = await db.transaction(async tx => {
const [{ entity }] = await db.addEntities(tx, [entityRequest]);
await db.setRelations(tx, entity?.metadata?.uid!, mockRelations);
return entity.metadata.uid;
});
const returnedEntity1 = await db.transaction(tx =>
db.entityByUid(tx, entityId!),
);
expect(returnedEntity1?.entity.relations).toEqual([
{ target: mockRelations[0].target, type: 'child' },
]);
const returnedEntity2 = await db.transaction(tx =>
db.entityByName(tx, mockRelations[0].source),
);
expect(returnedEntity2?.entity.relations).toEqual([
{ target: mockRelations[0].target, type: 'child' },
]);
const [returnedEntity3] = await db.transaction(tx => db.entities(tx));
expect(returnedEntity3?.entity.relations).toEqual([
{ target: mockRelations[0].target, type: 'child' },
]);
});
function makeRelation(source: string, type: string, target: string) {
return {
source: parseEntityRef(source, {
defaultKind: 'x',
defaultNamespace: 'x',
}),
type,
target: parseEntityRef(target, {
defaultKind: 'x',
defaultNamespace: 'x',
}),
};
}
it('should not allow setting relations on nonexistent entities', async () => {
await expect(
db.transaction(async tx => {
await db.setRelations(tx, 'nonexistent', [
makeRelation('a:b/c', 'rel1', 'x:y/z'),
]);
}),
).rejects.toThrow(/constraint failed/);
});
it('should allow setting relations on nonexistent entities without any relations', async () => {
await expect(
db.transaction(async tx => {
await db.setRelations(tx, 'nonexistent', []);
}),
).resolves.toBeUndefined();
});
it('adds multiple relations for entities', async () => {
const entity1 = {
apiVersion: 'v1',
kind: 'a',
metadata: {
name: 'c',
namespace: 'b',
},
};
const entity2 = {
apiVersion: 'v1',
kind: 'x',
metadata: {
name: 'z',
namespace: 'y',
},
};
const fromEntity1 = [
makeRelation('a:b/c', 'rel1', 'x:y/z'),
makeRelation('x:y/z', 'rel2', 'a:b/c'),
makeRelation('a:b/c', 'rel2', 'x:y/z'),
];
const fromEntity2 = [
makeRelation('a:b/c', 'rel4', 'x:y/z'),
makeRelation('a:b/c', 'rel5', 'x:y/z'),
makeRelation('x:y/z', 'rel6', 'a:b/c'),
// relations don't have to reference the originating entity, so this should be fine, but not show up
makeRelation('g:h/i', 'rel8', 'd:e/f'),
];
const { id2: secondEntityId } = await db.transaction(async tx => {
const [{ entity: e1 }, { entity: e2 }] = await db.addEntities(tx, [
{ entity: entity1 },
{ entity: entity2 },
]);
const id1 = e1?.metadata?.uid!;
const id2 = e2?.metadata?.uid!;
await db.setRelations(tx, id1, fromEntity1);
await db.setRelations(tx, id2, fromEntity2);
return { id1, id2 };
});
const res = await db.transaction(tx => db.entities(tx));
expect(
res.map(r => ({
name: r.entity.metadata.name,
relations: r.entity.relations,
})),
).toEqual([
{
name: 'c',
relations: [
{
type: 'rel1',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel2',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel4',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel5',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
],
},
{
name: 'z',
relations: [
{
type: 'rel2',
target: { kind: 'a', namespace: 'b', name: 'c' },
},
{
type: 'rel6',
target: { kind: 'a', namespace: 'b', name: 'c' },
},
],
},
]);
await db.transaction(tx => db.removeEntityByUid(tx, secondEntityId));
const res2 = await db.transaction(tx => db.entities(tx));
expect(
res2.map(r => ({
name: r.entity.metadata.name,
relations: r.entity.relations,
})),
).toEqual([
{
name: 'c',
relations: [
{
type: 'rel1',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel2',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
],
},
]);
});
});
describe('entityByName', () => {
it('can get entities case insensitively', async () => {
const entities: Entity[] = [
@@ -27,15 +27,18 @@ import {
generateEntityEtag,
generateEntityUid,
Location,
EntityRelationSpec,
parseEntityName,
} from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import type { Logger } from 'winston';
import { buildEntitySearch } from './search';
import type {
import {
Database,
DatabaseLocationUpdateLogEvent,
DatabaseLocationUpdateLogStatus,
DbEntitiesRelationsRow,
DbEntitiesRow,
DbEntitiesSearchRow,
DbEntityRequest,
@@ -123,6 +126,8 @@ export class CommonDatabase implements Database {
throw new InputError('May not specify etag for new entities');
} else if (entity.metadata.generation !== undefined) {
throw new InputError('May not specify generation for new entities');
} else if (entity.relations !== undefined) {
throw new InputError('May not specify relations for new entities');
}
const newEntity = {
@@ -281,7 +286,7 @@ export class CommonDatabase implements Database {
.select('entities.*')
.orderBy('full_name', 'asc');
return rows.map(row => this.toEntityResponse(row));
return Promise.all(rows.map(row => this.toEntityResponse(tx, row)));
}
async entityByName(
@@ -300,7 +305,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(rows[0]);
return this.toEntityResponse(tx, rows[0]);
}
async entityByUid(
@@ -317,7 +322,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(rows[0]);
return this.toEntityResponse(tx, rows[0]);
}
async removeEntityByUid(txOpaque: unknown, uid: string): Promise<void> {
@@ -330,6 +335,34 @@ export class CommonDatabase implements Database {
}
}
async setRelations(
txOpaque: unknown,
originatingEntityId: string,
relations: EntityRelationSpec[],
): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
// remove all relations that exist for the originating entity id.
await tx<DbEntitiesRelationsRow>('entities_relations')
.where({ originating_entity_id: originatingEntityId })
.del();
const serializeName = (e: EntityName) =>
`${e.kind}:${e.namespace}/${e.name}`.toLowerCase();
const relationsRows: DbEntitiesRelationsRow[] = relations.map(
({ source, target, type }) => ({
originating_entity_id: originatingEntityId,
source_full_name: serializeName(source),
target_full_name: serializeName(target),
type,
}),
);
// TODO(blam): translate constraint failures to sane NotFoundError instead
await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE);
}
async addLocation(location: Location): Promise<DbLocationsRow> {
return await this.database.transaction<DbLocationsRow>(async tx => {
const row: DbLocationsRow = {
@@ -469,12 +502,27 @@ export class CommonDatabase implements Database {
};
}
private toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
private async toEntityResponse(
tx: Knex.Transaction<any, any>,
row: DbEntitiesRow,
): Promise<DbEntityResponse> {
const entity = JSON.parse(row.data) as Entity;
entity.metadata.uid = row.id;
entity.metadata.etag = row.etag;
entity.metadata.generation = Number(row.generation); // cast due to sqlite
// TODO(Rugvip): This is here because it's simple for now, but we likely
// need to refactor this to be more efficient or introduce pagination.
const relations = await tx<DbEntitiesRelationsRow>('entities_relations')
.where({ source_full_name: row.full_name })
.orderBy(['type', 'target_full_name'])
.select();
entity.relations = relations.map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
return {
locationId: row.location_id || undefined,
entity,
+25 -1
View File
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import type { Entity, EntityName, Location } from '@backstage/catalog-model';
import type {
Entity,
EntityName,
Location,
EntityRelationSpec,
} from '@backstage/catalog-model';
export type DbEntitiesRow = {
id: string;
@@ -35,6 +40,13 @@ export type DbEntityResponse = {
entity: Entity;
};
export type DbEntitiesRelationsRow = {
originating_entity_id: string;
source_full_name: string;
type: string;
target_full_name: string;
};
export type DbEntitiesSearchRow = {
entity_id: string;
key: string;
@@ -152,6 +164,18 @@ export type Database = {
removeEntityByUid(tx: unknown, uid: string): Promise<void>;
/**
* Remove current relations for the entity and replace them with the new relations array
* @param tx An ongoing transaction
* @param entityUid the entity uid
* @param relations the relationships to be set
*/
setRelations(
tx: unknown,
entityUid: string,
relations: EntityRelationSpec[],
): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
removeLocation(tx: unknown, id: string): Promise<void>;
@@ -34,6 +34,7 @@ describe('HigherOrderOperations', () => {
addOrUpdateEntity: jest.fn(),
addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
setRelations: jest.fn(),
batchAddOrUpdateEntities: jest.fn(),
};
locationsCatalog = {
@@ -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;
}
}
@@ -35,6 +35,7 @@ describe('createRouter', () => {
addOrUpdateEntity: jest.fn(),
addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
setRelations: jest.fn(),
batchAddOrUpdateEntities: jest.fn(),
};
locationsCatalog = {
@@ -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>
);

Some files were not shown because too many files have changed in this diff Show More