diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index abbf348848..816eadbeaf 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -417,7 +417,7 @@ export interface EntityRefPresentation { // @public export interface EntityRefPresentationSnapshot { entityRef: string; - Icon?: IconComponent | undefined; + Icon?: IconComponent | undefined | false; primaryTitle: string; secondaryTitle?: string; } diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index 532fdde9a3..bb84c1b71a 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -84,8 +84,13 @@ export interface EntityRefPresentationSnapshot { * both in larger formats such as in a header, or in smaller formats such as * inline with regular text, so bear in mind that the legibility should be * high in both cases. + * + * A value of `false` here indicates the desire to not have an icon present + * for the given implementation. A value of `undefined` leaves it at the + * discretion of the display layer to choose what to do (such as for example + * showing a fallback icon). */ - Icon?: IconComponent | undefined; + Icon?: IconComponent | undefined | false; } /** diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 4b2c17865d..6647f8deaa 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -251,6 +251,7 @@ export interface DefaultEntityPresentationApiOptions { batchDelay?: HumanDuration; cacheTtl?: HumanDuration; catalogApi?: CatalogApi; + kindIcons?: Record; renderer?: DefaultEntityPresentationApiRenderer; } @@ -266,7 +267,7 @@ export interface DefaultEntityPresentationApiRenderer { defaultNamespace?: string; }; }) => { - snapshot: Omit; + snapshot: Omit; }; } diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts index 91e5b575ff..5f1119e598 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.ts @@ -14,23 +14,28 @@ * limitations under the License. */ -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { + Entity, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { IconComponent } from '@backstage/core-plugin-api'; import { CatalogApi, EntityPresentationApi, EntityRefPresentation, EntityRefPresentationSnapshot, } from '@backstage/plugin-catalog-react'; -import { HumanDuration } from '@backstage/types'; +import { HumanDuration, durationToMilliseconds } from '@backstage/types'; import DataLoader from 'dataloader'; import ExpiryMap from 'expiry-map'; import ObservableImpl from 'zen-observable'; import { DEFAULT_BATCH_DELAY, DEFAULT_CACHE_TTL, + DEFAULT_ICONS, createDefaultRenderer, } from './defaults'; -import { durationToMs } from './util'; /** * A custom renderer for the {@link DefaultEntityPresentationApi}. @@ -82,7 +87,7 @@ export interface DefaultEntityPresentationApiRenderer { defaultNamespace?: string; }; }) => { - snapshot: Omit; + snapshot: Omit; }; } @@ -123,6 +128,19 @@ export interface DefaultEntityPresentationApiOptions { */ batchDelay?: HumanDuration; + /** + * A mapping from kinds to icons. + * + * @remarks + * + * The keys are kinds (case insensitive) that map to icon values to represent + * kinds by. + * + * If you do not supply a set of icons here, a set of fallback icons will be + * used. If you supply the empty object, no fallback icons will be used. + */ + kindIcons?: Record; + /** * A custom renderer, if any. */ @@ -167,6 +185,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { readonly #cache: Map; readonly #cacheTtlMs: number; readonly #loader: DataLoader | undefined; + readonly #kindIcons: Record; // lowercased kinds readonly #renderer: DefaultEntityPresentationApiRenderer; private constructor(options: DefaultEntityPresentationApiOptions) { @@ -174,6 +193,13 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { const batchDelay = options.batchDelay ?? DEFAULT_BATCH_DELAY; const renderer = options.renderer ?? createDefaultRenderer({ async: true }); + const kindIcons: Record = {}; + Object.entries(options.kindIcons ?? DEFAULT_ICONS).forEach( + ([kind, icon]) => { + kindIcons[kind.toLocaleLowerCase('en-US')] = icon; + }, + ); + if (renderer.async) { if (!options.catalogApi) { throw new TypeError(`Asynchronous rendering requires a catalog API`); @@ -186,8 +212,9 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { }); } - this.#cacheTtlMs = durationToMs(cacheTtl); + this.#cacheTtlMs = durationToMilliseconds(cacheTtl); this.#cache = new Map(); + this.#kindIcons = kindIcons; this.#renderer = renderer; } @@ -199,25 +226,43 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { defaultNamespace?: string; }, ): EntityRefPresentation { - const { entityRef, entity, needsLoad } = + const { entityRef, kind, entity, needsLoad } = this.#getEntityForInitialRender(entityOrRef); - let rendered: Omit; - try { - const output = this.#renderer.render({ + // Make a wrapping helper for rendering + const render = (options: { + loading: boolean; + entity?: Entity; + }): EntityRefPresentationSnapshot => { + const { snapshot } = this.#renderer.render({ entityRef: entityRef, - loading: needsLoad, - entity: entity, + loading: options.loading, + entity: options.entity, context: context || {}, }); - rendered = output.snapshot; + return { + ...snapshot, + entityRef: entityRef, + Icon: this.#maybeFallbackIcon(snapshot.Icon, kind), + }; + }; + + // First the initial render + let initialSnapshot: EntityRefPresentationSnapshot; + try { + initialSnapshot = render({ + loading: needsLoad, + entity: entity, + }); } catch { // This is what gets presented if the renderer throws an error - rendered = { + initialSnapshot = { primaryTitle: entityRef, + entityRef: entityRef, }; } + // And then the following snapshot const observable = !needsLoad ? undefined : new ObservableImpl(subscriber => { @@ -231,16 +276,11 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { newEntity && newEntity.metadata.etag !== entity?.metadata.etag ) { - const output = this.#renderer.render({ - entityRef: entityRef, + const updatedSnapshot = render({ loading: false, entity: newEntity, - context: context || {}, - }); - subscriber.next({ - ...output.snapshot, - entityRef: entityRef, }); + subscriber.next(updatedSnapshot); } }) .catch(() => { @@ -261,16 +301,14 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { }); return { - snapshot: { - ...rendered, - entityRef: entityRef, - }, + snapshot: initialSnapshot, update$: observable, }; } #getEntityForInitialRender(entityOrRef: Entity | string): { entity: Entity | undefined; + kind: string; entityRef: string; needsLoad: boolean; } { @@ -281,6 +319,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { if (typeof entityOrRef !== 'string') { return { entity: entityOrRef, + kind: entityOrRef.kind, entityRef: stringifyEntityRef(entityOrRef), needsLoad: false, }; @@ -297,6 +336,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { return { entity: cachedEntity, + kind: parseEntityRef(entityOrRef).kind, entityRef: entityOrRef, needsLoad, }; @@ -308,8 +348,8 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { batchDelay: HumanDuration; renderer: DefaultEntityPresentationApiRenderer; }): DataLoader { - const cacheTtlMs = durationToMs(options.cacheTtl); - const batchDelayMs = durationToMs(options.batchDelay); + const cacheTtlMs = durationToMilliseconds(options.cacheTtl); + const batchDelayMs = durationToMilliseconds(options.batchDelay); return new DataLoader( async (entityRefs: readonly string[]) => { @@ -328,7 +368,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { return items; }, { - name: DefaultEntityPresentationApi.name, + name: 'DefaultEntityPresentationApi', // This cache is the one that the data loader uses internally for // memoizing requests; essentially what it achieves is that multiple // requests for the same entity ref will be batched up into a single @@ -345,4 +385,17 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi { }, ); } + + #maybeFallbackIcon( + renderedIcon: IconComponent | false | undefined, + kind: string, + ): IconComponent | false | undefined { + if (renderedIcon) { + return renderedIcon; + } else if (renderedIcon === false) { + return false; + } + + return this.#kindIcons[kind.toLocaleLowerCase('en-US')]; + } } diff --git a/plugins/catalog/src/apis/EntityPresentationApi/util.ts b/plugins/catalog/src/apis/EntityPresentationApi/util.ts deleted file mode 100644 index e5dfe6bd7b..0000000000 --- a/plugins/catalog/src/apis/EntityPresentationApi/util.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { HumanDuration } from '@backstage/types'; - -export function durationToMs(duration: HumanDuration): number { - const { - years = 0, - months = 0, - weeks = 0, - days = 0, - hours = 0, - minutes = 0, - seconds = 0, - milliseconds = 0, - } = duration; - - const totalDays = years * 365 + months * 30 + weeks * 7 + days; - const totalHours = totalDays * 24 + hours; - const totalMinutes = totalHours * 60 + minutes; - const totalSeconds = totalMinutes * 60 + seconds; - const totalMilliseconds = totalSeconds * 1000 + milliseconds; - - return totalMilliseconds; -}