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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
fix the accordion details design when job stage fail
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
fix the warning of all the core components test cases
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
rename stories folder top Chip
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
update ItemCard component and it's story
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
fix the responsive of page story
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+35
-45
@@ -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;
|
||||
});
|
||||
});
|
||||
+5
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
+20
-17
@@ -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);
|
||||
+10
-11
@@ -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'));
|
||||
|
||||
+25
-25
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -17,4 +17,5 @@
|
||||
export { plugin } from './plugin';
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export { useCurrency } from './hooks';
|
||||
export * from './types';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user