diff --git a/.changeset/pink-coins-sniff.md b/.changeset/pink-coins-sniff.md new file mode 100644 index 0000000000..dd2bc86b2a --- /dev/null +++ b/.changeset/pink-coins-sniff.md @@ -0,0 +1,16 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-react': patch +--- + +Introduce new cards to `@backstage/plugin-catalog` that can be added to entity pages: + +- `EntityHasSystemsCard` to display systems of a domain. +- `EntityHasComponentsCard` to display components of a system. +- `EntityHasSubcomponentsCard` to display subcomponents of a subcomponent. +- In addition, `EntityHasApisCard` to display APIs of a system is added to `@backstage/plugin-api-docs`. + +`@backstage/plugin-catalog-react` now provides `and` to build own cards for components and systems. +The styling of the tables and new cards was also applied to the existing `EntityConsumedApisCard`, +`EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index e849667c19..7fb624b9e9 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -16,8 +16,10 @@ import { ApiEntity, + DomainEntity, Entity, GroupEntity, + SystemEntity, UserEntity, } from '@backstage/catalog-model'; import { EmptyState } from '@backstage/core'; @@ -25,11 +27,15 @@ import { ApiDefinitionCard, ConsumedApisCard, ConsumingComponentsCard, + EntityHasApisCard, ProvidedApisCard, ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { AboutCard, + EntityHasComponentsCard, + EntityHasSubcomponentsCard, + EntityHasSystemsCard, EntityLinksCard, EntityPageLayout, } from '@backstage/plugin-catalog'; @@ -206,6 +212,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( )} + + + ); @@ -425,6 +434,51 @@ const GroupEntityPage = ({ entity }: { entity: Entity }) => ( ); +const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( + + + + + + + + + + + +); + +const SystemEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( + + + + + + + + +); + +const DomainEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + export const EntityPage = () => { const { entity } = useEntity(); @@ -437,6 +491,10 @@ export const EntityPage = () => { return ; case 'user': return ; + case 'system': + return ; + case 'domain': + return ; default: return ; } diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx index 30716a61fb..76daf15945 100644 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx @@ -27,12 +27,14 @@ import { formatEntityRefTitle, getEntityRelations, } from '@backstage/plugin-catalog-react'; +import { makeStyles } from '@material-ui/core'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; type EntityRow = { entity: ApiEntity; resolved: { + name: string; partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -43,10 +45,10 @@ type EntityRow = { const columns: TableColumn[] = [ { title: 'Name', - field: 'entity.metadata.name', + field: 'resolved.name', highlight: true, render: ({ entity }) => ( - {entity.metadata.name} + ), }, { @@ -88,10 +90,25 @@ const columns: TableColumn[] = [ type Props = { title: string; variant?: string; - entities: (ApiEntity | undefined)[]; + entities: ApiEntity[]; + emptyComponent?: JSX.Element; }; -export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const ApisTable = ({ + entities, + title, + emptyComponent, + variant = 'gridItem', +}: Props) => { + const classes = useStyles(); const tableStyle: React.CSSProperties = { minWidth: '0', width: '100%', @@ -101,43 +118,42 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { tableStyle.height = 'calc(100% - 10px)'; } - const rows = entities - // TODO: For now we skip all APIs that we can't find without a warning! - .filter(e => e !== undefined) - .map(entity => { - const partOfSystemRelations = getEntityRelations( - entity, - RELATION_PART_OF, - { - kind: 'system', - }, - ); - const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); - - return { - entity: entity as ApiEntity, - resolved: { - ownedByRelationsTitle: ownedByRelations - .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) - .join(', '), - ownedByRelations, - partOfSystemRelationTitle: partOfSystemRelations - .map(r => - formatEntityRefTitle(r, { - defaultKind: 'system', - }), - ) - .join(', '), - partOfSystemRelations, - }, - }; + const rows = entities.map(entity => { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'system', }); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity, + resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'API', + }), + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); return ( columns={columns} title={title} style={tableStyle} + emptyComponent={ + emptyComponent &&
{emptyComponent}
+ } options={{ // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; search: false, diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index 051b9588a0..5d2978a198 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_CONSUMES_API, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; +import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -79,7 +74,7 @@ describe('', () => { ); expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); - expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument(); + expect(getByText(/No Component consumes this API/i)).toBeInTheDocument(); }); it('shows consumed APIs', async () => { @@ -108,34 +103,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'openapi', - lifecycle: 'production', - definition: '...', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], - }); - apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ - type: 'openapi', - title: 'OpenAPI', - component: () =>
, + spec: {}, }); const { getByText } = await renderInTestApp( @@ -147,12 +115,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); + expect(getByText('Consumed APIs')).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/OpenAPI/)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/MySystem/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx index 6124ea979a..7e57a23b06 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -19,10 +19,15 @@ import { Entity, RELATION_CONSUMES_API, } from '@backstage/catalog-model'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; import { useEntity, useRelatedEntities } from '@backstage/plugin-catalog-react'; import React, { PropsWithChildren } from 'react'; -import { MissingConsumesApisEmptyState } from '../EmptyState'; import { ApisTable } from './ApisTable'; const ApisCard = ({ @@ -56,31 +61,31 @@ export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { ); } - if (error) { + if (error || !entities) { return ( - } /> ); } - if (!entities || entities.length === 0) { - return ( - - - - ); - } - return ( + No Component consumes this API.{' '} + + Learn how to consume APIs. + +
+ } + entities={entities as ApiEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx new file mode 100644 index 0000000000..7b97f0867e --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -0,0 +1,122 @@ +/* + * 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, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; +import { HasApisCard } from './HasApisCard'; + +describe('', () => { + const apiDocsConfig: jest.Mocked = { + getApiDefinitionWidget: jest.fn(), + } as any; + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + apiDocsConfigRef, + apiDocsConfig, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('APIs')).toBeInTheDocument(); + expect(getByText(/No API is part of this system/i)).toBeInTheDocument(); + }); + + it('shows related APIs', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'API', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'API', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('APIs')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx new file mode 100644 index 0000000000..5ed38e91c7 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx @@ -0,0 +1,86 @@ +/* + * 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 { ApiEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { useEntity, useRelatedEntities } from '@backstage/plugin-catalog-react'; +import React, { PropsWithChildren } from 'react'; +import { ApisTable } from './ApisTable'; + +const ApisCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + variant?: string; +}; + +export const HasApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'API', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No API is part of this system.{' '} + + Learn how to add APIs. + + + } + entities={entities as ApiEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 482534f58a..bb4d0ac82f 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_OWNED_BY, - RELATION_PART_OF, - RELATION_PROVIDES_API, -} from '@backstage/catalog-model'; +import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -79,7 +74,7 @@ describe('', () => { ); expect(getByText(/Provided APIs/i)).toBeInTheDocument(); - expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument(); + expect(getByText(/No Component provides this API/i)).toBeInTheDocument(); }); it('shows consumed APIs', async () => { @@ -108,34 +103,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'openapi', - lifecycle: 'production', - definition: '...', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], - }); - apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ - type: 'openapi', - title: 'OpenAPI', - component: () =>
, + spec: {}, }); const { getByText } = await renderInTestApp( @@ -149,10 +117,6 @@ describe('', () => { await waitFor(() => { expect(getByText(/Provided APIs/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/OpenAPI/)).toBeInTheDocument(); - expect(getByText(/MySystem/)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx index 92155e65f5..e760d7a8f0 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -19,10 +19,15 @@ import { Entity, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; import { useEntity, useRelatedEntities } from '@backstage/plugin-catalog-react'; import React, { PropsWithChildren } from 'react'; -import { MissingProvidesApisEmptyState } from '../EmptyState'; import { ApisTable } from './ApisTable'; const ApisCard = ({ @@ -56,31 +61,31 @@ export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { ); } - if (error) { + if (error || !entities) { return ( - } /> ); } - if (!entities || entities.length === 0) { - return ( - - - - ); - } - return ( + No Component provides this API.{' '} + + Learn how to provide APIs. + +
+ } + entities={entities as ApiEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/ApisCards/index.ts b/plugins/api-docs/src/components/ApisCards/index.ts index 2a01a1dc6e..9243bdedd2 100644 --- a/plugins/api-docs/src/components/ApisCards/index.ts +++ b/plugins/api-docs/src/components/ApisCards/index.ts @@ -15,4 +15,5 @@ */ export { ConsumedApisCard } from './ConsumedApisCard'; +export { HasApisCard } from './HasApisCard'; export { ProvidedApisCard } from './ProvidedApisCard'; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index f45a48842d..7944b3f90a 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_API_CONSUMED_BY, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; +import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -77,8 +72,8 @@ describe('', () => { , ); - expect(getByText(/Consumers/i)).toBeInTheDocument(); - expect(getByText(/No APIs consumed by this entity/i)).toBeInTheDocument(); + expect(getByText('Consumers')).toBeInTheDocument(); + expect(getByText(/No component consumes this API/i)).toBeInTheDocument(); }); it('shows consuming components', async () => { @@ -113,28 +108,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'service', - lifecycle: 'production', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], + spec: {}, }); const { getByText } = await renderInTestApp( @@ -146,11 +120,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText(/Consumers/i)).toBeInTheDocument(); + expect(getByText('Consumers')).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/MySystem/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx index 9ed5bf6d2a..ed987b33f5 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -19,11 +19,20 @@ import { Entity, RELATION_API_CONSUMED_BY, } from '@backstage/catalog-model'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import { useEntity, useRelatedEntities } from '@backstage/plugin-catalog-react'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + ComponentsTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; + import React, { PropsWithChildren } from 'react'; -import { MissingConsumesApisEmptyState } from '../EmptyState'; -import { ComponentsTable } from './ComponentsTable'; const ComponentsCard = ({ children, @@ -56,31 +65,31 @@ export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => { ); } - if (error) { + if (error || !entities) { return ( - } /> ); } - if (!entities || entities.length === 0) { - return ( - - - - ); - } - return ( + No component consumes this API.{' '} + + Learn how to consume APIs. + + + } + entities={entities as ComponentEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 4c55393d13..ab1751b9ba 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - Entity, - RELATION_API_PROVIDED_BY, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; +import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, @@ -77,8 +72,8 @@ describe('', () => { , ); - expect(getByText(/Providers/i)).toBeInTheDocument(); - expect(getByText(/No APIs provided by this entity/i)).toBeInTheDocument(); + expect(getByText('Providers')).toBeInTheDocument(); + expect(getByText(/No component provides this API/i)).toBeInTheDocument(); }); it('shows providing components', async () => { @@ -113,28 +108,7 @@ describe('', () => { name: 'target-name', namespace: 'my-namespace', }, - spec: { - type: 'service', - lifecycle: 'production', - }, - relations: [ - { - type: RELATION_PART_OF, - target: { - kind: 'System', - name: 'MySystem', - namespace: 'default', - }, - }, - { - type: RELATION_OWNED_BY, - target: { - kind: 'Group', - name: 'Test', - namespace: 'default', - }, - }, - ], + spec: {}, }); const { getByText } = await renderInTestApp( @@ -146,11 +120,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText(/Providers/i)).toBeInTheDocument(); + expect(getByText('Providers')).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); - expect(getByText(/Test/i)).toBeInTheDocument(); - expect(getByText(/MySystem/i)).toBeInTheDocument(); - expect(getByText(/production/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx index 06377ca0d9..39e179dd10 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -19,11 +19,19 @@ import { Entity, RELATION_API_PROVIDED_BY, } from '@backstage/catalog-model'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import { useEntity, useRelatedEntities } from '@backstage/plugin-catalog-react'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + ComponentsTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; import React, { PropsWithChildren } from 'react'; -import { MissingProvidesApisEmptyState } from '../EmptyState'; -import { ComponentsTable } from './ComponentsTable'; const ComponentsCard = ({ children, @@ -56,31 +64,31 @@ export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => { ); } - if (error) { + if (error || !entities) { return ( - } /> ); } - if (!entities || entities.length === 0) { - return ( - - - - ); - } - return ( + No component provides this API.{' '} + + Learn how to provide APIs. + + + } + entities={entities as ComponentEntity[]} /> ); }; diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index e7484f77c3..8bab7f28f4 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -24,4 +24,5 @@ export { EntityConsumingComponentsCard, EntityProvidedApisCard, EntityProvidingComponentsCard, + EntityHasApisCard, } from './plugin'; diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 429fd60095..1d57cc6cc2 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -106,3 +106,11 @@ export const EntityProvidingComponentsCard = apiDocsPlugin.provide( }, }), ); + +export const EntityHasApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/ApisCards').then(m => m.HasApisCard), + }, + }), +); diff --git a/plugins/catalog-react/src/components/EntityTables/ComponentsTable.test.tsx b/plugins/catalog-react/src/components/EntityTables/ComponentsTable.test.tsx new file mode 100644 index 0000000000..f29d1cfe60 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTables/ComponentsTable.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 { + ComponentEntity, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ComponentsTable } from './ComponentsTable'; + +describe('', () => { + it('shows empty table', async () => { + const { getByText } = await renderInTestApp( + EMPTY} + />, + ); + + expect(getByText('My Components')).toBeInTheDocument(); + expect(getByText('EMPTY')).toBeInTheDocument(); + }); + + it('shows components', async () => { + const entities: ComponentEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + description: 'Some description', + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'owner-data', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'my-system', + namespace: 'my-namespace', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + />, + ); + + await waitFor(() => { + expect(getByText('My Components')).toBeInTheDocument(); + expect(getByText('my-namespace/my-component')).toBeInTheDocument(); + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('Test')).toBeInTheDocument(); + expect(getByText('production')).toBeInTheDocument(); + expect(getByText('service')).toBeInTheDocument(); + expect(getByText('Some description')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/catalog-react/src/components/EntityTables/ComponentsTable.tsx similarity index 64% rename from plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx rename to plugins/catalog-react/src/components/EntityTables/ComponentsTable.tsx index bd9b0c765f..b79c7e705a 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ b/plugins/catalog-react/src/components/EntityTables/ComponentsTable.tsx @@ -21,17 +21,19 @@ import { RELATION_PART_OF, } from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; +import React from 'react'; +import { getEntityRelations } from '../../utils'; import { EntityRefLink, EntityRefLinks, formatEntityRefTitle, - getEntityRelations, -} from '@backstage/plugin-catalog-react'; -import React from 'react'; +} from '../EntityRefLink'; type EntityRow = { entity: ComponentEntity; resolved: { + name: string; partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -42,10 +44,10 @@ type EntityRow = { const columns: TableColumn[] = [ { title: 'Name', - field: 'entity.metadata.name', + field: 'resolved.name', highlight: true, render: ({ entity }) => ( - {entity.metadata.name} + ), }, { @@ -86,15 +88,25 @@ const columns: TableColumn[] = [ type Props = { title: string; variant?: string; - entities: (ComponentEntity | undefined)[]; + entities: ComponentEntity[]; + emptyComponent?: JSX.Element; }; -// TODO: In theory this could also be systems! +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + export const ComponentsTable = ({ entities, title, + emptyComponent, variant = 'gridItem', }: Props) => { + const classes = useStyles(); const tableStyle: React.CSSProperties = { minWidth: '0', width: '100%', @@ -104,43 +116,42 @@ export const ComponentsTable = ({ tableStyle.height = 'calc(100% - 10px)'; } - const rows = entities - // TODO: For now we skip all Components that we can't find without a warning! - .filter(e => e !== undefined) - .map(entity => { - const partOfSystemRelations = getEntityRelations( - entity, - RELATION_PART_OF, - { - kind: 'system', - }, - ); - const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); - - return { - entity: entity as ComponentEntity, - resolved: { - ownedByRelationsTitle: ownedByRelations - .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) - .join(', '), - ownedByRelations, - partOfSystemRelationTitle: partOfSystemRelations - .map(r => - formatEntityRefTitle(r, { - defaultKind: 'system', - }), - ) - .join(', '), - partOfSystemRelations, - }, - }; + const rows = entities.map(entity => { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'system', }); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity: entity, + resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'Component', + }), + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); return ( columns={columns} title={title} style={tableStyle} + emptyComponent={ + emptyComponent &&
{emptyComponent}
+ } options={{ // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; search: false, diff --git a/plugins/catalog-react/src/components/EntityTables/SystemsTable.test.tsx b/plugins/catalog-react/src/components/EntityTables/SystemsTable.test.tsx new file mode 100644 index 0000000000..9f89d7592e --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTables/SystemsTable.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 { + RELATION_OWNED_BY, + RELATION_PART_OF, + SystemEntity, +} from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { SystemsTable } from './SystemsTable'; + +describe('', () => { + it('shows empty table', async () => { + const { getByText } = await renderInTestApp( + EMPTY} + />, + ); + + expect(getByText('My Systems')).toBeInTheDocument(); + expect(getByText('EMPTY')).toBeInTheDocument(); + }); + + it('shows systems', async () => { + const entities: SystemEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + description: 'Some description', + }, + spec: { + owner: 'owner-data', + }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'Domain', + name: 'my-domain', + namespace: 'my-namespace', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + />, + ); + + await waitFor(() => { + expect(getByText('My Systems')).toBeInTheDocument(); + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('my-namespace/my-domain')).toBeInTheDocument(); + expect(getByText('Test')).toBeInTheDocument(); + expect(getByText('Some description')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTables/SystemsTable.tsx b/plugins/catalog-react/src/components/EntityTables/SystemsTable.tsx new file mode 100644 index 0000000000..8f30ea0543 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTables/SystemsTable.tsx @@ -0,0 +1,157 @@ +/* + * 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 { + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, + SystemEntity, +} from '@backstage/catalog-model'; +import { Table, TableColumn } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; +import React from 'react'; +import { getEntityRelations } from '../../utils'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, +} from '../EntityRefLink'; + +type EntityRow = { + entity: SystemEntity; + resolved: { + name: string; + partOfDomainRelationTitle?: string; + partOfDomainRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ + { + title: 'Name', + field: 'resolved.name', + highlight: true, + render: ({ entity }) => ( + + ), + }, + { + title: 'Domain', + field: 'resolved.partOfDomainRelationTitle', + render: ({ resolved }) => ( + + ), + }, + { + title: 'Owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), + }, + { + title: 'Description', + field: 'entity.metadata.description', + width: 'auto', + }, +]; + +type Props = { + title: string; + variant?: string; + entities: SystemEntity[]; + emptyComponent?: JSX.Element; +}; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const SystemsTable = ({ + entities, + title, + emptyComponent, + variant = 'gridItem', +}: Props) => { + const classes = useStyles(); + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + if (variant === 'gridItem') { + tableStyle.height = 'calc(100% - 10px)'; + } + + const rows = entities.map(entity => { + const partOfDomainRelations = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'domain', + }); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity, + resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'System', + }), + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfDomainRelationTitle: partOfDomainRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'domain', + }), + ) + .join(', '), + partOfDomainRelations, + }, + }; + }); + + return ( + + columns={columns} + title={title} + style={tableStyle} + emptyComponent={ + emptyComponent &&
{emptyComponent}
+ } + options={{ + // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; + search: false, + paging: false, + actionsColumnIndex: -1, + padding: 'dense', + }} + data={rows} + /> + ); +}; diff --git a/plugins/catalog-react/src/components/EntityTables/index.ts b/plugins/catalog-react/src/components/EntityTables/index.ts new file mode 100644 index 0000000000..f3e4f416ac --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTables/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +export { ComponentsTable } from './ComponentsTable'; +export { SystemsTable } from './SystemsTable'; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index fcfa8207b3..463aeb6250 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -13,5 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './EntityRefLink'; export * from './EntityProvider'; +export * from './EntityRefLink'; +export * from './EntityTables'; diff --git a/plugins/catalog/src/components/ComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/ComponentsCard/HasComponentsCard.test.tsx new file mode 100644 index 0000000000..58df0a53f8 --- /dev/null +++ b/plugins/catalog/src/components/ComponentsCard/HasComponentsCard.test.tsx @@ -0,0 +1,117 @@ +/* + * 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, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasComponentsCard } from './HasComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Components')).toBeInTheDocument(); + expect( + getByText(/No component is part of this system/i), + ).toBeInTheDocument(); + }); + + it('shows related components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Components')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/ComponentsCard/HasComponentsCard.tsx b/plugins/catalog/src/components/ComponentsCard/HasComponentsCard.tsx new file mode 100644 index 0000000000..c9b5879a05 --- /dev/null +++ b/plugins/catalog/src/components/ComponentsCard/HasComponentsCard.tsx @@ -0,0 +1,89 @@ +/* + * 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 { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + ComponentsTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React, { PropsWithChildren } from 'react'; + +const ComponentsCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + variant?: string; +}; + +export const HasComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'Component', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No component is part of this system.{' '} + + Learn how to add components. + + + } + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/catalog/src/components/ComponentsCard/index.ts b/plugins/catalog/src/components/ComponentsCard/index.ts new file mode 100644 index 0000000000..059392c3e8 --- /dev/null +++ b/plugins/catalog/src/components/ComponentsCard/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { HasComponentsCard } from './HasComponentsCard'; diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx new file mode 100644 index 0000000000..79b69d35ff --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx @@ -0,0 +1,117 @@ +/* + * 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, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasSubcomponentsCard } from './HasSubcomponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-components', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Subcomponents')).toBeInTheDocument(); + expect( + getByText(/No subcomponent is part of this component/i), + ).toBeInTheDocument(); + }); + + it('shows related subcomponents', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Subcomponents')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx new file mode 100644 index 0000000000..541ccf795d --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx @@ -0,0 +1,89 @@ +/* + * 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 { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + ComponentsTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React, { PropsWithChildren } from 'react'; + +const SubcomponentsCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + variant?: string; +}; + +export const HasSubcomponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + kind: 'Component', + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No subcomponent is part of this component.{' '} + + Learn how to add subcomponents. + + + } + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/index.ts b/plugins/catalog/src/components/HasSubcomponentsCard/index.ts new file mode 100644 index 0000000000..cef0221d3b --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { HasSubcomponentsCard } from './HasSubcomponentsCard'; diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx new file mode 100644 index 0000000000..08923a59c8 --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx @@ -0,0 +1,115 @@ +/* + * 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, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasSystemsCard } from './HasSystemsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Domain', + metadata: { + name: 'my-domain', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Systems')).toBeInTheDocument(); + expect(getByText(/No system is part of this domain/i)).toBeInTheDocument(); + }); + + it('shows related systems', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Domain', + metadata: { + name: 'my-domain', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'System', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntityByName.mockResolvedValue({ + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Systems')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx new file mode 100644 index 0000000000..0af5f1747d --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx @@ -0,0 +1,88 @@ +/* + * 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 { RELATION_HAS_PART, SystemEntity } from '@backstage/catalog-model'; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + SystemsTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React, { PropsWithChildren } from 'react'; + +const SystemsCard = ({ + children, + variant = 'gridItem', +}: PropsWithChildren<{ variant?: string }>) => { + return ( + + {children} + + ); +}; + +type Props = { + variant?: string; +}; + +export const HasSystemsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_HAS_PART, + }); + + if (loading) { + return ( + + + + ); + } + + if (error || !entities) { + return ( + + } + /> + + ); + } + + return ( + + No system is part of this domain.{' '} + + Learn how to add systems. + + + } + entities={entities as SystemEntity[]} + /> + ); +}; diff --git a/plugins/catalog/src/components/HasSystemsCard/index.ts b/plugins/catalog/src/components/HasSystemsCard/index.ts new file mode 100644 index 0000000000..a29d257e7e --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { HasSystemsCard } from './HasSystemsCard'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index ce8518eb55..6d1704503e 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -20,10 +20,14 @@ export { EntityPageLayout } from './components/EntityPageLayout'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; export { + CatalogEntityPage, + CatalogIndexPage, catalogPlugin, catalogPlugin as plugin, - CatalogIndexPage, - CatalogEntityPage, EntityAboutCard, - EntityLinksCard, + EntityHasComponentsCard, + EntityHasSubcomponentsCard, + EntityHasSystemsCard, + EntityLinksCard } from './plugin'; + diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index c2b910ce68..3b2a6f58f4 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -17,10 +17,10 @@ import { CatalogClient } from '@backstage/catalog-client'; import { createApiFactory, - createPlugin, - discoveryApiRef, createComponentExtension, + createPlugin, createRoutableExtension, + discoveryApiRef, identityApiRef, } from '@backstage/core'; import { @@ -60,9 +60,7 @@ export const CatalogIndexPage = catalogPlugin.provide( export const CatalogEntityPage = catalogPlugin.provide( createRoutableExtension({ component: () => - import('./components/CatalogEntityPage/CatalogEntityPage').then( - m => m.CatalogEntityPage, - ), + import('./components/CatalogEntityPage').then(m => m.CatalogEntityPage), mountPoint: entityRouteRef, }), ); @@ -83,3 +81,32 @@ export const EntityLinksCard = catalogPlugin.provide( }, }), ); + +export const EntityHasSystemsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasSystemsCard').then(m => m.HasSystemsCard), + }, + }), +); + +export const EntityHasComponentsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ComponentsCard').then(m => m.HasComponentsCard), + }, + }), +); + +export const EntityHasSubcomponentsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasSubcomponentsCard').then( + m => m.HasSubcomponentsCard, + ), + }, + }), +);