Merge pull request #33576 from backstage/maratd/replace-humanize-entity-ref
Replace deprecated humanizeEntityRef with Catalog Presentation API
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Deprecated `humanizeEntityRef` and `humanizeEntity` in favor of the Catalog Presentation API. Use `useEntityPresentation`, `EntityDisplayName`, or `entityPresentationApiRef` instead.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
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 several 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';
|
||||
|
||||
<EntityDisplayName entityRef="component:default/my-service" />;
|
||||
```
|
||||
|
||||
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 (
|
||||
<span>
|
||||
{Icon && <Icon fontSize="inherit" />}
|
||||
{primaryTitle}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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 (async, preferred for non-React)
|
||||
|
||||
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, {
|
||||
defaultKind: 'group',
|
||||
}).promise;
|
||||
const title = presentation.primaryTitle;
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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 | `<EntityDisplayName entityRef={entity} />` |
|
||||
| `humanizeEntityRef(entity)` in a React component | `useEntityPresentation(entity).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` |
|
||||
@@ -271,6 +271,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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
+17
-1
@@ -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('<StepPrepareCreatePullRequest />', () => {
|
||||
|
||||
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<typeof errorApiRef.T> = {
|
||||
error$: jest.fn(),
|
||||
post: jest.fn(),
|
||||
@@ -54,6 +69,7 @@ describe('<StepPrepareCreatePullRequest />', () => {
|
||||
apis={[
|
||||
[catalogImportApiRef, catalogImportApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[entityPresentationApiRef, entityPresentationApi],
|
||||
[errorApiRef, errorApi],
|
||||
[configApiRef, configApi],
|
||||
]}
|
||||
|
||||
+9
-4
@@ -20,7 +20,7 @@ import { toError } from '@backstage/errors';
|
||||
import { useTranslationRef } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
humanizeEntityRef,
|
||||
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,9 +162,13 @@ export const StepPrepareCreatePullRequest = (
|
||||
filter: { kind: 'group' },
|
||||
});
|
||||
|
||||
return groupEntities.items
|
||||
.map(e => humanizeEntityRef(e, { defaultKind: 'group' }))
|
||||
.sort();
|
||||
const presentations = await Promise.all(
|
||||
groupEntities.items.map(
|
||||
e =>
|
||||
entityPresentationApi.forEntity(e, { defaultKind: 'group' }).promise,
|
||||
),
|
||||
);
|
||||
return presentations.map(p => p.primaryTitle).sort();
|
||||
});
|
||||
|
||||
const handleResult = useCallback(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ColumnConfig } from '@backstage/ui';
|
||||
import { ComponentType } from 'react';
|
||||
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';
|
||||
@@ -488,6 +489,7 @@ export const entityDataTableColumns: Readonly<{
|
||||
createEntityRefColumn(options: {
|
||||
defaultKind?: string;
|
||||
isRowHeader?: boolean;
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): EntityColumnConfig;
|
||||
createEntityRelationColumn(options: {
|
||||
id: string;
|
||||
@@ -497,6 +499,7 @@ export const entityDataTableColumns: Readonly<{
|
||||
filter?: {
|
||||
kind: string;
|
||||
};
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): EntityColumnConfig;
|
||||
createOwnerColumn(): EntityColumnConfig;
|
||||
createSystemColumn(): EntityColumnConfig;
|
||||
|
||||
@@ -266,6 +266,7 @@ export type CatalogReactUserListPickerClassKey =
|
||||
export const columnFactories: Readonly<{
|
||||
createEntityRefColumn<T extends Entity>(options: {
|
||||
defaultKind?: string;
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<T>;
|
||||
createEntityRelationColumn<T extends Entity>(options: {
|
||||
title: string | JSX.Element;
|
||||
@@ -274,6 +275,7 @@ export const columnFactories: Readonly<{
|
||||
filter?: {
|
||||
kind: string;
|
||||
};
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<T>;
|
||||
createOwnerColumn<T extends Entity>(): TableColumn<T>;
|
||||
createDomainColumn<T extends Entity>(): TableColumn<T>;
|
||||
@@ -591,6 +593,16 @@ export interface EntityPresentationApi {
|
||||
// @public
|
||||
export const entityPresentationApiRef: ApiRef_2<EntityPresentationApi>;
|
||||
|
||||
// @public
|
||||
export function entityPresentationSnapshot(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
entityPresentationApi?: EntityPresentationApi,
|
||||
): EntityRefPresentationSnapshot;
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityProcessingStatusPicker: () => JSX_2.Element;
|
||||
|
||||
@@ -687,6 +699,7 @@ export const EntityTable: {
|
||||
columns: Readonly<{
|
||||
createEntityRefColumn<T extends Entity>(options: {
|
||||
defaultKind?: string;
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<T>;
|
||||
createEntityRelationColumn<T extends Entity>(options: {
|
||||
title: string | JSX.Element;
|
||||
@@ -695,6 +708,7 @@ export const EntityTable: {
|
||||
filter?: {
|
||||
kind: string;
|
||||
};
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<T>;
|
||||
createOwnerColumn<T extends Entity>(): TableColumn<T>;
|
||||
createDomainColumn<T extends Entity>(): TableColumn<T>;
|
||||
@@ -834,7 +848,7 @@ export function getEntitySourceLocation(
|
||||
scmIntegrationsApi: typeof scmIntegrationsApiRef.T,
|
||||
): EntitySourceLocation | undefined;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export function humanizeEntityRef(
|
||||
entityRef: Entity | CompoundEntityRef,
|
||||
opts?: {
|
||||
|
||||
@@ -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 or data mappers, use the
|
||||
* {@link entityPresentationSnapshot} helper for synchronous access, or
|
||||
* the API directly via `forEntity().promise` in async loaders.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const entityPresentationApiRef: ApiRef<EntityPresentationApi> =
|
||||
@@ -120,8 +135,20 @@ 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.
|
||||
*
|
||||
* 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
|
||||
* the Backstage interface.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,21 @@ 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.
|
||||
*
|
||||
* This function is primarily used as the internal fallback within the
|
||||
* {@link EntityPresentationApi} when no custom implementation is registered.
|
||||
* In React components, use the {@link useEntityPresentation} hook or the
|
||||
* {@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.
|
||||
|
||||
+173
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -21,4 +21,5 @@ export {
|
||||
type EntityRefPresentationSnapshot,
|
||||
} from './EntityPresentationApi';
|
||||
export { defaultEntityPresentation } from './defaultEntityPresentation';
|
||||
export { entityPresentationSnapshot } from './entityPresentationSnapshot';
|
||||
export { useEntityPresentation } from './useEntityPresentation';
|
||||
|
||||
@@ -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 entityPresentationSnapshot}.
|
||||
*
|
||||
* @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
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
RELATION_PART_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Cell, CellText, Column, ColumnConfig, TableItem } from '@backstage/ui';
|
||||
import { EntityRefLink, EntityRefLinks } from '../EntityRefLink';
|
||||
import {
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
humanizeEntityRef,
|
||||
} from '../EntityRefLink';
|
||||
entityPresentationSnapshot,
|
||||
EntityPresentationApi,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { EntityTableColumnTitle } from '../EntityTable/TitleColumn';
|
||||
import { getEntityRelations } from '../../utils';
|
||||
|
||||
@@ -41,6 +41,7 @@ export const columnFactories = Object.freeze({
|
||||
createEntityRefColumn(options: {
|
||||
defaultKind?: string;
|
||||
isRowHeader?: boolean;
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): EntityColumnConfig {
|
||||
const isRowHeader = options.isRowHeader ?? true;
|
||||
return {
|
||||
@@ -63,8 +64,11 @@ export const columnFactories = Object.freeze({
|
||||
</Cell>
|
||||
),
|
||||
sortValue: entity =>
|
||||
entity.metadata?.title ||
|
||||
humanizeEntityRef(entity, { defaultKind: options.defaultKind }),
|
||||
entityPresentationSnapshot(
|
||||
entity,
|
||||
{ defaultKind: options.defaultKind },
|
||||
options.entityPresentationApi,
|
||||
).primaryTitle,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -74,6 +78,7 @@ export const columnFactories = Object.freeze({
|
||||
relation: string;
|
||||
defaultKind?: string;
|
||||
filter?: { kind: string };
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): EntityColumnConfig {
|
||||
return {
|
||||
id: options.id,
|
||||
@@ -98,7 +103,14 @@ export const columnFactories = Object.freeze({
|
||||
),
|
||||
sortValue: entity =>
|
||||
getEntityRelations(entity, options.relation, options.filter)
|
||||
.map(r => humanizeEntityRef(r, { defaultKind: options.defaultKind }))
|
||||
.map(
|
||||
r =>
|
||||
entityPresentationSnapshot(
|
||||
r,
|
||||
{ defaultKind: options.defaultKind },
|
||||
options.entityPresentationApi,
|
||||
).primaryTitle,
|
||||
)
|
||||
.join(', '),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 entityPresentationSnapshot}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const EntityDisplayName = (
|
||||
|
||||
@@ -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 { humanizeEntity, humanizeEntityRef } from '../EntityRefLink/humanize';
|
||||
import {
|
||||
entityPresentationApiRef,
|
||||
entityPresentationSnapshot,
|
||||
} 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,7 +209,11 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => {
|
||||
defaultNamespace: 'default',
|
||||
})
|
||||
: o;
|
||||
return humanizeEntity(entity, humanizeEntityRef(entity));
|
||||
return entityPresentationSnapshot(
|
||||
entity,
|
||||
undefined,
|
||||
entityPresentationApi,
|
||||
).primaryTitle;
|
||||
}}
|
||||
onChange={(_: object, owners) => {
|
||||
setText('');
|
||||
|
||||
@@ -25,6 +25,12 @@ 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 useEntityPresentation} or {@link EntityDisplayName}
|
||||
* 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
|
||||
**/
|
||||
export function humanizeEntityRef(
|
||||
@@ -76,6 +82,10 @@ export function humanizeEntityRef(
|
||||
*
|
||||
* If neither of those are found or populated, fallback to `defaultName`.
|
||||
*
|
||||
* @deprecated Use {@link useEntityPresentation} or {@link EntityDisplayName}
|
||||
* 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.
|
||||
* @returns Readable name, defaults to `defaultName`.
|
||||
|
||||
@@ -22,26 +22,23 @@ 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 { entityPresentationSnapshot, EntityPresentationApi } from '../../apis';
|
||||
import { EntityTableColumnTitle } from './TitleColumn';
|
||||
|
||||
/** @public */
|
||||
export const columnFactories = Object.freeze({
|
||||
createEntityRefColumn<T extends Entity>(options: {
|
||||
defaultKind?: string;
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<T> {
|
||||
const { defaultKind } = options;
|
||||
const { defaultKind, entityPresentationApi } = options;
|
||||
function formatContent(entity: T): string {
|
||||
return (
|
||||
entity.metadata?.title ||
|
||||
humanizeEntityRef(entity, {
|
||||
defaultKind,
|
||||
})
|
||||
);
|
||||
return entityPresentationSnapshot(
|
||||
entity,
|
||||
{ defaultKind },
|
||||
entityPresentationApi,
|
||||
).primaryTitle;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -75,8 +72,15 @@ export const columnFactories = Object.freeze({
|
||||
relation: string;
|
||||
defaultKind?: string;
|
||||
filter?: { kind: string };
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<T> {
|
||||
const { title, relation, defaultKind, filter: entityFilter } = options;
|
||||
const {
|
||||
title,
|
||||
relation,
|
||||
defaultKind,
|
||||
filter: entityFilter,
|
||||
entityPresentationApi,
|
||||
} = options;
|
||||
|
||||
function getRelations(entity: T): CompoundEntityRef[] {
|
||||
return getEntityRelations(entity, relation, entityFilter);
|
||||
@@ -84,7 +88,14 @@ export const columnFactories = Object.freeze({
|
||||
|
||||
function formatContent(entity: T): string {
|
||||
return getRelations(entity)
|
||||
.map(r => humanizeEntityRef(r, { defaultKind }))
|
||||
.map(
|
||||
r =>
|
||||
entityPresentationSnapshot(
|
||||
r,
|
||||
{ defaultKind },
|
||||
entityPresentationApi,
|
||||
).primaryTitle,
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
|
||||
+2
-10
@@ -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<NodeType>) {
|
||||
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(
|
||||
|
||||
@@ -153,6 +153,7 @@ export const CatalogTable: {
|
||||
columns: Readonly<{
|
||||
createNameColumn(options?: {
|
||||
defaultKind?: string;
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<CatalogTableRow>;
|
||||
createSystemColumn(): TableColumn<CatalogTableRow>;
|
||||
createOwnerColumn(): TableColumn<CatalogTableRow>;
|
||||
|
||||
@@ -29,17 +29,19 @@ import {
|
||||
WarningPanel,
|
||||
} from '@backstage/core-components';
|
||||
import {
|
||||
entityPresentationApiRef,
|
||||
entityPresentationSnapshot,
|
||||
getEntityRelations,
|
||||
humanizeEntityRef,
|
||||
useEntityList,
|
||||
useStarredEntities,
|
||||
type EntityPresentationApi,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
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';
|
||||
@@ -47,6 +49,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,14 +72,13 @@ export interface CatalogTableProps {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const refCompare = (a: Entity, b: Entity) => {
|
||||
const toRef = (entity: Entity) =>
|
||||
entity.metadata.title ||
|
||||
humanizeEntityRef(entity, {
|
||||
defaultKind: 'Component',
|
||||
});
|
||||
|
||||
return toRef(a).localeCompare(toRef(b));
|
||||
const sortEntities = (entities: Entity[], api?: EntityPresentationApi) => {
|
||||
return sortBy(
|
||||
entities,
|
||||
e =>
|
||||
entityPresentationSnapshot(e, { defaultKind: 'Component' }, api)
|
||||
.primaryTitle,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -97,6 +99,8 @@ export const CatalogTable = (props: CatalogTableProps) => {
|
||||
emptyContent,
|
||||
} = props;
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
const apis = useApiHolder();
|
||||
const entityPresentationApi = apis.get(entityPresentationApiRef);
|
||||
const entityListContext = useEntityList();
|
||||
|
||||
const {
|
||||
@@ -234,7 +238,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}
|
||||
/>
|
||||
@@ -249,12 +253,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 = sortEntities(entities, entityPresentationApi).map(e =>
|
||||
toEntityRow(e, entityPresentationApi),
|
||||
);
|
||||
const pageSize = 20;
|
||||
const showPagination = rows.length > pageSize;
|
||||
|
||||
@@ -280,7 +286,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',
|
||||
});
|
||||
@@ -292,19 +298,25 @@ 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: entityPresentationSnapshot(
|
||||
entity,
|
||||
{ defaultKind: 'Component' },
|
||||
api,
|
||||
).primaryTitle,
|
||||
entityRef: stringifyEntityRef(entity),
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
|
||||
.map(
|
||||
r =>
|
||||
entityPresentationSnapshot(r, { defaultKind: 'group' }, api)
|
||||
.primaryTitle,
|
||||
)
|
||||
.join(', '),
|
||||
ownedByRelations,
|
||||
partOfSystemRelationTitle: partOfSystemRelations
|
||||
.map(r =>
|
||||
humanizeEntityRef(r, {
|
||||
defaultKind: 'system',
|
||||
}),
|
||||
.map(
|
||||
r =>
|
||||
entityPresentationSnapshot(r, { defaultKind: 'system' }, api)
|
||||
.primaryTitle,
|
||||
)
|
||||
.join(', '),
|
||||
partOfSystemRelations,
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
humanizeEntityRef,
|
||||
defaultEntityPresentation,
|
||||
EntityRefLink,
|
||||
EntityRefLinks,
|
||||
type EntityPresentationApi,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import Chip from '@material-ui/core/Chip';
|
||||
import { CatalogTableRow } from './types';
|
||||
@@ -31,14 +32,17 @@ import { EntityTableColumnTitle } from '@backstage/plugin-catalog-react/alpha';
|
||||
export const columnFactories = Object.freeze({
|
||||
createNameColumn(options?: {
|
||||
defaultKind?: string;
|
||||
entityPresentationApi?: EntityPresentationApi;
|
||||
}): TableColumn<CatalogTableRow> {
|
||||
function formatContent(entity: Entity): string {
|
||||
return (
|
||||
entity.metadata?.title ||
|
||||
humanizeEntityRef(entity, {
|
||||
if (options?.entityPresentationApi) {
|
||||
return options.entityPresentationApi.forEntity(entity, {
|
||||
defaultKind: options?.defaultKind,
|
||||
})
|
||||
);
|
||||
}).snapshot.primaryTitle;
|
||||
}
|
||||
return defaultEntityPresentation(entity, {
|
||||
defaultKind: options?.defaultKind,
|
||||
}).primaryTitle;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,15 +17,16 @@
|
||||
import { MouseEvent, useState, useCallback } from 'react';
|
||||
import {
|
||||
catalogApiRef,
|
||||
humanizeEntityRef,
|
||||
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 { Entity, GroupEntity } from '@backstage/catalog-model';
|
||||
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<HTMLElement | null>(null);
|
||||
@@ -85,8 +88,6 @@ export const GroupListPicker = (props: GroupListPickerProps) => {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
const getHumanEntityRef = (entity: Entity) => humanizeEntityRef(entity);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
@@ -101,7 +102,9 @@ export const GroupListPicker = (props: GroupListPickerProps) => {
|
||||
options={groups ?? []}
|
||||
groupBy={option => option.spec.type}
|
||||
getOptionLabel={option =>
|
||||
option.spec.profile?.displayName ?? getHumanEntityRef(option)
|
||||
entityPresentationApi
|
||||
? entityPresentationApi.forEntity(option).snapshot.primaryTitle
|
||||
: defaultEntityPresentation(option).primaryTitle
|
||||
}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, value) => setInputValue(value)}
|
||||
|
||||
+28
-3
@@ -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(
|
||||
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApiMock],
|
||||
[entityPresentationApiRef, entityPresentationApi],
|
||||
]}
|
||||
>
|
||||
<TemplateFormPage />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
@@ -41,7 +61,12 @@ describe('TemplateFormPage', () => {
|
||||
|
||||
it('Should have an link back to the edit page', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApiMock],
|
||||
[entityPresentationApiRef, entityPresentationApi],
|
||||
]}
|
||||
>
|
||||
<TemplateFormPage />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
|
||||
+14
-10
@@ -24,7 +24,7 @@ import { makeStyles } from '@material-ui/core/styles';
|
||||
import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
humanizeEntityRef,
|
||||
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,23 +167,26 @@ export const TemplateFormPreviewer = ({
|
||||
'spec.output',
|
||||
],
|
||||
})
|
||||
.then(({ items }) =>
|
||||
setTemplateOptions(
|
||||
items.map(template => ({
|
||||
label:
|
||||
template.metadata.title ??
|
||||
humanizeEntityRef(template, { defaultKind: 'template' }),
|
||||
.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}`,
|
||||
severity: 'error',
|
||||
}),
|
||||
),
|
||||
[catalogApi],
|
||||
[catalogApi, entityPresentationApi, alertApi],
|
||||
);
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
|
||||
import { RELATION_OWNED_BY, Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
entityPresentationSnapshot,
|
||||
getEntityRelations,
|
||||
humanizeEntityRef,
|
||||
type EntityPresentationApi,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { toLowerMaybe } from '../../../helpers';
|
||||
import { ConfigApi, RouteFunc } from '@backstage/core-plugin-api';
|
||||
@@ -32,6 +33,7 @@ export function entitiesToDocsMapper(
|
||||
entities: Entity[],
|
||||
getRouteToReaderPageFor: getRouteFunc,
|
||||
config: ConfigApi,
|
||||
entityPresentationApi?: EntityPresentationApi,
|
||||
) {
|
||||
return entities.map(entity => {
|
||||
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
|
||||
@@ -48,7 +50,14 @@ export function entitiesToDocsMapper(
|
||||
}),
|
||||
ownedByRelations,
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
|
||||
.map(
|
||||
r =>
|
||||
entityPresentationSnapshot(
|
||||
r,
|
||||
{ defaultKind: 'group' },
|
||||
entityPresentationApi,
|
||||
).primaryTitle,
|
||||
)
|
||||
.join(', '),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user