diff --git a/.changeset/plenty-eels-listen.md b/.changeset/plenty-eels-listen.md new file mode 100644 index 0000000000..6b4776f808 --- /dev/null +++ b/.changeset/plenty-eels-listen.md @@ -0,0 +1,19 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Attempt to load entity owner names in the EntityOwnerPicker through the `by-refs` endpoint. If `spec.profile.displayName` or `metadata.title` are populated, we now attempt to show those before showing the `humanizeEntityRef`'d version. + +**BREAKING**: `EntityOwnerFilter` now uses the full entity ref instead of the `humanizeEntityRef`. If you rely on `EntityOwnerFilter.values` or the `queryParameters.owners` of `useEntityList`, you will need to adjust your code like the following. + +```tsx +const { queryParameters: { owners } } = useEntityList(); +// or +const { filter: { owners } } = useEntityList(); + +// Instead of, +if (owners.some(ref => ref === humanizeEntityRef(myEntity))) ... + +// You'll need to use, +if (owners.some(ref => ref === stringifyEntityRef(myEntity))) ... +``` diff --git a/.changeset/tough-moles-whisper.md b/.changeset/tough-moles-whisper.md new file mode 100644 index 0000000000..9be6ebd102 --- /dev/null +++ b/.changeset/tough-moles-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': minor +--- + +Radar entry timeline added to the entry detail page diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx index bc413661a8..f89897524d 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx @@ -64,6 +64,7 @@ describe('DefaultApiExplorerPage', () => { }), getLocationByRef: () => Promise.resolve({ id: 'id', type: 'url', target: 'url' }), + getEntitiesByRefs: () => Promise.resolve({ items: [] }), getEntityByRef: async entityRef => { return { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 77f439018f..20b10f18cc 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -242,7 +242,6 @@ export class EntityOwnerFilter implements EntityFilter { constructor(values: string[]); // (undocumented) filterEntity(entity: Entity): boolean; - // (undocumented) toQueryValue(): string[]; // (undocumented) readonly values: string[]; diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx index b6e928b519..e4dc322c7c 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx @@ -14,12 +14,59 @@ * limitations under the License. */ -import { Entity, parseEntityRef } from '@backstage/catalog-model'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityOwnerFilter } from '../../filters'; import { EntityOwnerPicker } from './EntityOwnerPicker'; +import { ApiProvider } from '@backstage/core-app-api'; +import { + MockErrorApi, + renderWithEffects, + TestApiRegistry, +} from '@backstage/test-utils'; +import { catalogApiRef, CatalogApi } from '../..'; +import { errorApiRef } from '@backstage/core-plugin-api'; + +const ownerEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'some-owner', + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'some-owner-2', + }, + spec: { + profile: { + displayName: 'Some Owner 2', + }, + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'another-owner', + title: 'Another Owner', + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + namespace: 'test-namespace', + name: 'another-owner-2', + title: 'Another Owner in Another Namespace', + }, + }, +]; const sampleEntities: Entity[] = [ { @@ -50,6 +97,10 @@ const sampleEntities: Entity[] = [ type: 'ownedBy', targetRef: 'group:default/another-owner', }, + { + type: 'ownedBy', + targetRef: 'group:test-namespace/another-owner-2', + }, ], }, { @@ -67,77 +118,104 @@ const sampleEntities: Entity[] = [ }, ]; +const getEntitiesByRefs = jest.fn(async ({ entityRefs }) => ({ + items: entityRefs.map((e: string) => + ownerEntities.find(f => stringifyEntityRef(f) === e), + ), +})); +const mockCatalogApi: Partial = { + getEntitiesByRefs, +}; +const mockErrorApi = new MockErrorApi(); + describe('', () => { - it('renders all owners', () => { - render( - - - , + const mockApis = TestApiRegistry.from( + [catalogApiRef, mockCatalogApi], + [errorApiRef, mockErrorApi], + ); + + it('renders all owners', async () => { + await renderWithEffects( + + + + + , ); expect(screen.getByText('Owner')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('owner-picker-expand')); - sampleEntities - .flatMap(e => e.relations?.map(r => parseEntityRef(r.targetRef).name)) - .forEach(owner => { - expect(screen.getByText(owner as string)).toBeInTheDocument(); - }); + [ + 'Another Owner', + 'some-owner', + 'Some Owner 2', + 'Another Owner in Another Namespace', + ].forEach(owner => { + expect(screen.getByText(owner)).toBeInTheDocument(); + }); }); - it('renders unique owners in alphabetical order', () => { - render( - - - , + it('renders unique owners in alphabetical order', async () => { + await renderWithEffects( + + + + + , ); expect(screen.getByText('Owner')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('owner-picker-expand')); expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ - 'another-owner', + 'Another Owner', + 'Another Owner in Another Namespace', 'some-owner', - 'some-owner-2', + 'Some Owner 2', ]); }); - it('respects the query parameter filter value', () => { + it('respects the query parameter filter value', async () => { const updateFilters = jest.fn(); const queryParameters = { owners: ['another-owner'] }; - render( - - - , + await renderWithEffects( + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ - owners: new EntityOwnerFilter(['another-owner']), + owners: new EntityOwnerFilter(['group:default/another-owner']), }); }); - it('adds owners to filters', () => { + it('adds owners to filters', async () => { const updateFilters = jest.fn(); - render( - - - , + await renderWithEffects( + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ owners: undefined, @@ -146,28 +224,31 @@ describe('', () => { fireEvent.click(screen.getByTestId('owner-picker-expand')); fireEvent.click(screen.getByText('some-owner')); expect(updateFilters).toHaveBeenLastCalledWith({ - owners: new EntityOwnerFilter(['some-owner']), + owners: new EntityOwnerFilter(['group:default/some-owner']), }); }); - it('removes owners from filters', () => { + it('removes owners from filters', async () => { const updateFilters = jest.fn(); - render( - - - , + await renderWithEffects( + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ - owners: new EntityOwnerFilter(['some-owner']), + owners: new EntityOwnerFilter(['group:default/some-owner']), }); fireEvent.click(screen.getByTestId('owner-picker-expand')); + expect(screen.getByLabelText('some-owner')).toBeChecked(); fireEvent.click(screen.getByLabelText('some-owner')); @@ -176,49 +257,55 @@ describe('', () => { }); }); - it('responds to external queryParameters changes', () => { + it('responds to external queryParameters changes', async () => { const updateFilters = jest.fn(); - const rendered = render( - - - , + const rendered = await renderWithEffects( + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ - owners: new EntityOwnerFilter(['team-a']), + owners: new EntityOwnerFilter(['group:default/team-a']), }); rendered.rerender( - - - , + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ - owners: new EntityOwnerFilter(['team-b']), + owners: new EntityOwnerFilter(['group:default/team-b']), }); }); - it('removes owners from filters if there are none available', () => { + it('removes owners from filters if there are none available', async () => { const updateFilters = jest.fn(); - render( - - - , + await renderWithEffects( + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ owners: undefined, diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 185de3535a..8c4f2bfea0 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + Entity, + parseEntityRef, + RELATION_OWNED_BY, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { Box, Checkbox, @@ -31,7 +36,10 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useEntityList } from '../../hooks/useEntityListProvider'; import { EntityOwnerFilter } from '../../filters'; import { getEntityRelations } from '../../utils'; -import { humanizeEntityRef } from '../EntityRefLink'; +import useAsync from 'react-use/lib/useAsync'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '../../api'; +import { humanizeEntity, humanizeEntityRef } from '../EntityRefLink/humanize'; /** @public */ export type CatalogReactEntityOwnerPickerClassKey = 'input'; @@ -57,6 +65,8 @@ export const EntityOwnerPicker = () => { filters, queryParameters: { owners: ownersParameter }, } = useEntityList(); + const catalogApi = useApi(catalogApiRef); + const errorApi = useApi(errorApiRef); const queryParamOwners = useMemo( () => [ownersParameter].flat().filter(Boolean) as string[], @@ -64,43 +74,93 @@ export const EntityOwnerPicker = () => { ); const [selectedOwners, setSelectedOwners] = useState( - queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], + queryParamOwners.length + ? new EntityOwnerFilter(queryParamOwners).values + : filters.owners?.values ?? [], ); + const { + loading, + error, + value: ownerEntities, + } = useAsync(async () => { + const ownerEntityRefs = [ + ...new Set( + backendEntities + .flatMap((e: Entity) => + getEntityRelations(e, RELATION_OWNED_BY).map(o => + stringifyEntityRef(o), + ), + ) + .filter(Boolean) as string[], + ), + ]; + const { items: ownerEntitiesOrNull } = await catalogApi.getEntitiesByRefs({ + entityRefs: ownerEntityRefs, + fields: [ + 'kind', + 'metadata.name', + 'metadata.title', + 'metadata.namespace', + 'spec.profile.displayName', + ], + }); + const owners = ownerEntitiesOrNull.map((entity, index) => { + if (entity) { + return { + label: humanizeEntity(entity, { defaultKind: 'Group' }), + entityRef: stringifyEntityRef(entity), + }; + } + return { + label: humanizeEntityRef(parseEntityRef(ownerEntityRefs[index]), { + defaultKind: 'group', + }), + entityRef: ownerEntityRefs[index], + }; + }); + + return owners.sort((a, b) => + a.label.localeCompare(b.label, 'en-US', { + ignorePunctuation: true, + caseFirst: 'upper', + }), + ); + }, [backendEntities]); + + useEffect(() => { + if (error) { + errorApi.post( + { + ...error, + message: `EntityOwnerPicker failed to initialize: ${error.message}`, + }, + {}, + ); + } + }, [error, errorApi]); + // Set selected owners on query parameter updates; this happens at initial page load and from // external updates to the page location. useEffect(() => { if (queryParamOwners.length) { - setSelectedOwners(queryParamOwners); + const filter = new EntityOwnerFilter(queryParamOwners); + setSelectedOwners(filter.values); } }, [queryParamOwners]); - const availableOwners = useMemo( - () => - [ - ...new Set( - backendEntities - .flatMap((e: Entity) => - getEntityRelations(e, RELATION_OWNED_BY).map(o => - humanizeEntityRef(o, { defaultKind: 'group' }), - ), - ) - .filter(Boolean) as string[], - ), - ].sort(), - [backendEntities], - ); - useEffect(() => { - updateFilters({ - owners: - selectedOwners.length && availableOwners.length - ? new EntityOwnerFilter(selectedOwners) - : undefined, - }); - }, [selectedOwners, updateFilters, availableOwners]); + if (!loading && ownerEntities) { + updateFilters({ + owners: + selectedOwners.length && ownerEntities.length + ? new EntityOwnerFilter(selectedOwners) + : undefined, + }); + } + }, [selectedOwners, updateFilters, ownerEntities, loading]); - if (!availableOwners.length) return null; + if (!loading && !ownerEntities?.length) return null; return ( @@ -109,9 +169,17 @@ export const EntityOwnerPicker = () => { setSelectedOwners(value)} + loading={loading} + options={ownerEntities || []} + value={ + ownerEntities?.filter(e => + selectedOwners.some((f: string) => f === e.entityRef), + ) ?? [] + } + onChange={(_: object, value: { entityRef: string }[]) => + setSelectedOwners(value.map(e => e.entityRef)) + } + getOptionLabel={option => option.label} renderOption={(option, { selected }) => ( { /> } onClick={event => event.preventDefault()} - label={option} + label={option.label} /> )} size="small" diff --git a/plugins/catalog-react/src/components/EntityRefLink/humanize.test.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.test.ts index 80ce2100f6..e7d1649a29 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.test.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.test.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { humanizeEntityRef } from './humanize'; +import { Entity } from '@backstage/catalog-model'; +import { humanizeEntity, humanizeEntityRef } from './humanize'; describe('humanizeEntityRef', () => { it('formats entity in default namespace', () => { @@ -210,3 +211,59 @@ describe('humanizeEntityRef', () => { expect(title).toEqual('component:default/software'); }); }); + +describe('humanizeEntity', () => { + it('gives a readable name when one is provided at metadata.title', () => { + expect( + humanizeEntity({ + metadata: { name: 'my-entity', title: 'My Title' }, + } as Entity), + ).toBe('My Title'); + }); + + it.each([ + [ + 'User', + { + apiVersion: '1', + kind: 'User', + metadata: { + name: 'user-name', + }, + spec: { + profile: { + displayName: 'User Name', + }, + }, + }, + 'User Name', + ], + [ + 'Group', + { + apiVersion: '1', + kind: 'User', + metadata: { + name: 'team-name', + }, + spec: { + profile: { + displayName: 'Team Name', + }, + }, + }, + 'Team Name', + ], + ])( + 'gives a readable name for kind %s when one is provided at spec.profile.displayName', + (_, entity: Entity, expected) => { + expect(humanizeEntity(entity)).toBe(expected); + }, + ); + + it('should pass through to humanizeEntityRef when nothing matches', () => { + expect( + humanizeEntity({ kind: 'Group', metadata: { name: 'test' } } as Entity), + ).toBe('group:test'); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts index bfc8e2af84..7f1501280a 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts @@ -19,6 +19,7 @@ import { CompoundEntityRef, DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; +import get from 'lodash/get'; /** * @param defaultNamespace - if set to false then namespace is never omitted, @@ -65,3 +66,34 @@ export function humanizeEntityRef( : kind; return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`; } + +/** + * Convert an entity to its more readable name if available. + * + * If an entity is either User or Group, this will be its `spec.profile.displayName`. + * Otherwise, this is `metadata.title`. + * + * If neither of those are found or populated, fallback to `humanizeEntityRef`. + * + * @param entity - Entity to convert. + * @param opts - If entity readable name is not available, opts will be used to specify humanizeEntityRef options. + * @returns Readable name, defaults to unique identifier. + * + * @public + */ +export function humanizeEntity( + entity: Entity, + opts?: { + defaultKind?: string; + defaultNamespace?: string | false; + }, +) { + for (const path of ['spec.profile.displayName', 'metadata.title']) { + const value = get(entity, path); + if (value && typeof value === 'string') { + return value; + } + } + + return humanizeEntityRef(entity, opts); +} diff --git a/plugins/catalog-react/src/filters.test.ts b/plugins/catalog-react/src/filters.test.ts index 2a67190633..8ddac7e53b 100644 --- a/plugins/catalog-react/src/filters.test.ts +++ b/plugins/catalog-react/src/filters.test.ts @@ -15,11 +15,12 @@ */ import { AlphaEntity } from '@backstage/catalog-model/alpha'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { EntityErrorFilter, EntityOrphanFilter, + EntityOwnerFilter, EntityTextFilter, } from './filters'; @@ -143,3 +144,65 @@ describe('EntityErrorFilter', () => { expect(filter.filterEntity(entities[1])).toBeFalsy(); }); }); + +describe('EntityOwnerFilter', () => { + it('should handle humanizedEntityRefs', () => { + const filter = new EntityOwnerFilter(['my-user']); + expect( + filter.filterEntity({ + relations: [ + { + type: RELATION_OWNED_BY, + targetRef: 'group:default/my-user', + }, + ], + } as Entity), + ).toBeTruthy(); + expect(filter.values).toStrictEqual(['group:default/my-user']); + }); + + it('should handle full entityRefs', () => { + const filter = new EntityOwnerFilter(['group:default/my-user']); + expect( + filter.filterEntity({ + relations: [ + { + type: RELATION_OWNED_BY, + targetRef: 'group:default/my-user', + }, + ], + } as Entity), + ).toBeTruthy(); + expect(filter.values).toStrictEqual(['group:default/my-user']); + }); + + it('should gracefully reject non-entity refs', () => { + const filter = new EntityOwnerFilter(['group:default/my-user', '']); + expect( + filter.filterEntity({ + relations: [ + { + type: RELATION_OWNED_BY, + targetRef: 'group:default/my-user', + }, + ], + } as Entity), + ).toBeTruthy(); + expect(filter.values).toStrictEqual(['group:default/my-user']); + }); + + it('should handle non group full entity refs', () => { + const filter = new EntityOwnerFilter(['user:default/my-user', '']); + expect( + filter.filterEntity({ + relations: [ + { + type: RELATION_OWNED_BY, + targetRef: 'user:default/my-user', + }, + ], + } as Entity), + ).toBeTruthy(); + expect(filter.values).toStrictEqual(['user:default/my-user']); + }); +}); diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index 5bd81780d6..ad9e917699 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -14,9 +14,13 @@ * limitations under the License. */ -import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + Entity, + parseEntityRef, + RELATION_OWNED_BY, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { AlphaEntity } from '@backstage/catalog-model/alpha'; -import { humanizeEntityRef } from './components/EntityRefLink'; import { EntityFilter, UserListFilterKind } from './types'; import { getEntityRelations } from './utils'; @@ -113,18 +117,37 @@ export class EntityTextFilter implements EntityFilter { /** * Filter matching entities that are owned by group. * @public + * + * CAUTION: This class may contain both full and partial entity refs. */ export class EntityOwnerFilter implements EntityFilter { - constructor(readonly values: string[]) {} + readonly values: string[]; + constructor(values: string[]) { + this.values = values.reduce((fullRefs, ref) => { + // Attempt to remove bad entity references here. + try { + fullRefs.push( + stringifyEntityRef(parseEntityRef(ref, { defaultKind: 'Group' })), + ); + return fullRefs; + } catch (err) { + return fullRefs; + } + }, [] as string[]); + } filterEntity(entity: Entity): boolean { return this.values.some(v => getEntityRelations(entity, RELATION_OWNED_BY).some( - o => humanizeEntityRef(o, { defaultKind: 'group' }) === v, + o => stringifyEntityRef(o) === v, ), ); } + /** + * Get the URL query parameter value. May be a mix of full and humanized entity refs. + * @returns list of entity refs. + */ toQueryValue(): string[] { return this.values; } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx index 7b92df5809..3411656426 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx @@ -119,6 +119,13 @@ describe('DefaultCatalogPage', () => { ], }; }, + /** + * For the purposes of this test case, use existing functionality. The picker + * isn't being tested, just needs this method to render correctly. + */ + getEntitiesByRefs: async refs => { + return { items: refs.entityRefs.map(() => undefined) }; + }, }; const testProfile: Partial = { displayName: 'Display Name', diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx index 51fed918ec..dd6cd66f87 100644 --- a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.test.tsx @@ -19,11 +19,22 @@ import { screen } from '@testing-library/react'; import { Props, RadarDescription } from './RadarDescription'; import { renderInTestApp } from '@backstage/test-utils'; +import { Ring } from '../../utils/types'; + +const ring: Ring = { + id: 'example-ring', + name: 'example-ring', + color: 'red', +}; const minProps: Props = { open: true, title: 'example-title', description: 'example-description', + timeline: [ + { date: new Date(), ring: ring, description: 'test timeline 1' }, + { date: new Date(), ring: ring, description: 'test timeline 2' }, + ], links: [{ url: 'https://example.com/docs', title: 'example-link' }], onClose: () => {}, }; diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx index 92b15d985a..71ad4f3ff0 100644 --- a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx @@ -21,12 +21,26 @@ import { Button, DialogActions, DialogContent } from '@material-ui/core'; import LinkIcon from '@material-ui/icons/Link'; import { Link, MarkdownContent } from '@backstage/core-components'; import { isValidUrl } from '../../utils/components'; +import type { EntrySnapshot } from '../../utils/types'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import TableContainer from '@material-ui/core/TableContainer'; +import TableHead from '@material-ui/core/TableHead'; +import TableRow from '@material-ui/core/TableRow'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; +import AdjustIcon from '@material-ui/icons/Adjust'; +import { MovedState } from '../../api'; export type Props = { open: boolean; onClose: () => void; title: string; description: string; + timeline?: EntrySnapshot[]; url?: string; links?: Array<{ url: string; title: string }>; }; @@ -39,7 +53,7 @@ const RadarDescription = (props: Props): JSX.Element => { return isValidUrl(url) || Boolean(links && links.length > 0); } - const { open, onClose, title, description, url, links } = props; + const { open, onClose, title, description, timeline, url, links } = props; return ( @@ -48,6 +62,62 @@ const RadarDescription = (props: Props): JSX.Element => { + + History + + + + + + Moved in direction + Moved to ring + Moved on date + Description + + + + {timeline?.length === 0 && ( + + + No Timeline + + + )} + {timeline?.map(timeEntry => ( + + + {timeEntry.moved === MovedState.Up ? ( + + ) : ( + '' + )} + {timeEntry.moved === MovedState.Down ? ( + + ) : ( + '' + )} + {timeEntry.moved === MovedState.NoChange ? ( + + ) : ( + '' + )} + + + {timeEntry.ring.name ? timeEntry.ring.name : ''} + + + {timeEntry.date.toLocaleDateString() + ? timeEntry.date.toLocaleDateString() + : ''} + + + {timeEntry.description ? timeEntry.description : ''} + + + ))} + +
+
{showDialogActions(url, links) && ( diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index 829b9c7708..af9e840ead 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; import { WithLink } from '../../utils/components'; import { RadarDescription } from '../RadarDescription'; +import type { EntrySnapshot } from '../../utils/types'; export type Props = { x: number; @@ -27,6 +28,7 @@ export type Props = { url?: string; moved?: number; description?: string; + timeline?: EntrySnapshot[]; title?: string; onMouseEnter?: (event: React.MouseEvent) => void; onMouseLeave?: (event: React.MouseEvent) => void; @@ -67,6 +69,7 @@ const RadarEntry = (props: Props): JSX.Element => { const { moved, description, + timeline, title, color, url, @@ -107,6 +110,7 @@ const RadarEntry = (props: Props): JSX.Element => { onClose={handleClose} title={title ? title : 'no title'} description={description ? description : 'no description'} + timeline={timeline ? timeline : []} url={url} /> )} diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx index 7e31ddddd5..4273963690 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegendLink.tsx @@ -18,6 +18,7 @@ import Typography from '@material-ui/core/Typography'; import React from 'react'; import { WithLink } from '../../utils/components'; import { RadarDescription } from '../RadarDescription'; +import type { EntrySnapshot } from '../../utils/types'; type RadarLegendLinkProps = { url?: string; @@ -26,6 +27,7 @@ type RadarLegendLinkProps = { classes: ClassNameMap; active?: boolean; links: Array<{ url: string; title: string }>; + timeline: EntrySnapshot[]; }; export const RadarLegendLink = ({ @@ -35,6 +37,7 @@ export const RadarLegendLink = ({ classes, active, links, + timeline, }: RadarLegendLinkProps) => { const [open, setOpen] = React.useState(false); @@ -75,6 +78,7 @@ export const RadarLegendLink = ({ title={title ? title : 'no title'} url={url} description={description} + timeline={timeline ? timeline : []} links={links} /> )} diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx index da5c10831e..2005d21915 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegendRing.tsx @@ -68,6 +68,7 @@ export const RadarLegendRing = ({ description={entry.description} active={entry.active} links={entry.links ?? []} + timeline={entry.timeline ?? []} /> ))} diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index d9788be887..957017692b 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -76,6 +76,7 @@ const RadarPlot = (props: Props): JSX.Element => { description={entry.description} moved={entry.moved} title={entry.title} + timeline={entry.timeline} onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))} onMouseLeave={onEntryMouseLeave && (() => onEntryMouseLeave(entry))} /> diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 48c1fa248c..9ac2ac4ef4 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx @@ -38,6 +38,7 @@ import { DefaultTechDocsHome } from './DefaultTechDocsHome'; const mockCatalogApi = { getEntityByRef: () => Promise.resolve(), + getEntitiesByRefs: () => Promise.resolve({ items: [] }), getEntities: async () => ({ items: [ {