Merge pull request #7390 from backstage/blam/feat/all-the-errroz

Display errors from Ancestor entities
This commit is contained in:
Johan Haals
2021-10-06 13:15:08 +02:00
committed by GitHub
26 changed files with 471 additions and 39 deletions
@@ -34,6 +34,7 @@ describe('CatalogIdentityClient', () => {
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const tokenIssuer: jest.Mocked<TokenIssuer> = {
issueToken: jest.fn(),
@@ -77,6 +77,7 @@ describe('AwsALBAuthProvider', () => {
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const mockRequest = {
@@ -67,6 +67,7 @@ describe('createRouter', () => {
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
config = new ConfigReader({
@@ -57,6 +57,7 @@ describe('<CatalogGraphCard/>', () => {
addLocation: jest.fn(),
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
apis = ApiRegistry.with(catalogApiRef, catalog);
@@ -87,6 +87,7 @@ describe('<CatalogGraphPage/>', () => {
addLocation: jest.fn(),
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalog);
@@ -156,6 +156,7 @@ describe('<EntityRelationsGraph/>', () => {
addLocation: jest.fn(),
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalog);
@@ -37,6 +37,7 @@ describe('useEntityStore', () => {
addLocation: jest.fn(),
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
useApi.mockReturnValue(catalogApi);
@@ -105,6 +105,7 @@ describe('CatalogImportClient', () => {
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
let catalogImportClient: CatalogImportClient;
@@ -45,6 +45,7 @@ describe('<StepPrepareCreatePullRequest />', () => {
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const errorApi: jest.Mocked<typeof errorApiRef.T> = {
+23 -3
View File
@@ -7,10 +7,13 @@
import { AddLocationRequest } from '@backstage/catalog-client';
import { AddLocationResponse } from '@backstage/catalog-client';
import { ApiHolder } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { CatalogClient } from '@backstage/catalog-client';
import { CatalogEntitiesRequest } from '@backstage/catalog-client';
import { CatalogEntityAncestorsRequest } from '@backstage/catalog-client';
import { CatalogEntityAncestorsResponse } from '@backstage/catalog-client';
import { CatalogListResponse } from '@backstage/catalog-client';
import { CatalogRequestOptions } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
@@ -68,6 +71,11 @@ export class CatalogClientWrapper implements CatalogApi {
options?: CatalogRequestOptions,
): Promise<CatalogListResponse<Entity>>;
// (undocumented)
getEntityAncestors(
request: CatalogEntityAncestorsRequest,
options?: CatalogRequestOptions,
): Promise<CatalogEntityAncestorsResponse>;
// (undocumented)
getEntityByName(
compoundName: EntityName,
options?: CatalogRequestOptions,
@@ -346,7 +354,7 @@ export const EntityPageLayout: {
// Warning: (ae-missing-release-tag) "EntityProcessingErrorsPanel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export const EntityProcessingErrorsPanel: () => JSX.Element;
export const EntityProcessingErrorsPanel: () => JSX.Element | null;
// Warning: (ae-missing-release-tag) "EntitySwitch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -354,7 +362,14 @@ export const EntityProcessingErrorsPanel: () => JSX.Element;
export const EntitySwitch: {
({ children }: PropsWithChildren<{}>): JSX.Element | null;
Case: (_: {
if?: ((entity: Entity) => boolean) | undefined;
if?:
| ((
entity: Entity,
context: {
apis: ApiHolder;
},
) => boolean | Promise<boolean>)
| undefined;
children: ReactNode;
}) => null;
};
@@ -382,7 +397,12 @@ export const FilteredEntityLayout: ({
// Warning: (ae-missing-release-tag) "hasCatalogProcessingErrors" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const hasCatalogProcessingErrors: (entity: Entity) => boolean;
export const hasCatalogProcessingErrors: (
entity: Entity,
context: {
apis: ApiHolder;
},
) => Promise<boolean>;
// Warning: (ae-missing-release-tag) "isComponentType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
+1
View File
@@ -37,6 +37,7 @@
"@backstage/core-plugin-api": "^0.1.9",
"@backstage/integration-react": "^0.1.11",
"@backstage/plugin-catalog-react": "^0.5.1",
"@backstage/errors": "^0.1.2",
"@backstage/theme": "^0.2.10",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -23,6 +23,8 @@ import {
CatalogEntitiesRequest,
CatalogListResponse,
CatalogRequestOptions,
CatalogEntityAncestorsRequest,
CatalogEntityAncestorsResponse,
} from '@backstage/catalog-client';
import { IdentityApi } from '@backstage/core-plugin-api';
@@ -118,4 +120,13 @@ export class CatalogClientWrapper implements CatalogApi {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
async getEntityAncestors(
request: CatalogEntityAncestorsRequest,
options?: CatalogRequestOptions,
): Promise<CatalogEntityAncestorsResponse> {
return await this.client.getEntityAncestors(request, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
}
@@ -14,14 +14,25 @@
* limitations under the License.
*/
import { EntityProvider } from '@backstage/plugin-catalog-react';
import {
CatalogApi,
catalogApiRef,
EntityProvider,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { EntityProcessingErrorsPanel } from './EntityProcessingErrorsPanel';
import { Entity } from '@backstage/catalog-model';
import { Entity, getEntityName } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
describe('<EntityProcessErrors />', () => {
const catalogClient: jest.Mocked<CatalogApi> = {
getEntityAncestors: jest.fn(),
} as any;
const apis = ApiRegistry.with(catalogApiRef, catalogClient);
it('renders EntityProcessErrors if the entity has errors', async () => {
const entity: Entity = {
apiVersion: 'v1',
@@ -86,10 +97,16 @@ describe('<EntityProcessErrors />', () => {
},
};
catalogClient.getEntityAncestors.mockResolvedValue({
root: getEntityName(entity),
items: [{ entity, parents: [] }],
});
const { getByText, queryByText } = await renderInTestApp(
<EntityProvider entity={entity}>
<EntityProcessingErrorsPanel />
</EntityProvider>,
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<EntityProcessingErrorsPanel />
</EntityProvider>
</ApiProvider>,
);
expect(
@@ -99,5 +116,115 @@ describe('<EntityProcessErrors />', () => {
).toBeInTheDocument();
expect(getByText('Error: Foo')).toBeInTheDocument();
expect(queryByText('Error: This should not be rendered')).toBeNull();
expect(
queryByText('The error below originates from'),
).not.toBeInTheDocument();
});
it('renders EntityProcessErrors if the parent entity has errors', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
description: 'This is the description',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const parent: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'parent',
description: 'This is the description',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
status: {
items: [
{
type: 'backstage.io/catalog-processing',
level: 'error',
message:
'InputError: Policy check failed; caused by Error: Malformed envelope, /metadata/labels should be object',
error: {
name: 'InputError',
message:
'Policy check failed; caused by Error: Malformed envelope, /metadata/labels should be object',
cause: {
name: 'Error',
message:
'Malformed envelope, /metadata/labels should be object',
},
},
},
{
type: 'foo',
level: 'error',
message: 'InputError: This should not be rendered',
error: {
name: 'InputError',
message: 'Foo',
cause: {
name: 'Error',
message:
'Malformed envelope, /metadata/labels should be object',
},
},
},
{
type: 'backstage.io/catalog-processing',
level: 'error',
message: 'InputError: Foo',
error: {
name: 'InputError',
message: 'Foo',
cause: {
name: 'Error',
message:
'Malformed envelope, /metadata/labels should be object',
},
},
},
],
},
};
catalogClient.getEntityAncestors.mockResolvedValue({
root: getEntityName(entity),
items: [
{ entity, parents: [getEntityName(parent)] },
{ entity: parent, parents: [] },
],
});
const { getByText, queryByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<EntityProcessingErrorsPanel />
</EntityProvider>
</ApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(
getByText(
'Error: Policy check failed; caused by Error: Malformed envelope, /metadata/labels should be object',
),
).toBeInTheDocument();
expect(getByText('Error: Foo')).toBeInTheDocument();
expect(queryByText('Error: This should not be rendered')).toBeNull();
expect(queryByText('The error below originates from')).toBeInTheDocument();
});
});
@@ -14,36 +14,113 @@
* limitations under the License.
*/
import { Entity, UNSTABLE_EntityStatusItem } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Entity,
stringifyEntityRef,
UNSTABLE_EntityStatusItem,
compareEntityToRef,
} from '@backstage/catalog-model';
import {
catalogApiRef,
EntityRefLink,
useEntity,
} from '@backstage/plugin-catalog-react';
import { Box } from '@material-ui/core';
import React from 'react';
import { ResponseErrorPanel } from '@backstage/core-components';
import { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from '@backstage/catalog-client';
import {
CatalogApi,
ENTITY_STATUS_CATALOG_PROCESSING_TYPE,
} from '@backstage/catalog-client';
import { useApi, ApiHolder } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
import { SerializedError } from '@backstage/errors';
const errorfilter = (i: UNSTABLE_EntityStatusItem) =>
const errorFilter = (i: UNSTABLE_EntityStatusItem) =>
i.error &&
i.level === 'error' &&
i.type === ENTITY_STATUS_CATALOG_PROCESSING_TYPE;
export const hasCatalogProcessingErrors = (entity: Entity) =>
entity?.status?.items?.filter(errorfilter).length! > 0;
type GetOwnAndAncestorsErrorsResponse = {
items: {
errors: SerializedError[];
entity: Entity;
}[];
};
async function getOwnAndAncestorsErrors(
entityRef: string,
catalogApi: CatalogApi,
): Promise<GetOwnAndAncestorsErrorsResponse> {
const ancestors = await catalogApi.getEntityAncestors({ entityRef });
const items = ancestors.items
.map(item => {
const statuses = item.entity.status?.items ?? [];
const errors = statuses
.filter(errorFilter)
.map(e => e.error)
.filter((e): e is SerializedError => Boolean(e));
return { errors: errors, entity: item.entity };
})
.filter(item => item.errors.length > 0);
return { items };
}
export const hasCatalogProcessingErrors = async (
entity: Entity,
context: { apis: ApiHolder },
) => {
const catalogApi = context.apis.get(catalogApiRef);
if (!catalogApi) {
throw new Error(`No implementation available for ${catalogApiRef}`);
}
const errors = await getOwnAndAncestorsErrors(
stringifyEntityRef(entity),
catalogApi,
);
return errors.items.length > 0;
};
/**
* Displays a list of errors if the entity is invalid.
* Displays a list of errors from the ancestors of the current entity.
*/
export const EntityProcessingErrorsPanel = () => {
const { entity } = useEntity();
const catalogProcessingErrors =
(entity?.status?.items?.filter(
errorfilter,
) as Required<UNSTABLE_EntityStatusItem>[]) || [];
const entityRef = stringifyEntityRef(entity);
const catalogApi = useApi(catalogApiRef);
const { loading, error, value } = useAsync(async () => {
return getOwnAndAncestorsErrors(entityRef, catalogApi);
}, [entityRef, catalogApi]);
if (error) {
return (
<Box mb={1}>
<ResponseErrorPanel error={error} />
</Box>
);
}
if (loading || !value) {
return null;
}
return (
<>
{catalogProcessingErrors.map(({ error }, index) => (
{value.items.map((ancestorError, index) => (
<Box key={index} mb={1}>
<ResponseErrorPanel error={error} />
{!compareEntityToRef(
entity,
stringifyEntityRef(ancestorError.entity),
) && (
<Box p={1}>
The error below originates from{' '}
<EntityRefLink entityRef={ancestorError.entity} />
</Box>
)}
{ancestorError.errors.map((e, i) => (
<ResponseErrorPanel key={i} error={e} />
))}
</Box>
))}
</>
@@ -112,4 +112,63 @@ describe('EntitySwitch', () => {
expect(rendered.queryByText('A')).not.toBeInTheDocument();
expect(rendered.queryByText('B')).toBeInTheDocument();
});
it('should switch with async condition that is true', async () => {
const entity = { kind: 'component' } as Entity;
const shouldRender = () => Promise.resolve(true);
const rendered = render(
<Wrapper>
<EntityProvider entity={entity}>
<EntitySwitch>
<EntitySwitch.Case if={shouldRender} children="A" />
<EntitySwitch.Case children="B" />
</EntitySwitch>
</EntityProvider>
</Wrapper>,
);
await expect(rendered.findByText('A')).resolves.toBeInTheDocument();
expect(rendered.queryByText('B')).not.toBeInTheDocument();
});
it('should switch with sync condition that is false', async () => {
const entity = { kind: 'component' } as Entity;
const shouldRender = () => Promise.resolve(false);
const rendered = render(
<Wrapper>
<EntityProvider entity={entity}>
<EntitySwitch>
<EntitySwitch.Case if={shouldRender} children="A" />
<EntitySwitch.Case if={() => true} children="B" />
</EntitySwitch>
</EntityProvider>
</Wrapper>,
);
await expect(rendered.findByText('B')).resolves.toBeInTheDocument();
expect(rendered.queryByText('A')).not.toBeInTheDocument();
});
it('should switch with sync condition that throws', async () => {
const entity = { kind: 'component' } as Entity;
const shouldRender = () => Promise.reject();
const rendered = render(
<Wrapper>
<EntityProvider entity={entity}>
<EntitySwitch>
<EntitySwitch.Case if={shouldRender} children="A" />
<EntitySwitch.Case if={() => false} children="B" />
<EntitySwitch.Case children="C" />
</EntitySwitch>
</EntityProvider>
</Wrapper>,
);
await expect(rendered.findByText('C')).resolves.toBeInTheDocument();
expect(rendered.queryByText('A')).not.toBeInTheDocument();
expect(rendered.queryByText('B')).not.toBeInTheDocument();
});
});
@@ -16,45 +16,98 @@
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { PropsWithChildren, ReactNode } from 'react';
import React, { PropsWithChildren, ReactNode } from 'react';
import {
attachComponentData,
useApiHolder,
useElementFilter,
ApiHolder,
} from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
const ENTITY_SWITCH_KEY = 'core.backstage.entitySwitch';
const EntitySwitchCase = (_: {
if?: (entity: Entity) => boolean;
if?: (
entity: Entity,
context: { apis: ApiHolder },
) => boolean | Promise<boolean>;
children: ReactNode;
}) => null;
attachComponentData(EntitySwitchCase, ENTITY_SWITCH_KEY, true);
type SwitchCase = {
if?: (entity: Entity) => boolean;
if?: (
entity: Entity,
context: { apis: ApiHolder },
) => boolean | Promise<boolean>;
children: JSX.Element;
};
type SwitchCaseResult = {
if: boolean | Promise<boolean>;
children: JSX.Element;
};
export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => {
const { entity } = useEntity();
const switchCases = useElementFilter(children, collection =>
collection
.selectByComponentData({
key: ENTITY_SWITCH_KEY,
withStrictError: 'Child of EntitySwitch is not an EntitySwitch.Case',
})
.getElements()
.flatMap<SwitchCase>((element: React.ReactElement) => {
const { if: condition, children: elementsChildren } = element.props;
return [{ if: condition, children: elementsChildren }];
}),
const apis = useApiHolder();
const results = useElementFilter(
children,
collection =>
collection
.selectByComponentData({
key: ENTITY_SWITCH_KEY,
withStrictError: 'Child of EntitySwitch is not an EntitySwitch.Case',
})
.getElements()
.flatMap<SwitchCaseResult>((element: React.ReactElement) => {
const { if: condition, children: elementsChildren } =
element.props as SwitchCase;
return [
{
if: condition?.(entity, { apis }) ?? true,
children: elementsChildren,
},
];
}),
[apis, entity],
);
const hasAsyncCases = results.some(
r => typeof r.if === 'object' && 'then' in r.if,
);
const matchingCase = switchCases.find(switchCase =>
switchCase.if ? switchCase.if(entity) : true,
);
return matchingCase?.children ?? null;
if (hasAsyncCases) {
return <AsyncEntitySwitch results={results} />;
}
return results.find(r => r.if)?.children ?? null;
};
function AsyncEntitySwitch({ results }: { results: SwitchCaseResult[] }) {
const { loading, value } = useAsync(async () => {
const promises = results.map(
async ({ if: condition, children: output }) => {
try {
if (await condition) {
return output;
}
} catch {
/* ignored */
}
return null;
},
);
return (await Promise.all(promises)).find(Boolean) ?? null;
}, [results]);
if (loading || !value) {
return null;
}
return value;
}
EntitySwitch.Case = EntitySwitchCase;
@@ -32,6 +32,7 @@ describe('<DefaultExplorePage />', () => {
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -34,6 +34,7 @@ describe('<DomainExplorerContent />', () => {
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -33,6 +33,7 @@ describe('<GroupsExplorerContent />', () => {
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -37,6 +37,7 @@ describe('<FossaPage />', () => {
removeEntityByUid: jest.fn(),
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
const fossaApi: jest.Mocked<FossaApi> = {
getFindingSummary: jest.fn(),
@@ -51,6 +51,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked<CatalogApi> {
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
};
if (entity) {
mock.getEntityByName.mockReturnValue(entity);