diff --git a/.changeset/strong-ligers-lay.md b/.changeset/strong-ligers-lay.md new file mode 100644 index 0000000000..56f4a91a9d --- /dev/null +++ b/.changeset/strong-ligers-lay.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Display systems in catalog table and make both owner and system link to the entity pages. +The owner field is now taken from the relations of the entity instead of its spec. diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 684e0e3916..8741cb6157 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { + Entity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn, TableProps } from '@backstage/core'; import { Chip, Link } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; @@ -22,20 +27,31 @@ import { Alert } from '@material-ui/lab'; import React from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { findLocationForEntityMeta } from '../../data/utils'; -import { createEditLink } from '../createEditLink'; import { useStarredEntities } from '../../hooks/useStarredEntities'; import { entityRoute, entityRouteParams } from '../../routes'; +import { createEditLink } from '../createEditLink'; +import { EntityRefLink, formatEntityRefTitle } from '../EntityRefLink'; import { favouriteEntityIcon, favouriteEntityTooltip, } from '../FavouriteEntity/FavouriteEntity'; +import { getEntityRelations } from '../getEntityRelations'; -const columns: TableColumn[] = [ +type EntityRow = Entity & { + row: { + partOfSystemRelationTitle?: string; + partOfSystemRelation?: EntityName; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', field: 'metadata.name', highlight: true, - render: (entity: any) => ( + render: entity => ( [] = [ ), }, + { + title: 'System', + field: 'row.partOfSystemRelationTitle', + render: entity => ( + <> + {entity.row.partOfSystemRelation && ( + + )} + + ), + }, { title: 'Owner', - field: 'spec.owner', + field: 'row.ownedByRelationsTitle', + render: entity => ( + <> + {entity.row.ownedByRelations.map((t, i) => ( + + {i > 0 && ', '} + + + ))} + + ), }, { title: 'Lifecycle', @@ -65,7 +105,7 @@ const columns: TableColumn[] = [ cellStyle: { padding: '0px 16px 0px 20px', }, - render: (entity: Entity) => ( + render: entity => ( <> {entity.metadata.tags && entity.metadata.tags.map(t => ( @@ -141,8 +181,31 @@ export const CatalogTable = ({ }, ]; + const rows = entities.map(e => { + const [partOfSystemRelation] = getEntityRelations(e, RELATION_PART_OF, { + kind: 'system', + }); + const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + + return { + ...e, + row: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelation + ? formatEntityRefTitle(partOfSystemRelation, { + defaultKind: 'system', + }) + : undefined, + partOfSystemRelation, + }, + }; + }); + return ( - + isLoading={loading} columns={columns} options={{ @@ -155,7 +218,7 @@ export const CatalogTable = ({ pageSizeOptions: [20, 50, 100], }} title={`${titlePreamble} (${(entities && entities.length) || 0})`} - data={entities} + data={rows} actions={actions} /> ); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx index 28f5caca00..b6fe8a77d8 100644 --- a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx @@ -37,7 +37,10 @@ describe('', () => { wrapper: MemoryRouter, }); - expect(getByText('component:software')).toBeInTheDocument(); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); }); it('renders link for entity in other namespace', () => { @@ -57,7 +60,10 @@ describe('', () => { const { getByText } = render(, { wrapper: MemoryRouter, }); - expect(getByText('component:test/software')).toBeInTheDocument(); + expect(getByText('component:test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); }); it('renders link for entity and hides default kind', () => { @@ -80,7 +86,10 @@ describe('', () => { wrapper: MemoryRouter, }, ); - expect(getByText('test/software')).toBeInTheDocument(); + expect(getByText('test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); }); it('renders link for entity name in default namespace', () => { @@ -92,7 +101,10 @@ describe('', () => { const { getByText } = render(, { wrapper: MemoryRouter, }); - expect(getByText('component:software')).toBeInTheDocument(); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); }); it('renders link for entity name in other namespace', () => { @@ -104,7 +116,10 @@ describe('', () => { const { getByText } = render(, { wrapper: MemoryRouter, }); - expect(getByText('component:test/software')).toBeInTheDocument(); + expect(getByText('component:test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); }); it('renders link for entity name and hides default kind', () => { @@ -119,6 +134,29 @@ describe('', () => { wrapper: MemoryRouter, }, ); - expect(getByText('test/software')).toBeInTheDocument(); + expect(getByText('test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link with custom children', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + const { getByText } = render( + + Custom Children + , + { + wrapper: MemoryRouter, + }, + ); + expect(getByText('Custom Children')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); }); }); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx index a06ba69fbe..64b2bb8615 100644 --- a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx +++ b/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx @@ -17,17 +17,18 @@ import { Entity, EntityName, ENTITY_DEFAULT_NAMESPACE, - serializeEntityRef, } from '@backstage/catalog-model'; import { Link } from '@material-ui/core'; import React from 'react'; import { generatePath } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; import { entityRoute } from '../../routes'; +import { formatEntityRefTitle } from './format'; type EntityRefLinkProps = { entityRef: Entity | EntityName; defaultKind?: string; + children?: React.ReactNode; }; // TODO: This component is private for now, as it should probably belong into @@ -36,6 +37,7 @@ type EntityRefLinkProps = { export const EntityRefLink = ({ entityRef, defaultKind, + children, }: EntityRefLinkProps) => { let kind; let namespace; @@ -51,17 +53,8 @@ export const EntityRefLink = ({ name = entityRef.name; } - if (namespace === ENTITY_DEFAULT_NAMESPACE) { - namespace = undefined; - } - kind = kind.toLowerCase(); - const title = `${serializeEntityRef({ - kind: defaultKind && defaultKind.toLowerCase() === kind ? undefined : kind, - name, - namespace, - })}`; const routeParams = { kind, namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, @@ -74,7 +67,8 @@ export const EntityRefLink = ({ component={RouterLink} to={generatePath(`/catalog/${entityRoute.path}`, routeParams)} > - {title} + {children} + {!children && formatEntityRefTitle(entityRef, { defaultKind })} ); }; diff --git a/plugins/catalog/src/components/EntityRefLink/format.test.ts b/plugins/catalog/src/components/EntityRefLink/format.test.ts new file mode 100644 index 0000000000..142c914453 --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/format.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { formatEntityRefTitle } from './format'; + +describe('formatEntityRefTitle', () => { + it('formats entity in default namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity); + expect(title).toEqual('component:software'); + }); + + it('formats entity in other namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity); + expect(title).toEqual('component:test/software'); + }); + + it('formats entity and hides default kind', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity, { defaultKind: 'Component' }); + expect(title).toEqual('test/software'); + }); + + it('formats entity name in default namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'default', + name: 'software', + }; + const title = formatEntityRefTitle(entityName); + expect(title).toEqual('component:software'); + }); + + it('formats entity name in other namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + + const title = formatEntityRefTitle(entityName); + expect(title).toEqual('component:test/software'); + }); + + it('renders link for entity name and hides default kind', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + + const title = formatEntityRefTitle(entityName, { + defaultKind: 'component', + }); + expect(title).toEqual('test/software'); + }); +}); diff --git a/plugins/catalog/src/components/EntityRefLink/format.ts b/plugins/catalog/src/components/EntityRefLink/format.ts new file mode 100644 index 0000000000..28ba1bd22d --- /dev/null +++ b/plugins/catalog/src/components/EntityRefLink/format.ts @@ -0,0 +1,54 @@ +/* + * 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, + ENTITY_DEFAULT_NAMESPACE, + serializeEntityRef, +} from '@backstage/catalog-model'; + +export function formatEntityRefTitle( + entityRef: Entity | EntityName, + opts?: { defaultKind?: string }, +) { + const defaultKind = opts?.defaultKind; + let kind; + let namespace; + let name; + + if ('metadata' in entityRef) { + kind = entityRef.kind; + namespace = entityRef.metadata.namespace; + name = entityRef.metadata.name; + } else { + kind = entityRef.kind; + namespace = entityRef.namespace; + name = entityRef.name; + } + + if (namespace === ENTITY_DEFAULT_NAMESPACE) { + namespace = undefined; + } + + kind = kind.toLowerCase(); + + return `${serializeEntityRef({ + kind: defaultKind && defaultKind.toLowerCase() === kind ? undefined : kind, + name, + namespace, + })}`; +} diff --git a/plugins/catalog/src/components/EntityRefLink/index.ts b/plugins/catalog/src/components/EntityRefLink/index.ts index aa2c6641ef..76a7da38c5 100644 --- a/plugins/catalog/src/components/EntityRefLink/index.ts +++ b/plugins/catalog/src/components/EntityRefLink/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { EntityRefLink } from './EntityRefLink'; +export { formatEntityRefTitle } from './format';