Merge pull request #7390 from backstage/blam/feat/all-the-errroz
Display errors from Ancestor entities
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
---
|
||||
'@backstage/catalog-client': minor
|
||||
'@backstage/plugin-catalog': minor
|
||||
---
|
||||
|
||||
Updates the `<EntitySwitch if={asyncMethod}/>` to accept asynchronous `if` functions.
|
||||
|
||||
Adds the new `getEntityAncestors` method to `CatalogClient`.
|
||||
|
||||
Updates the `<EntityProcessingErrorsPanel />` to make use of the ancestry endpoint to display errors for entities further up the ancestry tree. This makes it easier to discover issues where for example the origin location has been removed or malformed.
|
||||
|
||||
`hasCatalogProcessingErrors()` is now changed to be asynchronous so any calls outside the already established entitySwitch need to be awaited.
|
||||
@@ -38,6 +38,11 @@ export interface CatalogApi {
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogListResponse<Entity>>;
|
||||
// (undocumented)
|
||||
getEntityAncestors(
|
||||
request: CatalogEntityAncestorsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogEntityAncestorsResponse>;
|
||||
// (undocumented)
|
||||
getEntityByName(
|
||||
name: EntityName,
|
||||
options?: CatalogRequestOptions,
|
||||
@@ -88,6 +93,11 @@ export class CatalogClient implements CatalogApi {
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogListResponse<Entity>>;
|
||||
// (undocumented)
|
||||
getEntityAncestors(
|
||||
request: CatalogEntityAncestorsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogEntityAncestorsResponse>;
|
||||
// (undocumented)
|
||||
getEntityByName(
|
||||
compoundName: EntityName,
|
||||
options?: CatalogRequestOptions,
|
||||
@@ -133,6 +143,20 @@ export type CatalogEntitiesRequest = {
|
||||
fields?: string[] | undefined;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type CatalogEntityAncestorsRequest = {
|
||||
entityRef: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type CatalogEntityAncestorsResponse = {
|
||||
root: EntityName;
|
||||
items: {
|
||||
entity: Entity;
|
||||
parents: EntityName[];
|
||||
}[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type CatalogListResponse<T> = {
|
||||
items: T[];
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Location,
|
||||
LOCATION_ANNOTATION,
|
||||
ORIGIN_LOCATION_ANNOTATION,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
stringifyLocationReference,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
CatalogEntitiesRequest,
|
||||
CatalogListResponse,
|
||||
CatalogRequestOptions,
|
||||
CatalogEntityAncestorsRequest,
|
||||
CatalogEntityAncestorsResponse,
|
||||
} from './types/api';
|
||||
import { DiscoveryApi } from './types/discovery';
|
||||
|
||||
@@ -44,6 +47,20 @@ export class CatalogClient implements CatalogApi {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
}
|
||||
|
||||
async getEntityAncestors(
|
||||
request: CatalogEntityAncestorsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogEntityAncestorsResponse> {
|
||||
const { kind, namespace, name } = parseEntityRef(request.entityRef);
|
||||
return await this.requestRequired(
|
||||
'GET',
|
||||
`/entities/by-name/${encodeURIComponent(kind)}/${encodeURIComponent(
|
||||
namespace,
|
||||
)}/${encodeURIComponent(name)}/ancestry`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async getLocationById(
|
||||
id: string,
|
||||
options?: CatalogRequestOptions,
|
||||
|
||||
@@ -28,6 +28,17 @@ export type CatalogEntitiesRequest = {
|
||||
fields?: string[] | undefined;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type CatalogEntityAncestorsRequest = {
|
||||
entityRef: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type CatalogEntityAncestorsResponse = {
|
||||
root: EntityName;
|
||||
items: { entity: Entity; parents: EntityName[] }[];
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type CatalogListResponse<T> = {
|
||||
items: T[];
|
||||
@@ -45,6 +56,10 @@ export interface CatalogApi {
|
||||
request?: CatalogEntitiesRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogListResponse<Entity>>;
|
||||
getEntityAncestors(
|
||||
request: CatalogEntityAncestorsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<CatalogEntityAncestorsResponse>;
|
||||
getEntityByName(
|
||||
name: EntityName,
|
||||
options?: CatalogRequestOptions,
|
||||
|
||||
@@ -21,6 +21,8 @@ export type {
|
||||
CatalogEntitiesRequest,
|
||||
CatalogListResponse,
|
||||
CatalogRequestOptions,
|
||||
CatalogEntityAncestorsRequest,
|
||||
CatalogEntityAncestorsResponse,
|
||||
} from './api';
|
||||
export type { DiscoveryApi } from './discovery';
|
||||
export { CATALOG_FILTER_EXISTS } from './api';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+1
@@ -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> = {
|
||||
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+132
-5
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+90
-13
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user