diff --git a/.changeset/pink-coins-sniff.md b/.changeset/pink-coins-sniff.md new file mode 100644 index 0000000000..b9714f116a --- /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 an `EntityTable` to build own cards for entities. +The styling of the tables and new cards was also applied to the existing `EntityConsumedApisCard`, +`EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. diff --git a/.changeset/spicy-pants-teach.md b/.changeset/spicy-pants-teach.md new file mode 100644 index 0000000000..8e16ae089e --- /dev/null +++ b/.changeset/spicy-pants-teach.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Expose `useRelatedEntities` from `@backstage/plugin-catalog-react` to retrieve +entities references via relations from the API. diff --git a/.changeset/violet-maps-occur.md b/.changeset/violet-maps-occur.md new file mode 100644 index 0000000000..112095d257 --- /dev/null +++ b/.changeset/violet-maps-occur.md @@ -0,0 +1,8 @@ +--- +'@backstage/core': patch +--- + +Add support for custom empty state of `Table` components. + +You can now optionally pass `emptyContent` to `Table` that is displayed +if the table has now rows. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 3bfa0dd155..c5a0b02529 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -222,6 +222,8 @@ squidfunk src stdout stefanalund +subcomponent +subcomponents subkey subtree superfences 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/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index 4be85e494f..64ceb761f8 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -14,8 +14,10 @@ * limitations under the License. */ +import { makeStyles } from '@material-ui/core'; import React from 'react'; -import { Table, SubvalueCell, TableColumn } from './'; +import { Link } from '../Link'; +import { SubvalueCell, Table, TableColumn } from './'; import { TableFilter } from './Table'; export default { @@ -23,7 +25,16 @@ export default { component: Table, }; -const containerStyle = { width: 850 }; +const useStyles = makeStyles(theme => ({ + container: { + width: 850, + }, + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); const generateTestData: (number: number) => Array<{}> = (rows = 10) => { const data: Array<{}> = []; @@ -43,6 +54,7 @@ const generateTestData: (number: number) => Array<{}> = (rows = 10) => { const testData10 = generateTestData(10); export const DefaultTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -66,7 +78,7 @@ export const DefaultTable = () => { ]; return ( -
+
{ ); }; -export const SubtitleTable = () => { +export const EmptyTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -101,7 +114,49 @@ export const SubtitleTable = () => { ]; return ( -
+
+
+ No data was added yet,  + learn how to add data. + + } + title="Backstage Table" + /> + + ); +}; + +export const SubtitleTable = () => { + const classes = useStyles(); + const columns: TableColumn[] = [ + { + title: 'Column 1', + field: 'col1', + highlight: true, + }, + { + title: 'Column 2', + field: 'col2', + }, + { + title: 'Numeric value', + field: 'number', + type: 'numeric', + }, + { + title: 'A Date', + field: 'date', + type: 'date', + }, + ]; + + return ( +
{ }; export const HiddenSearchTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -137,7 +193,7 @@ export const HiddenSearchTable = () => { ]; return ( -
+
{ }; export const SubvalueTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -181,13 +238,14 @@ export const SubvalueTable = () => { ]; return ( -
+
); }; export const DenseTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -211,7 +269,7 @@ export const DenseTable = () => { ]; return ( -
+
{ }; export const FilterTable = () => { + const classes = useStyles(); const columns: TableColumn[] = [ { title: 'Column 1', @@ -261,7 +320,7 @@ export const FilterTable = () => { ]; return ( -
+
', () => { ); expect(rendered.getByText('subtitle')).toBeInTheDocument(); }); + + it('renders custom empty component if empty', async () => { + const rendered = await renderInTestApp( +
EMPTY} + columns={minProps.columns} + data={[]} + />, + ); + expect(rendered.getByText('EMPTY')).toBeInTheDocument(); + }); }); diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index dbb2964845..3892d82bb3 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -42,12 +42,14 @@ import MTable, { Column, Icons, MaterialTableProps, + MTableBody, MTableHeader, MTableToolbar, Options, } from 'material-table'; import React, { forwardRef, + ReactNode, useCallback, useEffect, useRef, @@ -202,6 +204,7 @@ export interface TableProps subtitle?: string; filters?: TableFilter[]; initialState?: TableState; + emptyContent?: ReactNode; onStateChange?: (state: TableState) => any; } @@ -212,6 +215,7 @@ export function Table({ subtitle, filters, initialState, + emptyContent, onStateChange, ...props }: TableProps) { @@ -423,6 +427,23 @@ export function Table({ ], ); + const Body = useCallback( + bodyProps => { + if (emptyContent && data.length === 0) { + return ( + + + + + + ); + } + + return ; + }, + [data, emptyContent, columns], + ); + return (
{filtersOpen && data && filters?.length && ( @@ -438,6 +459,7 @@ export function Table({ ), Toolbar, + Body, }} options={{ ...defaultOptions, ...options }} columns={MTColumns} diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx deleted file mode 100644 index 30716a61fb..0000000000 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ApiEntity, - EntityName, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; -import { Table, TableColumn } from '@backstage/core'; -import { - EntityRefLink, - EntityRefLinks, - formatEntityRefTitle, - getEntityRelations, -} from '@backstage/plugin-catalog-react'; -import React from 'react'; -import { ApiTypeTitle } from '../ApiDefinitionCard'; - -type EntityRow = { - entity: ApiEntity; - resolved: { - partOfSystemRelationTitle?: string; - partOfSystemRelations: EntityName[]; - ownedByRelationsTitle?: string; - ownedByRelations: EntityName[]; - }; -}; - -const columns: TableColumn[] = [ - { - title: 'Name', - field: 'entity.metadata.name', - highlight: true, - render: ({ entity }) => ( - {entity.metadata.name} - ), - }, - { - title: 'System', - field: 'resolved.partOfSystemRelationTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Owner', - field: 'resolved.ownedByRelationsTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Lifecycle', - field: 'entity.spec.lifecycle', - }, - { - title: 'Type', - field: 'entity.spec.type', - render: ({ entity }) => , - }, - { - title: 'Description', - field: 'entity.metadata.description', - width: 'auto', - }, -]; - -type Props = { - title: string; - variant?: string; - entities: (ApiEntity | undefined)[]; -}; - -export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { - const tableStyle: React.CSSProperties = { - minWidth: '0', - width: '100%', - }; - - if (variant === 'gridItem') { - 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, - }, - }; - }); - - return ( - - columns={columns} - title={title} - style={tableStyle} - 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/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 8a808a6488..adfa3cd34c 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -19,70 +19,67 @@ import { Entity, RELATION_CONSUMES_API, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { ApisTable } from './ApisTable'; -import { MissingConsumesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; - -const ApisCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_CONSUMES_API, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_CONSUMES_API, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No Component consumes this API.{' '} + + Learn how to consume APIs. + +
+ } + columns={apiEntityColumns} + 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..9f1d487b65 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx @@ -0,0 +1,80 @@ +/* + * 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 { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; + +type Props = { + variant?: 'gridItem'; +}; + +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. + +
+ } + columns={apiEntityColumns} + 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..0d49cc328d 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 ed6e893a03..f02390b6d6 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -19,70 +19,67 @@ import { Entity, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { ApisTable } from './ApisTable'; -import { MissingProvidesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; - -const ApisCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { apiEntityColumns } from './presets'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_PROVIDES_API, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_PROVIDES_API, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No component provides this API.{' '} + + Learn how to provide APIs. + +
+ } + columns={apiEntityColumns} + 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/ApisCards/presets.tsx b/plugins/api-docs/src/components/ApisCards/presets.tsx new file mode 100644 index 0000000000..151cccf846 --- /dev/null +++ b/plugins/api-docs/src/components/ApisCards/presets.tsx @@ -0,0 +1,42 @@ +/* + * 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 } from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { EntityTable } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { ApiTypeTitle } from '../ApiDefinitionCard'; + +export function createSpecApiTypeColumn(): TableColumn { + return { + title: 'Type', + field: 'spec.type', + render: entity => , + }; +} + +// TODO: This could be moved to plugin-catalog-react if we wouldn't have a +// special createSpecApiTypeColumn. But this is required to use ApiTypeTitle to +// resolve the display name of an entity. Is the display name really worth it? + +export const apiEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }), + EntityTable.columns.createSystemColumn(), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + createSpecApiTypeColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx deleted file mode 100644 index bd9b0c765f..0000000000 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ComponentEntity, - EntityName, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; -import { Table, TableColumn } from '@backstage/core'; -import { - EntityRefLink, - EntityRefLinks, - formatEntityRefTitle, - getEntityRelations, -} from '@backstage/plugin-catalog-react'; -import React from 'react'; - -type EntityRow = { - entity: ComponentEntity; - resolved: { - partOfSystemRelationTitle?: string; - partOfSystemRelations: EntityName[]; - ownedByRelationsTitle?: string; - ownedByRelations: EntityName[]; - }; -}; - -const columns: TableColumn[] = [ - { - title: 'Name', - field: 'entity.metadata.name', - highlight: true, - render: ({ entity }) => ( - {entity.metadata.name} - ), - }, - { - title: 'System', - field: 'resolved.partOfSystemRelationTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Owner', - field: 'resolved.ownedByRelationsTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Lifecycle', - field: 'entity.spec.lifecycle', - }, - { - title: 'Type', - field: 'entity.spec.type', - }, - { - title: 'Description', - field: 'entity.metadata.description', - width: 'auto', - }, -]; - -type Props = { - title: string; - variant?: string; - entities: (ComponentEntity | undefined)[]; -}; - -// TODO: In theory this could also be systems! -export const ComponentsTable = ({ - entities, - title, - variant = 'gridItem', -}: Props) => { - const tableStyle: React.CSSProperties = { - minWidth: '0', - width: '100%', - }; - - if (variant === 'gridItem') { - 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, - }, - }; - }); - - return ( - - columns={columns} - title={title} - style={tableStyle} - 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/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 56d2fb977d..7e4d97fece 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -19,70 +19,66 @@ import { Entity, RELATION_API_CONSUMED_BY, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { MissingConsumesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; -import { ComponentsTable } from './ComponentsTable'; - -const ComponentsCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_API_CONSUMED_BY, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_API_CONSUMED_BY, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No component consumes this API.{' '} + + Learn how to consume APIs. + + + } + columns={EntityTable.componentEntityColumns} + 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 959ff3feed..d755d8b5af 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -19,70 +19,66 @@ import { Entity, RELATION_API_PROVIDED_BY, } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { EmptyState, InfoCard, Progress } from '@backstage/core'; -import React, { PropsWithChildren } from 'react'; -import { MissingProvidesApisEmptyState } from '../EmptyState'; -import { useRelatedEntities } from '../useRelatedEntities'; -import { ComponentsTable } from './ComponentsTable'; - -const ComponentsCard = ({ - children, - variant = 'gridItem', -}: PropsWithChildren<{ variant?: string }>) => { - return ( - - {children} - - ); -}; +import { + CodeSnippet, + InfoCard, + Link, + Progress, + WarningPanel, +} from '@backstage/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; type Props = { /** @deprecated The entity is now grabbed from context instead */ entity?: Entity; - variant?: string; + variant?: 'gridItem'; }; export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => { const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities( - entity, - RELATION_API_PROVIDED_BY, - ); + const { entities, loading, error } = useRelatedEntities(entity, { + type: RELATION_API_PROVIDED_BY, + }); if (loading) { return ( - + - + ); } - if (error) { + if (error || !entities) { return ( - - + } /> - - ); - } - - if (!entities || entities.length === 0) { - return ( - - - + ); } return ( - + No component provides this API.{' '} + + Learn how to provide APIs. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} /> ); }; diff --git a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx b/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx deleted file mode 100644 index 3e71168dde..0000000000 --- a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Button, makeStyles, Typography } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { CodeSnippet, EmptyState } from '@backstage/core'; - -const COMPONENT_YAML = `# Example -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: example -spec: - type: service - lifecycle: production - owner: guest - consumesApis: - - example-api -`; - -const useStyles = makeStyles(theme => ({ - code: { - borderRadius: 6, - margin: `${theme.spacing(2)}px 0px`, - background: theme.palette.type === 'dark' ? '#444' : '#fff', - }, -})); - -export const MissingConsumesApisEmptyState = () => { - const classes = useStyles(); - return ( - - Components can consume APIs that are displayed on this page. You need - to fill the consumesApis field to enable this tool. - - } - action={ - <> - - Link an API to your component as shown in the highlighted example - below: - -
- -
- - - } - /> - ); -}; diff --git a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx b/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx deleted file mode 100644 index 9bf3465a34..0000000000 --- a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Button, makeStyles, Typography } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; -import { CodeSnippet, EmptyState } from '@backstage/core'; - -const COMPONENT_YAML = `# Example -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: example -spec: - type: service - lifecycle: production - owner: guest - providesApis: - - example-api -`; - -const useStyles = makeStyles(theme => ({ - code: { - borderRadius: 6, - margin: `${theme.spacing(2)}px 0px`, - background: theme.palette.type === 'dark' ? '#444' : '#fff', - }, -})); - -export const MissingProvidesApisEmptyState = () => { - const classes = useStyles(); - return ( - - Components can implement APIs that are displayed on this page. You - need to fill the providesApis field to enable this tool. - - } - action={ - <> - - Link an API to your component as shown in the highlighted example - below: - -
- -
- - - } - /> - ); -}; 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/EntityTable/EntityTable.test.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx new file mode 100644 index 0000000000..33bbbd007d --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 } from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { EntityTable } from './EntityTable'; + +describe('', () => { + it('shows empty table', async () => { + const { getByText } = await renderInTestApp( + EMPTY} + columns={[]} + />, + ); + + expect(getByText('Entities')).toBeInTheDocument(); + expect(getByText('EMPTY')).toBeInTheDocument(); + }); + + it('shows entities', async () => { + const entities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'my-entity', + }, + spec: {}, + }, + ]; + + const { getByText } = await renderInTestApp( + EMPTY} + columns={[ + { + title: 'Name', + field: 'metadata.name', + }, + ]} + />, + ); + + await waitFor(() => { + expect(getByText('my-entity')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx new file mode 100644 index 0000000000..afcaaea305 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx @@ -0,0 +1,81 @@ +/* + * 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 } from '@backstage/catalog-model'; +import { Table, TableColumn } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; +import React, { ReactNode } from 'react'; +import * as columnFactories from './columns'; +import { componentEntityColumns, systemEntityColumns } from './presets'; + +type Props = { + title: string; + variant?: 'gridItem'; + entities: T[]; + emptyContent?: ReactNode; + columns: TableColumn[]; +}; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export function EntityTable({ + entities, + title, + emptyContent, + variant = 'gridItem', + columns, +}: Props) { + const classes = useStyles(); + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + if (variant === 'gridItem') { + tableStyle.height = 'calc(100% - 10px)'; + } + + return ( + + columns={columns} + title={title} + style={tableStyle} + emptyContent={ + emptyContent &&
{emptyContent}
+ } + options={{ + // TODO: Toolbar padding if off compared to other cards, should be: padding: 16px 24px; + search: false, + paging: false, + actionsColumnIndex: -1, + padding: 'dense', + }} + data={entities} + /> + ); +} + +EntityTable.columns = columnFactories; + +EntityTable.systemEntityColumns = systemEntityColumns; + +EntityTable.componentEntityColumns = componentEntityColumns; diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx new file mode 100644 index 0000000000..7ca99b1100 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -0,0 +1,158 @@ +/* + * 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, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import React from 'react'; +import { getEntityRelations } from '../../utils'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, +} from '../EntityRefLink'; + +export function createEntityRefColumn({ + defaultKind, +}: { + defaultKind?: string; +}): TableColumn { + function formatContent(entity: T): string { + return formatEntityRefTitle(entity, { + defaultKind, + }); + } + + return { + title: 'Name', + highlight: true, + customFilterAndSearch(filter, entity) { + // TODO: We could implement this more efficiently, like searching over + // each field that is displayed individually (kind, namespace, name). + // but that migth confuse the user as it will behave different than a + // simple text search. + // Another alternative would be to cache the values. But writing them + // into the entity feels bad too. + return formatContent(entity).includes(filter); + }, + customSort(entity1, entity2) { + // TODO: We could implement this more efficiently by comparing field by field. + // This has similar issues as above. + return formatContent(entity1).localeCompare(formatContent(entity2)); + }, + render: entity => ( + + ), + }; +} + +export function createEntityRelationColumn({ + title, + relation, + defaultKind, + filter: entityFilter, +}: { + title: string; + relation: string; + defaultKind?: string; + filter?: { kind: string }; +}): TableColumn { + function getRelations(entity: T): EntityName[] { + return getEntityRelations(entity, relation, entityFilter); + } + + function formatContent(entity: T): string { + return getRelations(entity) + .map(r => formatEntityRefTitle(r, { defaultKind })) + .join(', '); + } + + return { + title, + customFilterAndSearch(filter, entity) { + return formatContent(entity).includes(filter); + }, + customSort(entity1, entity2) { + return formatContent(entity1).localeCompare(formatContent(entity2)); + }, + render: entity => { + return ( + + ); + }, + }; +} + +export function createOwnerColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'Owner', + relation: RELATION_OWNED_BY, + defaultKind: 'group', + }); +} + +export function createDomainColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'Domain', + relation: RELATION_PART_OF, + defaultKind: 'domain', + filter: { + kind: 'domain', + }, + }); +} + +export function createSystemColumn(): TableColumn { + return createEntityRelationColumn({ + title: 'System', + relation: RELATION_PART_OF, + defaultKind: 'system', + filter: { + kind: 'system', + }, + }); +} + +export function createMetadataDescriptionColumn< + T extends Entity +>(): TableColumn { + return { + title: 'Description', + field: 'metadata.description', + width: 'auto', + }; +} + +export function createSpecLifecycleColumn(): TableColumn { + return { + title: 'Lifecycle', + field: 'spec.lifecycle', + }; +} + +export function createSpecTypeColumn(): TableColumn { + return { + title: 'Type', + field: 'spec.type', + }; +} diff --git a/plugins/catalog-react/src/components/EntityTable/index.ts b/plugins/catalog-react/src/components/EntityTable/index.ts new file mode 100644 index 0000000000..8a01b9baae --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { EntityTable } from './EntityTable'; diff --git a/plugins/catalog-react/src/components/EntityTable/presets.test.tsx b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx new file mode 100644 index 0000000000..b6b8c26e37 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx @@ -0,0 +1,137 @@ +/* + * 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, + SystemEntity, +} from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { EntityTable } from './EntityTable'; +import { componentEntityColumns, systemEntityColumns } from './presets'; + +describe('systemEntityColumns', () => { + 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} + columns={systemEntityColumns} + />, + ); + + await waitFor(() => { + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('my-namespace/my-domain')).toBeInTheDocument(); + expect(getByText('Test')).toBeInTheDocument(); + expect(getByText('Some description')).toBeInTheDocument(); + }); + }); +}); + +describe('componentEntityColumns', () => { + 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} + columns={componentEntityColumns} + />, + ); + + await waitFor(() => { + 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/catalog-react/src/components/EntityTable/presets.tsx b/plugins/catalog-react/src/components/EntityTable/presets.tsx new file mode 100644 index 0000000000..7ce6b75532 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTable/presets.tsx @@ -0,0 +1,43 @@ +/* + * 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, SystemEntity } from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { + createDomainColumn, + createEntityRefColumn, + createMetadataDescriptionColumn, + createOwnerColumn, + createSpecLifecycleColumn, + createSpecTypeColumn, + createSystemColumn, +} from './columns'; + +export const systemEntityColumns: TableColumn[] = [ + createEntityRefColumn({ defaultKind: 'system' }), + createDomainColumn(), + createOwnerColumn(), + createMetadataDescriptionColumn(), +]; + +export const componentEntityColumns: TableColumn[] = [ + createEntityRefColumn({ defaultKind: 'component' }), + createSystemColumn(), + createOwnerColumn(), + createSpecTypeColumn(), + createSpecLifecycleColumn(), + createMetadataDescriptionColumn(), +]; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index fcfa8207b3..5181b8f0ea 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 './EntityTable'; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 77de70b8d4..2ecf6e9a90 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -15,3 +15,4 @@ */ export { EntityContext, useEntity, useEntityFromUrl } from './useEntity'; export { useEntityCompoundName } from './useEntityCompoundName'; +export { useRelatedEntities } from './useRelatedEntities'; diff --git a/plugins/api-docs/src/components/useRelatedEntities.ts b/plugins/catalog-react/src/hooks/useRelatedEntities.ts similarity index 51% rename from plugins/api-docs/src/components/useRelatedEntities.ts rename to plugins/catalog-react/src/hooks/useRelatedEntities.ts index 5c01d38189..baf6cf5fc7 100644 --- a/plugins/api-docs/src/components/useRelatedEntities.ts +++ b/plugins/catalog-react/src/hooks/useRelatedEntities.ts @@ -15,36 +15,45 @@ */ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { useAsyncRetry } from 'react-use'; +import { useAsync } from 'react-use'; +import { catalogApiRef } from '../api'; -// TODO: Maybe this hook is interesting for others too? export function useRelatedEntities( entity: Entity, - type: string, + { type, kind }: { type?: string; kind?: string }, ): { - entities: (Entity | undefined)[] | undefined; + entities: Entity[] | undefined; loading: boolean; error: Error | undefined; } { const catalogApi = useApi(catalogApiRef); - const { loading, value, error } = useAsyncRetry< - (Entity | undefined)[] - >(async () => { + const { loading, value: entities, error } = useAsync(async () => { const relations = - entity.relations && entity.relations.filter(r => r.type === type); + entity.relations && + entity.relations.filter( + r => + (!type || r.type.toLowerCase() === type.toLowerCase()) && + (!kind || r.target.kind.toLowerCase() === kind.toLowerCase()), + ); if (!relations) { return []; } - return await Promise.all( + // TODO: This code could be more efficient if there was an endpoint in the + // backend that either returns the relations of entity (filtered by type) + // or if there is a way to perform a batch request by entity name. However, + // such an implementation would probably be better placed in the graphql API. + const results = await Promise.all( relations?.map(r => catalogApi.getEntityByName(r.target)), ); + // Skip entities that where not found, for example if a relation references + // an entity that doesn't exist. + return results.filter(e => e) as Entity[]; }, [entity, type]); return { - entities: value, + entities, loading, error, }; diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx new file mode 100644 index 0000000000..58df0a53f8 --- /dev/null +++ b/plugins/catalog/src/components/HasComponentsCard/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/HasComponentsCard/HasComponentsCard.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx new file mode 100644 index 0000000000..6485d4f525 --- /dev/null +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx @@ -0,0 +1,79 @@ +/* + * 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 { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +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. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/EmptyState/index.ts b/plugins/catalog/src/components/HasComponentsCard/index.ts similarity index 78% rename from plugins/api-docs/src/components/EmptyState/index.ts rename to plugins/catalog/src/components/HasComponentsCard/index.ts index d195c43eb1..059392c3e8 100644 --- a/plugins/api-docs/src/components/EmptyState/index.ts +++ b/plugins/catalog/src/components/HasComponentsCard/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState'; -export { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState'; +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..c2635b68e1 --- /dev/null +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx @@ -0,0 +1,79 @@ +/* + * 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 { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +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. + + + } + columns={EntityTable.componentEntityColumns} + entities={entities as ComponentEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/index.ts similarity index 57% rename from plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx rename to plugins/catalog/src/components/HasSubcomponentsCard/index.ts index b539753a95..cef0221d3b 100644 --- a/plugins/api-docs/src/components/EmptyState/MissingProvidesApisEmptyState.test.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/index.ts @@ -14,15 +14,4 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { MissingProvidesApisEmptyState } from './MissingProvidesApisEmptyState'; - -describe('', () => { - it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/providesApis:/i)).toBeInTheDocument(); - }); -}); +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..9faa640060 --- /dev/null +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx @@ -0,0 +1,78 @@ +/* + * 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 { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; +}; + +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. + + + } + columns={EntityTable.systemEntityColumns} + entities={entities as SystemEntity[]} + /> + ); +}; diff --git a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx b/plugins/catalog/src/components/HasSystemsCard/index.ts similarity index 57% rename from plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx rename to plugins/catalog/src/components/HasSystemsCard/index.ts index de753713c3..a29d257e7e 100644 --- a/plugins/api-docs/src/components/EmptyState/MissingConsumesApisEmptyState.test.tsx +++ b/plugins/catalog/src/components/HasSystemsCard/index.ts @@ -14,15 +14,4 @@ * limitations under the License. */ -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { MissingConsumesApisEmptyState } from './MissingConsumesApisEmptyState'; - -describe('', () => { - it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/consumesApis:/i)).toBeInTheDocument(); - }); -}); +export { HasSystemsCard } from './HasSystemsCard'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index ce8518eb55..800c5c4693 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -20,10 +20,13 @@ export { EntityPageLayout } from './components/EntityPageLayout'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; export { + CatalogEntityPage, + CatalogIndexPage, catalogPlugin, catalogPlugin as plugin, - CatalogIndexPage, - CatalogEntityPage, EntityAboutCard, + EntityHasComponentsCard, + EntityHasSubcomponentsCard, + EntityHasSystemsCard, EntityLinksCard, } from './plugin'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index c2b910ce68..a0932a8dba 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/HasComponentsCard').then(m => m.HasComponentsCard), + }, + }), +); + +export const EntityHasSubcomponentsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasSubcomponentsCard').then( + m => m.HasSubcomponentsCard, + ), + }, + }), +);
{emptyContent}