diff --git a/.changeset/orange-jokes-drum.md b/.changeset/orange-jokes-drum.md new file mode 100644 index 0000000000..ebc22ccd93 --- /dev/null +++ b/.changeset/orange-jokes-drum.md @@ -0,0 +1,12 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog': minor +--- + +Updates the `` to accept asynchronous `if` functions. + +Adds the new `getEntityAncestors` method to `CatalogClient`. + +Updates the `` 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. diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 8a81c0eb10..b7bb713957 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -38,6 +38,11 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise>; // (undocumented) + getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) getEntityByName( name: EntityName, options?: CatalogRequestOptions, @@ -88,6 +93,11 @@ export class CatalogClient implements CatalogApi { options?: CatalogRequestOptions, ): Promise>; // (undocumented) + getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise; + // (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 = { items: T[]; diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 1a6bc5c70d..75944467d8 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -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 { + 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, diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 679588b10a..c7025e9452 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -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 = { items: T[]; @@ -45,6 +56,10 @@ export interface CatalogApi { request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise>; + getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise; getEntityByName( name: EntityName, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 7963891a4c..3552a72e3c 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -21,6 +21,8 @@ export type { CatalogEntitiesRequest, CatalogListResponse, CatalogRequestOptions, + CatalogEntityAncestorsRequest, + CatalogEntityAncestorsResponse, } from './api'; export type { DiscoveryApi } from './discovery'; export { CATALOG_FILTER_EXISTS } from './api'; diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 5dd3c768bb..0ddcaa6d2c 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -34,6 +34,7 @@ describe('CatalogIdentityClient', () => { getLocationByEntity: jest.fn(), removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const tokenIssuer: jest.Mocked = { issueToken: jest.fn(), diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index c9647176cd..ca4e521316 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -77,6 +77,7 @@ describe('AwsALBAuthProvider', () => { removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const mockRequest = { diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 5dc877ec32..51ad1e7e11 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -67,6 +67,7 @@ describe('createRouter', () => { removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; config = new ConfigReader({ diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 8207517e03..7e3149fef7 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -57,6 +57,7 @@ describe('', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; apis = ApiRegistry.with(catalogApiRef, catalog); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index c291faadbd..8b467a017e 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -87,6 +87,7 @@ describe('', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const apis = ApiRegistry.with(catalogApiRef, catalog); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index fe3bef3e84..2d6c5cc93d 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -156,6 +156,7 @@ describe('', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const apis = ApiRegistry.with(catalogApiRef, catalog); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts index 2675469c15..a60750bab5 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts @@ -37,6 +37,7 @@ describe('useEntityStore', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; useApi.mockReturnValue(catalogApi); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 373acd4b5f..0ddd92e121 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -105,6 +105,7 @@ describe('CatalogImportClient', () => { getLocationById: jest.fn(), removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; let catalogImportClient: CatalogImportClient; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index 2c4dd54f9e..0a55d6337b 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -45,6 +45,7 @@ describe('', () => { removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const errorApi: jest.Mocked = { diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 1ee0d841c9..4937a3eb86 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -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>; // (undocumented) + getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise; + // (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) + | 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; // Warning: (ae-missing-release-tag) "isComponentType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 8c55c606b1..eab929d13f 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/catalog/src/CatalogClientWrapper.ts b/plugins/catalog/src/CatalogClientWrapper.ts index 46ca8ec0f9..0463cbf30e 100644 --- a/plugins/catalog/src/CatalogClientWrapper.ts +++ b/plugins/catalog/src/CatalogClientWrapper.ts @@ -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 { + return await this.client.getEntityAncestors(request, { + token: options?.token ?? (await this.identityApi.getIdToken()), + }); + } } diff --git a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx index a4e284a0eb..c406d0ed3c 100644 --- a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx +++ b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx @@ -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('', () => { + const catalogClient: jest.Mocked = { + 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('', () => { }, }; + catalogClient.getEntityAncestors.mockResolvedValue({ + root: getEntityName(entity), + items: [{ entity, parents: [] }], + }); const { getByText, queryByText } = await renderInTestApp( - - - , + + + + + , ); expect( @@ -99,5 +116,115 @@ describe('', () => { ).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( + + + + + , + { + 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(); }); }); diff --git a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.tsx b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.tsx index 833ca74daa..0efc9d5b03 100644 --- a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.tsx +++ b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.tsx @@ -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 { + 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[]) || []; + const entityRef = stringifyEntityRef(entity); + const catalogApi = useApi(catalogApiRef); + const { loading, error, value } = useAsync(async () => { + return getOwnAndAncestorsErrors(entityRef, catalogApi); + }, [entityRef, catalogApi]); + + if (error) { + return ( + + + + ); + } + + if (loading || !value) { + return null; + } return ( <> - {catalogProcessingErrors.map(({ error }, index) => ( + {value.items.map((ancestorError, index) => ( - + {!compareEntityToRef( + entity, + stringifyEntityRef(ancestorError.entity), + ) && ( + + The error below originates from{' '} + + + )} + {ancestorError.errors.map((e, i) => ( + + ))} ))} diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index f80a66b9bb..7f8bf35b74 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -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( + + + + + + + + , + ); + + 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( + + + + + true} children="B" /> + + + , + ); + + 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( + + + + + false} children="B" /> + + + + , + ); + + await expect(rendered.findByText('C')).resolves.toBeInTheDocument(); + expect(rendered.queryByText('A')).not.toBeInTheDocument(); + expect(rendered.queryByText('B')).not.toBeInTheDocument(); + }); }); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index 42898da018..5c50c2507c 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -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; children: ReactNode; }) => null; attachComponentData(EntitySwitchCase, ENTITY_SWITCH_KEY, true); type SwitchCase = { - if?: (entity: Entity) => boolean; + if?: ( + entity: Entity, + context: { apis: ApiHolder }, + ) => boolean | Promise; + children: JSX.Element; +}; + +type SwitchCaseResult = { + if: boolean | Promise; 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((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((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 ; + } + + 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; diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx index 84b922bef5..8927cf088b 100644 --- a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -32,6 +32,7 @@ describe('', () => { removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index 5fd2b02ee9..d78fc0428d 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -34,6 +34,7 @@ describe('', () => { removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index 56dfd59aa8..f0815e0116 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -33,6 +33,7 @@ describe('', () => { removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx index c7fca26a3b..78c8b7bb40 100644 --- a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -37,6 +37,7 @@ describe('', () => { removeEntityByUid: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const fossaApi: jest.Mocked = { getFindingSummary: jest.fn(), diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index 777fb0acf3..3d86a1726b 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -51,6 +51,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked { removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; if (entity) { mock.getEntityByName.mockReturnValue(entity);