From 5f9a531412b3370b7322069283275305b4b4b3cd Mon Sep 17 00:00:00 2001 From: Marat Dyatko Date: Wed, 25 Mar 2026 16:15:27 +0100 Subject: [PATCH 1/6] Replace deprecated humanizeEntityRef with Catalog Presentation API Migrate all humanizeEntityRef and humanizeEntity usages to the Catalog Presentation API across catalog, catalog-react, org-react, catalog-import, scaffolder, and techdocs plugins. - Use useEntityPresentation hook in React component contexts (AncestryPage) - Use defaultEntityPresentation for non-React contexts like sort comparators, filter functions, and data mappers - Add @deprecated tags to humanizeEntityRef and humanizeEntity - Improve TSDoc on entityPresentationApiRef, EntityPresentationApi, useEntityPresentation, EntityDisplayName, and defaultEntityPresentation with guidance on which to use when - Add Entity Presentation docs page with usage examples and migration guide Made-with: Cursor Signed-off-by: Marat Dyatko --- ...lace-humanize-entity-ref-catalog-import.md | 5 + ...place-humanize-entity-ref-catalog-react.md | 5 + .../replace-humanize-entity-ref-catalog.md | 5 + .../replace-humanize-entity-ref-org-react.md | 5 + .../replace-humanize-entity-ref-scaffolder.md | 5 + .../replace-humanize-entity-ref-techdocs.md | 5 + .../software-catalog/entity-presentation.md | 117 ++++++++++++++++++ mkdocs.yml | 1 + .../StepPrepareCreatePullRequest.tsx | 7 +- plugins/catalog-react/report.api.md | 2 +- .../EntityPresentationApi.ts | 30 ++++- .../defaultEntityPresentation.ts | 15 ++- .../useEntityPresentation.ts | 13 ++ .../EntityDataTable/columnFactories.tsx | 18 +-- .../EntityDisplayName/EntityDisplayName.tsx | 10 ++ .../EntityOwnerPicker/EntityOwnerPicker.tsx | 4 +- .../src/components/EntityRefLink/humanize.ts | 8 ++ .../src/components/EntityTable/columns.tsx | 16 +-- .../components/AncestryPage.tsx | 12 +- .../components/CatalogTable/CatalogTable.tsx | 26 ++-- .../src/components/CatalogTable/columns.tsx | 11 +- .../GroupListPicker/GroupListPicker.tsx | 8 +- .../TemplateFormPreviewer.tsx | 8 +- .../src/home/components/Tables/helpers.ts | 8 +- 24 files changed, 275 insertions(+), 69 deletions(-) create mode 100644 .changeset/replace-humanize-entity-ref-catalog-import.md create mode 100644 .changeset/replace-humanize-entity-ref-catalog-react.md create mode 100644 .changeset/replace-humanize-entity-ref-catalog.md create mode 100644 .changeset/replace-humanize-entity-ref-org-react.md create mode 100644 .changeset/replace-humanize-entity-ref-scaffolder.md create mode 100644 .changeset/replace-humanize-entity-ref-techdocs.md create mode 100644 docs/features/software-catalog/entity-presentation.md diff --git a/.changeset/replace-humanize-entity-ref-catalog-import.md b/.changeset/replace-humanize-entity-ref-catalog-import.md new file mode 100644 index 0000000000..544e91bac3 --- /dev/null +++ b/.changeset/replace-humanize-entity-ref-catalog-import.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `StepPrepareCreatePullRequest`. diff --git a/.changeset/replace-humanize-entity-ref-catalog-react.md b/.changeset/replace-humanize-entity-ref-catalog-react.md new file mode 100644 index 0000000000..b44e796624 --- /dev/null +++ b/.changeset/replace-humanize-entity-ref-catalog-react.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Replaced `humanizeEntityRef` with `defaultEntityPresentation` and `useEntityPresentation` from the Catalog Presentation API in `EntityOwnerPicker`, `EntityTable`, `EntityDataTable`, and `AncestryPage` components. diff --git a/.changeset/replace-humanize-entity-ref-catalog.md b/.changeset/replace-humanize-entity-ref-catalog.md new file mode 100644 index 0000000000..2a27c87f47 --- /dev/null +++ b/.changeset/replace-humanize-entity-ref-catalog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `CatalogTable` and its column factories. diff --git a/.changeset/replace-humanize-entity-ref-org-react.md b/.changeset/replace-humanize-entity-ref-org-react.md new file mode 100644 index 0000000000..afb04d3348 --- /dev/null +++ b/.changeset/replace-humanize-entity-ref-org-react.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org-react': patch +--- + +Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `GroupListPicker`. diff --git a/.changeset/replace-humanize-entity-ref-scaffolder.md b/.changeset/replace-humanize-entity-ref-scaffolder.md new file mode 100644 index 0000000000..5064875451 --- /dev/null +++ b/.changeset/replace-humanize-entity-ref-scaffolder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `TemplateFormPreviewer`. diff --git a/.changeset/replace-humanize-entity-ref-techdocs.md b/.changeset/replace-humanize-entity-ref-techdocs.md new file mode 100644 index 0000000000..374a0c2e8e --- /dev/null +++ b/.changeset/replace-humanize-entity-ref-techdocs.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in TechDocs table helpers. diff --git a/docs/features/software-catalog/entity-presentation.md b/docs/features/software-catalog/entity-presentation.md new file mode 100644 index 0000000000..5d65baac07 --- /dev/null +++ b/docs/features/software-catalog/entity-presentation.md @@ -0,0 +1,117 @@ +--- +id: entity-presentation +title: Entity Presentation +description: How to display entity names and control how entities are represented in the Backstage interface +--- + +The _Entity Presentation API_ controls how catalog entities are displayed +throughout the Backstage interface. Instead of rendering raw entity refs like +`component:default/my-service`, the API resolves a human-friendly display +name from fields such as `metadata.title` and `spec.profile.displayName`. + +## Displaying entity names + +There are three ways to display entity names, depending on context: + +### `EntityDisplayName` component + +The simplest option for React components. Renders a styled entity name with +an optional icon and tooltip: + +```tsx +import { EntityDisplayName } from '@backstage/plugin-catalog-react'; + +; +``` + +You can pass an entity ref string, an `Entity` object, or a +`CompoundEntityRef`. The component supports optional `hideIcon` and +`disableTooltip` props. + +### `useEntityPresentation` hook + +Use this hook when you need access to the raw presentation data in a React +component, for example to render the title in a custom layout: + +```tsx +import { useEntityPresentation } from '@backstage/plugin-catalog-react'; + +function MyComponent({ entityRef }: { entityRef: string }) { + const { primaryTitle, secondaryTitle, Icon } = + useEntityPresentation(entityRef); + + return ( + + {Icon && } + {primaryTitle} + + ); +} +``` + +The hook subscribes to the `EntityPresentationApi` and returns a snapshot +that may update over time as additional data is fetched in the background. +If no presentation API is registered, it falls back to +`defaultEntityPresentation`. + +### `defaultEntityPresentation` function + +A synchronous helper for non-React contexts where hooks are not available. +Use it in sort comparators, filter functions, table column factories, and +data mappers: + +```ts +import { defaultEntityPresentation } from '@backstage/plugin-catalog-react'; + +const title = defaultEntityPresentation(entity, { + defaultKind: 'Component', +}).primaryTitle; +``` + +This resolves `primaryTitle` as the first available value among +`spec.profile.displayName`, `metadata.title`, and a shortened entity ref. + +## Customizing entity presentation + +To customize how entities are rendered, provide your own implementation of +the `EntityPresentationApi` interface and register it with the app's API +factory: + +```ts +import { + entityPresentationApiRef, + type EntityPresentationApi, +} from '@backstage/plugin-catalog-react'; +import { createApiFactory } from '@backstage/core-plugin-api'; + +const myPresentationApi: EntityPresentationApi = { + forEntity(entityOrRef, context) { + // Return an EntityRefPresentation with snapshot, update$, and promise + }, +}; + +createApiFactory({ + api: entityPresentationApiRef, + deps: {}, + factory: () => myPresentationApi, +}); +``` + +The presentation snapshot includes `primaryTitle`, an optional +`secondaryTitle` for tooltips, and an optional `Icon` component. You can +also emit updated snapshots over time via the `update$` observable. + +## Migrating from `humanizeEntityRef` + +The `humanizeEntityRef` and `humanizeEntity` functions are deprecated. They +only produce a shortened entity ref string and do not resolve display names +from `metadata.title` or `spec.profile.displayName`. + +Replace them as follows: + +| Old code | Replacement | +| :------------------------------------------------------------------ | :---------------------------------------------------------------- | +| `humanizeEntityRef(entity)` in JSX | `` | +| `humanizeEntityRef(entity)` in a hook-accessible context | `useEntityPresentation(entity).primaryTitle` | +| `humanizeEntityRef(entity, { defaultKind })` in a sort/filter/label | `defaultEntityPresentation(entity, { defaultKind }).primaryTitle` | +| `humanizeEntity(entity, fallback)` | `defaultEntityPresentation(entity).primaryTitle` | diff --git a/mkdocs.yml b/mkdocs.yml index e01ab2a431..ef0b2bae91 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' - Catalog Customization: 'features/software-catalog/catalog-customization.md' + - Entity Presentation: 'features/software-catalog/entity-presentation.md' - API: 'features/software-catalog/api.md' - FAQ: 'features/software-catalog/faq.md' - Kubernetes: diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx index e42de5257e..3cb9fbbd39 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx @@ -20,7 +20,7 @@ import { assertError } from '@backstage/errors'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { catalogApiRef, - humanizeEntityRef, + defaultEntityPresentation, } from '@backstage/plugin-catalog-react'; import Box from '@material-ui/core/Box'; import FormHelperText from '@material-ui/core/FormHelperText'; @@ -162,7 +162,10 @@ export const StepPrepareCreatePullRequest = ( }); return groupEntities.items - .map(e => humanizeEntityRef(e, { defaultKind: 'group' })) + .map( + e => + defaultEntityPresentation(e, { defaultKind: 'group' }).primaryTitle, + ) .sort(); }); diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index f7f195c226..bb9d43edb0 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -834,7 +834,7 @@ export function getEntitySourceLocation( scmIntegrationsApi: typeof scmIntegrationsApiRef.T, ): EntitySourceLocation | undefined; -// @public (undocumented) +// @public @deprecated (undocumented) export function humanizeEntityRef( entityRef: Entity | CompoundEntityRef, opts?: { diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index 642dd6dbac..e293d1e0ab 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -25,6 +25,21 @@ import { Observable } from '@backstage/types'; /** * An API that handles how to represent entities in the interface. * + * @remarks + * + * There are several ways to consume this API depending on context: + * + * - In React components, use the {@link useEntityPresentation} hook to get a + * reactive presentation snapshot that updates over time. + * + * - For simple inline rendering, use the {@link EntityDisplayName} component + * which wraps the hook and renders a styled entity name with optional icon + * and tooltip. + * + * - In non-React contexts such as sort comparators, filter functions, or data + * mappers, use the {@link defaultEntityPresentation} function which + * synchronously extracts a display name from an already-loaded entity. + * * @public */ export const entityPresentationApiRef: ApiRef = @@ -120,8 +135,19 @@ export interface EntityRefPresentation { * * @remarks * - * Most consumers will want to use the {@link useEntityPresentation} hook - * instead of this interface directly. + * Most consumers will not need to interact with this interface directly. + * Instead, use one of the following: + * + * - {@link useEntityPresentation} — React hook for reactive presentation data. + * + * - {@link EntityDisplayName} — React component that renders an entity name + * with optional icon and tooltip. + * + * - {@link defaultEntityPresentation} — synchronous helper for non-React + * contexts where you already have the entity object. + * + * Implement this interface to customize how entities are displayed throughout + * the Backstage interface. * * @public */ diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts index b546f9aa93..9c1cbd5325 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts @@ -24,7 +24,20 @@ import get from 'lodash/get'; import { EntityRefPresentationSnapshot } from './EntityPresentationApi'; /** - * This returns the default representation of an entity. + * Returns the default representation of an entity. + * + * @remarks + * + * This is a synchronous helper that extracts a display name from an + * already-loaded entity or entity ref. It resolves `primaryTitle` as the + * first available value among `spec.profile.displayName`, `metadata.title`, + * and a shortened entity ref string. + * + * Use this in non-React contexts where hooks are not available, such as sort + * comparators, filter functions, table column factories, and data mappers. + * In React components, prefer the {@link useEntityPresentation} hook or the + * {@link EntityDisplayName} component, which support async enrichment via + * the {@link EntityPresentationApi}. * * @public * @param entityOrRef - Either an entity, or a ref to it. diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts index e397558ed0..2e01485dcc 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts @@ -32,6 +32,19 @@ import { useUpdatingObservable } from './useUpdatingObservable'; /** * Returns information about how to represent an entity in the interface. * + * @remarks + * + * This hook subscribes to the {@link EntityPresentationApi} and returns a + * snapshot that may update over time as richer data is fetched (for example, + * resolving `metadata.title` from a string entity ref). If no presentation + * API is registered, it falls back to {@link defaultEntityPresentation}. + * + * For simple inline rendering, consider using the {@link EntityDisplayName} + * component instead, which wraps this hook with icon and tooltip support. + * + * For non-React contexts such as sort comparators or data mappers, use + * {@link defaultEntityPresentation} directly. + * * @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 diff --git a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx index 49fb64caa8..647e7c7f3b 100644 --- a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx +++ b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx @@ -20,11 +20,8 @@ import { RELATION_PART_OF, } from '@backstage/catalog-model'; import { Cell, CellText, Column, ColumnConfig, TableItem } from '@backstage/ui'; -import { - EntityRefLink, - EntityRefLinks, - humanizeEntityRef, -} from '../EntityRefLink'; +import { EntityRefLink, EntityRefLinks } from '../EntityRefLink'; +import { defaultEntityPresentation } from '../../apis'; import { EntityTableColumnTitle } from '../EntityTable/TitleColumn'; import { getEntityRelations } from '../../utils'; @@ -63,8 +60,8 @@ export const columnFactories = Object.freeze({ ), sortValue: entity => - entity.metadata?.title || - humanizeEntityRef(entity, { defaultKind: options.defaultKind }), + defaultEntityPresentation(entity, { defaultKind: options.defaultKind }) + .primaryTitle, }; }, @@ -98,7 +95,12 @@ export const columnFactories = Object.freeze({ ), sortValue: entity => getEntityRelations(entity, options.relation, options.filter) - .map(r => humanizeEntityRef(r, { defaultKind: options.defaultKind })) + .map( + r => + defaultEntityPresentation(r, { + defaultKind: options.defaultKind, + }).primaryTitle, + ) .join(', '), }; }, diff --git a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx index a4134df465..6ed863f180 100644 --- a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx @@ -62,6 +62,16 @@ export type EntityDisplayNameProps = { /** * Shows a nice representation of a reference to an entity. * + * @remarks + * + * This component uses the {@link useEntityPresentation} hook internally and + * renders the entity's primary title with optional icon and tooltip. It is + * the simplest way to display an entity name in JSX. + * + * For more control over the presentation data, use the + * {@link useEntityPresentation} hook directly. For non-React contexts, use + * {@link defaultEntityPresentation}. + * * @public */ export const EntityDisplayName = ( diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index ed200c77c4..16e00e5ccd 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -33,7 +33,7 @@ import { EntityOwnerFilter } from '../../filters'; import { useDebouncedEffect } from '@react-hookz/web'; import PersonIcon from '@material-ui/icons/Person'; import GroupIcon from '@material-ui/icons/Group'; -import { humanizeEntity, humanizeEntityRef } from '../EntityRefLink/humanize'; +import { defaultEntityPresentation } from '../../apis'; import { useFetchEntities } from './useFetchEntities'; import { withStyles } from '@material-ui/core/styles'; import { useEntityPresentation } from '../../apis'; @@ -203,7 +203,7 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { defaultNamespace: 'default', }) : o; - return humanizeEntity(entity, humanizeEntityRef(entity)); + return defaultEntityPresentation(entity).primaryTitle; }} onChange={(_: object, owners) => { setText(''); diff --git a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts index 478f0dce9d..ae1fb5e685 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts @@ -25,6 +25,11 @@ import get from 'lodash/get'; * @param defaultNamespace - if set to false then namespace is never omitted, * if set to string which matches namespace of entity then omitted * + * @deprecated Use {@link defaultEntityPresentation} for non-React contexts, + * or {@link useEntityPresentation} / {@link EntityDisplayName} in React + * components. These provide richer display names using `metadata.title` and + * `spec.profile.displayName` in addition to the entity ref. + * * @public **/ export function humanizeEntityRef( @@ -76,6 +81,9 @@ export function humanizeEntityRef( * * If neither of those are found or populated, fallback to `defaultName`. * + * @deprecated Use {@link defaultEntityPresentation} instead, which provides + * the same resolution logic via `primaryTitle`. + * * @param entity - Entity to convert. * @param defaultName - If entity readable name is not available, `defaultName` will be returned. * @returns Readable name, defaults to `defaultName`. diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx index 1bea56dc27..6b47fa02ff 100644 --- a/plugins/catalog-react/src/components/EntityTable/columns.tsx +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -22,11 +22,8 @@ import { } from '@backstage/catalog-model'; import { OverflowTooltip, TableColumn } from '@backstage/core-components'; import { getEntityRelations } from '../../utils'; -import { - EntityRefLink, - EntityRefLinks, - humanizeEntityRef, -} from '../EntityRefLink'; +import { EntityRefLink, EntityRefLinks } from '../EntityRefLink'; +import { defaultEntityPresentation } from '../../apis'; import { EntityTableColumnTitle } from './TitleColumn'; /** @public */ @@ -36,12 +33,7 @@ export const columnFactories = Object.freeze({ }): TableColumn { const { defaultKind } = options; function formatContent(entity: T): string { - return ( - entity.metadata?.title || - humanizeEntityRef(entity, { - defaultKind, - }) - ); + return defaultEntityPresentation(entity, { defaultKind }).primaryTitle; } return { @@ -84,7 +76,7 @@ export const columnFactories = Object.freeze({ function formatContent(entity: T): string { return getRelations(entity) - .map(r => humanizeEntityRef(r, { defaultKind })) + .map(r => defaultEntityPresentation(r, { defaultKind }).primaryTitle) .join(', '); } diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx index 771eb7b279..ec7c0dd54f 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx @@ -35,8 +35,8 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import useAsync from 'react-use/esm/useAsync'; import { catalogApiRef } from '../../../api'; -import { humanizeEntityRef } from '../../EntityRefLink'; import { entityRouteRef } from '../../../routes'; +import { useEntityPresentation } from '../../../apis'; import { EntityKindIcon } from './EntityKindIcon'; import { catalogReactTranslationRef } from '../../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; @@ -137,15 +137,7 @@ function CustomNode({ node }: DependencyGraphTypes.RenderNodeProps) { const paddedWidth = paddedIconWidth + width + padding * 2; const paddedHeight = height + padding * 2; - const displayTitle = - node.metadata.title || - (node.kind && node.metadata.name && node.metadata.namespace - ? humanizeEntityRef({ - kind: node.kind, - name: node.metadata.name, - namespace: node.metadata.namespace || '', - }) - : node.id); + const { primaryTitle: displayTitle } = useEntityPresentation(node); const onClick = () => { navigate( diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 80b37002c5..200505f5be 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -29,8 +29,8 @@ import { WarningPanel, } from '@backstage/core-components'; import { + defaultEntityPresentation, getEntityRelations, - humanizeEntityRef, useEntityList, useStarredEntities, } from '@backstage/plugin-catalog-react'; @@ -71,10 +71,8 @@ export interface CatalogTableProps { const refCompare = (a: Entity, b: Entity) => { const toRef = (entity: Entity) => - entity.metadata.title || - humanizeEntityRef(entity, { - defaultKind: 'Component', - }); + defaultEntityPresentation(entity, { defaultKind: 'Component' }) + .primaryTitle; return toRef(a).localeCompare(toRef(b)); }; @@ -292,19 +290,21 @@ function toEntityRow(entity: Entity) { // 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', - }), + name: defaultEntityPresentation(entity, { defaultKind: 'Component' }) + .primaryTitle, entityRef: stringifyEntityRef(entity), ownedByRelationsTitle: ownedByRelations - .map(r => humanizeEntityRef(r, { defaultKind: 'group' })) + .map( + r => + defaultEntityPresentation(r, { defaultKind: 'group' }).primaryTitle, + ) .join(', '), ownedByRelations, partOfSystemRelationTitle: partOfSystemRelations - .map(r => - humanizeEntityRef(r, { - defaultKind: 'system', - }), + .map( + r => + defaultEntityPresentation(r, { defaultKind: 'system' }) + .primaryTitle, ) .join(', '), partOfSystemRelations, diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index d2216149cd..8a18b301e6 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { - humanizeEntityRef, + defaultEntityPresentation, EntityRefLink, EntityRefLinks, } from '@backstage/plugin-catalog-react'; @@ -33,12 +33,9 @@ export const columnFactories = Object.freeze({ defaultKind?: string; }): TableColumn { function formatContent(entity: Entity): string { - return ( - entity.metadata?.title || - humanizeEntityRef(entity, { - defaultKind: options?.defaultKind, - }) - ); + return defaultEntityPresentation(entity, { + defaultKind: options?.defaultKind, + }).primaryTitle; } return { diff --git a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx index 12dc69073b..b836d30aff 100644 --- a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx +++ b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx @@ -17,7 +17,7 @@ import { MouseEvent, useState, useCallback } from 'react'; import { catalogApiRef, - humanizeEntityRef, + defaultEntityPresentation, } from '@backstage/plugin-catalog-react'; import TextField from '@material-ui/core/TextField'; import Autocomplete from '@material-ui/lab/Autocomplete'; @@ -25,7 +25,7 @@ import useAsync from 'react-use/esm/useAsync'; import Popover from '@material-ui/core/Popover'; import { useApi } from '@backstage/core-plugin-api'; import { ResponseErrorPanel } from '@backstage/core-components'; -import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { GroupEntity } from '@backstage/catalog-model'; import { GroupListPickerButton } from './GroupListPickerButton'; /** @@ -85,8 +85,6 @@ export const GroupListPicker = (props: GroupListPickerProps) => { return ; } - const getHumanEntityRef = (entity: Entity) => humanizeEntityRef(entity); - return ( <> { options={groups ?? []} groupBy={option => option.spec.type} getOptionLabel={option => - option.spec.profile?.displayName ?? getHumanEntityRef(option) + defaultEntityPresentation(option).primaryTitle } inputValue={inputValue} onInputChange={(_, value) => setInputValue(value)} diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx index cb40ff2766..d2db8c4a62 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -24,7 +24,7 @@ import { makeStyles } from '@material-ui/core/styles'; import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { catalogApiRef, - humanizeEntityRef, + defaultEntityPresentation, } from '@backstage/plugin-catalog-react'; import { LayoutOptions, @@ -169,9 +169,9 @@ export const TemplateFormPreviewer = ({ .then(({ items }) => setTemplateOptions( items.map(template => ({ - label: - template.metadata.title ?? - humanizeEntityRef(template, { defaultKind: 'template' }), + label: defaultEntityPresentation(template, { + defaultKind: 'template', + }).primaryTitle, value: template, })), ), diff --git a/plugins/techdocs/src/home/components/Tables/helpers.ts b/plugins/techdocs/src/home/components/Tables/helpers.ts index 4c6383c35e..5c972b0233 100644 --- a/plugins/techdocs/src/home/components/Tables/helpers.ts +++ b/plugins/techdocs/src/home/components/Tables/helpers.ts @@ -16,8 +16,8 @@ import { RELATION_OWNED_BY, Entity } from '@backstage/catalog-model'; import { + defaultEntityPresentation, getEntityRelations, - humanizeEntityRef, } from '@backstage/plugin-catalog-react'; import { toLowerMaybe } from '../../../helpers'; import { ConfigApi, RouteFunc } from '@backstage/core-plugin-api'; @@ -48,7 +48,11 @@ export function entitiesToDocsMapper( }), ownedByRelations, ownedByRelationsTitle: ownedByRelations - .map(r => humanizeEntityRef(r, { defaultKind: 'group' })) + .map( + r => + defaultEntityPresentation(r, { defaultKind: 'group' }) + .primaryTitle, + ) .join(', '), }, }; From e5af44c846b4c5082739f4b266ea6f58350b75b6 Mon Sep 17 00:00:00 2001 From: Marat Dyatko Date: Fri, 27 Mar 2026 13:57:16 +0100 Subject: [PATCH 2/6] Address PR review feedback from freben - Consolidate changesets: one for catalog-react (deprecation), one combined for the remaining 5 plugins - Add entity-presentation to microsite/sidebars.ts - Update @deprecated tags to point to useEntityPresentation / entityPresentationApiRef only, not defaultEntityPresentation - Use entityPresentationApiRef with .promise in async loaders (StepPrepareCreatePullRequest, TemplateFormPreviewer) - Add optional entityPresentation?: EntityPresentationApi param to sync column factories and use .snapshot when available (columnFactories, EntityTable/columns, EntityOwnerPicker, CatalogTable) - Rewrite entity-presentation.md to recommend .snapshot/.promise instead of defaultEntityPresentation - Update TSDoc on entityPresentationApiRef, EntityPresentationApi, defaultEntityPresentation, useEntityPresentation, EntityDisplayName - Re-export presentation API types from alpha entry point - Update API reports - Fix test mocks for entityPresentationApiRef Made-with: Cursor Signed-off-by: Marat Dyatko Made-with: Cursor --- ...lace-humanize-entity-ref-catalog-import.md | 5 -- ...place-humanize-entity-ref-catalog-react.md | 2 +- .../replace-humanize-entity-ref-catalog.md | 5 -- .../replace-humanize-entity-ref-org-react.md | 5 -- .../replace-humanize-entity-ref-plugins.md | 9 +++ .../replace-humanize-entity-ref-scaffolder.md | 5 -- .../replace-humanize-entity-ref-techdocs.md | 5 -- .../software-catalog/entity-presentation.md | 48 +++++++++------ microsite/sidebars.ts | 1 + .../StepPrepareCreatePullRequest.test.tsx | 18 +++++- .../StepPrepareCreatePullRequest.tsx | 14 +++-- plugins/catalog-react/report-alpha.api.md | 61 +++++++++++++++++++ plugins/catalog-react/report.api.md | 4 ++ plugins/catalog-react/src/alpha/index.ts | 8 +++ .../EntityPresentationApi.ts | 11 ++-- .../defaultEntityPresentation.ts | 11 ++-- .../useEntityPresentation.ts | 2 +- .../EntityDataTable/columnFactories.tsx | 34 ++++++++--- .../EntityDisplayName/EntityDisplayName.tsx | 2 +- .../EntityOwnerPicker/EntityOwnerPicker.tsx | 12 +++- .../src/components/EntityRefLink/humanize.ts | 10 +-- .../src/components/EntityTable/columns.tsx | 30 +++++++-- plugins/catalog/report.api.md | 1 + .../components/CatalogTable/CatalogTable.tsx | 51 ++++++++++------ .../src/components/CatalogTable/columns.tsx | 7 +++ .../GroupListPicker/GroupListPicker.tsx | 9 ++- .../TemplateFormPage.test.tsx | 31 +++++++++- .../TemplateFormPreviewer.tsx | 22 ++++--- .../src/home/components/Tables/helpers.ts | 22 +++++-- 29 files changed, 323 insertions(+), 122 deletions(-) delete mode 100644 .changeset/replace-humanize-entity-ref-catalog-import.md delete mode 100644 .changeset/replace-humanize-entity-ref-catalog.md delete mode 100644 .changeset/replace-humanize-entity-ref-org-react.md create mode 100644 .changeset/replace-humanize-entity-ref-plugins.md delete mode 100644 .changeset/replace-humanize-entity-ref-scaffolder.md delete mode 100644 .changeset/replace-humanize-entity-ref-techdocs.md diff --git a/.changeset/replace-humanize-entity-ref-catalog-import.md b/.changeset/replace-humanize-entity-ref-catalog-import.md deleted file mode 100644 index 544e91bac3..0000000000 --- a/.changeset/replace-humanize-entity-ref-catalog-import.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-import': patch ---- - -Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `StepPrepareCreatePullRequest`. diff --git a/.changeset/replace-humanize-entity-ref-catalog-react.md b/.changeset/replace-humanize-entity-ref-catalog-react.md index b44e796624..7f44b101c4 100644 --- a/.changeset/replace-humanize-entity-ref-catalog-react.md +++ b/.changeset/replace-humanize-entity-ref-catalog-react.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-react': patch --- -Replaced `humanizeEntityRef` with `defaultEntityPresentation` and `useEntityPresentation` from the Catalog Presentation API in `EntityOwnerPicker`, `EntityTable`, `EntityDataTable`, and `AncestryPage` components. +Deprecated `humanizeEntityRef` and `humanizeEntity` in favor of the Catalog Presentation API. Use `useEntityPresentation`, `EntityDisplayName`, or `entityPresentationApiRef` instead. diff --git a/.changeset/replace-humanize-entity-ref-catalog.md b/.changeset/replace-humanize-entity-ref-catalog.md deleted file mode 100644 index 2a27c87f47..0000000000 --- a/.changeset/replace-humanize-entity-ref-catalog.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `CatalogTable` and its column factories. diff --git a/.changeset/replace-humanize-entity-ref-org-react.md b/.changeset/replace-humanize-entity-ref-org-react.md deleted file mode 100644 index afb04d3348..0000000000 --- a/.changeset/replace-humanize-entity-ref-org-react.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-org-react': patch ---- - -Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `GroupListPicker`. diff --git a/.changeset/replace-humanize-entity-ref-plugins.md b/.changeset/replace-humanize-entity-ref-plugins.md new file mode 100644 index 0000000000..5f523e7b88 --- /dev/null +++ b/.changeset/replace-humanize-entity-ref-plugins.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-org-react': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-techdocs': patch +--- + +Replaced deprecated `humanizeEntityRef` usage with the Catalog Presentation API. diff --git a/.changeset/replace-humanize-entity-ref-scaffolder.md b/.changeset/replace-humanize-entity-ref-scaffolder.md deleted file mode 100644 index 5064875451..0000000000 --- a/.changeset/replace-humanize-entity-ref-scaffolder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in `TemplateFormPreviewer`. diff --git a/.changeset/replace-humanize-entity-ref-techdocs.md b/.changeset/replace-humanize-entity-ref-techdocs.md deleted file mode 100644 index 374a0c2e8e..0000000000 --- a/.changeset/replace-humanize-entity-ref-techdocs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Replaced `humanizeEntityRef` with `defaultEntityPresentation` from the Catalog Presentation API in TechDocs table helpers. diff --git a/docs/features/software-catalog/entity-presentation.md b/docs/features/software-catalog/entity-presentation.md index 5d65baac07..c5ca8edada 100644 --- a/docs/features/software-catalog/entity-presentation.md +++ b/docs/features/software-catalog/entity-presentation.md @@ -11,7 +11,7 @@ name from fields such as `metadata.title` and `spec.profile.displayName`. ## Displaying entity names -There are three ways to display entity names, depending on context: +There are several ways to display entity names, depending on context: ### `EntityDisplayName` component @@ -51,25 +51,36 @@ function MyComponent({ entityRef }: { entityRef: string }) { The hook subscribes to the `EntityPresentationApi` and returns a snapshot that may update over time as additional data is fetched in the background. -If no presentation API is registered, it falls back to -`defaultEntityPresentation`. -### `defaultEntityPresentation` function +### Using the API directly -A synchronous helper for non-React contexts where hooks are not available. -Use it in sort comparators, filter functions, table column factories, and -data mappers: +In contexts where hooks are not available, you can use the +`entityPresentationApiRef` API directly. The API provides two access +patterns: + +- **`.snapshot`** for synchronous access (for example in sort comparators or + filter callbacks): ```ts -import { defaultEntityPresentation } from '@backstage/plugin-catalog-react'; +import { entityPresentationApiRef } from '@backstage/plugin-catalog-react'; -const title = defaultEntityPresentation(entity, { +const title = entityPresentationApi.forEntity(entity, { defaultKind: 'Component', -}).primaryTitle; +}).snapshot.primaryTitle; ``` -This resolves `primaryTitle` as the first available value among -`spec.profile.displayName`, `metadata.title`, and a shortened entity ref. +- **`.promise`** for async contexts (for example inside data loaders): + +```ts +const presentation = await entityPresentationApi.forEntity(entity, { + defaultKind: 'group', +}).promise; +const title = presentation.primaryTitle; +``` + +The `.snapshot` path uses cached data when available, so it performs well +even in tight loops like sorting. The `.promise` path resolves to a richer +presentation that may include data fetched from the catalog. ## Customizing entity presentation @@ -109,9 +120,10 @@ from `metadata.title` or `spec.profile.displayName`. Replace them as follows: -| Old code | Replacement | -| :------------------------------------------------------------------ | :---------------------------------------------------------------- | -| `humanizeEntityRef(entity)` in JSX | `` | -| `humanizeEntityRef(entity)` in a hook-accessible context | `useEntityPresentation(entity).primaryTitle` | -| `humanizeEntityRef(entity, { defaultKind })` in a sort/filter/label | `defaultEntityPresentation(entity, { defaultKind }).primaryTitle` | -| `humanizeEntity(entity, fallback)` | `defaultEntityPresentation(entity).primaryTitle` | +| Old code | Replacement | +| :---------------------------------------------------- | :--------------------------------------------------------------------- | +| `humanizeEntityRef(entity)` in JSX | `` | +| `humanizeEntityRef(entity)` in a React component | `useEntityPresentation(entity).primaryTitle` | +| `humanizeEntityRef(entity)` in a sort/filter callback | `entityPresentationApi.forEntity(entity).snapshot.primaryTitle` | +| `humanizeEntityRef(entity)` in an async loader | `(await entityPresentationApi.forEntity(entity).promise).primaryTitle` | +| `humanizeEntity(entity, fallback)` | `useEntityPresentation(entity).primaryTitle` | diff --git a/microsite/sidebars.ts b/microsite/sidebars.ts index 871fbe09e9..eb002736b2 100644 --- a/microsite/sidebars.ts +++ b/microsite/sidebars.ts @@ -263,6 +263,7 @@ export default { 'features/software-catalog/extending-the-model', 'features/software-catalog/external-integrations', 'features/software-catalog/catalog-customization', + 'features/software-catalog/entity-presentation', 'features/software-catalog/audit-events', { type: 'category', diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index 6f69b739bd..8c21966ed5 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -15,7 +15,11 @@ */ import { configApiRef, errorApiRef } from '@backstage/core-plugin-api'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + catalogApiRef, + defaultEntityPresentation, + entityPresentationApiRef, +} from '@backstage/plugin-catalog-react'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { mockApis, @@ -42,6 +46,17 @@ describe('', () => { const catalogApi = catalogApiMock.mock(); + const entityPresentationApi: typeof entityPresentationApiRef.T = { + forEntity(entityOrRef, context) { + const presentation = defaultEntityPresentation(entityOrRef, context); + return { + snapshot: presentation, + update$: { subscribe: () => ({ unsubscribe: () => {} }) } as any, + promise: Promise.resolve(presentation), + }; + }, + }; + const errorApi: jest.Mocked = { error$: jest.fn(), post: jest.fn(), @@ -54,6 +69,7 @@ describe('', () => { apis={[ [catalogImportApiRef, catalogImportApi], [catalogApiRef, catalogApi], + [entityPresentationApiRef, entityPresentationApi], [errorApiRef, errorApi], [configApiRef, configApi], ]} diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx index 3cb9fbbd39..758ade4ee9 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx @@ -20,7 +20,7 @@ import { assertError } from '@backstage/errors'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { catalogApiRef, - defaultEntityPresentation, + entityPresentationApiRef, } from '@backstage/plugin-catalog-react'; import Box from '@material-ui/core/Box'; import FormHelperText from '@material-ui/core/FormHelperText'; @@ -133,6 +133,7 @@ export const StepPrepareCreatePullRequest = ( const { t } = useTranslationRef(catalogImportTranslationRef); const classes = useStyles(); const catalogApi = useApi(catalogApiRef); + const entityPresentationApi = useApi(entityPresentationApiRef); const catalogImportApi = useApi(catalogImportApiRef); const errorApi = useApi(errorApiRef); @@ -161,12 +162,13 @@ export const StepPrepareCreatePullRequest = ( filter: { kind: 'group' }, }); - return groupEntities.items - .map( + const presentations = await Promise.all( + groupEntities.items.map( e => - defaultEntityPresentation(e, { defaultKind: 'group' }).primaryTitle, - ) - .sort(); + entityPresentationApi.forEntity(e, { defaultKind: 'group' }).promise, + ), + ); + return presentations.map(p => p.primaryTitle).sort(); }); const handleResult = useCallback( diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 0135fe7d64..17bd8de8af 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -6,16 +6,19 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ColumnConfig } from '@backstage/ui'; import { ComponentType } from 'react'; +import { CompoundEntityRef } from '@backstage/catalog-model'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { FilterPredicate } from '@backstage/filter-predicates'; +import { IconComponent } from '@backstage/core-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; import { JSX as JSX_3 } from 'react/jsx-runtime'; import { JSXElementConstructor } from 'react'; +import { Observable } from '@backstage/types'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; @@ -183,6 +186,15 @@ export const defaultEntityContentGroups: Record< string >; +// @public +export function defaultEntityPresentation( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, +): EntityRefPresentationSnapshot; + // @alpha export const EntityCardBlueprint: ExtensionBlueprint<{ kind: 'entity-card'; @@ -488,6 +500,7 @@ export const entityDataTableColumns: Readonly<{ createEntityRefColumn(options: { defaultKind?: string; isRowHeader?: boolean; + entityPresentation?: EntityPresentationApi; }): EntityColumnConfig; createEntityRelationColumn(options: { id: string; @@ -497,6 +510,7 @@ export const entityDataTableColumns: Readonly<{ filter?: { kind: string; }; + entityPresentation?: EntityPresentationApi; }): EntityColumnConfig; createOwnerColumn(): EntityColumnConfig; createSystemColumn(): EntityColumnConfig; @@ -520,6 +534,18 @@ export interface EntityDataTableProps { loading?: boolean; } +// @public +export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; + +// @public +export type EntityDisplayNameProps = { + entityRef: Entity | CompoundEntityRef | string; + hideIcon?: boolean; + disableTooltip?: boolean; + defaultKind?: string; + defaultNamespace?: string; +}; + // @alpha (undocumented) export const EntityHeaderBlueprint: ExtensionBlueprint<{ kind: 'entity-header'; @@ -627,6 +653,32 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ }; }>; +// @public +export interface EntityPresentationApi { + forEntity( + entityOrRef: Entity | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, + ): EntityRefPresentation; +} + +// @public +export interface EntityRefPresentation { + promise: Promise; + snapshot: EntityRefPresentationSnapshot; + update$?: Observable; +} + +// @public +export interface EntityRefPresentationSnapshot { + entityRef: string; + Icon?: IconComponent | undefined | false; + primaryTitle: string; + secondaryTitle?: string; +} + // @public (undocumented) export function EntityRelationCard( props: EntityRelationCardProps, @@ -700,6 +752,15 @@ export function useEntityPermission( error?: Error; }; +// @public +export function useEntityPresentation( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, +): EntityRefPresentationSnapshot; + // @alpha (undocumented) export type UseProps = () => | { diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index bb9d43edb0..806d329fe0 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -266,6 +266,7 @@ export type CatalogReactUserListPickerClassKey = export const columnFactories: Readonly<{ createEntityRefColumn(options: { defaultKind?: string; + entityPresentation?: EntityPresentationApi; }): TableColumn; createEntityRelationColumn(options: { title: string | JSX.Element; @@ -274,6 +275,7 @@ export const columnFactories: Readonly<{ filter?: { kind: string; }; + entityPresentation?: EntityPresentationApi; }): TableColumn; createOwnerColumn(): TableColumn; createDomainColumn(): TableColumn; @@ -687,6 +689,7 @@ export const EntityTable: { columns: Readonly<{ createEntityRefColumn(options: { defaultKind?: string; + entityPresentation?: EntityPresentationApi; }): TableColumn; createEntityRelationColumn(options: { title: string | JSX.Element; @@ -695,6 +698,7 @@ export const EntityTable: { filter?: { kind: string; }; + entityPresentation?: EntityPresentationApi; }): TableColumn; createOwnerColumn(): TableColumn; createDomainColumn(): TableColumn; diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts index d473b0923f..47418eaa2a 100644 --- a/plugins/catalog-react/src/alpha/index.ts +++ b/plugins/catalog-react/src/alpha/index.ts @@ -26,5 +26,13 @@ export const catalogReactTranslationRef = _catalogReactTranslationRef; export { isOwnerOf } from '../utils/isOwnerOf'; export { useEntityPermission } from '../hooks/useEntityPermission'; export * from '../components/EntityTable/TitleColumn'; +export type { + EntityPresentationApi, + EntityRefPresentation, + EntityRefPresentationSnapshot, +} from '../apis'; +export { useEntityPresentation, defaultEntityPresentation } from '../apis'; +export { EntityDisplayName } from '../components/EntityDisplayName'; +export type { EntityDisplayNameProps } from '../components/EntityDisplayName'; export * from '../components/EntityDataTable'; export * from '../components/EntityRelationCard'; diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index e293d1e0ab..316bc9aff5 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -36,9 +36,9 @@ import { Observable } from '@backstage/types'; * which wraps the hook and renders a styled entity name with optional icon * and tooltip. * - * - In non-React contexts such as sort comparators, filter functions, or data - * mappers, use the {@link defaultEntityPresentation} function which - * synchronously extracts a display name from an already-loaded entity. + * - In non-React contexts such as sort comparators or data mappers, use the + * API directly via `forEntity().snapshot` for synchronous access, or + * `forEntity().promise` in async loaders. * * @public */ @@ -143,8 +143,9 @@ export interface EntityRefPresentation { * - {@link EntityDisplayName} — React component that renders an entity name * with optional icon and tooltip. * - * - {@link defaultEntityPresentation} — synchronous helper for non-React - * contexts where you already have the entity object. + * For non-React contexts, you can use the API directly via + * `forEntity().snapshot` for synchronous access, or `forEntity().promise` + * for async contexts. * * Implement this interface to customize how entities are displayed throughout * the Backstage interface. diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts index 9c1cbd5325..1984593cfa 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts @@ -33,11 +33,12 @@ import { EntityRefPresentationSnapshot } from './EntityPresentationApi'; * first available value among `spec.profile.displayName`, `metadata.title`, * and a shortened entity ref string. * - * Use this in non-React contexts where hooks are not available, such as sort - * comparators, filter functions, table column factories, and data mappers. - * In React components, prefer the {@link useEntityPresentation} hook or the - * {@link EntityDisplayName} component, which support async enrichment via - * the {@link EntityPresentationApi}. + * This function is primarily used as the internal fallback within the + * {@link EntityPresentationApi} when no custom implementation is registered. + * Prefer using the API directly via `forEntity().snapshot` or + * `forEntity().promise`, which respects custom presentation overrides. + * In React components, use the {@link useEntityPresentation} hook or the + * {@link EntityDisplayName} component. * * @public * @param entityOrRef - Either an entity, or a ref to it. diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts index 2e01485dcc..125557ceb7 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts @@ -43,7 +43,7 @@ import { useUpdatingObservable } from './useUpdatingObservable'; * component instead, which wraps this hook with icon and tooltip support. * * For non-React contexts such as sort comparators or data mappers, use - * {@link defaultEntityPresentation} directly. + * the {@link EntityPresentationApi} directly via `forEntity().snapshot`. * * @public * @param entityOrRef - The entity to represent, or an entity ref to it. If you diff --git a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx index 647e7c7f3b..aed29dbd56 100644 --- a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx +++ b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx @@ -21,7 +21,7 @@ import { } from '@backstage/catalog-model'; import { Cell, CellText, Column, ColumnConfig, TableItem } from '@backstage/ui'; import { EntityRefLink, EntityRefLinks } from '../EntityRefLink'; -import { defaultEntityPresentation } from '../../apis'; +import { defaultEntityPresentation, EntityPresentationApi } from '../../apis'; import { EntityTableColumnTitle } from '../EntityTable/TitleColumn'; import { getEntityRelations } from '../../utils'; @@ -33,11 +33,24 @@ export interface EntityColumnConfig extends ColumnConfig { sortValue?: (entity: EntityRow) => string; } +function getEntityTitle( + entityOrRef: Entity | { kind: string; namespace?: string; name: string }, + context: { defaultKind?: string }, + entityPresentation?: EntityPresentationApi, +): string { + if (entityPresentation) { + return entityPresentation.forEntity(entityOrRef as Entity, context).snapshot + .primaryTitle; + } + return defaultEntityPresentation(entityOrRef as Entity, context).primaryTitle; +} + /** @public */ export const columnFactories = Object.freeze({ createEntityRefColumn(options: { defaultKind?: string; isRowHeader?: boolean; + entityPresentation?: EntityPresentationApi; }): EntityColumnConfig { const isRowHeader = options.isRowHeader ?? true; return { @@ -60,8 +73,11 @@ export const columnFactories = Object.freeze({ ), sortValue: entity => - defaultEntityPresentation(entity, { defaultKind: options.defaultKind }) - .primaryTitle, + getEntityTitle( + entity, + { defaultKind: options.defaultKind }, + options.entityPresentation, + ), }; }, @@ -71,6 +87,7 @@ export const columnFactories = Object.freeze({ relation: string; defaultKind?: string; filter?: { kind: string }; + entityPresentation?: EntityPresentationApi; }): EntityColumnConfig { return { id: options.id, @@ -95,11 +112,12 @@ export const columnFactories = Object.freeze({ ), sortValue: entity => getEntityRelations(entity, options.relation, options.filter) - .map( - r => - defaultEntityPresentation(r, { - defaultKind: options.defaultKind, - }).primaryTitle, + .map(r => + getEntityTitle( + r, + { defaultKind: options.defaultKind }, + options.entityPresentation, + ), ) .join(', '), }; diff --git a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx index 6ed863f180..a6b4ed94fe 100644 --- a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx @@ -70,7 +70,7 @@ export type EntityDisplayNameProps = { * * For more control over the presentation data, use the * {@link useEntityPresentation} hook directly. For non-React contexts, use - * {@link defaultEntityPresentation}. + * the {@link EntityPresentationApi} directly via `forEntity().snapshot`. * * @public */ diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 16e00e5ccd..1375140ebc 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -33,11 +33,15 @@ import { EntityOwnerFilter } from '../../filters'; import { useDebouncedEffect } from '@react-hookz/web'; import PersonIcon from '@material-ui/icons/Person'; import GroupIcon from '@material-ui/icons/Group'; -import { defaultEntityPresentation } from '../../apis'; +import { + defaultEntityPresentation, + entityPresentationApiRef, +} from '../../apis'; import { useFetchEntities } from './useFetchEntities'; import { withStyles } from '@material-ui/core/styles'; import { useEntityPresentation } from '../../apis'; import { catalogReactTranslationRef } from '../../translation'; +import { useApiHolder } from '@backstage/core-plugin-api'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { CatalogAutocomplete } from '../CatalogAutocomplete'; @@ -124,6 +128,8 @@ function RenderOptionLabel(props: { entity: Entity; isSelected: boolean }) { export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { const classes = useStyles(); const { mode = 'owners-only' } = props || {}; + const apis = useApiHolder(); + const entityPresentationApi = apis.get(entityPresentationApiRef); const { updateFilters, filters, @@ -203,6 +209,10 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { defaultNamespace: 'default', }) : o; + if (entityPresentationApi) { + return entityPresentationApi.forEntity(entity as Entity).snapshot + .primaryTitle; + } return defaultEntityPresentation(entity).primaryTitle; }} onChange={(_: object, owners) => { diff --git a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts index ae1fb5e685..80bd290806 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts @@ -25,9 +25,9 @@ import get from 'lodash/get'; * @param defaultNamespace - if set to false then namespace is never omitted, * if set to string which matches namespace of entity then omitted * - * @deprecated Use {@link defaultEntityPresentation} for non-React contexts, - * or {@link useEntityPresentation} / {@link EntityDisplayName} in React - * components. These provide richer display names using `metadata.title` and + * @deprecated Use {@link useEntityPresentation} or {@link EntityDisplayName} + * in React components, or access the {@link entityPresentationApiRef} directly. + * These provide richer display names using `metadata.title` and * `spec.profile.displayName` in addition to the entity ref. * * @public @@ -81,8 +81,8 @@ export function humanizeEntityRef( * * If neither of those are found or populated, fallback to `defaultName`. * - * @deprecated Use {@link defaultEntityPresentation} instead, which provides - * the same resolution logic via `primaryTitle`. + * @deprecated Use {@link useEntityPresentation} or {@link EntityDisplayName} + * in React components, or access the {@link entityPresentationApiRef} directly. * * @param entity - Entity to convert. * @param defaultName - If entity readable name is not available, `defaultName` will be returned. diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx index 6b47fa02ff..3768cf595e 100644 --- a/plugins/catalog-react/src/components/EntityTable/columns.tsx +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -23,17 +23,30 @@ import { import { OverflowTooltip, TableColumn } from '@backstage/core-components'; import { getEntityRelations } from '../../utils'; import { EntityRefLink, EntityRefLinks } from '../EntityRefLink'; -import { defaultEntityPresentation } from '../../apis'; +import { defaultEntityPresentation, EntityPresentationApi } from '../../apis'; import { EntityTableColumnTitle } from './TitleColumn'; +function getEntityTitle( + entityOrRef: Entity | CompoundEntityRef, + context: { defaultKind?: string }, + entityPresentation?: EntityPresentationApi, +): string { + if (entityPresentation) { + return entityPresentation.forEntity(entityOrRef as Entity, context).snapshot + .primaryTitle; + } + return defaultEntityPresentation(entityOrRef, context).primaryTitle; +} + /** @public */ export const columnFactories = Object.freeze({ createEntityRefColumn(options: { defaultKind?: string; + entityPresentation?: EntityPresentationApi; }): TableColumn { - const { defaultKind } = options; + const { defaultKind, entityPresentation } = options; function formatContent(entity: T): string { - return defaultEntityPresentation(entity, { defaultKind }).primaryTitle; + return getEntityTitle(entity, { defaultKind }, entityPresentation); } return { @@ -67,8 +80,15 @@ export const columnFactories = Object.freeze({ relation: string; defaultKind?: string; filter?: { kind: string }; + entityPresentation?: EntityPresentationApi; }): TableColumn { - const { title, relation, defaultKind, filter: entityFilter } = options; + const { + title, + relation, + defaultKind, + filter: entityFilter, + entityPresentation, + } = options; function getRelations(entity: T): CompoundEntityRef[] { return getEntityRelations(entity, relation, entityFilter); @@ -76,7 +96,7 @@ export const columnFactories = Object.freeze({ function formatContent(entity: T): string { return getRelations(entity) - .map(r => defaultEntityPresentation(r, { defaultKind }).primaryTitle) + .map(r => getEntityTitle(r, { defaultKind }, entityPresentation)) .join(', '); } diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 84d9a8bd91..816d469d13 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -153,6 +153,7 @@ export const CatalogTable: { columns: Readonly<{ createNameColumn(options?: { defaultKind?: string; + entityPresentation?: EntityPresentationApi; }): TableColumn; createSystemColumn(): TableColumn; createOwnerColumn(): TableColumn; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 200505f5be..a57a5f3d31 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -16,6 +16,7 @@ import { ANNOTATION_EDIT_URL, ANNOTATION_VIEW_URL, + CompoundEntityRef, Entity, RELATION_OWNED_BY, RELATION_PART_OF, @@ -30,9 +31,11 @@ import { } from '@backstage/core-components'; import { defaultEntityPresentation, + entityPresentationApiRef, getEntityRelations, useEntityList, useStarredEntities, + type EntityPresentationApi, } from '@backstage/plugin-catalog-react'; import CircularProgress from '@material-ui/core/CircularProgress'; import Typography from '@material-ui/core/Typography'; @@ -47,6 +50,7 @@ import { CatalogTableColumnsFunc, CatalogTableRow } from './types'; import { OffsetPaginatedCatalogTable } from './OffsetPaginatedCatalogTable'; import { CursorPaginatedCatalogTable } from './CursorPaginatedCatalogTable'; import { defaultCatalogTableColumnsFunc } from './defaultCatalogTableColumnsFunc'; +import { useApiHolder } from '@backstage/core-plugin-api'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { catalogTranslationRef } from '../../alpha'; import { FavoriteToggleIcon } from '@backstage/core-components'; @@ -69,12 +73,23 @@ export interface CatalogTableProps { subtitle?: string; } -const refCompare = (a: Entity, b: Entity) => { - const toRef = (entity: Entity) => - defaultEntityPresentation(entity, { defaultKind: 'Component' }) - .primaryTitle; +function getTitle( + entityOrRef: Entity | CompoundEntityRef, + context: { defaultKind?: string }, + api?: EntityPresentationApi, +): string { + if (api) { + const ref = + 'metadata' in entityOrRef ? entityOrRef : stringifyEntityRef(entityOrRef); + return api.forEntity(ref, context).snapshot.primaryTitle; + } + return defaultEntityPresentation(entityOrRef, context).primaryTitle; +} - return toRef(a).localeCompare(toRef(b)); +const refCompare = (a: Entity, b: Entity, api?: EntityPresentationApi) => { + return getTitle(a, { defaultKind: 'Component' }, api).localeCompare( + getTitle(b, { defaultKind: 'Component' }, api), + ); }; /** @@ -95,6 +110,8 @@ export const CatalogTable = (props: CatalogTableProps) => { emptyContent, } = props; const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); + const apis = useApiHolder(); + const entityPresentationApi = apis.get(entityPresentationApiRef); const entityListContext = useEntityList(); const { @@ -232,7 +249,7 @@ export const CatalogTable = (props: CatalogTableProps) => { actions={actions} subtitle={subtitle} options={options} - data={entities.map(toEntityRow)} + data={entities.map(e => toEntityRow(e, entityPresentationApi))} next={pageInfo?.next} prev={pageInfo?.prev} /> @@ -247,12 +264,14 @@ export const CatalogTable = (props: CatalogTableProps) => { actions={actions} subtitle={subtitle} options={options} - data={entities.map(toEntityRow)} + data={entities.map(e => toEntityRow(e, entityPresentationApi))} /> ); } - const rows = entities.sort(refCompare).map(toEntityRow); + const rows = entities + .sort((a, b) => refCompare(a, b, entityPresentationApi)) + .map(e => toEntityRow(e, entityPresentationApi)); const pageSize = 20; const showPagination = rows.length > pageSize; @@ -278,7 +297,7 @@ export const CatalogTable = (props: CatalogTableProps) => { CatalogTable.columns = columnFactories; CatalogTable.defaultColumnsFunc = defaultCatalogTableColumnsFunc; -function toEntityRow(entity: Entity) { +function toEntityRow(entity: Entity, api?: EntityPresentationApi) { const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { kind: 'system', }); @@ -290,22 +309,14 @@ function toEntityRow(entity: Entity) { // 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: defaultEntityPresentation(entity, { defaultKind: 'Component' }) - .primaryTitle, + name: getTitle(entity, { defaultKind: 'Component' }, api), entityRef: stringifyEntityRef(entity), ownedByRelationsTitle: ownedByRelations - .map( - r => - defaultEntityPresentation(r, { defaultKind: 'group' }).primaryTitle, - ) + .map(r => getTitle(r, { defaultKind: 'group' }, api)) .join(', '), ownedByRelations, partOfSystemRelationTitle: partOfSystemRelations - .map( - r => - defaultEntityPresentation(r, { defaultKind: 'system' }) - .primaryTitle, - ) + .map(r => getTitle(r, { defaultKind: 'system' }, api)) .join(', '), partOfSystemRelations, }, diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 8a18b301e6..05a8b1979b 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -17,6 +17,7 @@ import { defaultEntityPresentation, EntityRefLink, EntityRefLinks, + type EntityPresentationApi, } from '@backstage/plugin-catalog-react'; import Chip from '@material-ui/core/Chip'; import { CatalogTableRow } from './types'; @@ -31,8 +32,14 @@ import { EntityTableColumnTitle } from '@backstage/plugin-catalog-react/alpha'; export const columnFactories = Object.freeze({ createNameColumn(options?: { defaultKind?: string; + entityPresentation?: EntityPresentationApi; }): TableColumn { function formatContent(entity: Entity): string { + if (options?.entityPresentation) { + return options.entityPresentation.forEntity(entity, { + defaultKind: options?.defaultKind, + }).snapshot.primaryTitle; + } return defaultEntityPresentation(entity, { defaultKind: options?.defaultKind, }).primaryTitle; diff --git a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx index b836d30aff..b193d4e092 100644 --- a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx +++ b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx @@ -18,12 +18,13 @@ import { MouseEvent, useState, useCallback } from 'react'; import { catalogApiRef, defaultEntityPresentation, + entityPresentationApiRef, } from '@backstage/plugin-catalog-react'; import TextField from '@material-ui/core/TextField'; import Autocomplete from '@material-ui/lab/Autocomplete'; import useAsync from 'react-use/esm/useAsync'; import Popover from '@material-ui/core/Popover'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, useApiHolder } from '@backstage/core-plugin-api'; import { ResponseErrorPanel } from '@backstage/core-components'; import { GroupEntity } from '@backstage/catalog-model'; import { GroupListPickerButton } from './GroupListPickerButton'; @@ -43,6 +44,8 @@ export type GroupListPickerProps = { /** @public */ export const GroupListPicker = (props: GroupListPickerProps) => { const catalogApi = useApi(catalogApiRef); + const apis = useApiHolder(); + const entityPresentationApi = apis.get(entityPresentationApiRef); const { onChange, groupTypes, placeholder = '', defaultValue = '' } = props; const [anchorEl, setAnchorEl] = useState(null); @@ -99,7 +102,9 @@ export const GroupListPicker = (props: GroupListPickerProps) => { options={groups ?? []} groupBy={option => option.spec.type} getOptionLabel={option => - defaultEntityPresentation(option).primaryTitle + entityPresentationApi + ? entityPresentationApi.forEntity(option).snapshot.primaryTitle + : defaultEntityPresentation(option).primaryTitle } inputValue={inputValue} onInputChange={(_, value) => setInputValue(value)} diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx index 1174d84b11..1fd7544ea4 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx @@ -18,14 +18,34 @@ import { screen } from '@testing-library/react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { TemplateFormPage } from './TemplateFormPage'; import { rootRouteRef } from '../../../routes'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + catalogApiRef, + defaultEntityPresentation, + entityPresentationApiRef, +} from '@backstage/plugin-catalog-react'; describe('TemplateFormPage', () => { const catalogApiMock = { getEntities: jest.fn().mockResolvedValue([]) }; + const entityPresentationApi: typeof entityPresentationApiRef.T = { + forEntity(entityOrRef, context) { + const presentation = defaultEntityPresentation(entityOrRef, context); + return { + snapshot: presentation, + update$: { subscribe: () => ({ unsubscribe: () => {} }) } as any, + promise: Promise.resolve(presentation), + }; + }, + }; + it('Should render without exploding', async () => { await renderInTestApp( - + , { @@ -41,7 +61,12 @@ describe('TemplateFormPage', () => { it('Should have an link back to the edit page', async () => { await renderInTestApp( - + , { diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx index d2db8c4a62..b436f56427 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -24,7 +24,7 @@ import { makeStyles } from '@material-ui/core/styles'; import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { catalogApiRef, - defaultEntityPresentation, + entityPresentationApiRef, } from '@backstage/plugin-catalog-react'; import { LayoutOptions, @@ -139,6 +139,7 @@ export const TemplateFormPreviewer = ({ const classes = useStyles(); const alertApi = useApi(alertApiRef); const catalogApi = useApi(catalogApiRef); + const entityPresentationApi = useApi(entityPresentationApiRef); const navigate = useNavigate(); const editLink = useRouteRef(editRouteRef); @@ -166,16 +167,19 @@ export const TemplateFormPreviewer = ({ 'spec.output', ], }) - .then(({ items }) => - setTemplateOptions( - items.map(template => ({ - label: defaultEntityPresentation(template, { - defaultKind: 'template', - }).primaryTitle, + .then(async ({ items }) => { + const options = await Promise.all( + items.map(async template => ({ + label: ( + await entityPresentationApi.forEntity(template, { + defaultKind: 'template', + }).promise + ).primaryTitle, value: template, })), - ), - ) + ); + setTemplateOptions(options); + }) .catch(e => alertApi.post({ message: `Error loading existing templates: ${e.message}`, diff --git a/plugins/techdocs/src/home/components/Tables/helpers.ts b/plugins/techdocs/src/home/components/Tables/helpers.ts index 5c972b0233..4ec3123fe8 100644 --- a/plugins/techdocs/src/home/components/Tables/helpers.ts +++ b/plugins/techdocs/src/home/components/Tables/helpers.ts @@ -14,10 +14,15 @@ * limitations under the License. */ -import { RELATION_OWNED_BY, Entity } from '@backstage/catalog-model'; +import { + RELATION_OWNED_BY, + Entity, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { defaultEntityPresentation, getEntityRelations, + type EntityPresentationApi, } from '@backstage/plugin-catalog-react'; import { toLowerMaybe } from '../../../helpers'; import { ConfigApi, RouteFunc } from '@backstage/core-plugin-api'; @@ -32,6 +37,7 @@ export function entitiesToDocsMapper( entities: Entity[], getRouteToReaderPageFor: getRouteFunc, config: ConfigApi, + entityPresentation?: EntityPresentationApi, ) { return entities.map(entity => { const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); @@ -48,11 +54,15 @@ export function entitiesToDocsMapper( }), ownedByRelations, ownedByRelationsTitle: ownedByRelations - .map( - r => - defaultEntityPresentation(r, { defaultKind: 'group' }) - .primaryTitle, - ) + .map(r => { + if (entityPresentation) { + return entityPresentation.forEntity(stringifyEntityRef(r), { + defaultKind: 'group', + }).snapshot.primaryTitle; + } + return defaultEntityPresentation(r, { defaultKind: 'group' }) + .primaryTitle; + }) .join(', '), }, }; From 5bc450b7f194f2ba6b112c549b41c12c71351f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 30 Mar 2026 20:57:39 +0200 Subject: [PATCH 3/6] Rename entityPresentation parameter to entityPresentationApi for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/report-alpha.api.md | 4 ++-- plugins/catalog-react/report.api.md | 8 ++++---- .../EntityDataTable/columnFactories.tsx | 16 +++++++-------- .../src/components/EntityTable/columns.tsx | 20 +++++++++---------- plugins/catalog/report.api.md | 2 +- .../src/components/CatalogTable/columns.tsx | 6 +++--- .../src/home/components/Tables/helpers.ts | 6 +++--- 7 files changed, 31 insertions(+), 31 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 17bd8de8af..c2818b78ee 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -500,7 +500,7 @@ export const entityDataTableColumns: Readonly<{ createEntityRefColumn(options: { defaultKind?: string; isRowHeader?: boolean; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): EntityColumnConfig; createEntityRelationColumn(options: { id: string; @@ -510,7 +510,7 @@ export const entityDataTableColumns: Readonly<{ filter?: { kind: string; }; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): EntityColumnConfig; createOwnerColumn(): EntityColumnConfig; createSystemColumn(): EntityColumnConfig; diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index 806d329fe0..251a0e853a 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -266,7 +266,7 @@ export type CatalogReactUserListPickerClassKey = export const columnFactories: Readonly<{ createEntityRefColumn(options: { defaultKind?: string; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn; createEntityRelationColumn(options: { title: string | JSX.Element; @@ -275,7 +275,7 @@ export const columnFactories: Readonly<{ filter?: { kind: string; }; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn; createOwnerColumn(): TableColumn; createDomainColumn(): TableColumn; @@ -689,7 +689,7 @@ export const EntityTable: { columns: Readonly<{ createEntityRefColumn(options: { defaultKind?: string; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn; createEntityRelationColumn(options: { title: string | JSX.Element; @@ -698,7 +698,7 @@ export const EntityTable: { filter?: { kind: string; }; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn; createOwnerColumn(): TableColumn; createDomainColumn(): TableColumn; diff --git a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx index aed29dbd56..5c425907d3 100644 --- a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx +++ b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx @@ -36,11 +36,11 @@ export interface EntityColumnConfig extends ColumnConfig { function getEntityTitle( entityOrRef: Entity | { kind: string; namespace?: string; name: string }, context: { defaultKind?: string }, - entityPresentation?: EntityPresentationApi, + entityPresentationApi?: EntityPresentationApi, ): string { - if (entityPresentation) { - return entityPresentation.forEntity(entityOrRef as Entity, context).snapshot - .primaryTitle; + if (entityPresentationApi) { + return entityPresentationApi.forEntity(entityOrRef as Entity, context) + .snapshot.primaryTitle; } return defaultEntityPresentation(entityOrRef as Entity, context).primaryTitle; } @@ -50,7 +50,7 @@ export const columnFactories = Object.freeze({ createEntityRefColumn(options: { defaultKind?: string; isRowHeader?: boolean; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): EntityColumnConfig { const isRowHeader = options.isRowHeader ?? true; return { @@ -76,7 +76,7 @@ export const columnFactories = Object.freeze({ getEntityTitle( entity, { defaultKind: options.defaultKind }, - options.entityPresentation, + options.entityPresentationApi, ), }; }, @@ -87,7 +87,7 @@ export const columnFactories = Object.freeze({ relation: string; defaultKind?: string; filter?: { kind: string }; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): EntityColumnConfig { return { id: options.id, @@ -116,7 +116,7 @@ export const columnFactories = Object.freeze({ getEntityTitle( r, { defaultKind: options.defaultKind }, - options.entityPresentation, + options.entityPresentationApi, ), ) .join(', '), diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx index 3768cf595e..01d1c7e1f3 100644 --- a/plugins/catalog-react/src/components/EntityTable/columns.tsx +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -29,11 +29,11 @@ import { EntityTableColumnTitle } from './TitleColumn'; function getEntityTitle( entityOrRef: Entity | CompoundEntityRef, context: { defaultKind?: string }, - entityPresentation?: EntityPresentationApi, + entityPresentationApi?: EntityPresentationApi, ): string { - if (entityPresentation) { - return entityPresentation.forEntity(entityOrRef as Entity, context).snapshot - .primaryTitle; + if (entityPresentationApi) { + return entityPresentationApi.forEntity(entityOrRef as Entity, context) + .snapshot.primaryTitle; } return defaultEntityPresentation(entityOrRef, context).primaryTitle; } @@ -42,11 +42,11 @@ function getEntityTitle( export const columnFactories = Object.freeze({ createEntityRefColumn(options: { defaultKind?: string; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn { - const { defaultKind, entityPresentation } = options; + const { defaultKind, entityPresentationApi } = options; function formatContent(entity: T): string { - return getEntityTitle(entity, { defaultKind }, entityPresentation); + return getEntityTitle(entity, { defaultKind }, entityPresentationApi); } return { @@ -80,14 +80,14 @@ export const columnFactories = Object.freeze({ relation: string; defaultKind?: string; filter?: { kind: string }; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn { const { title, relation, defaultKind, filter: entityFilter, - entityPresentation, + entityPresentationApi, } = options; function getRelations(entity: T): CompoundEntityRef[] { @@ -96,7 +96,7 @@ export const columnFactories = Object.freeze({ function formatContent(entity: T): string { return getRelations(entity) - .map(r => getEntityTitle(r, { defaultKind }, entityPresentation)) + .map(r => getEntityTitle(r, { defaultKind }, entityPresentationApi)) .join(', '); } diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 816d469d13..d8c5c85402 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -153,7 +153,7 @@ export const CatalogTable: { columns: Readonly<{ createNameColumn(options?: { defaultKind?: string; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn; createSystemColumn(): TableColumn; createOwnerColumn(): TableColumn; diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx index 05a8b1979b..55b2d5c6a8 100644 --- a/plugins/catalog/src/components/CatalogTable/columns.tsx +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -32,11 +32,11 @@ import { EntityTableColumnTitle } from '@backstage/plugin-catalog-react/alpha'; export const columnFactories = Object.freeze({ createNameColumn(options?: { defaultKind?: string; - entityPresentation?: EntityPresentationApi; + entityPresentationApi?: EntityPresentationApi; }): TableColumn { function formatContent(entity: Entity): string { - if (options?.entityPresentation) { - return options.entityPresentation.forEntity(entity, { + if (options?.entityPresentationApi) { + return options.entityPresentationApi.forEntity(entity, { defaultKind: options?.defaultKind, }).snapshot.primaryTitle; } diff --git a/plugins/techdocs/src/home/components/Tables/helpers.ts b/plugins/techdocs/src/home/components/Tables/helpers.ts index 4ec3123fe8..4f2424340e 100644 --- a/plugins/techdocs/src/home/components/Tables/helpers.ts +++ b/plugins/techdocs/src/home/components/Tables/helpers.ts @@ -37,7 +37,7 @@ export function entitiesToDocsMapper( entities: Entity[], getRouteToReaderPageFor: getRouteFunc, config: ConfigApi, - entityPresentation?: EntityPresentationApi, + entityPresentationApi?: EntityPresentationApi, ) { return entities.map(entity => { const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); @@ -55,8 +55,8 @@ export function entitiesToDocsMapper( ownedByRelations, ownedByRelationsTitle: ownedByRelations .map(r => { - if (entityPresentation) { - return entityPresentation.forEntity(stringifyEntityRef(r), { + if (entityPresentationApi) { + return entityPresentationApi.forEntity(stringifyEntityRef(r), { defaultKind: 'group', }).snapshot.primaryTitle; } From 0277801a960137a8e7e92903f2da751282d63b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 30 Mar 2026 21:16:11 +0200 Subject: [PATCH 4/6] Remove duplicate presentation API exports from alpha, import from public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- plugins/catalog-react/report-alpha.api.md | 60 +------------------ plugins/catalog-react/src/alpha/index.ts | 8 --- .../EntityDataTable/columnFactories.tsx | 5 +- 3 files changed, 5 insertions(+), 68 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index c2818b78ee..19f1b2ce3a 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -6,19 +6,17 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ColumnConfig } from '@backstage/ui'; import { ComponentType } from 'react'; -import { CompoundEntityRef } from '@backstage/catalog-model'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityPresentationApi } from '@backstage/plugin-catalog-react'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { FilterPredicate } from '@backstage/filter-predicates'; -import { IconComponent } from '@backstage/core-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; import { JSX as JSX_3 } from 'react/jsx-runtime'; import { JSXElementConstructor } from 'react'; -import { Observable } from '@backstage/types'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; @@ -186,15 +184,6 @@ export const defaultEntityContentGroups: Record< string >; -// @public -export function defaultEntityPresentation( - entityOrRef: Entity | CompoundEntityRef | string, - context?: { - defaultKind?: string; - defaultNamespace?: string; - }, -): EntityRefPresentationSnapshot; - // @alpha export const EntityCardBlueprint: ExtensionBlueprint<{ kind: 'entity-card'; @@ -534,18 +523,6 @@ export interface EntityDataTableProps { loading?: boolean; } -// @public -export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element; - -// @public -export type EntityDisplayNameProps = { - entityRef: Entity | CompoundEntityRef | string; - hideIcon?: boolean; - disableTooltip?: boolean; - defaultKind?: string; - defaultNamespace?: string; -}; - // @alpha (undocumented) export const EntityHeaderBlueprint: ExtensionBlueprint<{ kind: 'entity-header'; @@ -653,32 +630,6 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ }; }>; -// @public -export interface EntityPresentationApi { - forEntity( - entityOrRef: Entity | string, - context?: { - defaultKind?: string; - defaultNamespace?: string; - }, - ): EntityRefPresentation; -} - -// @public -export interface EntityRefPresentation { - promise: Promise; - snapshot: EntityRefPresentationSnapshot; - update$?: Observable; -} - -// @public -export interface EntityRefPresentationSnapshot { - entityRef: string; - Icon?: IconComponent | undefined | false; - primaryTitle: string; - secondaryTitle?: string; -} - // @public (undocumented) export function EntityRelationCard( props: EntityRelationCardProps, @@ -752,15 +703,6 @@ export function useEntityPermission( error?: Error; }; -// @public -export function useEntityPresentation( - entityOrRef: Entity | CompoundEntityRef | string, - context?: { - defaultKind?: string; - defaultNamespace?: string; - }, -): EntityRefPresentationSnapshot; - // @alpha (undocumented) export type UseProps = () => | { diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts index 47418eaa2a..d473b0923f 100644 --- a/plugins/catalog-react/src/alpha/index.ts +++ b/plugins/catalog-react/src/alpha/index.ts @@ -26,13 +26,5 @@ export const catalogReactTranslationRef = _catalogReactTranslationRef; export { isOwnerOf } from '../utils/isOwnerOf'; export { useEntityPermission } from '../hooks/useEntityPermission'; export * from '../components/EntityTable/TitleColumn'; -export type { - EntityPresentationApi, - EntityRefPresentation, - EntityRefPresentationSnapshot, -} from '../apis'; -export { useEntityPresentation, defaultEntityPresentation } from '../apis'; -export { EntityDisplayName } from '../components/EntityDisplayName'; -export type { EntityDisplayNameProps } from '../components/EntityDisplayName'; export * from '../components/EntityDataTable'; export * from '../components/EntityRelationCard'; diff --git a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx index 5c425907d3..b0ff221e9d 100644 --- a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx +++ b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx @@ -21,7 +21,10 @@ import { } from '@backstage/catalog-model'; import { Cell, CellText, Column, ColumnConfig, TableItem } from '@backstage/ui'; import { EntityRefLink, EntityRefLinks } from '../EntityRefLink'; -import { defaultEntityPresentation, EntityPresentationApi } from '../../apis'; +import { + defaultEntityPresentation, + EntityPresentationApi, +} from '@backstage/plugin-catalog-react'; import { EntityTableColumnTitle } from '../EntityTable/TitleColumn'; import { getEntityRelations } from '../../utils'; From f55c195f039802376a753ee9adad6e84f953e1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 30 Mar 2026 21:42:51 +0200 Subject: [PATCH 5/6] Use lodash sortBy for entity sorting to avoid repeated presentation lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../src/components/CatalogTable/CatalogTable.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index a57a5f3d31..6c55ff21a3 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -42,7 +42,7 @@ import Typography from '@material-ui/core/Typography'; import { visuallyHidden } from '@mui/utils'; import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; -import { capitalize } from 'lodash'; +import { capitalize, sortBy } from 'lodash'; import pluralize from 'pluralize'; import { ReactNode, useMemo } from 'react'; import { columnFactories } from './columns'; @@ -86,10 +86,8 @@ function getTitle( return defaultEntityPresentation(entityOrRef, context).primaryTitle; } -const refCompare = (a: Entity, b: Entity, api?: EntityPresentationApi) => { - return getTitle(a, { defaultKind: 'Component' }, api).localeCompare( - getTitle(b, { defaultKind: 'Component' }, api), - ); +const sortEntities = (entities: Entity[], api?: EntityPresentationApi) => { + return sortBy(entities, e => getTitle(e, { defaultKind: 'Component' }, api)); }; /** @@ -269,9 +267,9 @@ export const CatalogTable = (props: CatalogTableProps) => { ); } - const rows = entities - .sort((a, b) => refCompare(a, b, entityPresentationApi)) - .map(e => toEntityRow(e, entityPresentationApi)); + const rows = sortEntities(entities, entityPresentationApi).map(e => + toEntityRow(e, entityPresentationApi), + ); const pageSize = 20; const showPagination = rows.length > pageSize; From b32ab396700d3ca8c9a0f6fe595411ba94e35ca4 Mon Sep 17 00:00:00 2001 From: Marat Dyatko Date: Tue, 7 Apr 2026 12:41:29 +0200 Subject: [PATCH 6/6] Fix unsafe type casts via shared entityPresentationSnapshot utility Extract entityPresentationSnapshot helper to eliminate unsafe `as Entity` casts when passing CompoundEntityRef to EntityPresentationApi.forEntity(), which only accepts Entity | string. The helper safely discriminates input types and stringifies CompoundEntityRef before calling the API. - Add entityPresentationSnapshot as a public export from catalog-react - Remove duplicated getEntityTitle/getTitle helpers across 5 files - Fix useAsync dependency array in TemplateFormPreviewer - Update TSDoc across all presentation API surfaces to reference the new helper - Update entity-presentation.md docs with usage guidance and migration table Made-with: Cursor Signed-off-by: Marat Dyatko --- .../software-catalog/entity-presentation.md | 60 +++--- plugins/catalog-react/report.api.md | 10 + .../EntityPresentationApi.ts | 8 +- .../defaultEntityPresentation.ts | 6 +- .../entityPresentationSnapshot.test.ts | 173 ++++++++++++++++++ .../entityPresentationSnapshot.ts | 70 +++++++ .../src/apis/EntityPresentationApi/index.ts | 1 + .../useEntityPresentation.ts | 2 +- .../EntityDataTable/columnFactories.tsx | 31 +--- .../EntityDisplayName/EntityDisplayName.tsx | 2 +- .../EntityOwnerPicker/EntityOwnerPicker.tsx | 12 +- .../src/components/EntityRefLink/humanize.ts | 10 +- .../src/components/EntityTable/columns.tsx | 29 ++- .../components/CatalogTable/CatalogTable.tsx | 41 +++-- .../TemplateFormPreviewer.tsx | 2 +- .../src/home/components/Tables/helpers.ts | 25 +-- 16 files changed, 371 insertions(+), 111 deletions(-) create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.test.ts create mode 100644 plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.ts diff --git a/docs/features/software-catalog/entity-presentation.md b/docs/features/software-catalog/entity-presentation.md index c5ca8edada..a883f93339 100644 --- a/docs/features/software-catalog/entity-presentation.md +++ b/docs/features/software-catalog/entity-presentation.md @@ -52,24 +52,12 @@ function MyComponent({ entityRef }: { entityRef: string }) { The hook subscribes to the `EntityPresentationApi` and returns a snapshot that may update over time as additional data is fetched in the background. -### Using the API directly +### Using the API directly (async, preferred for non-React) -In contexts where hooks are not available, you can use the -`entityPresentationApiRef` API directly. The API provides two access -patterns: - -- **`.snapshot`** for synchronous access (for example in sort comparators or - filter callbacks): - -```ts -import { entityPresentationApiRef } from '@backstage/plugin-catalog-react'; - -const title = entityPresentationApi.forEntity(entity, { - defaultKind: 'Component', -}).snapshot.primaryTitle; -``` - -- **`.promise`** for async contexts (for example inside data loaders): +In non-React **async** contexts where you can `await` -- such as data +loaders, `useAsync` callbacks, or event handlers -- use the +`entityPresentationApiRef` API directly with `.promise` for the richest +possible presentation: ```ts const presentation = await entityPresentationApi.forEntity(entity, { @@ -78,9 +66,39 @@ const presentation = await entityPresentationApi.forEntity(entity, { const title = presentation.primaryTitle; ``` -The `.snapshot` path uses cached data when available, so it performs well -even in tight loops like sorting. The `.promise` path resolves to a richer -presentation that may include data fetched from the catalog. +The `.promise` path resolves to a full presentation that may include data +fetched from the catalog. **This is the preferred approach whenever an +async context is available.** + +### `entityPresentationSnapshot` helper (synchronous fallback) + +When a synchronous return value is required and `await` is not possible -- +such as in sort comparators, column factories, or filter callbacks -- use +`entityPresentationSnapshot` as a fallback. It accepts `Entity`, +`CompoundEntityRef`, or string ref inputs and uses the presentation API +when available, falling back to `defaultEntityPresentation` otherwise: + +```ts +import { + entityPresentationSnapshot, + entityPresentationApiRef, +} from '@backstage/plugin-catalog-react'; + +// In a column factory or sort comparator where you have +// the API instance (or undefined if not registered): +const title = entityPresentationSnapshot( + entity, + { + defaultKind: 'Component', + }, + entityPresentationApi, +).primaryTitle; +``` + +Because this function is synchronous, it uses cached data from the +presentation API. If the entity has been seen before, the snapshot will +contain the full resolved title; otherwise it falls back to what can be +extracted from the ref alone. ## Customizing entity presentation @@ -124,6 +142,6 @@ Replace them as follows: | :---------------------------------------------------- | :--------------------------------------------------------------------- | | `humanizeEntityRef(entity)` in JSX | `` | | `humanizeEntityRef(entity)` in a React component | `useEntityPresentation(entity).primaryTitle` | -| `humanizeEntityRef(entity)` in a sort/filter callback | `entityPresentationApi.forEntity(entity).snapshot.primaryTitle` | | `humanizeEntityRef(entity)` in an async loader | `(await entityPresentationApi.forEntity(entity).promise).primaryTitle` | +| `humanizeEntityRef(entity)` in a sort/filter callback | `entityPresentationSnapshot(entity, ctx, api).primaryTitle` | | `humanizeEntity(entity, fallback)` | `useEntityPresentation(entity).primaryTitle` | diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index 251a0e853a..419a3c3f94 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -593,6 +593,16 @@ export interface EntityPresentationApi { // @public export const entityPresentationApiRef: ApiRef_2; +// @public +export function entityPresentationSnapshot( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { + defaultKind?: string; + defaultNamespace?: string; + }, + entityPresentationApi?: EntityPresentationApi, +): EntityRefPresentationSnapshot; + // @public (undocumented) export const EntityProcessingStatusPicker: () => JSX_2.Element; diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts index 316bc9aff5..99ae0ceacc 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/EntityPresentationApi.ts @@ -37,8 +37,8 @@ import { Observable } from '@backstage/types'; * and tooltip. * * - In non-React contexts such as sort comparators or data mappers, use the - * API directly via `forEntity().snapshot` for synchronous access, or - * `forEntity().promise` in async loaders. + * {@link entityPresentationSnapshot} helper for synchronous access, or + * the API directly via `forEntity().promise` in async loaders. * * @public */ @@ -143,8 +143,8 @@ export interface EntityRefPresentation { * - {@link EntityDisplayName} — React component that renders an entity name * with optional icon and tooltip. * - * For non-React contexts, you can use the API directly via - * `forEntity().snapshot` for synchronous access, or `forEntity().promise` + * For non-React contexts, use the {@link entityPresentationSnapshot} helper + * for synchronous access, or the API directly via `forEntity().promise` * for async contexts. * * Implement this interface to customize how entities are displayed throughout diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts index 1984593cfa..98f200c67a 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/defaultEntityPresentation.ts @@ -35,10 +35,10 @@ import { EntityRefPresentationSnapshot } from './EntityPresentationApi'; * * This function is primarily used as the internal fallback within the * {@link EntityPresentationApi} when no custom implementation is registered. - * Prefer using the API directly via `forEntity().snapshot` or - * `forEntity().promise`, which respects custom presentation overrides. * In React components, use the {@link useEntityPresentation} hook or the - * {@link EntityDisplayName} component. + * {@link EntityDisplayName} component. In non-React contexts, use + * {@link entityPresentationSnapshot} which respects custom presentation + * overrides and falls back to this function when no API is registered. * * @public * @param entityOrRef - Either an entity, or a ref to it. diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.test.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.test.ts new file mode 100644 index 0000000000..8ff27c3282 --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright 2026 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 { entityPresentationSnapshot } from './entityPresentationSnapshot'; +import { + EntityPresentationApi, + EntityRefPresentation, + EntityRefPresentationSnapshot, +} from './EntityPresentationApi'; + +function createMockApi(): EntityPresentationApi & { + calls: Array<{ entityOrRef: Entity | string; context?: object }>; +} { + const calls: Array<{ entityOrRef: Entity | string; context?: object }> = []; + return { + calls, + forEntity(entityOrRef, context) { + calls.push({ entityOrRef, context }); + const snapshot: EntityRefPresentationSnapshot = { + entityRef: typeof entityOrRef === 'string' ? entityOrRef : 'mock-ref', + primaryTitle: 'from-api', + secondaryTitle: undefined, + Icon: undefined, + }; + return { + snapshot, + promise: Promise.resolve(snapshot), + } as EntityRefPresentation; + }, + }; +} + +describe('entityPresentationSnapshot', () => { + describe('with EntityPresentationApi', () => { + it('passes Entity directly to forEntity', () => { + const api = createMockApi(); + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'test', namespace: 'default' }, + }; + + const result = entityPresentationSnapshot(entity, undefined, api); + + expect(result.primaryTitle).toBe('from-api'); + expect(api.calls).toHaveLength(1); + expect(api.calls[0].entityOrRef).toBe(entity); + }); + + it('stringifies CompoundEntityRef before passing to forEntity', () => { + const api = createMockApi(); + const ref: CompoundEntityRef = { + kind: 'group', + namespace: 'default', + name: 'my-team', + }; + + const result = entityPresentationSnapshot(ref, undefined, api); + + expect(result.primaryTitle).toBe('from-api'); + expect(api.calls).toHaveLength(1); + expect(api.calls[0].entityOrRef).toBe('group:default/my-team'); + expect(typeof api.calls[0].entityOrRef).toBe('string'); + }); + + it('passes string ref directly to forEntity', () => { + const api = createMockApi(); + + const result = entityPresentationSnapshot( + 'component:default/test', + undefined, + api, + ); + + expect(result.primaryTitle).toBe('from-api'); + expect(api.calls).toHaveLength(1); + expect(api.calls[0].entityOrRef).toBe('component:default/test'); + }); + + it('forwards context to forEntity', () => { + const api = createMockApi(); + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'test', namespace: 'default' }, + }; + + entityPresentationSnapshot( + entity, + { defaultKind: 'component', defaultNamespace: 'custom' }, + api, + ); + + expect(api.calls[0].context).toEqual({ + defaultKind: 'component', + defaultNamespace: 'custom', + }); + }); + }); + + describe('without EntityPresentationApi', () => { + it('falls back to defaultEntityPresentation for Entity', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + title: 'My Component', + }, + }; + + const result = entityPresentationSnapshot(entity); + + expect(result).toEqual({ + entityRef: 'component:default/test', + primaryTitle: 'My Component', + secondaryTitle: 'component:default/test', + Icon: undefined, + }); + }); + + it('falls back to defaultEntityPresentation for CompoundEntityRef', () => { + const ref: CompoundEntityRef = { + kind: 'group', + namespace: 'default', + name: 'my-team', + }; + + const result = entityPresentationSnapshot(ref); + + expect(result).toEqual({ + entityRef: 'group:default/my-team', + primaryTitle: 'my-team', + secondaryTitle: 'group:default/my-team', + Icon: undefined, + }); + }); + + it('falls back to defaultEntityPresentation for string ref', () => { + const result = entityPresentationSnapshot('component:default/test'); + + expect(result).toEqual({ + entityRef: 'component:default/test', + primaryTitle: 'test', + secondaryTitle: 'component:default/test', + Icon: undefined, + }); + }); + + it('forwards context to defaultEntityPresentation', () => { + const result = entityPresentationSnapshot('component:default/test', { + defaultKind: 'component', + }); + + expect(result.primaryTitle).toBe('test'); + }); + }); +}); diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.ts new file mode 100644 index 0000000000..a5ee876bdf --- /dev/null +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/entityPresentationSnapshot.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2026 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 { + EntityPresentationApi, + EntityRefPresentationSnapshot, +} from './EntityPresentationApi'; +import { defaultEntityPresentation } from './defaultEntityPresentation'; + +/** + * Returns a synchronous presentation snapshot for an entity in non-React + * contexts. + * + * @remarks + * + * This is the synchronous, non-React counterpart to + * {@link useEntityPresentation}. It handles `Entity`, `CompoundEntityRef`, + * and string ref inputs uniformly, using the provided + * {@link EntityPresentationApi} when available and falling back to + * {@link defaultEntityPresentation} otherwise. + * + * Because this function is synchronous, it uses cached data from the + * presentation API (via `.snapshot`). If the entity has been seen before, + * the snapshot will contain the full resolved title; otherwise it falls + * back to what can be extracted from the ref alone. This is the correct + * trade-off for sort comparators, column factories, filter callbacks, and + * data mappers where a synchronous return value is required. + * + * In async contexts such as data loaders where you can `await`, prefer + * using the {@link EntityPresentationApi} directly via + * `forEntity().promise` for the richest possible presentation. + * + * @public + * @param entityOrRef - An entity, a compound entity ref, or a string entity ref. + * @param context - Optional context that may affect the presentation. + * @param entityPresentationApi - Optional presentation API instance. When not + * provided, falls back to {@link defaultEntityPresentation}. + */ +export function entityPresentationSnapshot( + entityOrRef: Entity | CompoundEntityRef | string, + context?: { defaultKind?: string; defaultNamespace?: string }, + entityPresentationApi?: EntityPresentationApi, +): EntityRefPresentationSnapshot { + if (entityPresentationApi) { + const ref = + typeof entityOrRef === 'string' || 'metadata' in entityOrRef + ? entityOrRef + : stringifyEntityRef(entityOrRef); + return entityPresentationApi.forEntity(ref, context).snapshot; + } + return defaultEntityPresentation(entityOrRef, context); +} diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/index.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/index.ts index 405afe23f6..8369a69167 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/index.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/index.ts @@ -21,4 +21,5 @@ export { type EntityRefPresentationSnapshot, } from './EntityPresentationApi'; export { defaultEntityPresentation } from './defaultEntityPresentation'; +export { entityPresentationSnapshot } from './entityPresentationSnapshot'; export { useEntityPresentation } from './useEntityPresentation'; diff --git a/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts index 125557ceb7..68694f167a 100644 --- a/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts +++ b/plugins/catalog-react/src/apis/EntityPresentationApi/useEntityPresentation.ts @@ -43,7 +43,7 @@ import { useUpdatingObservable } from './useUpdatingObservable'; * component instead, which wraps this hook with icon and tooltip support. * * For non-React contexts such as sort comparators or data mappers, use - * the {@link EntityPresentationApi} directly via `forEntity().snapshot`. + * {@link entityPresentationSnapshot}. * * @public * @param entityOrRef - The entity to represent, or an entity ref to it. If you diff --git a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx index b0ff221e9d..80a4cc8070 100644 --- a/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx +++ b/plugins/catalog-react/src/components/EntityDataTable/columnFactories.tsx @@ -22,7 +22,7 @@ import { import { Cell, CellText, Column, ColumnConfig, TableItem } from '@backstage/ui'; import { EntityRefLink, EntityRefLinks } from '../EntityRefLink'; import { - defaultEntityPresentation, + entityPresentationSnapshot, EntityPresentationApi, } from '@backstage/plugin-catalog-react'; import { EntityTableColumnTitle } from '../EntityTable/TitleColumn'; @@ -36,18 +36,6 @@ export interface EntityColumnConfig extends ColumnConfig { sortValue?: (entity: EntityRow) => string; } -function getEntityTitle( - entityOrRef: Entity | { kind: string; namespace?: string; name: string }, - context: { defaultKind?: string }, - entityPresentationApi?: EntityPresentationApi, -): string { - if (entityPresentationApi) { - return entityPresentationApi.forEntity(entityOrRef as Entity, context) - .snapshot.primaryTitle; - } - return defaultEntityPresentation(entityOrRef as Entity, context).primaryTitle; -} - /** @public */ export const columnFactories = Object.freeze({ createEntityRefColumn(options: { @@ -76,11 +64,11 @@ export const columnFactories = Object.freeze({ ), sortValue: entity => - getEntityTitle( + entityPresentationSnapshot( entity, { defaultKind: options.defaultKind }, options.entityPresentationApi, - ), + ).primaryTitle, }; }, @@ -115,12 +103,13 @@ export const columnFactories = Object.freeze({ ), sortValue: entity => getEntityRelations(entity, options.relation, options.filter) - .map(r => - getEntityTitle( - r, - { defaultKind: options.defaultKind }, - options.entityPresentationApi, - ), + .map( + r => + entityPresentationSnapshot( + r, + { defaultKind: options.defaultKind }, + options.entityPresentationApi, + ).primaryTitle, ) .join(', '), }; diff --git a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx index a6b4ed94fe..1f60333acd 100644 --- a/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx +++ b/plugins/catalog-react/src/components/EntityDisplayName/EntityDisplayName.tsx @@ -70,7 +70,7 @@ export type EntityDisplayNameProps = { * * For more control over the presentation data, use the * {@link useEntityPresentation} hook directly. For non-React contexts, use - * the {@link EntityPresentationApi} directly via `forEntity().snapshot`. + * {@link entityPresentationSnapshot}. * * @public */ diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 1375140ebc..291fcf5376 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -34,8 +34,8 @@ import { useDebouncedEffect } from '@react-hookz/web'; import PersonIcon from '@material-ui/icons/Person'; import GroupIcon from '@material-ui/icons/Group'; import { - defaultEntityPresentation, entityPresentationApiRef, + entityPresentationSnapshot, } from '../../apis'; import { useFetchEntities } from './useFetchEntities'; import { withStyles } from '@material-ui/core/styles'; @@ -209,11 +209,11 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { defaultNamespace: 'default', }) : o; - if (entityPresentationApi) { - return entityPresentationApi.forEntity(entity as Entity).snapshot - .primaryTitle; - } - return defaultEntityPresentation(entity).primaryTitle; + return entityPresentationSnapshot( + entity, + undefined, + entityPresentationApi, + ).primaryTitle; }} onChange={(_: object, owners) => { setText(''); diff --git a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts index 80bd290806..fe697365b6 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/humanize.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/humanize.ts @@ -26,9 +26,10 @@ import get from 'lodash/get'; * if set to string which matches namespace of entity then omitted * * @deprecated Use {@link useEntityPresentation} or {@link EntityDisplayName} - * in React components, or access the {@link entityPresentationApiRef} directly. - * These provide richer display names using `metadata.title` and - * `spec.profile.displayName` in addition to the entity ref. + * in React components. In non-React contexts such as sort comparators or + * data mappers, use {@link entityPresentationSnapshot}. These provide richer + * display names using `metadata.title` and `spec.profile.displayName` in + * addition to the entity ref. * * @public **/ @@ -82,7 +83,8 @@ export function humanizeEntityRef( * If neither of those are found or populated, fallback to `defaultName`. * * @deprecated Use {@link useEntityPresentation} or {@link EntityDisplayName} - * in React components, or access the {@link entityPresentationApiRef} directly. + * in React components. In non-React contexts, use + * {@link entityPresentationSnapshot}. * * @param entity - Entity to convert. * @param defaultName - If entity readable name is not available, `defaultName` will be returned. diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx index 01d1c7e1f3..82dd8e7309 100644 --- a/plugins/catalog-react/src/components/EntityTable/columns.tsx +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -23,21 +23,9 @@ import { import { OverflowTooltip, TableColumn } from '@backstage/core-components'; import { getEntityRelations } from '../../utils'; import { EntityRefLink, EntityRefLinks } from '../EntityRefLink'; -import { defaultEntityPresentation, EntityPresentationApi } from '../../apis'; +import { entityPresentationSnapshot, EntityPresentationApi } from '../../apis'; import { EntityTableColumnTitle } from './TitleColumn'; -function getEntityTitle( - entityOrRef: Entity | CompoundEntityRef, - context: { defaultKind?: string }, - entityPresentationApi?: EntityPresentationApi, -): string { - if (entityPresentationApi) { - return entityPresentationApi.forEntity(entityOrRef as Entity, context) - .snapshot.primaryTitle; - } - return defaultEntityPresentation(entityOrRef, context).primaryTitle; -} - /** @public */ export const columnFactories = Object.freeze({ createEntityRefColumn(options: { @@ -46,7 +34,11 @@ export const columnFactories = Object.freeze({ }): TableColumn { const { defaultKind, entityPresentationApi } = options; function formatContent(entity: T): string { - return getEntityTitle(entity, { defaultKind }, entityPresentationApi); + return entityPresentationSnapshot( + entity, + { defaultKind }, + entityPresentationApi, + ).primaryTitle; } return { @@ -96,7 +88,14 @@ export const columnFactories = Object.freeze({ function formatContent(entity: T): string { return getRelations(entity) - .map(r => getEntityTitle(r, { defaultKind }, entityPresentationApi)) + .map( + r => + entityPresentationSnapshot( + r, + { defaultKind }, + entityPresentationApi, + ).primaryTitle, + ) .join(', '); } diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 6c55ff21a3..b021aae0e1 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -16,7 +16,6 @@ import { ANNOTATION_EDIT_URL, ANNOTATION_VIEW_URL, - CompoundEntityRef, Entity, RELATION_OWNED_BY, RELATION_PART_OF, @@ -30,8 +29,8 @@ import { WarningPanel, } from '@backstage/core-components'; import { - defaultEntityPresentation, entityPresentationApiRef, + entityPresentationSnapshot, getEntityRelations, useEntityList, useStarredEntities, @@ -73,21 +72,13 @@ export interface CatalogTableProps { subtitle?: string; } -function getTitle( - entityOrRef: Entity | CompoundEntityRef, - context: { defaultKind?: string }, - api?: EntityPresentationApi, -): string { - if (api) { - const ref = - 'metadata' in entityOrRef ? entityOrRef : stringifyEntityRef(entityOrRef); - return api.forEntity(ref, context).snapshot.primaryTitle; - } - return defaultEntityPresentation(entityOrRef, context).primaryTitle; -} - const sortEntities = (entities: Entity[], api?: EntityPresentationApi) => { - return sortBy(entities, e => getTitle(e, { defaultKind: 'Component' }, api)); + return sortBy( + entities, + e => + entityPresentationSnapshot(e, { defaultKind: 'Component' }, api) + .primaryTitle, + ); }; /** @@ -307,14 +298,26 @@ function toEntityRow(entity: Entity, api?: EntityPresentationApi) { // 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: getTitle(entity, { defaultKind: 'Component' }, api), + name: entityPresentationSnapshot( + entity, + { defaultKind: 'Component' }, + api, + ).primaryTitle, entityRef: stringifyEntityRef(entity), ownedByRelationsTitle: ownedByRelations - .map(r => getTitle(r, { defaultKind: 'group' }, api)) + .map( + r => + entityPresentationSnapshot(r, { defaultKind: 'group' }, api) + .primaryTitle, + ) .join(', '), ownedByRelations, partOfSystemRelationTitle: partOfSystemRelations - .map(r => getTitle(r, { defaultKind: 'system' }, api)) + .map( + r => + entityPresentationSnapshot(r, { defaultKind: 'system' }, api) + .primaryTitle, + ) .join(', '), partOfSystemRelations, }, diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx index b436f56427..efb1a06822 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -186,7 +186,7 @@ export const TemplateFormPreviewer = ({ severity: 'error', }), ), - [catalogApi], + [catalogApi, entityPresentationApi, alertApi], ); const handleSelectChange = useCallback( diff --git a/plugins/techdocs/src/home/components/Tables/helpers.ts b/plugins/techdocs/src/home/components/Tables/helpers.ts index 4f2424340e..b0b5c41fcd 100644 --- a/plugins/techdocs/src/home/components/Tables/helpers.ts +++ b/plugins/techdocs/src/home/components/Tables/helpers.ts @@ -14,13 +14,9 @@ * limitations under the License. */ +import { RELATION_OWNED_BY, Entity } from '@backstage/catalog-model'; import { - RELATION_OWNED_BY, - Entity, - stringifyEntityRef, -} from '@backstage/catalog-model'; -import { - defaultEntityPresentation, + entityPresentationSnapshot, getEntityRelations, type EntityPresentationApi, } from '@backstage/plugin-catalog-react'; @@ -54,15 +50,14 @@ export function entitiesToDocsMapper( }), ownedByRelations, ownedByRelationsTitle: ownedByRelations - .map(r => { - if (entityPresentationApi) { - return entityPresentationApi.forEntity(stringifyEntityRef(r), { - defaultKind: 'group', - }).snapshot.primaryTitle; - } - return defaultEntityPresentation(r, { defaultKind: 'group' }) - .primaryTitle; - }) + .map( + r => + entityPresentationSnapshot( + r, + { defaultKind: 'group' }, + entityPresentationApi, + ).primaryTitle, + ) .join(', '), }, };