allow custom icons

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-10-23 21:37:39 +02:00
parent 3988751087
commit 1f06689380
5 changed files with 89 additions and 68 deletions
+1 -1
View File
@@ -417,7 +417,7 @@ export interface EntityRefPresentation {
// @public
export interface EntityRefPresentationSnapshot {
entityRef: string;
Icon?: IconComponent | undefined;
Icon?: IconComponent | undefined | false;
primaryTitle: string;
secondaryTitle?: string;
}
@@ -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;
}
/**
+2 -1
View File
@@ -251,6 +251,7 @@ export interface DefaultEntityPresentationApiOptions {
batchDelay?: HumanDuration;
cacheTtl?: HumanDuration;
catalogApi?: CatalogApi;
kindIcons?: Record<string, IconComponent>;
renderer?: DefaultEntityPresentationApiRenderer;
}
@@ -266,7 +267,7 @@ export interface DefaultEntityPresentationApiRenderer {
defaultNamespace?: string;
};
}) => {
snapshot: Omit<EntityRefPresentationSnapshot, 'entityRef' | 'entity'>;
snapshot: Omit<EntityRefPresentationSnapshot, 'entityRef'>;
};
}
@@ -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<EntityRefPresentationSnapshot, 'entityRef' | 'entity'>;
snapshot: Omit<EntityRefPresentationSnapshot, 'entityRef'>;
};
}
@@ -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<string, IconComponent>;
/**
* A custom renderer, if any.
*/
@@ -167,6 +185,7 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi {
readonly #cache: Map<string, CacheEntry>;
readonly #cacheTtlMs: number;
readonly #loader: DataLoader<string, Entity | undefined> | undefined;
readonly #kindIcons: Record<string, IconComponent>; // 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<string, IconComponent> = {};
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<EntityRefPresentationSnapshot, 'entityRef' | 'entity'>;
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<EntityRefPresentationSnapshot>(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<string, Entity | undefined> {
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')];
}
}
@@ -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;
}