diff --git a/.changeset/heavy-experts-accept.md b/.changeset/heavy-experts-accept.md new file mode 100644 index 0000000000..e6f2f435b8 --- /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 `EntityDisplayName` 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 789fc2bfdc..1fa40e2d0c 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -506,10 +506,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 7422440955..e2000b5658 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'; @@ -11,6 +13,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 +87,7 @@ export const CatalogFilterLayout: { // @public (undocumented) export type CatalogReactComponentsNameToClassKey = { CatalogReactUserListPicker: CatalogReactUserListPickerClassKey; + CatalogReactEntityDisplayName: CatalogReactEntityDisplayNameClassKey; CatalogReactEntityLifecyclePicker: CatalogReactEntityLifecyclePickerClassKey; CatalogReactEntitySearchBar: CatalogReactEntitySearchBarClassKey; CatalogReactEntityTagPicker: CatalogReactEntityTagPickerClassKey; @@ -91,6 +95,9 @@ export type CatalogReactComponentsNameToClassKey = { CatalogReactEntityProcessingStatusPicker: CatalogReactEntityProcessingStatusPickerClassKey; }; +// @public +export type CatalogReactEntityDisplayNameClassKey = 'root' | 'icon'; + // @public (undocumented) export type CatalogReactEntityLifecyclePickerClassKey = 'input'; @@ -152,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, @@ -174,6 +190,18 @@ export type EntityAutocompletePickerProps< initialSelectedOptions?: string[]; }; +// @public +export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; + +// @public +export type EntityDisplayNameProps = { + entityRef: Entity | CompoundEntityRef | string; + noIcon?: boolean; + noTooltip?: boolean; + defaultKind?: string; + defaultNamespace?: string; +}; + // @public export class EntityErrorFilter implements EntityFilter { constructor(value: boolean); @@ -331,6 +359,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; @@ -354,6 +396,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; @@ -366,21 +409,26 @@ 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 { + entityRef: string; + Icon?: IconComponent | undefined | false; + primaryTitle: string; + secondaryTitle?: string; +} // @public export function entityRouteParams(entity: Entity): { @@ -628,6 +676,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..945bce1d27 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -0,0 +1,141 @@ +/* + * 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: 'plugin.catalog.entity-presentation', + }); + +/** + * The visual presentation of an entity reference at some point in time. + * + * @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; + /** + * 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 the 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. + * + * 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 | false; +} + +/** + * 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..ea755dfebc --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.test.ts @@ -0,0 +1,260 @@ +/* + * 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + entityRef: 'component:default/test', + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation('component:default/test', { + defaultKind: 'X', + }), + ).toEqual({ + entityRef: 'component:default/test', + primaryTitle: 'component:test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation('component:default/test', { + defaultNamespace: 'X', + }), + ).toEqual({ + 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({ + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }); + + expect(defaultEntityPresentation('name')).toEqual({ + 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({ + entityRef: 'component:default/test', + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect( + defaultEntityPresentation( + { kind: 'component', namespace: 'default', name: 'test' }, + { + defaultKind: 'X', + }, + ), + ).toEqual({ + 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({ + 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({ + entityRef: 'component:default/test', + primaryTitle: 'default/test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }); + + expect(defaultEntityPresentation('')).toEqual({ + 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({ + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }); + + expect(defaultEntityPresentation(undefined as unknown as Entity)).toEqual( + { + entityRef: 'unknown:default/unknown', + primaryTitle: 'unknown', + secondaryTitle: 'unknown:default/unknown', + Icon: expect.anything(), + }, + ); + + expect( + defaultEntityPresentation(Symbol.for('Prince') as unknown as Entity), + ).toEqual({ + 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..eacb659062 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts @@ -0,0 +1,197 @@ +/* + * 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 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].find( + candidate => candidate && typeof candidate === 'string', + )!; + + const secondary = [ + primary !== entityRef ? entityRef : undefined, + type, + description, + ] + .filter(candidate => candidate && typeof candidate === 'string') + .join(' | '); + + return { + 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')].find( + candidate => candidate && typeof candidate === 'string', + ); + + const namespace = [ + get(entityOrRef, 'metadata.namespace'), + get(entityOrRef, 'namespace'), + ].find(candidate => candidate && typeof candidate === 'string'); + + const name = [ + get(entityOrRef, 'metadata.name'), + get(entityOrRef, 'name'), + ].find(candidate => candidate && typeof candidate === 'string'); + + const title = [get(entityOrRef, 'metadata.title')].find( + candidate => candidate && typeof candidate === 'string', + ); + + const description = [get(entityOrRef, 'metadata.description')].find( + candidate => candidate && typeof candidate === 'string', + ); + + const displayName = [get(entityOrRef, 'spec.profile.displayName')].find( + candidate => candidate && typeof candidate === 'string', + ); + + const type = [get(entityOrRef, 'spec.type')].find( + candidate => candidate && typeof candidate === 'string', + ); + + 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 || 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 ( + (defaultNamespaceLower && + namespace.toLocaleLowerCase('en-US') !== defaultNamespaceLower) || + namespace !== DEFAULT_NAMESPACE + ) { + result = `${namespace}/${result}`; + } + + if ( + defaultKindLower && + kind.toLocaleLowerCase('en-US') !== defaultKindLower + ) { + 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..a15119d177 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/useUpdatingObservable.ts @@ -0,0 +1,60 @@ +/* + * 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, 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, setSnapshot] = useState(value); + + useEffect(() => { + setSnapshot(value); + + const subscription = observable?.subscribe({ + next: updatedValue => { + setSnapshot(updatedValue); + }, + complete: () => { + subscription?.unsubscribe(); + }, + }); + + return () => { + subscription?.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return snapshot; +} 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..6bdbe83a84 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx @@ -0,0 +1,99 @@ +/* + * 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; + noIcon?: boolean; + noTooltip?: boolean; + defaultKind?: string; + defaultNamespace?: string; +}; + +/** + * Shows a nice representation of a reference to an entity. + * + * @public + */ +export const EntityDisplayName = ( + props: EntityDisplayNameProps, +): JSX.Element => { + const { entityRef, noIcon, noTooltip, 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 && !noIcon ? ( + + + + ) : null} + + ); + + // Optionally, a tooltip as the outermost layer + if (secondaryTitle && !noTooltip) { + 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..c1a37b9682 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('software').closest('a')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -65,7 +65,7 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:test/software')).toHaveAttribute( + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -93,7 +93,7 @@ describe('', () => { }, }, ); - expect(screen.getByText('test/software')).toHaveAttribute( + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -110,7 +110,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', ); @@ -127,7 +127,7 @@ describe('', () => { '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, }); - expect(screen.getByText('component:test/software')).toHaveAttribute( + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -147,7 +147,7 @@ describe('', () => { }, }, ); - expect(screen.getByText('test/software')).toHaveAttribute( + expect(screen.getByText('test/software').closest('a')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -169,7 +169,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/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/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..6647f8deaa 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; + kindIcons?: Record; + renderer?: DefaultEntityPresentationApiRenderer; +} + +// @public +export interface DefaultEntityPresentationApiRenderer { + async?: boolean; + 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 9b879e3c50..5ddf22d1f1 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -64,6 +64,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..42ff777cbd --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts @@ -0,0 +1,178 @@ +/* + * 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', + 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', + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: expect.anything(), + }, + { + entityRef: 'component:default/test', + 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', + 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..5f1119e598 --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts @@ -0,0 +1,401 @@ +/* + * 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, + 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, 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'; + +/** + * A custom renderer for the {@link DefaultEntityPresentationApi}. + * + * @public + */ +export interface DefaultEntityPresentationApiRenderer { + /** + * 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 10 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 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. + */ + 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 #kindIcons: Record; // lowercased kinds + 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 }); + + 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`); + } + this.#loader = this.#createLoader({ + cacheTtl, + batchDelay, + renderer, + catalogApi: options.catalogApi, + }); + } + + this.#cacheTtlMs = durationToMilliseconds(cacheTtl); + this.#cache = new Map(); + this.#kindIcons = kindIcons; + this.#renderer = renderer; + } + + /** {@inheritdoc @backstage/plugin-catalog-react#EntityPresentationApi.forEntity} */ + forEntity( + entityOrRef: Entity | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, + ): EntityRefPresentation { + const { entityRef, kind, entity, needsLoad } = + this.#getEntityForInitialRender(entityOrRef); + + // Make a wrapping helper for rendering + const render = (options: { + loading: boolean; + entity?: Entity; + }): EntityRefPresentationSnapshot => { + const { snapshot } = this.#renderer.render({ + entityRef: entityRef, + loading: options.loading, + entity: options.entity, + context: context || {}, + }); + 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 + initialSnapshot = { + primaryTitle: entityRef, + entityRef: entityRef, + }; + } + + // And then the following snapshot + 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 updatedSnapshot = render({ + loading: false, + entity: newEntity, + }); + subscriber.next(updatedSnapshot); + } + }) + .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: initialSnapshot, + update$: observable, + }; + } + + #getEntityForInitialRender(entityOrRef: Entity | string): { + entity: Entity | undefined; + kind: string; + 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, + kind: entityOrRef.kind, + 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, + kind: parseEntityRef(entityOrRef).kind, + entityRef: entityOrRef, + needsLoad, + }; + } + + #createLoader(options: { + catalogApi: CatalogApi; + cacheTtl: HumanDuration; + batchDelay: HumanDuration; + renderer: DefaultEntityPresentationApiRenderer; + }): DataLoader { + const cacheTtlMs = durationToMilliseconds(options.cacheTtl); + const batchDelayMs = durationToMilliseconds(options.batchDelay); + + return new DataLoader( + async (entityRefs: readonly string[]) => { + const { items } = await options.catalogApi!.getEntitiesByRefs({ + entityRefs: entityRefs as string[], + }); + + const now = Date.now(); + entityRefs.forEach((entityRef, index) => { + this.#cache.set(entityRef, { + updatedAt: now, + entity: items[index], + }); + }); + + return items; + }, + { + 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 + // 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, + }, + ); + } + + #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/defaults.tsx b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx new file mode 100644 index 0000000000..afcd1f5464 --- /dev/null +++ b/plugins/catalog/src/apis/EntityPresentationApi/defaults.tsx @@ -0,0 +1,65 @@ +/* + * 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: 10 }; + +export const DEFAULT_BATCH_DELAY: HumanDuration = { milliseconds: 50 }; + +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/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 9505dad889..adad5d22af 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,7 @@ 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/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 df1fabab0a..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:default/test-ownership')).toBeInTheDocument(); - expect(screen.getByText('foo:bar/foobar')).toBeInTheDocument(); + expect(screen.getByText('test-ownership')).toBeInTheDocument(); + expect(screen.getByText('bar/foobar')).toBeInTheDocument(); }); }); 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} - /> + diff --git a/yarn.lock b/yarn.lock index 146f399b59..ccbafcc1bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5974,6 +5974,8 @@ __metadata: "@testing-library/react": ^14.0.0 "@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 @@ -27347,6 +27349,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" @@ -33975,6 +33986,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" @@ -36567,6 +36587,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"