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"