From 1e5b7d993a22c3dccb97632a2799f605d30924d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 15 Apr 2023 16:26:09 +0200 Subject: [PATCH 01/12] Add an entityPresentationApiRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/heavy-experts-accept.md | 17 + .changeset/smart-dancers-watch.md | 5 + .changeset/wild-cows-watch.md | 10 + packages/core-plugin-api/api-report.md | 4 +- packages/core-plugin-api/src/icons/types.ts | 4 +- plugins/catalog-react/api-report.md | 90 ++++- .../EntityPresentationApi.ts | 93 +++++ .../defaultEntityPresentation.test.ts | 278 +++++++++++++ .../defaultEntityPresentation.ts | 201 ++++++++++ .../src/apis/EntityPresentationApi/index.ts | 24 ++ .../useEntityPresentation.ts | 86 ++++ .../useUpdatingObservable.ts | 63 +++ plugins/catalog-react/src/apis/index.ts | 1 + .../EntityDisplayName.stories.tsx | 33 ++ .../EntityDisplayName.test.tsx | 95 +++++ .../EntityDisplayName/EntityDisplayName.tsx | 98 +++++ .../src/components/EntityDisplayName/index.ts | 21 + .../EntityRefLink/EntityRefLink.test.tsx | 26 +- .../EntityRefLink/EntityRefLink.tsx | 111 +++--- .../EntityRefLink/EntityRefLinks.test.tsx | 6 +- .../EntityRefLink/EntityRefLinks.tsx | 55 +-- .../FetchedEntityRefLinks.test.tsx | 219 ---------- .../EntityRefLink/FetchedEntityRefLinks.tsx | 111 ------ plugins/catalog-react/src/components/index.ts | 1 + .../src/overridableComponents.ts | 3 + plugins/catalog/api-report.md | 47 +++ plugins/catalog/package.json | 2 + .../DefaultEntityPresentationApi.test.ts | 204 ++++++++++ .../DefaultEntityPresentationApi.ts | 374 ++++++++++++++++++ .../apis/EntityPresentationApi/defaults.tsx | 78 ++++ .../src/apis/EntityPresentationApi/index.ts | 21 + .../src/apis/EntityPresentationApi/util.ts | 38 ++ plugins/catalog/src/apis/index.ts | 1 + .../components/CatalogTable/CatalogTable.tsx | 5 + .../src/components/CatalogTable/columns.tsx | 3 +- .../src/components/CatalogTable/types.ts | 4 + .../EntityLayout/EntityLayout.test.tsx | 1 + .../components/EntityLayout/EntityLayout.tsx | 7 +- plugins/catalog/src/plugin.ts | 8 + .../UserProfileCard/UserProfileCard.test.tsx | 2 +- .../General/UserSettingsIdentityCard.test.tsx | 2 +- yarn.lock | 27 ++ 42 files changed, 2024 insertions(+), 455 deletions(-) create mode 100644 .changeset/heavy-experts-accept.md create mode 100644 .changeset/smart-dancers-watch.md create mode 100644 .changeset/wild-cows-watch.md create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/index.ts create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts create mode 100644 plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.stories.tsx create mode 100644 plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.test.tsx create mode 100644 plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx create mode 100644 plugins/catalog-react/src/components/EntityDisplayName/index.ts delete mode 100644 plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx delete mode 100644 plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.tsx create mode 100644 plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts create mode 100644 plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts create mode 100644 plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx create mode 100644 plugins/catalog/src/apis/EntityPresentationApi/index.ts create mode 100644 plugins/catalog/src/apis/EntityPresentationApi/util.ts diff --git a/.changeset/heavy-experts-accept.md b/.changeset/heavy-experts-accept.md new file mode 100644 index 0000000000..ec232d1c4d --- /dev/null +++ b/.changeset/heavy-experts-accept.md @@ -0,0 +1,17 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Added an `EntityPresentationApi` and associated `entityPresentationApiRef`. This +API lets you control how references to entities (e.g. in links, headings, +iconography etc) are represented in the user interface. + +Usage of this API is initially added to the `EntityRefLink` and `EntityRefLinks` +components, so that they can render richer, more correct representation of +entity refs. There's also a new `EntityName` component, which works just like +the `EntityRefLink` but without the link. + +Along with that change, the `fetchEntities` and `getTitle` props of +`EntityRefLinksProps` are deprecated and no longer used, since the same need +instead is fulfilled (and by default always enabled) by the +`entityPresentationApiRef`. diff --git a/.changeset/smart-dancers-watch.md b/.changeset/smart-dancers-watch.md new file mode 100644 index 0000000000..b47a6ae5c2 --- /dev/null +++ b/.changeset/smart-dancers-watch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +`IconComponent` can now have a `fontSize` of `inherit`, which is useful for in-line icons. diff --git a/.changeset/wild-cows-watch.md b/.changeset/wild-cows-watch.md new file mode 100644 index 0000000000..770fb1a692 --- /dev/null +++ b/.changeset/wild-cows-watch.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Added the `DefaultEntityPresentationApi`, which is an implementation of the +`EntityPresentationApi` that `@backstage/plugin-catalog-react` exposes through +its `entityPresentationApiRef`. This implementation is also by default made +available automatically by the catalog plugin, unless you replace it with a +custom one. It batch fetches and caches data from the catalog as needed for +display, and is customizable by adopters to add their own rendering functions. diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 7873b23f13..760cdeee28 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -503,10 +503,10 @@ export const googleAuthApiRef: ApiRef< // @public export type IconComponent = ComponentType< | { - fontSize?: 'large' | 'small' | 'default'; + fontSize?: 'large' | 'small' | 'default' | 'inherit'; } | { - fontSize?: 'medium' | 'large' | 'small'; + fontSize?: 'medium' | 'large' | 'small' | 'inherit'; } >; diff --git a/packages/core-plugin-api/src/icons/types.ts b/packages/core-plugin-api/src/icons/types.ts index aa264294e3..4d54629de2 100644 --- a/packages/core-plugin-api/src/icons/types.ts +++ b/packages/core-plugin-api/src/icons/types.ts @@ -36,10 +36,10 @@ import { ComponentType } from 'react'; export type IconComponent = ComponentType< /* Material UI v4 */ | { - fontSize?: 'large' | 'small' | 'default'; + fontSize?: 'large' | 'small' | 'default' | 'inherit'; } /* Material UI v5: https://mui.com/material-ui/migration/v5-component-changes/#icon */ | { - fontSize?: 'medium' | 'large' | 'small'; + fontSize?: 'medium' | 'large' | 'small' | 'inherit'; } >; diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index a72df6bb3d..c3c5d7747e 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -11,6 +11,7 @@ import { ComponentProps } from 'react'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { IconButton } from '@material-ui/core'; +import { IconComponent } from '@backstage/core-plugin-api'; import { InfoCardVariants } from '@backstage/core-components'; import { LinkProps } from '@backstage/core-components'; import { Observable } from '@backstage/types'; @@ -84,6 +85,7 @@ export const CatalogFilterLayout: { // @public (undocumented) export type CatalogReactComponentsNameToClassKey = { CatalogReactUserListPicker: CatalogReactUserListPickerClassKey; + CatalogReactEntityDisplayName: CatalogReactEntityDisplayNameClassKey; CatalogReactEntityLifecyclePicker: CatalogReactEntityLifecyclePickerClassKey; CatalogReactEntitySearchBar: CatalogReactEntitySearchBarClassKey; CatalogReactEntityTagPicker: CatalogReactEntityTagPickerClassKey; @@ -91,6 +93,9 @@ export type CatalogReactComponentsNameToClassKey = { CatalogReactEntityProcessingStatusPicker: CatalogReactEntityProcessingStatusPickerClassKey; }; +// @public +export type CatalogReactEntityDisplayNameClassKey = 'root' | 'icon'; + // @public (undocumented) export type CatalogReactEntityLifecyclePickerClassKey = 'input'; @@ -174,6 +179,26 @@ export type EntityAutocompletePickerProps< initialSelectedOptions?: string[]; }; +// @public +export function defaultEntityPresentation( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, +): EntityRefPresentationSnapshot; + +// @public +export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; + +// @public +export type EntityDisplayNameProps = { + entityRef: Entity | CompoundEntityRef | string; + variant?: 'simple' | string; + defaultKind?: string; + defaultNamespace?: string; +}; + // @public export class EntityErrorFilter implements EntityFilter { constructor(value: boolean); @@ -323,6 +348,20 @@ export type EntityPeekAheadPopoverProps = PropsWithChildren<{ delayTime?: number; }>; +// @public +export interface EntityPresentationApi { + forEntity( + entityOrRef: Entity | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, + ): EntityRefPresentation; +} + +// @public +export const entityPresentationApiRef: ApiRef; + // @public (undocumented) export const EntityProcessingStatusPicker: () => React_2.JSX.Element; @@ -346,6 +385,7 @@ export const EntityRefLink: (props: EntityRefLinkProps) => JSX.Element; export type EntityRefLinkProps = { entityRef: Entity | CompoundEntityRef | string; defaultKind?: string; + defaultNamespace?: string; title?: string; children?: React_2.ReactNode; } & Omit; @@ -358,21 +398,32 @@ export function EntityRefLinks< // @public export type EntityRefLinksProps< TRef extends string | CompoundEntityRef | Entity, -> = ( - | { - defaultKind?: string; - entityRefs: TRef[]; - fetchEntities?: false; - getTitle?(entity: TRef): string | undefined; - } - | { - defaultKind?: string; - entityRefs: TRef[]; - fetchEntities: true; - getTitle(entity: Entity): string | undefined; - } -) & - Omit; +> = { + defaultKind?: string; + entityRefs: TRef[]; + fetchEntities?: boolean; + getTitle?(entity: TRef): string | undefined; +} & Omit; + +// @public +export interface EntityRefPresentation { + snapshot: EntityRefPresentationSnapshot; + update$?: Observable; +} + +// @public +export interface EntityRefPresentationSnapshot { + // (undocumented) + entity?: Entity | undefined; + // (undocumented) + entityRef: string; + // (undocumented) + Icon?: IconComponent | undefined; + // (undocumented) + primaryTitle: string; + // (undocumented) + secondaryTitle?: string; +} // @public export function entityRouteParams(entity: Entity): { @@ -598,6 +649,15 @@ export function useEntityOwnership(): { isOwnedEntity: (entity: Entity) => boolean; }; +// @public +export function useEntityPresentation( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, +): EntityRefPresentationSnapshot; + // @public export function useEntityTypeFilter(): { loading: boolean; diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts new file mode 100644 index 0000000000..8d3bcce068 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + ApiRef, + IconComponent, + createApiRef, +} from '@backstage/core-plugin-api'; +import { Observable } from '@backstage/types'; + +/** + * An API that handles how to represent entities in the interface. + * + * @public + */ +export const entityPresentationApiRef: ApiRef = + createApiRef({ + id: 'catalog-react.entity-presentation', + }); + +/** + * The visual presentation of an entity reference at some point in time. + * + * @public + */ +export interface EntityRefPresentationSnapshot { + entityRef: string; + entity?: Entity | undefined; + primaryTitle: string; + secondaryTitle?: string; + Icon?: IconComponent | undefined; +} + +/** + * The visual presentation of an entity reference. + * + * @public + */ +export interface EntityRefPresentation { + /** + * The representation that's suitable to use for this entity right now. + */ + snapshot: EntityRefPresentationSnapshot; + /** + * Some presentation implementations support emitting updated snapshots over + * time, for example after retrieving additional data from the catalog or + * elsewhere. + */ + update$?: Observable; +} + +/** + * An API that decides how to visually represent entities in the interface. + * + * @remarks + * + * Most consumers will want to use the {@link useEntityPresentation} hook + * instead of this interface directly. + * + * @public + */ +export interface EntityPresentationApi { + /** + * Fetches the presentation for an entity. + * + * @param entityOrRef - Either an entity, or a string ref to it. If you pass + * in an entity, it is assumed that it is not a partial one - i.e. only pass + * in an entity if you know that it was fetched in such a way that it + * contains all of the fields that the representation renderer needs. + * @param context - Contextual information that may affect the presentation. + */ + forEntity( + entityOrRef: Entity | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, + ): EntityRefPresentation; +} diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts new file mode 100644 index 0000000000..e875de6fd1 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts @@ -0,0 +1,278 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import { defaultEntityPresentation } from './defaultEntityPresentation'; + +describe('defaultEntityPresentation', () => { + describe('entity given', () => { + it('happy path', () => { + expect( + defaultEntityPresentation({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + description: 'desc', + }, + spec: { + type: 'type', + }, + }), + ).toEqual({ + entity: expect.anything(), + entityRef: 'component:default/test', + primaryTitle: 'test', + secondaryTitle: 'component:default/test | type | desc', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + title: 'title', + description: 'desc', + }, + spec: { + type: 'type', + }, + }), + ).toEqual({ + entity: expect.anything(), + entityRef: 'component:default/test', + primaryTitle: 'title', + secondaryTitle: 'component:default/test | type | desc', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + title: 'title', + description: 'desc', + }, + spec: { + type: 'type', + profile: { + displayName: 'displayName', + }, + }, + }), + ).toEqual({ + entity: expect.anything(), + entityRef: 'component:default/test', + primaryTitle: 'displayName', + secondaryTitle: 'component:default/test | type | desc', + Icon: expect.anything(), + }); + }); + + it('handles the absolute minimum', () => { + expect( + defaultEntityPresentation({ + kind: 'Component', + metadata: { name: 'test' }, + } as Entity), + ).toEqual({ + entity: expect.anything(), + entityRef: 'component:default/test', + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + }); + + it('fails without throwing on malformed entities', () => { + expect( + defaultEntityPresentation({ metadata: 7 } as unknown as Entity), + ).toEqual({ + entity: expect.anything(), + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }); + }); + }); + + describe('string ref given', () => { + it('happy path', () => { + expect(defaultEntityPresentation('component:default/test')).toEqual({ + entity: undefined, + entityRef: 'component:default/test', + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation('component:default/test', { + defaultKind: 'X', + }), + ).toEqual({ + entity: undefined, + entityRef: 'component:default/test', + primaryTitle: 'component:test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation('component:default/test', { + defaultNamespace: 'X', + }), + ).toEqual({ + entity: undefined, + entityRef: 'component:default/test', + primaryTitle: 'default/test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + }); + + it('works without throwing on malformed and shortened refs', () => { + expect(defaultEntityPresentation('')).toEqual({ + entity: undefined, + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }); + + expect(defaultEntityPresentation('name')).toEqual({ + entity: undefined, + entityRef: 'unknown:default/name', + primaryTitle: 'name', + secondaryTitle: 'unknown:default/name', + Icon: expect.anything(), + }); + }); + }); + + describe('compound ref given', () => { + it('happy path', () => { + expect( + defaultEntityPresentation({ + kind: 'Component', + namespace: 'default', + name: 'test', + }), + ).toEqual({ + entity: undefined, + entityRef: 'component:default/test', + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation( + { kind: 'component', namespace: 'default', name: 'test' }, + { + defaultKind: 'X', + }, + ), + ).toEqual({ + entity: undefined, + entityRef: 'component:default/test', + primaryTitle: 'component:test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation( + { kind: 'component', namespace: 'default', name: 'test' }, + { + defaultNamespace: 'X', + }, + ), + ).toEqual({ + entity: undefined, + entityRef: 'component:default/test', + primaryTitle: 'default/test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + }); + + it('works without throwing on malformed refs', () => { + expect( + defaultEntityPresentation( + { kind: 'component', name: 'test' } as CompoundEntityRef, + { + defaultNamespace: 'X', + }, + ), + ).toEqual({ + entity: undefined, + entityRef: 'component:default/test', + primaryTitle: 'default/test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect(defaultEntityPresentation('')).toEqual({ + entity: undefined, + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }); + }); + }); + + describe('entirely invalid input type given', () => { + it('sad path', () => { + expect(defaultEntityPresentation(null as unknown as Entity)).toEqual({ + entity: undefined, + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }); + + expect(defaultEntityPresentation(undefined as unknown as Entity)).toEqual( + { + entity: undefined, + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }, + ); + + expect( + defaultEntityPresentation(Symbol.for('Prince') as unknown as Entity), + ).toEqual({ + entity: undefined, + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }); + }); + }); +}); diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts new file mode 100644 index 0000000000..31ed742a8d --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts @@ -0,0 +1,201 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + CompoundEntityRef, + DEFAULT_NAMESPACE, + Entity, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { IconComponent } from '@backstage/core-plugin-api'; +import ApartmentIcon from '@material-ui/icons/Apartment'; +import BusinessIcon from '@material-ui/icons/Business'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import HelpIcon from '@material-ui/icons/Help'; +import LibraryAddIcon from '@material-ui/icons/LibraryAdd'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import MemoryIcon from '@material-ui/icons/Memory'; +import PeopleIcon from '@material-ui/icons/People'; +import PersonIcon from '@material-ui/icons/Person'; +import get from 'lodash/get'; +import { EntityRefPresentationSnapshot } from './EntityPresentationApi'; + +const UNKNOWN_KIND_ICON: IconComponent = HelpIcon; + +const DEFAULT_ICONS: Record = { + api: ExtensionIcon, + component: MemoryIcon, + system: BusinessIcon, + domain: ApartmentIcon, + location: LocationOnIcon, + user: PersonIcon, + group: PeopleIcon, + template: LibraryAddIcon, +}; + +/** + * This returns the default representation of an entity. + * + * @public + * @param entityOrRef - Either an entity, or a ref to it. + * @param context - Contextual information that may affect the presentation. + */ +export function defaultEntityPresentation( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, +): EntityRefPresentationSnapshot { + // NOTE(freben): This code may look convoluted, but it tries its very best to + // be defensive and handling any type of malformed input and still producing + // some form of result without crashing. + const { kind, namespace, name, title, description, displayName, type } = + getParts(entityOrRef); + + const Icon = + (kind && DEFAULT_ICONS[kind.toLocaleLowerCase('en-US')]) || + UNKNOWN_KIND_ICON; + + const entity: Entity | undefined = + typeof entityOrRef === 'object' && + entityOrRef !== null && + 'metadata' in entityOrRef + ? entityOrRef + : undefined; + + const entityRef: string = stringifyEntityRef({ + kind: kind || 'unknown', + namespace: namespace || DEFAULT_NAMESPACE, + name: name || 'unknown', + }); + + const shortRef = getShortRef({ kind, namespace, name, context }); + + const primary = [displayName, title, shortRef].filter( + candidate => candidate && typeof candidate === 'string', + )[0]!; + + const secondary = [ + primary !== entityRef ? entityRef : undefined, + type, + description, + ] + .filter(candidate => candidate && typeof candidate === 'string') + .join(' | '); + + return { + entity, + entityRef, + primaryTitle: primary, + secondaryTitle: secondary || undefined, + Icon, + }; +} + +// Try to extract display-worthy parts of an entity or ref as best we can, without throwing +function getParts(entityOrRef: Entity | CompoundEntityRef | string): { + kind?: string; + namespace?: string; + name?: string; + title?: string; + description?: string; + displayName?: string; + type?: string; +} { + if (typeof entityOrRef === 'string') { + let colonI = entityOrRef.indexOf(':'); + const slashI = entityOrRef.indexOf('/'); + + // If the / is ahead of the :, treat the rest as the name + if (slashI !== -1 && slashI < colonI) { + colonI = -1; + } + + const kind = colonI === -1 ? undefined : entityOrRef.slice(0, colonI); + const namespace = + slashI === -1 ? undefined : entityOrRef.slice(colonI + 1, slashI); + const name = entityOrRef.slice(Math.max(colonI + 1, slashI + 1)); + + return { kind, namespace, name }; + } + + if (typeof entityOrRef === 'object' && entityOrRef !== null) { + const kind = [get(entityOrRef, 'kind')].filter( + candidate => candidate && typeof candidate === 'string', + )[0]; + + const namespace = [ + get(entityOrRef, 'metadata.namespace'), + get(entityOrRef, 'namespace'), + ].filter(candidate => candidate && typeof candidate === 'string')[0]; + + const name = [ + get(entityOrRef, 'metadata.name'), + get(entityOrRef, 'name'), + ].filter(candidate => candidate && typeof candidate === 'string')[0]; + + const title = [get(entityOrRef, 'metadata.title')].filter( + candidate => candidate && typeof candidate === 'string', + )[0]; + + const description = [get(entityOrRef, 'metadata.description')].filter( + candidate => candidate && typeof candidate === 'string', + )[0]; + + const displayName = [get(entityOrRef, 'spec.profile.displayName')].filter( + candidate => candidate && typeof candidate === 'string', + )[0]; + + const type = [get(entityOrRef, 'spec.type')].filter( + candidate => candidate && typeof candidate === 'string', + )[0]; + + return { kind, namespace, name, title, description, displayName, type }; + } + + return {}; +} + +function getShortRef(options: { + kind?: string; + namespace?: string; + name?: string; + context?: { defaultKind?: string; defaultNamespace?: string }; +}): string { + const kind = options.kind?.toLocaleLowerCase('en-US') || 'unknown'; + const namespace = + options.namespace?.toLocaleLowerCase('en-US') || DEFAULT_NAMESPACE; + const name = options.name?.toLocaleLowerCase('en-US') || 'unknown'; + const defaultKind = options.context?.defaultKind?.toLocaleLowerCase('en-US'); + const defaultNamespace = + options.context?.defaultNamespace?.toLocaleLowerCase('en-US'); + + let result = name; + + if ( + (defaultNamespace && namespace !== defaultNamespace) || + namespace !== DEFAULT_NAMESPACE + ) { + result = `${namespace}/${result}`; + } + + if (defaultKind && kind !== defaultKind) { + result = `${kind}:${result}`; + } + + return result; +} diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/index.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/index.ts new file mode 100644 index 0000000000..405afe23f6 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { + entityPresentationApiRef, + type EntityPresentationApi, + type EntityRefPresentation, + type EntityRefPresentationSnapshot, +} from './EntityPresentationApi'; +export { defaultEntityPresentation } from './defaultEntityPresentation'; +export { useEntityPresentation } from './useEntityPresentation'; diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts new file mode 100644 index 0000000000..bae3936cc8 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + CompoundEntityRef, + Entity, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { useApiHolder } from '@backstage/core-plugin-api'; +import { useMemo } from 'react'; +import { + EntityRefPresentation, + EntityRefPresentationSnapshot, + entityPresentationApiRef, +} from './EntityPresentationApi'; +import { defaultEntityPresentation } from './defaultEntityPresentation'; +import { useUpdatingObservable } from './useUpdatingObservable'; + +/** + * Returns information about how to represent an entity in the interface. + * + * @public + * @param entityOrRef - The entity to represent, or an entity ref to it. If you + * pass in an entity, it is assumed that it is NOT a partial one - i.e. only + * pass in an entity if you know that it was fetched in such a way that it + * contains all of the fields that the representation renderer needs. + * @param context - Optional context that control details of the presentation. + * @returns A snapshot of the entity presentation, which may change over time + */ +export function useEntityPresentation( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, +): EntityRefPresentationSnapshot { + // Defensively allow for a missing presentation API, which makes this hook + // safe to use in tests. + const apis = useApiHolder(); + const entityPresentationApi = apis.get(entityPresentationApiRef); + + const deps = [ + entityPresentationApi, + JSON.stringify(entityOrRef), + JSON.stringify(context || null), + ]; + + const presentation = useMemo( + () => { + if (!entityPresentationApi) { + return { snapshot: defaultEntityPresentation(entityOrRef, context) }; + } + + return entityPresentationApi.forEntity( + typeof entityOrRef === 'string' || 'metadata' in entityOrRef + ? entityOrRef + : stringifyEntityRef(entityOrRef), + context, + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + deps, + ); + + // NOTE(freben): We intentionally do not use the plain useObservable from the + // react-use library here. That hook does not support a dependencies array, + // and also it only subscribes once to the initially passed in observable and + // won't properly react when either initial value or the actual observable + // changes. + return useUpdatingObservable(presentation.snapshot, presentation.update$, [ + presentation, + ]); +} diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts new file mode 100644 index 0000000000..37972a5432 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { Observable } from '@backstage/types'; +import { DependencyList, useEffect, useRef, useState } from 'react'; + +/** + * Subscribe to an observable and return the latest value from it. + * + * @remarks + * + * This implementation differs in a few important ways from the plain + * useObservable from the react-use library. That hook does not support a + * dependencies array, and also it only subscribes once to the initially passed + * in observable and won't properly react when either initial value or the + * actual observable changes. + * + * This hook will ensure to resubscribe and reconsider the initial value, + * whenever the dependencies change. + */ +export function useUpdatingObservable( + value: T, + observable: Observable | undefined, + deps: DependencyList, +): T { + const snapshot = useRef(value); + const [, setCounter] = useState(0); + + useEffect(() => { + snapshot.current = value; + setCounter(counter => counter + 1); + + const subscription = observable?.subscribe({ + next: updatedValue => { + snapshot.current = updatedValue; + setCounter(counter => counter + 1); + }, + complete: () => { + subscription?.unsubscribe(); + }, + }); + + return () => { + subscription?.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return snapshot.current; +} diff --git a/plugins/catalog-react/src/apis/index.ts b/plugins/catalog-react/src/apis/index.ts index 5c7e980890..271d749afc 100644 --- a/plugins/catalog-react/src/apis/index.ts +++ b/plugins/catalog-react/src/apis/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export * from './EntityPresentationApi'; export * from './StarredEntitiesApi'; diff --git a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.stories.tsx b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.stories.tsx new file mode 100644 index 0000000000..d68618b07c --- /dev/null +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.stories.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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, { ComponentType } from 'react'; +import { EntityDisplayName, EntityDisplayNameProps } from './EntityDisplayName'; +import { wrapInTestApp } from '@backstage/test-utils'; + +const defaultArgs = { + entityRef: 'component:default/playback', +}; + +export default { + title: 'Catalog /EntityDisplayName', + decorators: [(Story: ComponentType<{}>) => wrapInTestApp()], +}; + +export const Default = (args: EntityDisplayNameProps) => ( + +); +Default.args = defaultArgs; diff --git a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.test.tsx b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.test.tsx new file mode 100644 index 0000000000..573cfa9011 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import ObservableImpl from 'zen-observable'; +import { + EntityRefPresentation, + EntityRefPresentationSnapshot, + entityPresentationApiRef, +} from '../../apis'; +import { EntityDisplayName } from './EntityDisplayName'; + +function defer() { + let resolve = (_value: T) => {}; + const promise = new Promise(_resolve => { + resolve = _resolve; + }); + return { promise, resolve }; +} + +describe('', () => { + const entityPresentationApi = { + forEntity: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('works with the sync the happy path', async () => { + entityPresentationApi.forEntity.mockReturnValue({ + snapshot: { + entityRef: 'component:default/foo', + primaryTitle: 'foo', + }, + update$: undefined, + } as EntityRefPresentation); + + await renderInTestApp( + + + , + ); + + expect(screen.getByText('foo')).toBeInTheDocument(); + }); + + it('works with the async the happy path', async () => { + const { promise, resolve } = defer(); + + entityPresentationApi.forEntity.mockReturnValue({ + snapshot: { + entityRef: 'component:default/foo', + primaryTitle: 'foo', + }, + update$: new ObservableImpl(subscriber => { + promise.then(value => subscriber.next(value)); + }), + } as EntityRefPresentation); + + await renderInTestApp( + + + , + ); + + expect(screen.getByText('foo')).toBeInTheDocument(); + + resolve({ + entityRef: 'component:default/foo', + primaryTitle: 'bar', + }); + + await expect(screen.findByText('bar')).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx new file mode 100644 index 0000000000..735828b18f --- /dev/null +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import { Box, Theme, Tooltip, makeStyles } from '@material-ui/core'; +import React from 'react'; +import { useEntityPresentation } from '../../apis'; + +/** + * The available style class keys for {@link EntityDisplayName}, under the name + * "CatalogReactEntityDisplayName". + * + * @public + */ +export type CatalogReactEntityDisplayNameClassKey = 'root' | 'icon'; + +const useStyles = makeStyles( + (theme: Theme) => ({ + root: { + display: 'inline-flex', + alignItems: 'center', + }, + icon: { + marginLeft: theme.spacing(0.5), + color: theme.palette.text.secondary, + lineHeight: 0, + }, + }), + { name: 'CatalogReactEntityDisplayName' }, +); + +/** + * Props for {@link EntityDisplayName}. + * + * @public + */ +export type EntityDisplayNameProps = { + entityRef: Entity | CompoundEntityRef | string; + variant?: 'simple' | string; + defaultKind?: string; + defaultNamespace?: string; +}; + +/** + * Shows a nice representation of a reference to an entity. + * + * @public + */ +export const EntityDisplayName = ( + props: EntityDisplayNameProps, +): JSX.Element => { + const { entityRef, variant, defaultKind, defaultNamespace } = props; + + const classes = useStyles(); + const { primaryTitle, secondaryTitle, Icon } = useEntityPresentation( + entityRef, + { defaultKind, defaultNamespace }, + ); + + // The innermost "body" content + let content = <>{primaryTitle}; + + // Optionally an icon, and wrapper around them both + content = ( + + {content} + {Icon && variant !== 'simple' ? ( + + + + ) : null} + + ); + + // Optionally, a tooltip as the outermost layer + if (secondaryTitle) { + content = ( + + {content} + + ); + } + + return content; +}; diff --git a/plugins/catalog-react/src/components/EntityDisplayName/index.ts b/plugins/catalog-react/src/components/EntityDisplayName/index.ts new file mode 100644 index 0000000000..0a9faa6f41 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityDisplayName/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + EntityDisplayName, + type CatalogReactEntityDisplayNameClassKey, + type EntityDisplayNameProps, +} from './EntityDisplayName'; diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx index cecf27be24..65d9be2190 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx @@ -27,6 +27,7 @@ describe('', () => { kind: 'Component', metadata: { name: 'software', + namespace: 'default', }, spec: { owner: 'guest', @@ -39,8 +40,7 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - - expect(screen.getByText('component:software')).toHaveAttribute( + expect(screen.getByText('component:software').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -65,10 +65,9 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:test/software')).toHaveAttribute( - 'href', - '/catalog/test/component/software', - ); + expect( + screen.getByText('component:test/software').closest('a'), + ).toHaveAttribute('href', '/catalog/test/component/software'); }); it('renders link for entity and hides default kind', async () => { @@ -93,7 +92,7 @@ describe('', () => { }, }, ); - expect(screen.getByText('test/software')).toHaveAttribute( + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -110,7 +109,7 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:software')).toHaveAttribute( + expect(screen.getByText('component:software').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -127,10 +126,9 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:test/software')).toHaveAttribute( - 'href', - '/catalog/test/component/software', - ); + expect( + screen.getByText('component:test/software').closest('a'), + ).toHaveAttribute('href', '/catalog/test/component/software'); }); it('renders link for entity name and hides default kind', async () => { @@ -147,7 +145,7 @@ describe('', () => { }, }, ); - expect(screen.getByText('test/software')).toHaveAttribute( + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -169,7 +167,7 @@ describe('', () => { }, }, ); - expect(screen.getByText('Custom Children')).toHaveAttribute( + expect(screen.getByText('Custom Children').closest('a')).toHaveAttribute( 'href', '/catalog/test/component/software', ); diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx index d682abdc2b..32627fccb2 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx @@ -15,17 +15,16 @@ */ import { - Entity, CompoundEntityRef, DEFAULT_NAMESPACE, + Entity, parseEntityRef, } from '@backstage/catalog-model'; -import React, { forwardRef } from 'react'; -import { entityRouteRef } from '../../routes'; -import { humanizeEntityRef } from './humanize'; import { Link, LinkProps } from '@backstage/core-components'; import { useRouteRef } from '@backstage/core-plugin-api'; -import { Tooltip } from '@material-ui/core'; +import React, { forwardRef } from 'react'; +import { entityRouteRef } from '../../routes'; +import { EntityDisplayName } from '../EntityDisplayName'; /** * Props for {@link EntityRefLink}. @@ -35,6 +34,8 @@ import { Tooltip } from '@material-ui/core'; export type EntityRefLinkProps = { entityRef: Entity | CompoundEntityRef | string; defaultKind?: string; + defaultNamespace?: string; + /** @deprecated This option should no longer be used; presentation is requested through the {@link entityPresentationApiRef} instead */ title?: string; children?: React.ReactNode; } & Omit; @@ -46,52 +47,68 @@ export type EntityRefLinkProps = { */ export const EntityRefLink = forwardRef( (props, ref) => { - const { entityRef, defaultKind, title, children, ...linkProps } = props; - const entityRoute = useRouteRef(entityRouteRef); + const { + entityRef, + defaultKind, + defaultNamespace, + title, + children, + ...linkProps + } = props; + const entityRoute = useEntityRoute(props.entityRef); - let kind; - let namespace; - let name; - - if (typeof entityRef === 'string') { - const parsed = parseEntityRef(entityRef); - kind = parsed.kind; - namespace = parsed.namespace; - name = parsed.name; - } else 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; - } - - kind = kind.toLocaleLowerCase('en-US'); - namespace = namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE; - - const routeParams = { - kind: encodeURIComponent(kind), - namespace: encodeURIComponent(namespace), - name: encodeURIComponent(name), - }; - const formattedEntityRefTitle = humanizeEntityRef( - { kind, namespace, name }, - { defaultKind }, + const content = children ?? title ?? ( + ); - const link = ( - - {children} - {!children && (title ?? formattedEntityRefTitle)} + return ( + + {content} ); - - return title ? ( - {link} - ) : ( - link - ); }, ) as (props: EntityRefLinkProps) => JSX.Element; + +// Hook that computes the route to a given entity / ref. This is a bit +// contrived, because it tries to retain the casing of the entity name if +// present, but not of other parts. This is in an attempt to make slightly more +// nice-looking URLs. +function useEntityRoute( + entityRef: Entity | CompoundEntityRef | string, +): string { + const entityRoute = useRouteRef(entityRouteRef); + + let kind; + let namespace; + let name; + + if (typeof entityRef === 'string') { + const parsed = parseEntityRef(entityRef); + kind = parsed.kind; + namespace = parsed.namespace; + name = parsed.name; + } else 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; + } + + kind = kind.toLocaleLowerCase('en-US'); + namespace = namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE; + + const routeParams = { + kind: encodeURIComponent(kind), + namespace: encodeURIComponent(namespace), + name: encodeURIComponent(name), + }; + + return entityRoute(routeParams); +} diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx index 4e0d52ef4e..31a5be33d0 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx @@ -34,7 +34,7 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:software')).toHaveAttribute( + expect(screen.getByText('software').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -59,11 +59,11 @@ describe('', () => { }, }); expect(screen.getByText(',')).toBeInTheDocument(); - expect(screen.getByText('component:software')).toHaveAttribute( + expect(screen.getByText('software').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/software', ); - expect(screen.getByText('api:interface')).toHaveAttribute( + expect(screen.getByText('interface').closest('a')).toHaveAttribute( 'href', '/catalog/default/api/interface', ); diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx index 2502fbbb6e..929b3f9376 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; +import { + Entity, + CompoundEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; import React from 'react'; import { EntityRefLink } from './EntityRefLink'; import { LinkProps } from '@backstage/core-components'; -import { FetchedEntityRefLinks } from './FetchedEntityRefLinks'; /** * Props for {@link EntityRefLink}. @@ -26,21 +29,14 @@ import { FetchedEntityRefLinks } from './FetchedEntityRefLinks'; */ export type EntityRefLinksProps< TRef extends string | CompoundEntityRef | Entity, -> = ( - | { - defaultKind?: string; - entityRefs: TRef[]; - fetchEntities?: false; - getTitle?(entity: TRef): string | undefined; - } - | { - defaultKind?: string; - entityRefs: TRef[]; - fetchEntities: true; - getTitle(entity: Entity): string | undefined; - } -) & - Omit; +> = { + defaultKind?: string; + entityRefs: TRef[]; + /** @deprecated This option is no longer used; presentation is handled by entityPresentationApiRef instead */ + fetchEntities?: boolean; + /** @deprecated This option is no longer used; presentation is handled by entityPresentationApiRef instead */ + getTitle?(entity: TRef): string | undefined; +} & Omit; /** * Shows a list of clickable links to entities. @@ -50,32 +46,17 @@ export type EntityRefLinksProps< export function EntityRefLinks< TRef extends string | CompoundEntityRef | Entity, >(props: EntityRefLinksProps) { - const { entityRefs, defaultKind, fetchEntities, getTitle, ...linkProps } = - props; - - if (fetchEntities) { - return ( - - ); - } + const { entityRefs, ...linkProps } = props; return ( <> {entityRefs.map((r: TRef, i: number) => { + const entityRefString = + typeof r === 'string' ? r : stringifyEntityRef(r); return ( - + {i > 0 && ', '} - + ); })} diff --git a/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx deleted file mode 100644 index 9db6511662..0000000000 --- a/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * 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 { FetchedEntityRefLinks } from './FetchedEntityRefLinks'; -import { entityRouteRef } from '../../routes'; -import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; -import { Entity } from '@backstage/catalog-model'; -import React from 'react'; -import { JsonObject } from '@backstage/types'; -import { catalogApiRef } from '../../api'; -import { CatalogApi } from '@backstage/catalog-client'; - -describe('', () => { - const getTitle = (e: Entity): string => - (e.spec?.profile!! as JsonObject).displayName!!.toString()!!; - - it('should fetch entities and render the custom display text', async () => { - const entityRefs = [ - { - kind: 'Component', - namespace: 'default', - name: 'software', - }, - { - kind: 'API', - namespace: 'default', - name: 'interface', - }, - ]; - - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: entityRefs.map(ref => ({ - apiVersion: 'backstage.io/v1alpha1', - kind: ref.kind, - metadata: { - name: ref.name, - namespace: ref.namespace, - }, - spec: { - profile: { - displayName: ref.name.toLocaleUpperCase('en-US'), - }, - type: 'organization', - }, - })), - }), - }; - - await renderInTestApp( - - - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, - }, - ); - - expect(screen.getByText('SOFTWARE')).toHaveAttribute( - 'href', - '/catalog/default/component/software', - ); - - expect(screen.getByText('INTERFACE')).toHaveAttribute( - 'href', - '/catalog/default/api/interface', - ); - }); - - it('should use entities as they are provided and render the custom display text', async () => { - const entityRefs = [ - { - kind: 'Component', - namespace: 'default', - name: 'tool', - }, - { - kind: 'API', - namespace: 'default', - name: 'implementation', - }, - ].map(ref => ({ - apiVersion: 'backstage.io/v1alpha1', - kind: ref.kind, - metadata: { - name: ref.name, - namespace: ref.namespace, - }, - spec: { - profile: { - displayName: ref.name.toLocaleUpperCase('en-US'), - }, - type: 'organization', - }, - })); - - const catalogApi: Partial = {}; - - await renderInTestApp( - - - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, - }, - ); - - expect(screen.getByText('TOOL')).toHaveAttribute( - 'href', - '/catalog/default/component/tool', - ); - - expect(screen.getByText('IMPLEMENTATION')).toHaveAttribute( - 'href', - '/catalog/default/api/implementation', - ); - }); - - it('should handle heterogeneous array of values to render the custom display text', async () => { - const entityRefs = [ - ...[ - { - kind: 'Component', - namespace: 'default', - name: 'tool', - }, - { - kind: 'API', - namespace: 'default', - name: 'implementation', - }, - ].map(ref => ({ - apiVersion: 'backstage.io/v1alpha1', - kind: ref.kind, - metadata: { - name: ref.name, - namespace: ref.namespace, - }, - spec: { - profile: { - displayName: ref.name.toLocaleUpperCase('en-US'), - }, - type: 'organization', - }, - })), - { - kind: 'Component', - namespace: 'default', - name: 'interface', - }, - ]; - - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'interface', - namespace: 'default', - }, - spec: { - profile: { - displayName: 'INTERFACE', - }, - type: 'organization', - }, - }, - ], - }), - }; - - await renderInTestApp( - - - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, - }, - ); - - expect(screen.getByText('TOOL')).toHaveAttribute( - 'href', - '/catalog/default/component/tool', - ); - - expect(screen.getByText('IMPLEMENTATION')).toHaveAttribute( - 'href', - '/catalog/default/api/implementation', - ); - - expect(screen.getByText('INTERFACE')).toHaveAttribute( - 'href', - '/catalog/default/component/interface', - ); - }); -}); diff --git a/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.tsx b/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.tsx deleted file mode 100644 index d8f013d50c..0000000000 --- a/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2022 The Backstage Authors - * - * 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, - CompoundEntityRef, - parseEntityRef, -} from '@backstage/catalog-model'; -import React from 'react'; -import { EntityRefLink } from './EntityRefLink'; -import { ErrorPanel, LinkProps, Progress } from '@backstage/core-components'; -import useAsync from 'react-use/lib/useAsync'; -import { catalogApiRef } from '../../api'; -import { useApi } from '@backstage/core-plugin-api'; - -/** - * Props for {@link FetchedEntityRefLinks}. - * - * @public - */ -export type FetchedEntityRefLinksProps< - TRef extends string | CompoundEntityRef | Entity, -> = { - defaultKind?: string; - entityRefs: TRef[]; - getTitle(entity: Entity): string | undefined; -} & Omit; - -/** - * Shows a list of clickable links to entities with auto-fetching of entities - * for customising a displayed text via title attribute. - * - * @public - */ -export function FetchedEntityRefLinks< - TRef extends string | CompoundEntityRef | Entity, ->(props: FetchedEntityRefLinksProps) { - const { entityRefs, defaultKind, getTitle, ...linkProps } = props; - - const catalogApi = useApi(catalogApiRef); - - const { - value: entities = new Array(), - loading, - error, - } = useAsync(async () => { - const refs = entityRefs.reduce((acc, current) => { - if (typeof current === 'object' && 'metadata' in current) { - return acc; - } - return [...acc, parseEntityRef(current)]; - }, new Array()); - - const pureEntities = entityRefs.filter( - ref => typeof ref === 'object' && 'metadata' in ref, - ) as Array; - - return refs.length > 0 - ? [ - ...( - await catalogApi.getEntities({ - filter: refs.map(ref => ({ - kind: ref.kind, - 'metadata.namespace': ref.namespace, - 'metadata.name': ref.name, - })), - }) - ).items, - ...pureEntities, - ] - : pureEntities; - }, [entityRefs]); - - if (loading) { - return ; - } - - if (error) { - return ; - } - - return ( - <> - {entities.map((r: Entity, i) => { - return ( - - {i > 0 && ', '} - - - ); - })} - - ); -} diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index f77d13d2d3..5b7247cdbd 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -18,6 +18,7 @@ export * from './CatalogFilterLayout'; export * from './EntityKindPicker'; export * from './EntityLifecyclePicker'; export * from './EntityOwnerPicker'; +export * from './EntityDisplayName'; export * from './EntityRefLink'; export * from './EntityPeekAheadPopover'; export * from './EntitySearchBar'; diff --git a/plugins/catalog-react/src/overridableComponents.ts b/plugins/catalog-react/src/overridableComponents.ts index c3b392fcfe..e94af1c4fb 100644 --- a/plugins/catalog-react/src/overridableComponents.ts +++ b/plugins/catalog-react/src/overridableComponents.ts @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Overrides } from '@material-ui/core/styles/overrides'; import { StyleRules } from '@material-ui/core/styles/withStyles'; import { CatalogReactUserListPickerClassKey, + CatalogReactEntityDisplayNameClassKey, CatalogReactEntityLifecyclePickerClassKey, CatalogReactEntitySearchBarClassKey, CatalogReactEntityTagPickerClassKey, @@ -28,6 +30,7 @@ import { /** @public */ export type CatalogReactComponentsNameToClassKey = { CatalogReactUserListPicker: CatalogReactUserListPickerClassKey; + CatalogReactEntityDisplayName: CatalogReactEntityDisplayNameClassKey; CatalogReactEntityLifecyclePicker: CatalogReactEntityLifecyclePickerClassKey; CatalogReactEntitySearchBar: CatalogReactEntitySearchBarClassKey; CatalogReactEntityTagPicker: CatalogReactEntityTagPickerClassKey; diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 8173c395ba..7ed2879367 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -7,11 +7,16 @@ import { ApiHolder } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { CatalogApi } from '@backstage/plugin-catalog-react'; import { ComponentEntity } from '@backstage/catalog-model'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { EntityOwnerPickerProps } from '@backstage/plugin-catalog-react'; +import { EntityPresentationApi } from '@backstage/plugin-catalog-react'; +import { EntityRefPresentation } from '@backstage/plugin-catalog-react'; +import { EntityRefPresentationSnapshot } from '@backstage/plugin-catalog-react'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { HumanDuration } from '@backstage/types'; import { IconComponent } from '@backstage/core-plugin-api'; import { IndexableDocument } from '@backstage/plugin-search-common'; import { InfoCardVariants } from '@backstage/core-components'; @@ -196,6 +201,7 @@ export interface CatalogTableRow { // (undocumented) resolved: { name: string; + entityRef: string; partOfSystemRelationTitle?: string; partOfSystemRelations: CompoundEntityRef[]; ownedByRelationsTitle?: string; @@ -224,6 +230,47 @@ export interface DefaultCatalogPageProps { tableOptions?: TableProps['options']; } +// @public +export class DefaultEntityPresentationApi implements EntityPresentationApi { + static create( + options: DefaultEntityPresentationApiOptions, + ): EntityPresentationApi; + static createLocal(): EntityPresentationApi; + // (undocumented) + forEntity( + entityOrRef: Entity | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, + ): EntityRefPresentation; +} + +// @public +export interface DefaultEntityPresentationApiOptions { + batchDelay?: HumanDuration; + cacheTtl?: HumanDuration; + catalogApi?: CatalogApi; + renderer?: DefaultEntityPresentationApiRenderer; +} + +// @public +export interface DefaultEntityPresentationApiRenderer { + async?: boolean; + extraFields?: string[]; + render: (options: { + entityRef: string; + loading: boolean; + entity: Entity | undefined; + context: { + defaultKind?: string; + defaultNamespace?: string; + }; + }) => { + snapshot: Omit; + }; +} + // @public export class DefaultStarredEntitiesApi implements StarredEntitiesApi { constructor(opts: { storageApi: StorageApi }); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index dc03fb7b86..42d26b4b02 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -63,6 +63,8 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@types/react": "^16.13.1 || ^17.0.0", + "dataloader": "^2.0.0", + "expiry-map": "^2.0.0", "history": "^5.0.0", "lodash": "^4.17.21", "pluralize": "^8.0.0", diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts new file mode 100644 index 0000000000..eb20b20f9f --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts @@ -0,0 +1,204 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { + EntityRefPresentation, + EntityRefPresentationSnapshot, +} from '@backstage/plugin-catalog-react'; +import { DefaultEntityPresentationApi } from './DefaultEntityPresentationApi'; + +describe('DefaultEntityPresentationApi', () => { + it('works in local mode', () => { + const api = DefaultEntityPresentationApi.createLocal(); + + expect(api.forEntity('component:default/test')).toEqual({ + snapshot: { + entityRef: 'component:default/test', + entity: undefined, + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }, + update$: undefined, + }); + + expect( + api.forEntity('component:default/test', { defaultKind: 'Other' }), + ).toEqual({ + snapshot: { + entityRef: 'component:default/test', + entity: undefined, + primaryTitle: 'component:test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }, + update$: undefined, + }); + + expect( + api.forEntity('component:default/test', { + defaultNamespace: 'other', + }), + ).toEqual({ + snapshot: { + entityRef: 'component:default/test', + entity: undefined, + primaryTitle: 'default/test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }, + update$: undefined, + }); + + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + }, + spec: { + type: 'service', + }, + }; + + expect(api.forEntity(entity)).toEqual({ + snapshot: { + entityRef: 'component:default/test', + entity: entity, + primaryTitle: 'test', + secondaryTitle: 'component:default/test | service', + Icon: expect.anything(), + }, + update$: undefined, + }); + }); + + it('works in catalog mode', async () => { + const catalogApi = { + getEntitiesByRefs: jest.fn(), + }; + const api = DefaultEntityPresentationApi.create({ + catalogApi: catalogApi as Partial as any, + }); + + catalogApi.getEntitiesByRefs.mockResolvedValueOnce({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + etag: 'something', + }, + spec: { + type: 'service', + }, + }, + ], + }); + + // return simple presentation, call catalog, return full presentation + await expect( + consumePresentation(api.forEntity('component:default/test')), + ).resolves.toEqual([ + { + entityRef: 'component:default/test', + entity: undefined, + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }, + { + entityRef: 'component:default/test', + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + etag: 'something', + name: 'test', + namespace: 'default', + }, + spec: { + type: 'service', + }, + }, + primaryTitle: 'test', + secondaryTitle: 'component:default/test | service', + Icon: expect.anything(), + }, + ]); + + // use cached entity, immediately return full presentation + await expect( + consumePresentation(api.forEntity('component:default/test')), + ).resolves.toEqual([ + { + entityRef: 'component:default/test', + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + etag: 'something', + name: 'test', + namespace: 'default', + }, + spec: { + type: 'service', + }, + }, + primaryTitle: 'test', + secondaryTitle: 'component:default/test | service', + Icon: expect.anything(), + }, + ]); + + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(1); + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledWith( + expect.objectContaining({ + entityRefs: ['component:default/test'], + }), + ); + }); +}); + +async function consumePresentation( + presentation: EntityRefPresentation, +): Promise { + const result: EntityRefPresentationSnapshot[] = []; + const { snapshot, update$ } = presentation; + + result.push(snapshot); + + if (update$) { + await new Promise(resolve => { + const sub = update$.subscribe({ + next: newSnapshot => { + result.push(newSnapshot); + }, + complete: () => { + sub.unsubscribe(); + resolve(); + }, + }); + }); + } + + return result; +} diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts new file mode 100644 index 0000000000..9ec8af7760 --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts @@ -0,0 +1,374 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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, stringifyEntityRef } from '@backstage/catalog-model'; +import { + CatalogApi, + EntityPresentationApi, + EntityRefPresentation, + EntityRefPresentationSnapshot, +} from '@backstage/plugin-catalog-react'; +import { HumanDuration } from '@backstage/types'; +import DataLoader from 'dataloader'; +import ExpiryMap from 'expiry-map'; +import uniq from 'lodash/uniq'; +import ObservableImpl from 'zen-observable'; +import { + DEFAULT_BATCH_DELAY, + DEFAULT_CACHE_TTL, + DEFAULT_ENTITY_FIELDS, + createDefaultRenderer, +} from './defaults'; +import { durationToMs } from './util'; + +/** + * A custom renderer for the {@link DefaultEntityPresentationApi}. + * + * @public + */ +export interface DefaultEntityPresentationApiRenderer { + /** + * An extra set of fields to request for entities from the catalog API. + * + * @remarks + * + * You may want to specify this to get additional entity fields. The smaller + * the set of fields, the more efficient requests will be to the catalog + * backend. + * + * The default set of fields is: apiVersion, kind, the scalar metadata fields + * (uid, etag, name, namespace, title, description), spec.type, and + * spec.profile. + * + * This field is ignored if async is set to false. + */ + extraFields?: string[]; + + /** + * Whether to request the entity from the catalog API asynchronously. + * + * @remarks + * + * If this is set to true, entity data will be streamed in from the catalog + * whenever needed, and the render function may be called more than once: + * first when no entity data existed (or with old cached data), and then again + * at a later point when data is loaded from the catalog that proved to be + * different from the old one. + * + * @defaultValue true + */ + async?: boolean; + + /** + * The actual render function. + * + * @remarks + * + * This function may be called multiple times. + * + * The loading flag signals that the framework MAY be trying to load more + * entity data from the catalog and call the render function again, if it + * succeeds. In some cases you may want to render a loading state in that + * case. + * + * The entity may or may not be given. If the caller of the presentation API + * did present an entity upfront, then that's what will be passed in here. + * Otherwise, it may be a server-side entity that either comes from a local + * cache or directly from the server. + * + * In either case, the renderer should return a presentation that is the most + * useful possible for the end user, given the data that is available. + */ + render: (options: { + entityRef: string; + loading: boolean; + entity: Entity | undefined; + context: { + defaultKind?: string; + defaultNamespace?: string; + }; + }) => { + snapshot: Omit; + }; +} + +/** + * Options for the {@link DefaultEntityPresentationApi}. + * + * @public + */ +export interface DefaultEntityPresentationApiOptions { + /** + * The catalog API to use. If you want to use any asynchronous features, you + * must supply one. + */ + catalogApi?: CatalogApi; + + /** + * When to expire entities that have been loaded from the catalog API and + * cached for a while. + * + * @defaultValue 30 seconds + * @remarks + * + * The higher this value, the lower the load on the catalog API, but also the + * higher the risk of users seeing stale data. + */ + cacheTtl?: HumanDuration; + + /** + * For how long to wait before sending a batch of entity references to the + * catalog API. + * + * @defaultValue 50 milliseconds + * @remarks + * + * The higher this value, the greater the chance of batching up requests from + * across a page, but also the longer the lag time before displaying accurate + * information. + */ + batchDelay?: HumanDuration; + + /** + * A custom renderer, if any. + */ + renderer?: DefaultEntityPresentationApiRenderer; +} + +interface CacheEntry { + updatedAt: number; + entity: Entity | undefined; +} + +/** + * Default implementation of the {@link @backstage/plugin-catalog-react#EntityPresentationApi}. + * + * @public + */ +export class DefaultEntityPresentationApi implements EntityPresentationApi { + /** + * Creates a new presentation API that does not reach out to the catalog. + */ + static createLocal(): EntityPresentationApi { + return new DefaultEntityPresentationApi({ + renderer: createDefaultRenderer({ async: false }), + }); + } + + /** + * Creates a new presentation API that calls out to the catalog as needed to + * get additional information about entities. + */ + static create( + options: DefaultEntityPresentationApiOptions, + ): EntityPresentationApi { + return new DefaultEntityPresentationApi(options); + } + + // This cache holds on to all entity data ever loaded, no matter how old. Each + // entry is tagged with a timestamp of when it was inserted. We use this map + // to be able to always render SOME data even though the information is old. + // Entities change very rarely, so it's likely that the rendered information + // was perfectly fine in the first place. + readonly #cache: Map; + readonly #cacheTtlMs: number; + readonly #loader: DataLoader | undefined; + readonly #renderer: DefaultEntityPresentationApiRenderer; + + private constructor(options: DefaultEntityPresentationApiOptions) { + const cacheTtl = options.cacheTtl ?? DEFAULT_CACHE_TTL; + const batchDelay = options.batchDelay ?? DEFAULT_BATCH_DELAY; + const renderer = options.renderer ?? createDefaultRenderer({ async: true }); + + if (renderer.async) { + if (!options.catalogApi) { + throw new TypeError(`Asynchronous rendering requires a catalog API`); + } + this.#loader = this.#createLoader({ + cacheTtl, + batchDelay, + renderer, + catalogApi: options.catalogApi, + }); + } + + this.#cacheTtlMs = durationToMs(cacheTtl); + this.#cache = new Map(); + this.#renderer = renderer; + } + + /** {@inheritdoc @backstage/plugin-catalog-react#EntityPresentationApi.forEntity} */ + forEntity( + entityOrRef: Entity | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, + ): EntityRefPresentation { + const { entityRef, entity, needsLoad } = + this.#getEntityForInitialRender(entityOrRef); + + let rendered: Omit; + try { + const output = this.#renderer.render({ + entityRef: entityRef, + loading: needsLoad, + entity: entity, + context: context || {}, + }); + rendered = output.snapshot; + } catch { + // This is what gets presented if the renderer throws an error + rendered = { + primaryTitle: entityRef, + }; + } + + const observable = !needsLoad + ? undefined + : new ObservableImpl(subscriber => { + let aborted = false; + + Promise.resolve() + .then(() => this.#loader?.load(entityRef)) + .then(newEntity => { + if ( + !aborted && + newEntity && + newEntity.metadata.etag !== entity?.metadata.etag + ) { + const output = this.#renderer.render({ + entityRef: entityRef, + loading: false, + entity: newEntity, + context: context || {}, + }); + subscriber.next({ + ...output.snapshot, + entityRef: entityRef, + entity: newEntity, + }); + } + }) + .catch(() => { + // Intentionally ignored - we do not propagate errors to the + // observable here. The presentation API should be error free and + // always return SOMETHING that makes sense to render, and we have + // already ensured above that the initial snapshot was that. + }) + .finally(() => { + if (!aborted) { + subscriber.complete(); + } + }); + + return () => { + aborted = true; + }; + }); + + return { + snapshot: { + ...rendered, + entityRef: entityRef, + entity: entity, + }, + update$: observable, + }; + } + + #getEntityForInitialRender(entityOrRef: Entity | string): { + entity: Entity | undefined; + entityRef: string; + needsLoad: boolean; + } { + // If we were given an entity in the first place, we use it for a single + // pass of rendering and assume that it's up to date and not partial (i.e. + // we expect that it wasn't fetched in such a way that the required fields + // of the renderer were excluded) + if (typeof entityOrRef !== 'string') { + return { + entity: entityOrRef, + entityRef: stringifyEntityRef(entityOrRef), + needsLoad: false, + }; + } + + const cached = this.#cache.get(entityOrRef); + const cachedEntity: Entity | undefined = cached?.entity; + const cacheNeedsUpdate = + !cached || Date.now() - cached.updatedAt > this.#cacheTtlMs; + const needsLoad = + cacheNeedsUpdate && + this.#renderer.async !== false && + this.#loader !== undefined; + + return { + entity: cachedEntity, + entityRef: entityOrRef, + needsLoad, + }; + } + + #createLoader(options: { + catalogApi: CatalogApi; + cacheTtl: HumanDuration; + batchDelay: HumanDuration; + renderer: DefaultEntityPresentationApiRenderer; + }): DataLoader { + const cacheTtlMs = durationToMs(options.cacheTtl); + const batchDelayMs = durationToMs(options.batchDelay); + + const entityFields = uniq( + [DEFAULT_ENTITY_FIELDS, options.renderer?.extraFields ?? []].flat(), + ); + + return new DataLoader( + async (entityRefs: readonly string[]) => { + const { items } = await options.catalogApi!.getEntitiesByRefs({ + entityRefs: entityRefs as string[], + fields: [...entityFields], + }); + + const now = Date.now(); + entityRefs.forEach((entityRef, index) => { + this.#cache.set(entityRef, { + updatedAt: now, + entity: items[index], + }); + }); + + return items; + }, + { + name: DefaultEntityPresentationApi.name, + // This cache is the one that the data loader uses internally for + // memoizing requests; essentially what it achieves is that multiple + // requests for the same entity ref will be batched up into a single + // request and then the resulting promises are held on to. We put an + // expiring map here, which makes it so that it re-fetches data with the + // expiry cadence of that map. Otherwise it would only fetch a given ref + // once and then never try again. This cache does therefore not fulfill + // the same purpose as the one that is in the root of the class. + cacheMap: new ExpiryMap(cacheTtlMs), + maxBatchSize: 100, + batchScheduleFn: batchDelayMs + ? cb => setTimeout(cb, batchDelayMs) + : undefined, + }, + ); + } +} diff --git a/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx new file mode 100644 index 0000000000..b3f4c5ec3c --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { IconComponent } from '@backstage/core-plugin-api'; +import { defaultEntityPresentation } from '@backstage/plugin-catalog-react'; +import { HumanDuration } from '@backstage/types'; +import ApartmentIcon from '@material-ui/icons/Apartment'; +import BusinessIcon from '@material-ui/icons/Business'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import HelpIcon from '@material-ui/icons/Help'; +import LibraryAddIcon from '@material-ui/icons/LibraryAdd'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; +import MemoryIcon from '@material-ui/icons/Memory'; +import PeopleIcon from '@material-ui/icons/People'; +import PersonIcon from '@material-ui/icons/Person'; +import { DefaultEntityPresentationApiRenderer } from './DefaultEntityPresentationApi'; + +export const DEFAULT_CACHE_TTL: HumanDuration = { seconds: 30 }; + +export const DEFAULT_BATCH_DELAY: HumanDuration = { milliseconds: 50 }; + +export const DEFAULT_ENTITY_FIELDS: string[] = [ + 'apiVersion', + 'kind', + 'metadata.uid', + 'metadata.etag', + 'metadata.name', + 'metadata.namespace', + 'metadata.title', + 'metadata.description', + 'spec.type', + 'spec.profile', +]; + +export const UNKNOWN_KIND_ICON: IconComponent = HelpIcon; + +export const DEFAULT_ICONS: Record = { + api: ExtensionIcon, + component: MemoryIcon, + system: BusinessIcon, + domain: ApartmentIcon, + location: LocationOnIcon, + user: PersonIcon, + group: PeopleIcon, + template: LibraryAddIcon, +}; + +export function createDefaultRenderer(options: { + async: boolean; +}): DefaultEntityPresentationApiRenderer { + return { + async: options.async, + + render: ({ entityRef, entity, context }) => { + const presentation = defaultEntityPresentation( + entity || entityRef, + context, + ); + return { + snapshot: presentation, + loadEntity: options.async, + }; + }, + }; +} diff --git a/plugins/catalog/src/apis/EntityPresentationApi/index.ts b/plugins/catalog/src/apis/EntityPresentationApi/index.ts new file mode 100644 index 0000000000..c1c6426ea0 --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { + DefaultEntityPresentationApi, + type DefaultEntityPresentationApiOptions, + type DefaultEntityPresentationApiRenderer, +} from './DefaultEntityPresentationApi'; diff --git a/plugins/catalog/src/apis/EntityPresentationApi/util.ts b/plugins/catalog/src/apis/EntityPresentationApi/util.ts new file mode 100644 index 0000000000..e5dfe6bd7b --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/util.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { HumanDuration } from '@backstage/types'; + +export function durationToMs(duration: HumanDuration): number { + const { + years = 0, + months = 0, + weeks = 0, + days = 0, + hours = 0, + minutes = 0, + seconds = 0, + milliseconds = 0, + } = duration; + + const totalDays = years * 365 + months * 30 + weeks * 7 + days; + const totalHours = totalDays * 24 + hours; + const totalMinutes = totalHours * 60 + minutes; + const totalSeconds = totalMinutes * 60 + seconds; + const totalMilliseconds = totalSeconds * 1000 + milliseconds; + + return totalMilliseconds; +} diff --git a/plugins/catalog/src/apis/index.ts b/plugins/catalog/src/apis/index.ts index 5c7e980890..271d749afc 100644 --- a/plugins/catalog/src/apis/index.ts +++ b/plugins/catalog/src/apis/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export * from './EntityPresentationApi'; export * from './StarredEntitiesApi'; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 67579d5273..24ccb22908 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_OWNED_BY, RELATION_PART_OF, + stringifyEntityRef, } from '@backstage/catalog-model'; import { CodeSnippet, @@ -203,9 +204,13 @@ export const CatalogTable = (props: CatalogTableProps) => { return { entity, resolved: { + // This name is here for backwards compatibility mostly; the + // presentation of refs in the table should in general be handled with + // EntityRefLink / EntityName components name: humanizeEntityRef(entity, { defaultKind: 'Component', }), + entityRef: stringifyEntityRef(entity), ownedByRelationsTitle: ownedByRelations .map(r => humanizeEntityRef(r, { defaultKind: 'group' })) .join(', '), diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index fc6e1aaf6b..3c31fcadf1 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -43,7 +43,7 @@ export const columnFactories = Object.freeze({ return { title: 'Name', - field: 'resolved.name', + field: 'resolved.entityRef', highlight: true, customSort({ entity: entity1 }, { entity: entity2 }) { // TODO: We could implement this more efficiently by comparing field by field. @@ -54,7 +54,6 @@ export const columnFactories = Object.freeze({ ), }; diff --git a/plugins/catalog/src/components/CatalogTable/types.ts b/plugins/catalog/src/components/CatalogTable/types.ts index 5bbb3af122..f2b3324169 100644 --- a/plugins/catalog/src/components/CatalogTable/types.ts +++ b/plugins/catalog/src/components/CatalogTable/types.ts @@ -20,7 +20,11 @@ import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; export interface CatalogTableRow { entity: Entity; resolved: { + // This name is here for backwards compatibility mostly; the presentation of + // refs in the table should in general be handled with EntityRefLink / + // EntityName components name: string; + entityRef: string; partOfSystemRelationTitle?: string; partOfSystemRelations: CompoundEntityRef[]; ownedByRelationsTitle?: string; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 08c751c6c6..33414152be 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -79,6 +79,7 @@ describe('EntityLayout', () => { kind: 'MyKind', metadata: { name: 'my-entity', + namespace: 'default', title: 'My Entity', }, } as Entity; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index a190984ffe..71c6bcc860 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -36,6 +36,7 @@ import { useRouteRefParams, } from '@backstage/core-plugin-api'; import { + EntityDisplayName, EntityRefLinks, entityRouteRef, FavoriteEntity, @@ -78,7 +79,11 @@ function EntityLayoutTitle(props: { whiteSpace="nowrap" overflow="hidden" > - {title} + {entity ? ( + + ) : ( + title + )} {entity && } diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 3410984ec3..d310639fb1 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -18,6 +18,7 @@ import { CatalogClient } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { catalogApiRef, + entityPresentationApiRef, entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; @@ -52,6 +53,7 @@ import { HasSystemsCardProps } from './components/HasSystemsCard'; import { RelatedEntitiesCardProps } from './components/RelatedEntitiesCard'; import { CatalogSearchResultListItemProps } from './components/CatalogSearchResultListItem'; import { rootRouteRef } from './routes'; +import { DefaultEntityPresentationApi } from './apis/EntityPresentationApi'; /** @public */ export const catalogPlugin = createPlugin({ @@ -72,6 +74,12 @@ export const catalogPlugin = createPlugin({ factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }), }), + createApiFactory({ + api: entityPresentationApiRef, + deps: { catalogApi: catalogApiRef }, + factory: ({ catalogApi }) => + DefaultEntityPresentationApi.create({ catalogApi }), + }), ], routes: { catalogIndex: rootRouteRef, diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx index 555815c2de..a18e9980e9 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx @@ -65,7 +65,7 @@ describe('UserSummary Test', () => { 'src', 'https://example.com/staff/calum.jpeg', ); - expect(screen.getByText('examplegroup')).toHaveAttribute( + expect(screen.getByText('examplegroup').closest('a')).toHaveAttribute( 'href', '/catalog/default/group/examplegroup', ); diff --git a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx index df1fabab0a..0129542b03 100644 --- a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx @@ -45,7 +45,7 @@ describe('', () => { }, ); - expect(screen.getByText('user:default/test-ownership')).toBeInTheDocument(); + expect(screen.getByText('user:test-ownership')).toBeInTheDocument(); expect(screen.getByText('foo:bar/foobar')).toBeInTheDocument(); }); }); diff --git a/yarn.lock b/yarn.lock index fad4640b35..8bd773cf70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5872,6 +5872,8 @@ __metadata: "@testing-library/react": ^12.1.3 "@testing-library/user-event": ^14.0.0 "@types/react": ^16.13.1 || ^17.0.0 + dataloader: ^2.0.0 + expiry-map: ^2.0.0 history: ^5.0.0 lodash: ^4.17.21 pluralize: ^8.0.0 @@ -25825,6 +25827,15 @@ __metadata: languageName: node linkType: hard +"expiry-map@npm:^2.0.0": + version: 2.0.0 + resolution: "expiry-map@npm:2.0.0" + dependencies: + map-age-cleaner: ^0.2.0 + checksum: 9be8662e1a5c1084fb6d0ddc5402658dd06101c330454062b2f5efbf1477259d272e54ec16663d7d12a93d08ed510535781c36acb214696c5bc3a690a02a7a9d + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -32078,6 +32089,15 @@ __metadata: languageName: node linkType: hard +"map-age-cleaner@npm:^0.2.0": + version: 0.2.0 + resolution: "map-age-cleaner@npm:0.2.0" + dependencies: + p-defer: ^1.0.0 + checksum: 13a6810b76b0067efa7f4b0f3dc58b58b4a4b5faa4cae5a0e8d5d59eda04d7074724eee426c9b5890a1d7e14d1e2902a090587acc8e2430198e79ab1556a2dad + languageName: node + linkType: hard + "map-cache@npm:^0.2.0": version: 0.2.2 resolution: "map-cache@npm:0.2.2" @@ -34515,6 +34535,13 @@ __metadata: languageName: node linkType: hard +"p-defer@npm:^1.0.0": + version: 1.0.0 + resolution: "p-defer@npm:1.0.0" + checksum: 4271b935c27987e7b6f229e5de4cdd335d808465604644cb7b4c4c95bef266735859a93b16415af8a41fd663ee9e3b97a1a2023ca9def613dba1bad2a0da0c7b + languageName: node + linkType: hard + "p-filter@npm:^2.1.0": version: 2.1.0 resolution: "p-filter@npm:2.1.0" From e5ce16aa44a81acb373ab39570349f94d62c378e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 18 Aug 2023 13:25:19 +0200 Subject: [PATCH 02/12] address comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/heavy-experts-accept.md | 2 +- .../defaultEntityPresentation.ts | 46 ++++++++++--------- .../EntityRefLink/EntityRefLink.test.tsx | 18 ++++---- .../UnregisterEntityDialog.test.tsx | 8 ++-- .../TemplateCard/TemplateCard.test.tsx | 13 +++--- .../General/UserSettingsIdentityCard.test.tsx | 4 +- 6 files changed, 49 insertions(+), 42 deletions(-) diff --git a/.changeset/heavy-experts-accept.md b/.changeset/heavy-experts-accept.md index ec232d1c4d..e6f2f435b8 100644 --- a/.changeset/heavy-experts-accept.md +++ b/.changeset/heavy-experts-accept.md @@ -8,7 +8,7 @@ iconography etc) are represented in the user interface. Usage of this API is initially added to the `EntityRefLink` and `EntityRefLinks` components, so that they can render richer, more correct representation of -entity refs. There's also a new `EntityName` component, which works just like +entity refs. There's also a new `EntityDisplayName` component, which works just like the `EntityRefLink` but without the link. Along with that change, the `fetchEntities` and `getTitle` props of diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts index 31ed742a8d..ab9ea68060 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts @@ -85,9 +85,9 @@ export function defaultEntityPresentation( const shortRef = getShortRef({ kind, namespace, name, context }); - const primary = [displayName, title, shortRef].filter( + const primary = [displayName, title, shortRef].find( candidate => candidate && typeof candidate === 'string', - )[0]!; + )!; const secondary = [ primary !== entityRef ? entityRef : undefined, @@ -134,35 +134,35 @@ function getParts(entityOrRef: Entity | CompoundEntityRef | string): { } if (typeof entityOrRef === 'object' && entityOrRef !== null) { - const kind = [get(entityOrRef, 'kind')].filter( + const kind = [get(entityOrRef, 'kind')].find( candidate => candidate && typeof candidate === 'string', - )[0]; + ); const namespace = [ get(entityOrRef, 'metadata.namespace'), get(entityOrRef, 'namespace'), - ].filter(candidate => candidate && typeof candidate === 'string')[0]; + ].find(candidate => candidate && typeof candidate === 'string'); const name = [ get(entityOrRef, 'metadata.name'), get(entityOrRef, 'name'), - ].filter(candidate => candidate && typeof candidate === 'string')[0]; + ].find(candidate => candidate && typeof candidate === 'string'); - const title = [get(entityOrRef, 'metadata.title')].filter( + const title = [get(entityOrRef, 'metadata.title')].find( candidate => candidate && typeof candidate === 'string', - )[0]; + ); - const description = [get(entityOrRef, 'metadata.description')].filter( + const description = [get(entityOrRef, 'metadata.description')].find( candidate => candidate && typeof candidate === 'string', - )[0]; + ); - const displayName = [get(entityOrRef, 'spec.profile.displayName')].filter( + const displayName = [get(entityOrRef, 'spec.profile.displayName')].find( candidate => candidate && typeof candidate === 'string', - )[0]; + ); - const type = [get(entityOrRef, 'spec.type')].filter( + const type = [get(entityOrRef, 'spec.type')].find( candidate => candidate && typeof candidate === 'string', - )[0]; + ); return { kind, namespace, name, title, description, displayName, type }; } @@ -177,23 +177,27 @@ function getShortRef(options: { context?: { defaultKind?: string; defaultNamespace?: string }; }): string { const kind = options.kind?.toLocaleLowerCase('en-US') || 'unknown'; - const namespace = - options.namespace?.toLocaleLowerCase('en-US') || DEFAULT_NAMESPACE; - const name = options.name?.toLocaleLowerCase('en-US') || 'unknown'; - const defaultKind = options.context?.defaultKind?.toLocaleLowerCase('en-US'); - const defaultNamespace = + const namespace = options.namespace || DEFAULT_NAMESPACE; + const name = options.name || 'unknown'; + const defaultKindLower = + options.context?.defaultKind?.toLocaleLowerCase('en-US'); + const defaultNamespaceLower = options.context?.defaultNamespace?.toLocaleLowerCase('en-US'); let result = name; if ( - (defaultNamespace && namespace !== defaultNamespace) || + (defaultNamespaceLower && + namespace.toLocaleLowerCase('en-US') !== defaultNamespaceLower) || namespace !== DEFAULT_NAMESPACE ) { result = `${namespace}/${result}`; } - if (defaultKind && kind !== defaultKind) { + if ( + defaultKindLower && + kind.toLocaleLowerCase('en-US') !== defaultKindLower + ) { result = `${kind}:${result}`; } diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx index 65d9be2190..c1a37b9682 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx @@ -40,7 +40,7 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:software').closest('a')).toHaveAttribute( + expect(screen.getByText('software').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -65,9 +65,10 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect( - screen.getByText('component:test/software').closest('a'), - ).toHaveAttribute('href', '/catalog/test/component/software'); + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); }); it('renders link for entity and hides default kind', async () => { @@ -109,7 +110,7 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:software').closest('a')).toHaveAttribute( + expect(screen.getByText('software').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -126,9 +127,10 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect( - screen.getByText('component:test/software').closest('a'), - ).toHaveAttribute('href', '/catalog/test/component/software'); + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); }); it('renders link for entity name and hides default kind', async () => { diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx index 7f139854f6..7c8ddb3afb 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx @@ -285,8 +285,8 @@ describe('UnregisterEntityDialog', () => { expect( screen.getByText(/will unregister the following entities/), ).toBeInTheDocument(); - expect(screen.getByText(/k1:ns1\/n1/)).toBeInTheDocument(); - expect(screen.getByText(/k2:ns2\/n2/)).toBeInTheDocument(); + expect(screen.getByText(/ns1\/n1/)).toBeInTheDocument(); + expect(screen.getByText(/ns2\/n2/)).toBeInTheDocument(); }); await userEvent.click(screen.getByText('Unregister Location')); @@ -333,8 +333,8 @@ describe('UnregisterEntityDialog', () => { expect( screen.getByText(/will unregister the following entities/), ).toBeInTheDocument(); - expect(screen.getByText(/k1:ns1\/n1/)).toBeInTheDocument(); - expect(screen.getByText(/k2:ns2\/n2/)).toBeInTheDocument(); + expect(screen.getByText(/ns1\/n1/)).toBeInTheDocument(); + expect(screen.getByText(/ns2\/n2/)).toBeInTheDocument(); }); await userEvent.click(screen.getByText('Advanced Options')); diff --git a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx index 2d5b203cfb..5c373411fa 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx @@ -89,7 +89,7 @@ describe('TemplateCard', () => { expect(description.querySelector('strong')).toBeInTheDocument(); }); - it('should render no descroption if none is provided through the template', async () => { + it('should render no description if none is provided through the template', async () => { const mockTemplate: TemplateEntityV1beta3 = { apiVersion: 'scaffolder.backstage.io/v1beta3', kind: 'Template', @@ -186,11 +186,12 @@ describe('TemplateCard', () => { }, ); - expect(getByRole('link', { name: 'my-test-user' })).toBeInTheDocument(); - expect(getByRole('link', { name: 'my-test-user' })).toHaveAttribute( - 'href', - '/catalog/group/default/my-test-user', - ); + expect( + getByRole('link', { name: 'group:default/my-test-user' }), + ).toBeInTheDocument(); + expect( + getByRole('link', { name: 'group:default/my-test-user' }), + ).toHaveAttribute('href', '/catalog/group/default/my-test-user'); }); it('should call the onSelected handler when clicking the choose button', async () => { diff --git a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx index 0129542b03..1be797980b 100644 --- a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx @@ -45,7 +45,7 @@ describe('', () => { }, ); - expect(screen.getByText('user:test-ownership')).toBeInTheDocument(); - expect(screen.getByText('foo:bar/foobar')).toBeInTheDocument(); + expect(screen.getByText('test-ownership')).toBeInTheDocument(); + expect(screen.getByText('bar/foobar')).toBeInTheDocument(); }); }); From e83ca583aebe6b42620c1fce8beddeaad0d9e402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 18 Aug 2023 16:40:16 +0200 Subject: [PATCH 03/12] simplify useUpdatingObservable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../EntityPresentationApi/useUpdatingObservable.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts index 37972a5432..a15119d177 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts @@ -15,7 +15,7 @@ */ import { Observable } from '@backstage/types'; -import { DependencyList, useEffect, useRef, useState } from 'react'; +import { DependencyList, useEffect, useState } from 'react'; /** * Subscribe to an observable and return the latest value from it. @@ -36,17 +36,14 @@ export function useUpdatingObservable( observable: Observable | undefined, deps: DependencyList, ): T { - const snapshot = useRef(value); - const [, setCounter] = useState(0); + const [snapshot, setSnapshot] = useState(value); useEffect(() => { - snapshot.current = value; - setCounter(counter => counter + 1); + setSnapshot(value); const subscription = observable?.subscribe({ next: updatedValue => { - snapshot.current = updatedValue; - setCounter(counter => counter + 1); + setSnapshot(updatedValue); }, complete: () => { subscription?.unsubscribe(); @@ -59,5 +56,5 @@ export function useUpdatingObservable( // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); - return snapshot.current; + return snapshot; } From c1ed76a2b1d07b4943e802ceb7049692844dbfe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 18 Aug 2023 17:01:00 +0200 Subject: [PATCH 04/12] clarifying type comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../EntityPresentationApi.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index 8d3bcce068..68ccacf29a 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -38,10 +38,64 @@ export const entityPresentationApiRef: ApiRef = * @public */ export interface EntityRefPresentationSnapshot { + /** + * The ref to the entity that this snapshot represents. + * + * @remarks + * + * Note that when the input data was broken or had missing vital pieces of + * information, this string may contain placeholders such as "unknown". You + * can therefore not necessarily assume that the ref is completely valid and + * usable for example for forming a clickable link to the entity. + */ entityRef: string; + /** + * The entity that this snapshot represents. + * + * @remarks + * + * Note that this may be a partial entity, i.e. it may not contain all of the + * fields that the kind at hand normally contains, since the input to the + * representation renderer might have been a partial entity. So be careful + * when accessing fields on this. + */ entity?: Entity | undefined; + /** + * A string that can be used as a plain representation of the entity, for + * example in a header or a link. + * + * @remarks + * + * The title may be short and not contain all of the information that the + * entity holds. When rendering the primary title, you may also want to + * make sure to add more contextual information nearby such as the icon or + * secondary title, since the primary could for example just be the + * `metadata.name` of tne entity which might be ambiguous to the reader. + */ primaryTitle: string; + /** + * Optionally, some additional textual information about the entity, to be + * used as a clarification on top of the primary title. + * + * @remarks + * + * This text can for example be rendered in a tooltip or be used as a + * subtitle. It may not be sufficient to display on its own; it should + * typically be used in conjunction with the primary title. It can contain + * such information as the entity ref and/or a `spec.type` etc. + */ secondaryTitle?: string; + /** + * Optionally, an icon that represents the kind/type of entity. + * + * @remarks + * + * This icon should ideally be easily recognizable as the kind of entity, and + * be used consistently throughout the Backstage interface. It can be rendered + * both in larger formats such as in a header, or in smaller formats such as + * inline with regular text, so bear in mind that the legibility should be + * high in both cases. + */ Icon?: IconComponent | undefined; } From 11fef2dca17a8d3acbd4c0d700f2e181a0c6491e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 22 Aug 2023 08:50:10 +0200 Subject: [PATCH 05/12] api report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/api-report.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index c3c5d7747e..96f9a68082 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import { ApiRef } from '@backstage/core-plugin-api'; import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { CatalogApi } from '@backstage/catalog-client'; @@ -413,15 +415,10 @@ export interface EntityRefPresentation { // @public export interface EntityRefPresentationSnapshot { - // (undocumented) entity?: Entity | undefined; - // (undocumented) entityRef: string; - // (undocumented) Icon?: IconComponent | undefined; - // (undocumented) primaryTitle: string; - // (undocumented) secondaryTitle?: string; } From 1286ff185af7b4dd32708641924ec98c5e1ae3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 14 Sep 2023 15:19:15 +0200 Subject: [PATCH 06/12] updated ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/api-report.md | 18 +++++++++--------- .../EntityPresentationApi.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 96f9a68082..0d41f0ca58 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -159,6 +159,15 @@ export type DefaultEntityFilters = { namespace?: EntityNamespaceFilter; }; +// @public +export function defaultEntityPresentation( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, +): EntityRefPresentationSnapshot; + // @public (undocumented) export function EntityAutocompletePicker< T extends DefaultEntityFilters = DefaultEntityFilters, @@ -181,15 +190,6 @@ export type EntityAutocompletePickerProps< initialSelectedOptions?: string[]; }; -// @public -export function defaultEntityPresentation( - entityOrRef: Entity | CompoundEntityRef | string, - context?: { - defaultKind?: string; - defaultNamespace?: string; - }, -): EntityRefPresentationSnapshot; - // @public export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index 68ccacf29a..17f16d69a4 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -29,7 +29,7 @@ import { Observable } from '@backstage/types'; */ export const entityPresentationApiRef: ApiRef = createApiRef({ - id: 'catalog-react.entity-presentation', + id: 'plugin.catalog.entity-presentation', }); /** From 18bc2d867ccb0c780a0a3b4d5050d47e2bade917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Oct 2023 16:33:37 +0200 Subject: [PATCH 07/12] replace the variant with noIcon and noTitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/api-report.md | 3 ++- .../components/EntityDisplayName/EntityDisplayName.tsx | 9 +++++---- .../catalog/src/components/EntityLayout/EntityLayout.tsx | 6 +----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 0d41f0ca58..a1ba49f580 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -196,7 +196,8 @@ export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; // @public export type EntityDisplayNameProps = { entityRef: Entity | CompoundEntityRef | string; - variant?: 'simple' | string; + noIcon?: boolean; + noTooltip?: boolean; defaultKind?: string; defaultNamespace?: string; }; diff --git a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx index 735828b18f..6bdbe83a84 100644 --- a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx @@ -49,7 +49,8 @@ const useStyles = makeStyles( */ export type EntityDisplayNameProps = { entityRef: Entity | CompoundEntityRef | string; - variant?: 'simple' | string; + noIcon?: boolean; + noTooltip?: boolean; defaultKind?: string; defaultNamespace?: string; }; @@ -62,7 +63,7 @@ export type EntityDisplayNameProps = { export const EntityDisplayName = ( props: EntityDisplayNameProps, ): JSX.Element => { - const { entityRef, variant, defaultKind, defaultNamespace } = props; + const { entityRef, noIcon, noTooltip, defaultKind, defaultNamespace } = props; const classes = useStyles(); const { primaryTitle, secondaryTitle, Icon } = useEntityPresentation( @@ -77,7 +78,7 @@ export const EntityDisplayName = ( content = ( {content} - {Icon && variant !== 'simple' ? ( + {Icon && !noIcon ? ( @@ -86,7 +87,7 @@ export const EntityDisplayName = ( ); // Optionally, a tooltip as the outermost layer - if (secondaryTitle) { + if (secondaryTitle && !noTooltip) { content = ( {content} diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 71c6bcc860..d253756411 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -79,11 +79,7 @@ function EntityLayoutTitle(props: { whiteSpace="nowrap" overflow="hidden" > - {entity ? ( - - ) : ( - title - )} + {entity ? : title} {entity && } From 762f6091d4aadf354eda77dc3ef375e9d2dd2862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Oct 2023 16:40:44 +0200 Subject: [PATCH 08/12] reduce cacheTtl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../apis/EntityPresentationApi/DefaultEntityPresentationApi.ts | 2 +- plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts index 9ec8af7760..e4ee60f2b4 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts @@ -121,7 +121,7 @@ export interface DefaultEntityPresentationApiOptions { * When to expire entities that have been loaded from the catalog API and * cached for a while. * - * @defaultValue 30 seconds + * @defaultValue 10 seconds * @remarks * * The higher this value, the lower the load on the catalog API, but also the diff --git a/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx index b3f4c5ec3c..237c57db0f 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx +++ b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx @@ -28,7 +28,7 @@ import PeopleIcon from '@material-ui/icons/People'; import PersonIcon from '@material-ui/icons/Person'; import { DefaultEntityPresentationApiRenderer } from './DefaultEntityPresentationApi'; -export const DEFAULT_CACHE_TTL: HumanDuration = { seconds: 30 }; +export const DEFAULT_CACHE_TTL: HumanDuration = { seconds: 10 }; export const DEFAULT_BATCH_DELAY: HumanDuration = { milliseconds: 50 }; From 430f2931b30efb373ea341b9c93f2f02054cbdc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Oct 2023 16:45:28 +0200 Subject: [PATCH 09/12] always fetch full entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog/api-report.md | 1 - .../DefaultEntityPresentationApi.ts | 24 ------------------- .../apis/EntityPresentationApi/defaults.tsx | 13 ---------- 3 files changed, 38 deletions(-) diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 7ed2879367..4b2c17865d 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -257,7 +257,6 @@ export interface DefaultEntityPresentationApiOptions { // @public export interface DefaultEntityPresentationApiRenderer { async?: boolean; - extraFields?: string[]; render: (options: { entityRef: string; loading: boolean; diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts index e4ee60f2b4..24b9b744a0 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts @@ -24,12 +24,10 @@ import { import { HumanDuration } from '@backstage/types'; import DataLoader from 'dataloader'; import ExpiryMap from 'expiry-map'; -import uniq from 'lodash/uniq'; import ObservableImpl from 'zen-observable'; import { DEFAULT_BATCH_DELAY, DEFAULT_CACHE_TTL, - DEFAULT_ENTITY_FIELDS, createDefaultRenderer, } from './defaults'; import { durationToMs } from './util'; @@ -40,23 +38,6 @@ import { durationToMs } from './util'; * @public */ export interface DefaultEntityPresentationApiRenderer { - /** - * An extra set of fields to request for entities from the catalog API. - * - * @remarks - * - * You may want to specify this to get additional entity fields. The smaller - * the set of fields, the more efficient requests will be to the catalog - * backend. - * - * The default set of fields is: apiVersion, kind, the scalar metadata fields - * (uid, etag, name, namespace, title, description), spec.type, and - * spec.profile. - * - * This field is ignored if async is set to false. - */ - extraFields?: string[]; - /** * Whether to request the entity from the catalog API asynchronously. * @@ -332,15 +313,10 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { const cacheTtlMs = durationToMs(options.cacheTtl); const batchDelayMs = durationToMs(options.batchDelay); - const entityFields = uniq( - [DEFAULT_ENTITY_FIELDS, options.renderer?.extraFields ?? []].flat(), - ); - return new DataLoader( async (entityRefs: readonly string[]) => { const { items } = await options.catalogApi!.getEntitiesByRefs({ entityRefs: entityRefs as string[], - fields: [...entityFields], }); const now = Date.now(); diff --git a/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx index 237c57db0f..afcd1f5464 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx +++ b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx @@ -32,19 +32,6 @@ export const DEFAULT_CACHE_TTL: HumanDuration = { seconds: 10 }; export const DEFAULT_BATCH_DELAY: HumanDuration = { milliseconds: 50 }; -export const DEFAULT_ENTITY_FIELDS: string[] = [ - 'apiVersion', - 'kind', - 'metadata.uid', - 'metadata.etag', - 'metadata.name', - 'metadata.namespace', - 'metadata.title', - 'metadata.description', - 'spec.type', - 'spec.profile', -]; - export const UNKNOWN_KIND_ICON: IconComponent = HelpIcon; export const DEFAULT_ICONS: Record = { From 3988751087f752fe7e03d7ff371ae439b49b9800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Oct 2023 16:53:13 +0200 Subject: [PATCH 10/12] stop returning the entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/api-report.md | 1 - .../EntityPresentationApi.ts | 11 -------- .../defaultEntityPresentation.test.ts | 18 ------------- .../defaultEntityPresentation.ts | 8 ------ .../DefaultEntityPresentationApi.test.ts | 26 ------------------- .../DefaultEntityPresentationApi.ts | 2 -- .../General/UserSettingsIdentityCard.tsx | 10 ++----- 7 files changed, 2 insertions(+), 74 deletions(-) diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index a1ba49f580..abbf348848 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -416,7 +416,6 @@ export interface EntityRefPresentation { // @public export interface EntityRefPresentationSnapshot { - entity?: Entity | undefined; entityRef: string; Icon?: IconComponent | undefined; primaryTitle: string; diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index 17f16d69a4..532fdde9a3 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -49,17 +49,6 @@ export interface EntityRefPresentationSnapshot { * usable for example for forming a clickable link to the entity. */ entityRef: string; - /** - * The entity that this snapshot represents. - * - * @remarks - * - * Note that this may be a partial entity, i.e. it may not contain all of the - * fields that the kind at hand normally contains, since the input to the - * representation renderer might have been a partial entity. So be careful - * when accessing fields on this. - */ - entity?: Entity | undefined; /** * A string that can be used as a plain representation of the entity, for * example in a header or a link. diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts index e875de6fd1..ea755dfebc 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts @@ -34,7 +34,6 @@ describe('defaultEntityPresentation', () => { }, }), ).toEqual({ - entity: expect.anything(), entityRef: 'component:default/test', primaryTitle: 'test', secondaryTitle: 'component:default/test | type | desc', @@ -56,7 +55,6 @@ describe('defaultEntityPresentation', () => { }, }), ).toEqual({ - entity: expect.anything(), entityRef: 'component:default/test', primaryTitle: 'title', secondaryTitle: 'component:default/test | type | desc', @@ -81,7 +79,6 @@ describe('defaultEntityPresentation', () => { }, }), ).toEqual({ - entity: expect.anything(), entityRef: 'component:default/test', primaryTitle: 'displayName', secondaryTitle: 'component:default/test | type | desc', @@ -96,7 +93,6 @@ describe('defaultEntityPresentation', () => { metadata: { name: 'test' }, } as Entity), ).toEqual({ - entity: expect.anything(), entityRef: 'component:default/test', primaryTitle: 'test', secondaryTitle: 'component:default/test', @@ -108,7 +104,6 @@ describe('defaultEntityPresentation', () => { expect( defaultEntityPresentation({ metadata: 7 } as unknown as Entity), ).toEqual({ - entity: expect.anything(), entityRef: 'unknown:default/unknown', primaryTitle: 'unknown', secondaryTitle: 'unknown:default/unknown', @@ -120,7 +115,6 @@ describe('defaultEntityPresentation', () => { describe('string ref given', () => { it('happy path', () => { expect(defaultEntityPresentation('component:default/test')).toEqual({ - entity: undefined, entityRef: 'component:default/test', primaryTitle: 'test', secondaryTitle: 'component:default/test', @@ -132,7 +126,6 @@ describe('defaultEntityPresentation', () => { defaultKind: 'X', }), ).toEqual({ - entity: undefined, entityRef: 'component:default/test', primaryTitle: 'component:test', secondaryTitle: 'component:default/test', @@ -144,7 +137,6 @@ describe('defaultEntityPresentation', () => { defaultNamespace: 'X', }), ).toEqual({ - entity: undefined, entityRef: 'component:default/test', primaryTitle: 'default/test', secondaryTitle: 'component:default/test', @@ -154,7 +146,6 @@ describe('defaultEntityPresentation', () => { it('works without throwing on malformed and shortened refs', () => { expect(defaultEntityPresentation('')).toEqual({ - entity: undefined, entityRef: 'unknown:default/unknown', primaryTitle: 'unknown', secondaryTitle: 'unknown:default/unknown', @@ -162,7 +153,6 @@ describe('defaultEntityPresentation', () => { }); expect(defaultEntityPresentation('name')).toEqual({ - entity: undefined, entityRef: 'unknown:default/name', primaryTitle: 'name', secondaryTitle: 'unknown:default/name', @@ -180,7 +170,6 @@ describe('defaultEntityPresentation', () => { name: 'test', }), ).toEqual({ - entity: undefined, entityRef: 'component:default/test', primaryTitle: 'test', secondaryTitle: 'component:default/test', @@ -195,7 +184,6 @@ describe('defaultEntityPresentation', () => { }, ), ).toEqual({ - entity: undefined, entityRef: 'component:default/test', primaryTitle: 'component:test', secondaryTitle: 'component:default/test', @@ -210,7 +198,6 @@ describe('defaultEntityPresentation', () => { }, ), ).toEqual({ - entity: undefined, entityRef: 'component:default/test', primaryTitle: 'default/test', secondaryTitle: 'component:default/test', @@ -227,7 +214,6 @@ describe('defaultEntityPresentation', () => { }, ), ).toEqual({ - entity: undefined, entityRef: 'component:default/test', primaryTitle: 'default/test', secondaryTitle: 'component:default/test', @@ -235,7 +221,6 @@ describe('defaultEntityPresentation', () => { }); expect(defaultEntityPresentation('')).toEqual({ - entity: undefined, entityRef: 'unknown:default/unknown', primaryTitle: 'unknown', secondaryTitle: 'unknown:default/unknown', @@ -247,7 +232,6 @@ describe('defaultEntityPresentation', () => { describe('entirely invalid input type given', () => { it('sad path', () => { expect(defaultEntityPresentation(null as unknown as Entity)).toEqual({ - entity: undefined, entityRef: 'unknown:default/unknown', primaryTitle: 'unknown', secondaryTitle: 'unknown:default/unknown', @@ -256,7 +240,6 @@ describe('defaultEntityPresentation', () => { expect(defaultEntityPresentation(undefined as unknown as Entity)).toEqual( { - entity: undefined, entityRef: 'unknown:default/unknown', primaryTitle: 'unknown', secondaryTitle: 'unknown:default/unknown', @@ -267,7 +250,6 @@ describe('defaultEntityPresentation', () => { expect( defaultEntityPresentation(Symbol.for('Prince') as unknown as Entity), ).toEqual({ - entity: undefined, entityRef: 'unknown:default/unknown', primaryTitle: 'unknown', secondaryTitle: 'unknown:default/unknown', diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts index ab9ea68060..eacb659062 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts @@ -70,13 +70,6 @@ export function defaultEntityPresentation( (kind && DEFAULT_ICONS[kind.toLocaleLowerCase('en-US')]) || UNKNOWN_KIND_ICON; - const entity: Entity | undefined = - typeof entityOrRef === 'object' && - entityOrRef !== null && - 'metadata' in entityOrRef - ? entityOrRef - : undefined; - const entityRef: string = stringifyEntityRef({ kind: kind || 'unknown', namespace: namespace || DEFAULT_NAMESPACE, @@ -98,7 +91,6 @@ export function defaultEntityPresentation( .join(' | '); return { - entity, entityRef, primaryTitle: primary, secondaryTitle: secondary || undefined, diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts index eb20b20f9f..42ff777cbd 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts @@ -80,7 +80,6 @@ describe('DefaultEntityPresentationApi', () => { expect(api.forEntity(entity)).toEqual({ snapshot: { entityRef: 'component:default/test', - entity: entity, primaryTitle: 'test', secondaryTitle: 'component:default/test | service', Icon: expect.anything(), @@ -120,25 +119,12 @@ describe('DefaultEntityPresentationApi', () => { ).resolves.toEqual([ { entityRef: 'component:default/test', - entity: undefined, primaryTitle: 'test', secondaryTitle: 'component:default/test', Icon: expect.anything(), }, { entityRef: 'component:default/test', - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - etag: 'something', - name: 'test', - namespace: 'default', - }, - spec: { - type: 'service', - }, - }, primaryTitle: 'test', secondaryTitle: 'component:default/test | service', Icon: expect.anything(), @@ -151,18 +137,6 @@ describe('DefaultEntityPresentationApi', () => { ).resolves.toEqual([ { entityRef: 'component:default/test', - entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - etag: 'something', - name: 'test', - namespace: 'default', - }, - spec: { - type: 'service', - }, - }, primaryTitle: 'test', secondaryTitle: 'component:default/test | service', Icon: expect.anything(), diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts index 24b9b744a0..91e5b575ff 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts @@ -240,7 +240,6 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { subscriber.next({ ...output.snapshot, entityRef: entityRef, - entity: newEntity, }); } }) @@ -265,7 +264,6 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { snapshot: { ...rendered, entityRef: entityRef, - entity: entity, }, update$: observable, }; diff --git a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.tsx b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.tsx index a398877ce9..d8d3f677aa 100644 --- a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.tsx @@ -33,19 +33,13 @@ const Contents = () => { User Entity:{' '} - ref} - /> + Ownership Entities:{' '} - ref} - /> + From 1f06689380810795f18b6d74829a36fd98ca3f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Oct 2023 21:37:39 +0200 Subject: [PATCH 11/12] allow custom icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/api-report.md | 2 +- .../EntityPresentationApi.ts | 7 +- plugins/catalog/api-report.md | 3 +- .../DefaultEntityPresentationApi.ts | 107 +++++++++++++----- .../src/apis/EntityPresentationApi/util.ts | 38 ------- 5 files changed, 89 insertions(+), 68 deletions(-) delete mode 100644 plugins/catalog/src/apis/EntityPresentationApi/util.ts diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index abbf348848..816eadbeaf 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -417,7 +417,7 @@ export interface EntityRefPresentation { // @public export interface EntityRefPresentationSnapshot { entityRef: string; - Icon?: IconComponent | undefined; + Icon?: IconComponent | undefined | false; primaryTitle: string; secondaryTitle?: string; } diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index 532fdde9a3..bb84c1b71a 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -84,8 +84,13 @@ export interface EntityRefPresentationSnapshot { * both in larger formats such as in a header, or in smaller formats such as * inline with regular text, so bear in mind that the legibility should be * high in both cases. + * + * A value of `false` here indicates the desire to not have an icon present + * for the given implementation. A value of `undefined` leaves it at the + * discretion of the display layer to choose what to do (such as for example + * showing a fallback icon). */ - Icon?: IconComponent | undefined; + Icon?: IconComponent | undefined | false; } /** diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 4b2c17865d..6647f8deaa 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -251,6 +251,7 @@ export interface DefaultEntityPresentationApiOptions { batchDelay?: HumanDuration; cacheTtl?: HumanDuration; catalogApi?: CatalogApi; + kindIcons?: Record; renderer?: DefaultEntityPresentationApiRenderer; } @@ -266,7 +267,7 @@ export interface DefaultEntityPresentationApiRenderer { defaultNamespace?: string; }; }) => { - snapshot: Omit; + snapshot: Omit; }; } diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts index 91e5b575ff..5f1119e598 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts @@ -14,23 +14,28 @@ * limitations under the License. */ -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { + Entity, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { IconComponent } from '@backstage/core-plugin-api'; import { CatalogApi, EntityPresentationApi, EntityRefPresentation, EntityRefPresentationSnapshot, } from '@backstage/plugin-catalog-react'; -import { HumanDuration } from '@backstage/types'; +import { HumanDuration, durationToMilliseconds } from '@backstage/types'; import DataLoader from 'dataloader'; import ExpiryMap from 'expiry-map'; import ObservableImpl from 'zen-observable'; import { DEFAULT_BATCH_DELAY, DEFAULT_CACHE_TTL, + DEFAULT_ICONS, createDefaultRenderer, } from './defaults'; -import { durationToMs } from './util'; /** * A custom renderer for the {@link DefaultEntityPresentationApi}. @@ -82,7 +87,7 @@ export interface DefaultEntityPresentationApiRenderer { defaultNamespace?: string; }; }) => { - snapshot: Omit; + snapshot: Omit; }; } @@ -123,6 +128,19 @@ export interface DefaultEntityPresentationApiOptions { */ batchDelay?: HumanDuration; + /** + * A mapping from kinds to icons. + * + * @remarks + * + * The keys are kinds (case insensitive) that map to icon values to represent + * kinds by. + * + * If you do not supply a set of icons here, a set of fallback icons will be + * used. If you supply the empty object, no fallback icons will be used. + */ + kindIcons?: Record; + /** * A custom renderer, if any. */ @@ -167,6 +185,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { readonly #cache: Map; readonly #cacheTtlMs: number; readonly #loader: DataLoader | undefined; + readonly #kindIcons: Record; // lowercased kinds readonly #renderer: DefaultEntityPresentationApiRenderer; private constructor(options: DefaultEntityPresentationApiOptions) { @@ -174,6 +193,13 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { const batchDelay = options.batchDelay ?? DEFAULT_BATCH_DELAY; const renderer = options.renderer ?? createDefaultRenderer({ async: true }); + const kindIcons: Record = {}; + Object.entries(options.kindIcons ?? DEFAULT_ICONS).forEach( + ([kind, icon]) => { + kindIcons[kind.toLocaleLowerCase('en-US')] = icon; + }, + ); + if (renderer.async) { if (!options.catalogApi) { throw new TypeError(`Asynchronous rendering requires a catalog API`); @@ -186,8 +212,9 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { }); } - this.#cacheTtlMs = durationToMs(cacheTtl); + this.#cacheTtlMs = durationToMilliseconds(cacheTtl); this.#cache = new Map(); + this.#kindIcons = kindIcons; this.#renderer = renderer; } @@ -199,25 +226,43 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { defaultNamespace?: string; }, ): EntityRefPresentation { - const { entityRef, entity, needsLoad } = + const { entityRef, kind, entity, needsLoad } = this.#getEntityForInitialRender(entityOrRef); - let rendered: Omit; - try { - const output = this.#renderer.render({ + // Make a wrapping helper for rendering + const render = (options: { + loading: boolean; + entity?: Entity; + }): EntityRefPresentationSnapshot => { + const { snapshot } = this.#renderer.render({ entityRef: entityRef, - loading: needsLoad, - entity: entity, + loading: options.loading, + entity: options.entity, context: context || {}, }); - rendered = output.snapshot; + return { + ...snapshot, + entityRef: entityRef, + Icon: this.#maybeFallbackIcon(snapshot.Icon, kind), + }; + }; + + // First the initial render + let initialSnapshot: EntityRefPresentationSnapshot; + try { + initialSnapshot = render({ + loading: needsLoad, + entity: entity, + }); } catch { // This is what gets presented if the renderer throws an error - rendered = { + initialSnapshot = { primaryTitle: entityRef, + entityRef: entityRef, }; } + // And then the following snapshot const observable = !needsLoad ? undefined : new ObservableImpl(subscriber => { @@ -231,16 +276,11 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { newEntity && newEntity.metadata.etag !== entity?.metadata.etag ) { - const output = this.#renderer.render({ - entityRef: entityRef, + const updatedSnapshot = render({ loading: false, entity: newEntity, - context: context || {}, - }); - subscriber.next({ - ...output.snapshot, - entityRef: entityRef, }); + subscriber.next(updatedSnapshot); } }) .catch(() => { @@ -261,16 +301,14 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { }); return { - snapshot: { - ...rendered, - entityRef: entityRef, - }, + snapshot: initialSnapshot, update$: observable, }; } #getEntityForInitialRender(entityOrRef: Entity | string): { entity: Entity | undefined; + kind: string; entityRef: string; needsLoad: boolean; } { @@ -281,6 +319,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { if (typeof entityOrRef !== 'string') { return { entity: entityOrRef, + kind: entityOrRef.kind, entityRef: stringifyEntityRef(entityOrRef), needsLoad: false, }; @@ -297,6 +336,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { return { entity: cachedEntity, + kind: parseEntityRef(entityOrRef).kind, entityRef: entityOrRef, needsLoad, }; @@ -308,8 +348,8 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { batchDelay: HumanDuration; renderer: DefaultEntityPresentationApiRenderer; }): DataLoader { - const cacheTtlMs = durationToMs(options.cacheTtl); - const batchDelayMs = durationToMs(options.batchDelay); + const cacheTtlMs = durationToMilliseconds(options.cacheTtl); + const batchDelayMs = durationToMilliseconds(options.batchDelay); return new DataLoader( async (entityRefs: readonly string[]) => { @@ -328,7 +368,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { return items; }, { - name: DefaultEntityPresentationApi.name, + name: 'DefaultEntityPresentationApi', // This cache is the one that the data loader uses internally for // memoizing requests; essentially what it achieves is that multiple // requests for the same entity ref will be batched up into a single @@ -345,4 +385,17 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { }, ); } + + #maybeFallbackIcon( + renderedIcon: IconComponent | false | undefined, + kind: string, + ): IconComponent | false | undefined { + if (renderedIcon) { + return renderedIcon; + } else if (renderedIcon === false) { + return false; + } + + return this.#kindIcons[kind.toLocaleLowerCase('en-US')]; + } } diff --git a/plugins/catalog/src/apis/EntityPresentationApi/util.ts b/plugins/catalog/src/apis/EntityPresentationApi/util.ts deleted file mode 100644 index e5dfe6bd7b..0000000000 --- a/plugins/catalog/src/apis/EntityPresentationApi/util.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * 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 { HumanDuration } from '@backstage/types'; - -export function durationToMs(duration: HumanDuration): number { - const { - years = 0, - months = 0, - weeks = 0, - days = 0, - hours = 0, - minutes = 0, - seconds = 0, - milliseconds = 0, - } = duration; - - const totalDays = years * 365 + months * 30 + weeks * 7 + days; - const totalHours = totalDays * 24 + hours; - const totalMinutes = totalHours * 60 + minutes; - const totalSeconds = totalMinutes * 60 + seconds; - const totalMilliseconds = totalSeconds * 1000 + milliseconds; - - return totalMilliseconds; -} From db8ede850be03153539cef78cae0a3413d3af74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 24 Oct 2023 09:18:54 +0200 Subject: [PATCH 12/12] splel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/apis/EntityPresentationApi/EntityPresentationApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index bb84c1b71a..945bce1d27 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -59,7 +59,7 @@ export interface EntityRefPresentationSnapshot { * entity holds. When rendering the primary title, you may also want to * make sure to add more contextual information nearby such as the icon or * secondary title, since the primary could for example just be the - * `metadata.name` of tne entity which might be ambiguous to the reader. + * `metadata.name` of the entity which might be ambiguous to the reader. */ primaryTitle: string; /**