Add an entityPresentationApiRef
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': minor
|
||||
---
|
||||
|
||||
Added an `EntityPresentationApi` and associated `entityPresentationApiRef`. This
|
||||
API lets you control how references to entities (e.g. in links, headings,
|
||||
iconography etc) are represented in the user interface.
|
||||
|
||||
Usage of this API is initially added to the `EntityRefLink` and `EntityRefLinks`
|
||||
components, so that they can render richer, more correct representation of
|
||||
entity refs. There's also a new `EntityName` component, which works just like
|
||||
the `EntityRefLink` but without the link.
|
||||
|
||||
Along with that change, the `fetchEntities` and `getTitle` props of
|
||||
`EntityRefLinksProps` are deprecated and no longer used, since the same need
|
||||
instead is fulfilled (and by default always enabled) by the
|
||||
`entityPresentationApiRef`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': minor
|
||||
---
|
||||
|
||||
`IconComponent` can now have a `fontSize` of `inherit`, which is useful for in-line icons.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': minor
|
||||
---
|
||||
|
||||
Added the `DefaultEntityPresentationApi`, which is an implementation of the
|
||||
`EntityPresentationApi` that `@backstage/plugin-catalog-react` exposes through
|
||||
its `entityPresentationApiRef`. This implementation is also by default made
|
||||
available automatically by the catalog plugin, unless you replace it with a
|
||||
custom one. It batch fetches and caches data from the catalog as needed for
|
||||
display, and is customizable by adopters to add their own rendering functions.
|
||||
@@ -503,10 +503,10 @@ export const googleAuthApiRef: ApiRef<
|
||||
// @public
|
||||
export type IconComponent = ComponentType<
|
||||
| {
|
||||
fontSize?: 'large' | 'small' | 'default';
|
||||
fontSize?: 'large' | 'small' | 'default' | 'inherit';
|
||||
}
|
||||
| {
|
||||
fontSize?: 'medium' | 'large' | 'small';
|
||||
fontSize?: 'medium' | 'large' | 'small' | 'inherit';
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
@@ -36,10 +36,10 @@ import { ComponentType } from 'react';
|
||||
export type IconComponent = ComponentType<
|
||||
/* Material UI v4 */
|
||||
| {
|
||||
fontSize?: 'large' | 'small' | 'default';
|
||||
fontSize?: 'large' | 'small' | 'default' | 'inherit';
|
||||
}
|
||||
/* Material UI v5: https://mui.com/material-ui/migration/v5-component-changes/#icon */
|
||||
| {
|
||||
fontSize?: 'medium' | 'large' | 'small';
|
||||
fontSize?: 'medium' | 'large' | 'small' | 'inherit';
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ComponentProps } from 'react';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { InfoCardVariants } from '@backstage/core-components';
|
||||
import { LinkProps } from '@backstage/core-components';
|
||||
import { Observable } from '@backstage/types';
|
||||
@@ -84,6 +85,7 @@ export const CatalogFilterLayout: {
|
||||
// @public (undocumented)
|
||||
export type CatalogReactComponentsNameToClassKey = {
|
||||
CatalogReactUserListPicker: CatalogReactUserListPickerClassKey;
|
||||
CatalogReactEntityDisplayName: CatalogReactEntityDisplayNameClassKey;
|
||||
CatalogReactEntityLifecyclePicker: CatalogReactEntityLifecyclePickerClassKey;
|
||||
CatalogReactEntitySearchBar: CatalogReactEntitySearchBarClassKey;
|
||||
CatalogReactEntityTagPicker: CatalogReactEntityTagPickerClassKey;
|
||||
@@ -91,6 +93,9 @@ export type CatalogReactComponentsNameToClassKey = {
|
||||
CatalogReactEntityProcessingStatusPicker: CatalogReactEntityProcessingStatusPickerClassKey;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CatalogReactEntityDisplayNameClassKey = 'root' | 'icon';
|
||||
|
||||
// @public (undocumented)
|
||||
export type CatalogReactEntityLifecyclePickerClassKey = 'input';
|
||||
|
||||
@@ -174,6 +179,26 @@ export type EntityAutocompletePickerProps<
|
||||
initialSelectedOptions?: string[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export function defaultEntityPresentation(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentationSnapshot;
|
||||
|
||||
// @public
|
||||
export const EntityDisplayName: (props: EntityDisplayNameProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type EntityDisplayNameProps = {
|
||||
entityRef: Entity | CompoundEntityRef | string;
|
||||
variant?: 'simple' | string;
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class EntityErrorFilter implements EntityFilter {
|
||||
constructor(value: boolean);
|
||||
@@ -323,6 +348,20 @@ export type EntityPeekAheadPopoverProps = PropsWithChildren<{
|
||||
delayTime?: number;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export interface EntityPresentationApi {
|
||||
forEntity(
|
||||
entityOrRef: Entity | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentation;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const entityPresentationApiRef: ApiRef<EntityPresentationApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityProcessingStatusPicker: () => React_2.JSX.Element;
|
||||
|
||||
@@ -346,6 +385,7 @@ export const EntityRefLink: (props: EntityRefLinkProps) => JSX.Element;
|
||||
export type EntityRefLinkProps = {
|
||||
entityRef: Entity | CompoundEntityRef | string;
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
title?: string;
|
||||
children?: React_2.ReactNode;
|
||||
} & Omit<LinkProps, 'to'>;
|
||||
@@ -358,21 +398,32 @@ export function EntityRefLinks<
|
||||
// @public
|
||||
export type EntityRefLinksProps<
|
||||
TRef extends string | CompoundEntityRef | Entity,
|
||||
> = (
|
||||
| {
|
||||
defaultKind?: string;
|
||||
entityRefs: TRef[];
|
||||
fetchEntities?: false;
|
||||
getTitle?(entity: TRef): string | undefined;
|
||||
}
|
||||
| {
|
||||
defaultKind?: string;
|
||||
entityRefs: TRef[];
|
||||
fetchEntities: true;
|
||||
getTitle(entity: Entity): string | undefined;
|
||||
}
|
||||
) &
|
||||
Omit<LinkProps, 'to'>;
|
||||
> = {
|
||||
defaultKind?: string;
|
||||
entityRefs: TRef[];
|
||||
fetchEntities?: boolean;
|
||||
getTitle?(entity: TRef): string | undefined;
|
||||
} & Omit<LinkProps, 'to'>;
|
||||
|
||||
// @public
|
||||
export interface EntityRefPresentation {
|
||||
snapshot: EntityRefPresentationSnapshot;
|
||||
update$?: Observable<EntityRefPresentationSnapshot>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface EntityRefPresentationSnapshot {
|
||||
// (undocumented)
|
||||
entity?: Entity | undefined;
|
||||
// (undocumented)
|
||||
entityRef: string;
|
||||
// (undocumented)
|
||||
Icon?: IconComponent | undefined;
|
||||
// (undocumented)
|
||||
primaryTitle: string;
|
||||
// (undocumented)
|
||||
secondaryTitle?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function entityRouteParams(entity: Entity): {
|
||||
@@ -598,6 +649,15 @@ export function useEntityOwnership(): {
|
||||
isOwnedEntity: (entity: Entity) => boolean;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function useEntityPresentation(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentationSnapshot;
|
||||
|
||||
// @public
|
||||
export function useEntityTypeFilter(): {
|
||||
loading: boolean;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiRef,
|
||||
IconComponent,
|
||||
createApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* An API that handles how to represent entities in the interface.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const entityPresentationApiRef: ApiRef<EntityPresentationApi> =
|
||||
createApiRef({
|
||||
id: 'catalog-react.entity-presentation',
|
||||
});
|
||||
|
||||
/**
|
||||
* The visual presentation of an entity reference at some point in time.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityRefPresentationSnapshot {
|
||||
entityRef: string;
|
||||
entity?: Entity | undefined;
|
||||
primaryTitle: string;
|
||||
secondaryTitle?: string;
|
||||
Icon?: IconComponent | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual presentation of an entity reference.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityRefPresentation {
|
||||
/**
|
||||
* The representation that's suitable to use for this entity right now.
|
||||
*/
|
||||
snapshot: EntityRefPresentationSnapshot;
|
||||
/**
|
||||
* Some presentation implementations support emitting updated snapshots over
|
||||
* time, for example after retrieving additional data from the catalog or
|
||||
* elsewhere.
|
||||
*/
|
||||
update$?: Observable<EntityRefPresentationSnapshot>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An API that decides how to visually represent entities in the interface.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Most consumers will want to use the {@link useEntityPresentation} hook
|
||||
* instead of this interface directly.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPresentationApi {
|
||||
/**
|
||||
* Fetches the presentation for an entity.
|
||||
*
|
||||
* @param entityOrRef - Either an entity, or a string ref to it. If you pass
|
||||
* in an entity, it is assumed that it is not a partial one - i.e. only pass
|
||||
* in an entity if you know that it was fetched in such a way that it
|
||||
* contains all of the fields that the representation renderer needs.
|
||||
* @param context - Contextual information that may affect the presentation.
|
||||
*/
|
||||
forEntity(
|
||||
entityOrRef: Entity | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentation;
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
|
||||
import { defaultEntityPresentation } from './defaultEntityPresentation';
|
||||
|
||||
describe('defaultEntityPresentation', () => {
|
||||
describe('entity given', () => {
|
||||
it('happy path', () => {
|
||||
expect(
|
||||
defaultEntityPresentation({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
namespace: 'default',
|
||||
description: 'desc',
|
||||
},
|
||||
spec: {
|
||||
type: 'type',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
entity: expect.anything(),
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test | type | desc',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(
|
||||
defaultEntityPresentation({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
namespace: 'default',
|
||||
title: 'title',
|
||||
description: 'desc',
|
||||
},
|
||||
spec: {
|
||||
type: 'type',
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
entity: expect.anything(),
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'title',
|
||||
secondaryTitle: 'component:default/test | type | desc',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(
|
||||
defaultEntityPresentation({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
namespace: 'default',
|
||||
title: 'title',
|
||||
description: 'desc',
|
||||
},
|
||||
spec: {
|
||||
type: 'type',
|
||||
profile: {
|
||||
displayName: 'displayName',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
entity: expect.anything(),
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'displayName',
|
||||
secondaryTitle: 'component:default/test | type | desc',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the absolute minimum', () => {
|
||||
expect(
|
||||
defaultEntityPresentation({
|
||||
kind: 'Component',
|
||||
metadata: { name: 'test' },
|
||||
} as Entity),
|
||||
).toEqual({
|
||||
entity: expect.anything(),
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('fails without throwing on malformed entities', () => {
|
||||
expect(
|
||||
defaultEntityPresentation({ metadata: 7 } as unknown as Entity),
|
||||
).toEqual({
|
||||
entity: expect.anything(),
|
||||
entityRef: 'unknown:default/unknown',
|
||||
primaryTitle: 'unknown',
|
||||
secondaryTitle: 'unknown:default/unknown',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('string ref given', () => {
|
||||
it('happy path', () => {
|
||||
expect(defaultEntityPresentation('component:default/test')).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(
|
||||
defaultEntityPresentation('component:default/test', {
|
||||
defaultKind: 'X',
|
||||
}),
|
||||
).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'component:test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(
|
||||
defaultEntityPresentation('component:default/test', {
|
||||
defaultNamespace: 'X',
|
||||
}),
|
||||
).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'default/test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('works without throwing on malformed and shortened refs', () => {
|
||||
expect(defaultEntityPresentation('')).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'unknown:default/unknown',
|
||||
primaryTitle: 'unknown',
|
||||
secondaryTitle: 'unknown:default/unknown',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(defaultEntityPresentation('name')).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'unknown:default/name',
|
||||
primaryTitle: 'name',
|
||||
secondaryTitle: 'unknown:default/name',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('compound ref given', () => {
|
||||
it('happy path', () => {
|
||||
expect(
|
||||
defaultEntityPresentation({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'test',
|
||||
}),
|
||||
).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(
|
||||
defaultEntityPresentation(
|
||||
{ kind: 'component', namespace: 'default', name: 'test' },
|
||||
{
|
||||
defaultKind: 'X',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'component:test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(
|
||||
defaultEntityPresentation(
|
||||
{ kind: 'component', namespace: 'default', name: 'test' },
|
||||
{
|
||||
defaultNamespace: 'X',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'default/test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('works without throwing on malformed refs', () => {
|
||||
expect(
|
||||
defaultEntityPresentation(
|
||||
{ kind: 'component', name: 'test' } as CompoundEntityRef,
|
||||
{
|
||||
defaultNamespace: 'X',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'component:default/test',
|
||||
primaryTitle: 'default/test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(defaultEntityPresentation('')).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'unknown:default/unknown',
|
||||
primaryTitle: 'unknown',
|
||||
secondaryTitle: 'unknown:default/unknown',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('entirely invalid input type given', () => {
|
||||
it('sad path', () => {
|
||||
expect(defaultEntityPresentation(null as unknown as Entity)).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'unknown:default/unknown',
|
||||
primaryTitle: 'unknown',
|
||||
secondaryTitle: 'unknown:default/unknown',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
|
||||
expect(defaultEntityPresentation(undefined as unknown as Entity)).toEqual(
|
||||
{
|
||||
entity: undefined,
|
||||
entityRef: 'unknown:default/unknown',
|
||||
primaryTitle: 'unknown',
|
||||
secondaryTitle: 'unknown:default/unknown',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
defaultEntityPresentation(Symbol.for('Prince') as unknown as Entity),
|
||||
).toEqual({
|
||||
entity: undefined,
|
||||
entityRef: 'unknown:default/unknown',
|
||||
primaryTitle: 'unknown',
|
||||
secondaryTitle: 'unknown:default/unknown',
|
||||
Icon: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
Entity,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import ApartmentIcon from '@material-ui/icons/Apartment';
|
||||
import BusinessIcon from '@material-ui/icons/Business';
|
||||
import ExtensionIcon from '@material-ui/icons/Extension';
|
||||
import HelpIcon from '@material-ui/icons/Help';
|
||||
import LibraryAddIcon from '@material-ui/icons/LibraryAdd';
|
||||
import LocationOnIcon from '@material-ui/icons/LocationOn';
|
||||
import MemoryIcon from '@material-ui/icons/Memory';
|
||||
import PeopleIcon from '@material-ui/icons/People';
|
||||
import PersonIcon from '@material-ui/icons/Person';
|
||||
import get from 'lodash/get';
|
||||
import { EntityRefPresentationSnapshot } from './EntityPresentationApi';
|
||||
|
||||
const UNKNOWN_KIND_ICON: IconComponent = HelpIcon;
|
||||
|
||||
const DEFAULT_ICONS: Record<string, IconComponent> = {
|
||||
api: ExtensionIcon,
|
||||
component: MemoryIcon,
|
||||
system: BusinessIcon,
|
||||
domain: ApartmentIcon,
|
||||
location: LocationOnIcon,
|
||||
user: PersonIcon,
|
||||
group: PeopleIcon,
|
||||
template: LibraryAddIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
* This returns the default representation of an entity.
|
||||
*
|
||||
* @public
|
||||
* @param entityOrRef - Either an entity, or a ref to it.
|
||||
* @param context - Contextual information that may affect the presentation.
|
||||
*/
|
||||
export function defaultEntityPresentation(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentationSnapshot {
|
||||
// NOTE(freben): This code may look convoluted, but it tries its very best to
|
||||
// be defensive and handling any type of malformed input and still producing
|
||||
// some form of result without crashing.
|
||||
const { kind, namespace, name, title, description, displayName, type } =
|
||||
getParts(entityOrRef);
|
||||
|
||||
const Icon =
|
||||
(kind && DEFAULT_ICONS[kind.toLocaleLowerCase('en-US')]) ||
|
||||
UNKNOWN_KIND_ICON;
|
||||
|
||||
const entity: Entity | undefined =
|
||||
typeof entityOrRef === 'object' &&
|
||||
entityOrRef !== null &&
|
||||
'metadata' in entityOrRef
|
||||
? entityOrRef
|
||||
: undefined;
|
||||
|
||||
const entityRef: string = stringifyEntityRef({
|
||||
kind: kind || 'unknown',
|
||||
namespace: namespace || DEFAULT_NAMESPACE,
|
||||
name: name || 'unknown',
|
||||
});
|
||||
|
||||
const shortRef = getShortRef({ kind, namespace, name, context });
|
||||
|
||||
const primary = [displayName, title, shortRef].filter(
|
||||
candidate => candidate && typeof candidate === 'string',
|
||||
)[0]!;
|
||||
|
||||
const secondary = [
|
||||
primary !== entityRef ? entityRef : undefined,
|
||||
type,
|
||||
description,
|
||||
]
|
||||
.filter(candidate => candidate && typeof candidate === 'string')
|
||||
.join(' | ');
|
||||
|
||||
return {
|
||||
entity,
|
||||
entityRef,
|
||||
primaryTitle: primary,
|
||||
secondaryTitle: secondary || undefined,
|
||||
Icon,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to extract display-worthy parts of an entity or ref as best we can, without throwing
|
||||
function getParts(entityOrRef: Entity | CompoundEntityRef | string): {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
} {
|
||||
if (typeof entityOrRef === 'string') {
|
||||
let colonI = entityOrRef.indexOf(':');
|
||||
const slashI = entityOrRef.indexOf('/');
|
||||
|
||||
// If the / is ahead of the :, treat the rest as the name
|
||||
if (slashI !== -1 && slashI < colonI) {
|
||||
colonI = -1;
|
||||
}
|
||||
|
||||
const kind = colonI === -1 ? undefined : entityOrRef.slice(0, colonI);
|
||||
const namespace =
|
||||
slashI === -1 ? undefined : entityOrRef.slice(colonI + 1, slashI);
|
||||
const name = entityOrRef.slice(Math.max(colonI + 1, slashI + 1));
|
||||
|
||||
return { kind, namespace, name };
|
||||
}
|
||||
|
||||
if (typeof entityOrRef === 'object' && entityOrRef !== null) {
|
||||
const kind = [get(entityOrRef, 'kind')].filter(
|
||||
candidate => candidate && typeof candidate === 'string',
|
||||
)[0];
|
||||
|
||||
const namespace = [
|
||||
get(entityOrRef, 'metadata.namespace'),
|
||||
get(entityOrRef, 'namespace'),
|
||||
].filter(candidate => candidate && typeof candidate === 'string')[0];
|
||||
|
||||
const name = [
|
||||
get(entityOrRef, 'metadata.name'),
|
||||
get(entityOrRef, 'name'),
|
||||
].filter(candidate => candidate && typeof candidate === 'string')[0];
|
||||
|
||||
const title = [get(entityOrRef, 'metadata.title')].filter(
|
||||
candidate => candidate && typeof candidate === 'string',
|
||||
)[0];
|
||||
|
||||
const description = [get(entityOrRef, 'metadata.description')].filter(
|
||||
candidate => candidate && typeof candidate === 'string',
|
||||
)[0];
|
||||
|
||||
const displayName = [get(entityOrRef, 'spec.profile.displayName')].filter(
|
||||
candidate => candidate && typeof candidate === 'string',
|
||||
)[0];
|
||||
|
||||
const type = [get(entityOrRef, 'spec.type')].filter(
|
||||
candidate => candidate && typeof candidate === 'string',
|
||||
)[0];
|
||||
|
||||
return { kind, namespace, name, title, description, displayName, type };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getShortRef(options: {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name?: string;
|
||||
context?: { defaultKind?: string; defaultNamespace?: string };
|
||||
}): string {
|
||||
const kind = options.kind?.toLocaleLowerCase('en-US') || 'unknown';
|
||||
const namespace =
|
||||
options.namespace?.toLocaleLowerCase('en-US') || DEFAULT_NAMESPACE;
|
||||
const name = options.name?.toLocaleLowerCase('en-US') || 'unknown';
|
||||
const defaultKind = options.context?.defaultKind?.toLocaleLowerCase('en-US');
|
||||
const defaultNamespace =
|
||||
options.context?.defaultNamespace?.toLocaleLowerCase('en-US');
|
||||
|
||||
let result = name;
|
||||
|
||||
if (
|
||||
(defaultNamespace && namespace !== defaultNamespace) ||
|
||||
namespace !== DEFAULT_NAMESPACE
|
||||
) {
|
||||
result = `${namespace}/${result}`;
|
||||
}
|
||||
|
||||
if (defaultKind && kind !== defaultKind) {
|
||||
result = `${kind}:${result}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
entityPresentationApiRef,
|
||||
type EntityPresentationApi,
|
||||
type EntityRefPresentation,
|
||||
type EntityRefPresentationSnapshot,
|
||||
} from './EntityPresentationApi';
|
||||
export { defaultEntityPresentation } from './defaultEntityPresentation';
|
||||
export { useEntityPresentation } from './useEntityPresentation';
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
Entity,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useApiHolder } from '@backstage/core-plugin-api';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
EntityRefPresentation,
|
||||
EntityRefPresentationSnapshot,
|
||||
entityPresentationApiRef,
|
||||
} from './EntityPresentationApi';
|
||||
import { defaultEntityPresentation } from './defaultEntityPresentation';
|
||||
import { useUpdatingObservable } from './useUpdatingObservable';
|
||||
|
||||
/**
|
||||
* Returns information about how to represent an entity in the interface.
|
||||
*
|
||||
* @public
|
||||
* @param entityOrRef - The entity to represent, or an entity ref to it. If you
|
||||
* pass in an entity, it is assumed that it is NOT a partial one - i.e. only
|
||||
* pass in an entity if you know that it was fetched in such a way that it
|
||||
* contains all of the fields that the representation renderer needs.
|
||||
* @param context - Optional context that control details of the presentation.
|
||||
* @returns A snapshot of the entity presentation, which may change over time
|
||||
*/
|
||||
export function useEntityPresentation(
|
||||
entityOrRef: Entity | CompoundEntityRef | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentationSnapshot {
|
||||
// Defensively allow for a missing presentation API, which makes this hook
|
||||
// safe to use in tests.
|
||||
const apis = useApiHolder();
|
||||
const entityPresentationApi = apis.get(entityPresentationApiRef);
|
||||
|
||||
const deps = [
|
||||
entityPresentationApi,
|
||||
JSON.stringify(entityOrRef),
|
||||
JSON.stringify(context || null),
|
||||
];
|
||||
|
||||
const presentation = useMemo<EntityRefPresentation>(
|
||||
() => {
|
||||
if (!entityPresentationApi) {
|
||||
return { snapshot: defaultEntityPresentation(entityOrRef, context) };
|
||||
}
|
||||
|
||||
return entityPresentationApi.forEntity(
|
||||
typeof entityOrRef === 'string' || 'metadata' in entityOrRef
|
||||
? entityOrRef
|
||||
: stringifyEntityRef(entityOrRef),
|
||||
context,
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
deps,
|
||||
);
|
||||
|
||||
// NOTE(freben): We intentionally do not use the plain useObservable from the
|
||||
// react-use library here. That hook does not support a dependencies array,
|
||||
// and also it only subscribes once to the initially passed in observable and
|
||||
// won't properly react when either initial value or the actual observable
|
||||
// changes.
|
||||
return useUpdatingObservable(presentation.snapshot, presentation.update$, [
|
||||
presentation,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Observable } from '@backstage/types';
|
||||
import { DependencyList, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Subscribe to an observable and return the latest value from it.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This implementation differs in a few important ways from the plain
|
||||
* useObservable from the react-use library. That hook does not support a
|
||||
* dependencies array, and also it only subscribes once to the initially passed
|
||||
* in observable and won't properly react when either initial value or the
|
||||
* actual observable changes.
|
||||
*
|
||||
* This hook will ensure to resubscribe and reconsider the initial value,
|
||||
* whenever the dependencies change.
|
||||
*/
|
||||
export function useUpdatingObservable<T>(
|
||||
value: T,
|
||||
observable: Observable<T> | undefined,
|
||||
deps: DependencyList,
|
||||
): T {
|
||||
const snapshot = useRef(value);
|
||||
const [, setCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
snapshot.current = value;
|
||||
setCounter(counter => counter + 1);
|
||||
|
||||
const subscription = observable?.subscribe({
|
||||
next: updatedValue => {
|
||||
snapshot.current = updatedValue;
|
||||
setCounter(counter => counter + 1);
|
||||
},
|
||||
complete: () => {
|
||||
subscription?.unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return snapshot.current;
|
||||
}
|
||||
@@ -14,4 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './EntityPresentationApi';
|
||||
export * from './StarredEntitiesApi';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { EntityDisplayName, EntityDisplayNameProps } from './EntityDisplayName';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
|
||||
const defaultArgs = {
|
||||
entityRef: 'component:default/playback',
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Catalog /EntityDisplayName',
|
||||
decorators: [(Story: ComponentType<{}>) => wrapInTestApp(<Story />)],
|
||||
};
|
||||
|
||||
export const Default = (args: EntityDisplayNameProps) => (
|
||||
<EntityDisplayName {...args} />
|
||||
);
|
||||
Default.args = defaultArgs;
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import {
|
||||
EntityRefPresentation,
|
||||
EntityRefPresentationSnapshot,
|
||||
entityPresentationApiRef,
|
||||
} from '../../apis';
|
||||
import { EntityDisplayName } from './EntityDisplayName';
|
||||
|
||||
function defer<T>() {
|
||||
let resolve = (_value: T) => {};
|
||||
const promise = new Promise<T>(_resolve => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('<EntityDisplayName />', () => {
|
||||
const entityPresentationApi = {
|
||||
forEntity: jest.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('works with the sync the happy path', async () => {
|
||||
entityPresentationApi.forEntity.mockReturnValue({
|
||||
snapshot: {
|
||||
entityRef: 'component:default/foo',
|
||||
primaryTitle: 'foo',
|
||||
},
|
||||
update$: undefined,
|
||||
} as EntityRefPresentation);
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[[entityPresentationApiRef, entityPresentationApi]]}
|
||||
>
|
||||
<EntityDisplayName entityRef="component:default/foo" />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('foo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('works with the async the happy path', async () => {
|
||||
const { promise, resolve } = defer<EntityRefPresentationSnapshot>();
|
||||
|
||||
entityPresentationApi.forEntity.mockReturnValue({
|
||||
snapshot: {
|
||||
entityRef: 'component:default/foo',
|
||||
primaryTitle: 'foo',
|
||||
},
|
||||
update$: new ObservableImpl(subscriber => {
|
||||
promise.then(value => subscriber.next(value));
|
||||
}),
|
||||
} as EntityRefPresentation);
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[[entityPresentationApiRef, entityPresentationApi]]}
|
||||
>
|
||||
<EntityDisplayName entityRef="component:default/foo" />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('foo')).toBeInTheDocument();
|
||||
|
||||
resolve({
|
||||
entityRef: 'component:default/foo',
|
||||
primaryTitle: 'bar',
|
||||
});
|
||||
|
||||
await expect(screen.findByText('bar')).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
|
||||
import { Box, Theme, Tooltip, makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { useEntityPresentation } from '../../apis';
|
||||
|
||||
/**
|
||||
* The available style class keys for {@link EntityDisplayName}, under the name
|
||||
* "CatalogReactEntityDisplayName".
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CatalogReactEntityDisplayNameClassKey = 'root' | 'icon';
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme: Theme) => ({
|
||||
root: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {
|
||||
marginLeft: theme.spacing(0.5),
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 0,
|
||||
},
|
||||
}),
|
||||
{ name: 'CatalogReactEntityDisplayName' },
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for {@link EntityDisplayName}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EntityDisplayNameProps = {
|
||||
entityRef: Entity | CompoundEntityRef | string;
|
||||
variant?: 'simple' | string;
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a nice representation of a reference to an entity.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const EntityDisplayName = (
|
||||
props: EntityDisplayNameProps,
|
||||
): JSX.Element => {
|
||||
const { entityRef, variant, defaultKind, defaultNamespace } = props;
|
||||
|
||||
const classes = useStyles();
|
||||
const { primaryTitle, secondaryTitle, Icon } = useEntityPresentation(
|
||||
entityRef,
|
||||
{ defaultKind, defaultNamespace },
|
||||
);
|
||||
|
||||
// The innermost "body" content
|
||||
let content = <>{primaryTitle}</>;
|
||||
|
||||
// Optionally an icon, and wrapper around them both
|
||||
content = (
|
||||
<Box component="span" className={classes.root}>
|
||||
{content}
|
||||
{Icon && variant !== 'simple' ? (
|
||||
<Box component="span" className={classes.icon}>
|
||||
<Icon fontSize="inherit" />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Optionally, a tooltip as the outermost layer
|
||||
if (secondaryTitle) {
|
||||
content = (
|
||||
<Tooltip enterDelay={1500} title={secondaryTitle}>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
EntityDisplayName,
|
||||
type CatalogReactEntityDisplayNameClassKey,
|
||||
type EntityDisplayNameProps,
|
||||
} from './EntityDisplayName';
|
||||
@@ -27,6 +27,7 @@ describe('<EntityRefLink />', () => {
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'software',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
owner: 'guest',
|
||||
@@ -39,8 +40,7 @@ describe('<EntityRefLink />', () => {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('component:software')).toHaveAttribute(
|
||||
expect(screen.getByText('component:software').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/software',
|
||||
);
|
||||
@@ -65,10 +65,9 @@ describe('<EntityRefLink />', () => {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('component:test/software')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/test/component/software',
|
||||
);
|
||||
expect(
|
||||
screen.getByText('component:test/software').closest('a'),
|
||||
).toHaveAttribute('href', '/catalog/test/component/software');
|
||||
});
|
||||
|
||||
it('renders link for entity and hides default kind', async () => {
|
||||
@@ -93,7 +92,7 @@ describe('<EntityRefLink />', () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(screen.getByText('test/software')).toHaveAttribute(
|
||||
expect(screen.getByText('test/software').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/test/component/software',
|
||||
);
|
||||
@@ -110,7 +109,7 @@ describe('<EntityRefLink />', () => {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('component:software')).toHaveAttribute(
|
||||
expect(screen.getByText('component:software').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/software',
|
||||
);
|
||||
@@ -127,10 +126,9 @@ describe('<EntityRefLink />', () => {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('component:test/software')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/test/component/software',
|
||||
);
|
||||
expect(
|
||||
screen.getByText('component:test/software').closest('a'),
|
||||
).toHaveAttribute('href', '/catalog/test/component/software');
|
||||
});
|
||||
|
||||
it('renders link for entity name and hides default kind', async () => {
|
||||
@@ -147,7 +145,7 @@ describe('<EntityRefLink />', () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(screen.getByText('test/software')).toHaveAttribute(
|
||||
expect(screen.getByText('test/software').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/test/component/software',
|
||||
);
|
||||
@@ -169,7 +167,7 @@ describe('<EntityRefLink />', () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(screen.getByText('Custom Children')).toHaveAttribute(
|
||||
expect(screen.getByText('Custom Children').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/test/component/software',
|
||||
);
|
||||
|
||||
@@ -15,17 +15,16 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { entityRouteRef } from '../../routes';
|
||||
import { humanizeEntityRef } from './humanize';
|
||||
import { Link, LinkProps } from '@backstage/core-components';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Tooltip } from '@material-ui/core';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { entityRouteRef } from '../../routes';
|
||||
import { EntityDisplayName } from '../EntityDisplayName';
|
||||
|
||||
/**
|
||||
* Props for {@link EntityRefLink}.
|
||||
@@ -35,6 +34,8 @@ import { Tooltip } from '@material-ui/core';
|
||||
export type EntityRefLinkProps = {
|
||||
entityRef: Entity | CompoundEntityRef | string;
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
/** @deprecated This option should no longer be used; presentation is requested through the {@link entityPresentationApiRef} instead */
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
} & Omit<LinkProps, 'to'>;
|
||||
@@ -46,52 +47,68 @@ export type EntityRefLinkProps = {
|
||||
*/
|
||||
export const EntityRefLink = forwardRef<any, EntityRefLinkProps>(
|
||||
(props, ref) => {
|
||||
const { entityRef, defaultKind, title, children, ...linkProps } = props;
|
||||
const entityRoute = useRouteRef(entityRouteRef);
|
||||
const {
|
||||
entityRef,
|
||||
defaultKind,
|
||||
defaultNamespace,
|
||||
title,
|
||||
children,
|
||||
...linkProps
|
||||
} = props;
|
||||
const entityRoute = useEntityRoute(props.entityRef);
|
||||
|
||||
let kind;
|
||||
let namespace;
|
||||
let name;
|
||||
|
||||
if (typeof entityRef === 'string') {
|
||||
const parsed = parseEntityRef(entityRef);
|
||||
kind = parsed.kind;
|
||||
namespace = parsed.namespace;
|
||||
name = parsed.name;
|
||||
} else if ('metadata' in entityRef) {
|
||||
kind = entityRef.kind;
|
||||
namespace = entityRef.metadata.namespace;
|
||||
name = entityRef.metadata.name;
|
||||
} else {
|
||||
kind = entityRef.kind;
|
||||
namespace = entityRef.namespace;
|
||||
name = entityRef.name;
|
||||
}
|
||||
|
||||
kind = kind.toLocaleLowerCase('en-US');
|
||||
namespace = namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE;
|
||||
|
||||
const routeParams = {
|
||||
kind: encodeURIComponent(kind),
|
||||
namespace: encodeURIComponent(namespace),
|
||||
name: encodeURIComponent(name),
|
||||
};
|
||||
const formattedEntityRefTitle = humanizeEntityRef(
|
||||
{ kind, namespace, name },
|
||||
{ defaultKind },
|
||||
const content = children ?? title ?? (
|
||||
<EntityDisplayName
|
||||
entityRef={entityRef}
|
||||
defaultKind={defaultKind}
|
||||
defaultNamespace={defaultNamespace}
|
||||
/>
|
||||
);
|
||||
|
||||
const link = (
|
||||
<Link {...linkProps} ref={ref} to={entityRoute(routeParams)}>
|
||||
{children}
|
||||
{!children && (title ?? formattedEntityRefTitle)}
|
||||
return (
|
||||
<Link {...linkProps} ref={ref} to={entityRoute}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return title ? (
|
||||
<Tooltip title={formattedEntityRefTitle}>{link}</Tooltip>
|
||||
) : (
|
||||
link
|
||||
);
|
||||
},
|
||||
) as (props: EntityRefLinkProps) => JSX.Element;
|
||||
|
||||
// Hook that computes the route to a given entity / ref. This is a bit
|
||||
// contrived, because it tries to retain the casing of the entity name if
|
||||
// present, but not of other parts. This is in an attempt to make slightly more
|
||||
// nice-looking URLs.
|
||||
function useEntityRoute(
|
||||
entityRef: Entity | CompoundEntityRef | string,
|
||||
): string {
|
||||
const entityRoute = useRouteRef(entityRouteRef);
|
||||
|
||||
let kind;
|
||||
let namespace;
|
||||
let name;
|
||||
|
||||
if (typeof entityRef === 'string') {
|
||||
const parsed = parseEntityRef(entityRef);
|
||||
kind = parsed.kind;
|
||||
namespace = parsed.namespace;
|
||||
name = parsed.name;
|
||||
} else if ('metadata' in entityRef) {
|
||||
kind = entityRef.kind;
|
||||
namespace = entityRef.metadata.namespace;
|
||||
name = entityRef.metadata.name;
|
||||
} else {
|
||||
kind = entityRef.kind;
|
||||
namespace = entityRef.namespace;
|
||||
name = entityRef.name;
|
||||
}
|
||||
|
||||
kind = kind.toLocaleLowerCase('en-US');
|
||||
namespace = namespace?.toLocaleLowerCase('en-US') ?? DEFAULT_NAMESPACE;
|
||||
|
||||
const routeParams = {
|
||||
kind: encodeURIComponent(kind),
|
||||
namespace: encodeURIComponent(namespace),
|
||||
name: encodeURIComponent(name),
|
||||
};
|
||||
|
||||
return entityRoute(routeParams);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('<EntityRefLinks />', () => {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('component:software')).toHaveAttribute(
|
||||
expect(screen.getByText('software').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/software',
|
||||
);
|
||||
@@ -59,11 +59,11 @@ describe('<EntityRefLinks />', () => {
|
||||
},
|
||||
});
|
||||
expect(screen.getByText(',')).toBeInTheDocument();
|
||||
expect(screen.getByText('component:software')).toHaveAttribute(
|
||||
expect(screen.getByText('software').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/software',
|
||||
);
|
||||
expect(screen.getByText('api:interface')).toHaveAttribute(
|
||||
expect(screen.getByText('interface').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/api/interface',
|
||||
);
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
CompoundEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import React from 'react';
|
||||
import { EntityRefLink } from './EntityRefLink';
|
||||
import { LinkProps } from '@backstage/core-components';
|
||||
import { FetchedEntityRefLinks } from './FetchedEntityRefLinks';
|
||||
|
||||
/**
|
||||
* Props for {@link EntityRefLink}.
|
||||
@@ -26,21 +29,14 @@ import { FetchedEntityRefLinks } from './FetchedEntityRefLinks';
|
||||
*/
|
||||
export type EntityRefLinksProps<
|
||||
TRef extends string | CompoundEntityRef | Entity,
|
||||
> = (
|
||||
| {
|
||||
defaultKind?: string;
|
||||
entityRefs: TRef[];
|
||||
fetchEntities?: false;
|
||||
getTitle?(entity: TRef): string | undefined;
|
||||
}
|
||||
| {
|
||||
defaultKind?: string;
|
||||
entityRefs: TRef[];
|
||||
fetchEntities: true;
|
||||
getTitle(entity: Entity): string | undefined;
|
||||
}
|
||||
) &
|
||||
Omit<LinkProps, 'to'>;
|
||||
> = {
|
||||
defaultKind?: string;
|
||||
entityRefs: TRef[];
|
||||
/** @deprecated This option is no longer used; presentation is handled by entityPresentationApiRef instead */
|
||||
fetchEntities?: boolean;
|
||||
/** @deprecated This option is no longer used; presentation is handled by entityPresentationApiRef instead */
|
||||
getTitle?(entity: TRef): string | undefined;
|
||||
} & Omit<LinkProps, 'to'>;
|
||||
|
||||
/**
|
||||
* Shows a list of clickable links to entities.
|
||||
@@ -50,32 +46,17 @@ export type EntityRefLinksProps<
|
||||
export function EntityRefLinks<
|
||||
TRef extends string | CompoundEntityRef | Entity,
|
||||
>(props: EntityRefLinksProps<TRef>) {
|
||||
const { entityRefs, defaultKind, fetchEntities, getTitle, ...linkProps } =
|
||||
props;
|
||||
|
||||
if (fetchEntities) {
|
||||
return (
|
||||
<FetchedEntityRefLinks
|
||||
{...linkProps}
|
||||
defaultKind={defaultKind}
|
||||
entityRefs={entityRefs}
|
||||
getTitle={getTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { entityRefs, ...linkProps } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{entityRefs.map((r: TRef, i: number) => {
|
||||
const entityRefString =
|
||||
typeof r === 'string' ? r : stringifyEntityRef(r);
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<React.Fragment key={`${i}.${entityRefString}`}>
|
||||
{i > 0 && ', '}
|
||||
<EntityRefLink
|
||||
{...linkProps}
|
||||
defaultKind={defaultKind}
|
||||
entityRef={r}
|
||||
title={getTitle ? getTitle(r) : undefined}
|
||||
/>
|
||||
<EntityRefLink {...linkProps} entityRef={r} />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { FetchedEntityRefLinks } from './FetchedEntityRefLinks';
|
||||
import { entityRouteRef } from '../../routes';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import React from 'react';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
|
||||
describe('<FetchedEntityRefLinks />', () => {
|
||||
const getTitle = (e: Entity): string =>
|
||||
(e.spec?.profile!! as JsonObject).displayName!!.toString()!!;
|
||||
|
||||
it('should fetch entities and render the custom display text', async () => {
|
||||
const entityRefs = [
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'software',
|
||||
},
|
||||
{
|
||||
kind: 'API',
|
||||
namespace: 'default',
|
||||
name: 'interface',
|
||||
},
|
||||
];
|
||||
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve({
|
||||
items: entityRefs.map(ref => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: ref.kind,
|
||||
metadata: {
|
||||
name: ref.name,
|
||||
namespace: ref.namespace,
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: ref.name.toLocaleUpperCase('en-US'),
|
||||
},
|
||||
type: 'organization',
|
||||
},
|
||||
})),
|
||||
}),
|
||||
};
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
|
||||
<FetchedEntityRefLinks entityRefs={entityRefs} getTitle={getTitle} />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('SOFTWARE')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/software',
|
||||
);
|
||||
|
||||
expect(screen.getByText('INTERFACE')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/api/interface',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use entities as they are provided and render the custom display text', async () => {
|
||||
const entityRefs = [
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'tool',
|
||||
},
|
||||
{
|
||||
kind: 'API',
|
||||
namespace: 'default',
|
||||
name: 'implementation',
|
||||
},
|
||||
].map(ref => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: ref.kind,
|
||||
metadata: {
|
||||
name: ref.name,
|
||||
namespace: ref.namespace,
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: ref.name.toLocaleUpperCase('en-US'),
|
||||
},
|
||||
type: 'organization',
|
||||
},
|
||||
}));
|
||||
|
||||
const catalogApi: Partial<CatalogApi> = {};
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
|
||||
<FetchedEntityRefLinks entityRefs={entityRefs} getTitle={getTitle} />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('TOOL')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/tool',
|
||||
);
|
||||
|
||||
expect(screen.getByText('IMPLEMENTATION')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/api/implementation',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle heterogeneous array of values to render the custom display text', async () => {
|
||||
const entityRefs = [
|
||||
...[
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'tool',
|
||||
},
|
||||
{
|
||||
kind: 'API',
|
||||
namespace: 'default',
|
||||
name: 'implementation',
|
||||
},
|
||||
].map(ref => ({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: ref.kind,
|
||||
metadata: {
|
||||
name: ref.name,
|
||||
namespace: ref.namespace,
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: ref.name.toLocaleUpperCase('en-US'),
|
||||
},
|
||||
type: 'organization',
|
||||
},
|
||||
})),
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'interface',
|
||||
},
|
||||
];
|
||||
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'interface',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'INTERFACE',
|
||||
},
|
||||
type: 'organization',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
|
||||
<FetchedEntityRefLinks entityRefs={entityRefs} getTitle={getTitle} />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('TOOL')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/tool',
|
||||
);
|
||||
|
||||
expect(screen.getByText('IMPLEMENTATION')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/api/implementation',
|
||||
);
|
||||
|
||||
expect(screen.getByText('INTERFACE')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/component/interface',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
Entity,
|
||||
CompoundEntityRef,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import React from 'react';
|
||||
import { EntityRefLink } from './EntityRefLink';
|
||||
import { ErrorPanel, LinkProps, Progress } from '@backstage/core-components';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* Props for {@link FetchedEntityRefLinks}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FetchedEntityRefLinksProps<
|
||||
TRef extends string | CompoundEntityRef | Entity,
|
||||
> = {
|
||||
defaultKind?: string;
|
||||
entityRefs: TRef[];
|
||||
getTitle(entity: Entity): string | undefined;
|
||||
} & Omit<LinkProps, 'to'>;
|
||||
|
||||
/**
|
||||
* Shows a list of clickable links to entities with auto-fetching of entities
|
||||
* for customising a displayed text via title attribute.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function FetchedEntityRefLinks<
|
||||
TRef extends string | CompoundEntityRef | Entity,
|
||||
>(props: FetchedEntityRefLinksProps<TRef>) {
|
||||
const { entityRefs, defaultKind, getTitle, ...linkProps } = props;
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const {
|
||||
value: entities = new Array<Entity>(),
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async () => {
|
||||
const refs = entityRefs.reduce((acc, current) => {
|
||||
if (typeof current === 'object' && 'metadata' in current) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, parseEntityRef(current)];
|
||||
}, new Array<CompoundEntityRef>());
|
||||
|
||||
const pureEntities = entityRefs.filter(
|
||||
ref => typeof ref === 'object' && 'metadata' in ref,
|
||||
) as Array<Entity>;
|
||||
|
||||
return refs.length > 0
|
||||
? [
|
||||
...(
|
||||
await catalogApi.getEntities({
|
||||
filter: refs.map(ref => ({
|
||||
kind: ref.kind,
|
||||
'metadata.namespace': ref.namespace,
|
||||
'metadata.name': ref.name,
|
||||
})),
|
||||
})
|
||||
).items,
|
||||
...pureEntities,
|
||||
]
|
||||
: pureEntities;
|
||||
}, [entityRefs]);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{entities.map((r: Entity, i) => {
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && ', '}
|
||||
<EntityRefLink
|
||||
{...linkProps}
|
||||
defaultKind={defaultKind}
|
||||
entityRef={r}
|
||||
title={getTitle(r as Entity)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export * from './CatalogFilterLayout';
|
||||
export * from './EntityKindPicker';
|
||||
export * from './EntityLifecyclePicker';
|
||||
export * from './EntityOwnerPicker';
|
||||
export * from './EntityDisplayName';
|
||||
export * from './EntityRefLink';
|
||||
export * from './EntityPeekAheadPopover';
|
||||
export * from './EntitySearchBar';
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Overrides } from '@material-ui/core/styles/overrides';
|
||||
import { StyleRules } from '@material-ui/core/styles/withStyles';
|
||||
|
||||
import {
|
||||
CatalogReactUserListPickerClassKey,
|
||||
CatalogReactEntityDisplayNameClassKey,
|
||||
CatalogReactEntityLifecyclePickerClassKey,
|
||||
CatalogReactEntitySearchBarClassKey,
|
||||
CatalogReactEntityTagPickerClassKey,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
/** @public */
|
||||
export type CatalogReactComponentsNameToClassKey = {
|
||||
CatalogReactUserListPicker: CatalogReactUserListPickerClassKey;
|
||||
CatalogReactEntityDisplayName: CatalogReactEntityDisplayNameClassKey;
|
||||
CatalogReactEntityLifecyclePicker: CatalogReactEntityLifecyclePickerClassKey;
|
||||
CatalogReactEntitySearchBar: CatalogReactEntitySearchBarClassKey;
|
||||
CatalogReactEntityTagPicker: CatalogReactEntityTagPickerClassKey;
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { CatalogApi } from '@backstage/plugin-catalog-react';
|
||||
import { ComponentEntity } from '@backstage/catalog-model';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityOwnerPickerProps } from '@backstage/plugin-catalog-react';
|
||||
import { EntityPresentationApi } from '@backstage/plugin-catalog-react';
|
||||
import { EntityRefPresentation } from '@backstage/plugin-catalog-react';
|
||||
import { EntityRefPresentationSnapshot } from '@backstage/plugin-catalog-react';
|
||||
import { ExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { IndexableDocument } from '@backstage/plugin-search-common';
|
||||
import { InfoCardVariants } from '@backstage/core-components';
|
||||
@@ -196,6 +201,7 @@ export interface CatalogTableRow {
|
||||
// (undocumented)
|
||||
resolved: {
|
||||
name: string;
|
||||
entityRef: string;
|
||||
partOfSystemRelationTitle?: string;
|
||||
partOfSystemRelations: CompoundEntityRef[];
|
||||
ownedByRelationsTitle?: string;
|
||||
@@ -224,6 +230,47 @@ export interface DefaultCatalogPageProps {
|
||||
tableOptions?: TableProps<CatalogTableRow>['options'];
|
||||
}
|
||||
|
||||
// @public
|
||||
export class DefaultEntityPresentationApi implements EntityPresentationApi {
|
||||
static create(
|
||||
options: DefaultEntityPresentationApiOptions,
|
||||
): EntityPresentationApi;
|
||||
static createLocal(): EntityPresentationApi;
|
||||
// (undocumented)
|
||||
forEntity(
|
||||
entityOrRef: Entity | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentation;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface DefaultEntityPresentationApiOptions {
|
||||
batchDelay?: HumanDuration;
|
||||
cacheTtl?: HumanDuration;
|
||||
catalogApi?: CatalogApi;
|
||||
renderer?: DefaultEntityPresentationApiRenderer;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface DefaultEntityPresentationApiRenderer {
|
||||
async?: boolean;
|
||||
extraFields?: string[];
|
||||
render: (options: {
|
||||
entityRef: string;
|
||||
loading: boolean;
|
||||
entity: Entity | undefined;
|
||||
context: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
};
|
||||
}) => {
|
||||
snapshot: Omit<EntityRefPresentationSnapshot, 'entityRef' | 'entity'>;
|
||||
};
|
||||
}
|
||||
|
||||
// @public
|
||||
export class DefaultStarredEntitiesApi implements StarredEntitiesApi {
|
||||
constructor(opts: { storageApi: StorageApi });
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"dataloader": "^2.0.0",
|
||||
"expiry-map": "^2.0.0",
|
||||
"history": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"pluralize": "^8.0.0",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
EntityRefPresentation,
|
||||
EntityRefPresentationSnapshot,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { DefaultEntityPresentationApi } from './DefaultEntityPresentationApi';
|
||||
|
||||
describe('DefaultEntityPresentationApi', () => {
|
||||
it('works in local mode', () => {
|
||||
const api = DefaultEntityPresentationApi.createLocal();
|
||||
|
||||
expect(api.forEntity('component:default/test')).toEqual({
|
||||
snapshot: {
|
||||
entityRef: 'component:default/test',
|
||||
entity: undefined,
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
update$: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
api.forEntity('component:default/test', { defaultKind: 'Other' }),
|
||||
).toEqual({
|
||||
snapshot: {
|
||||
entityRef: 'component:default/test',
|
||||
entity: undefined,
|
||||
primaryTitle: 'component:test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
update$: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
api.forEntity('component:default/test', {
|
||||
defaultNamespace: 'other',
|
||||
}),
|
||||
).toEqual({
|
||||
snapshot: {
|
||||
entityRef: 'component:default/test',
|
||||
entity: undefined,
|
||||
primaryTitle: 'default/test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
update$: undefined,
|
||||
});
|
||||
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
},
|
||||
};
|
||||
|
||||
expect(api.forEntity(entity)).toEqual({
|
||||
snapshot: {
|
||||
entityRef: 'component:default/test',
|
||||
entity: entity,
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test | service',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
update$: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('works in catalog mode', async () => {
|
||||
const catalogApi = {
|
||||
getEntitiesByRefs: jest.fn(),
|
||||
};
|
||||
const api = DefaultEntityPresentationApi.create({
|
||||
catalogApi: catalogApi as Partial<CatalogApi> as any,
|
||||
});
|
||||
|
||||
catalogApi.getEntitiesByRefs.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
namespace: 'default',
|
||||
etag: 'something',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// return simple presentation, call catalog, return full presentation
|
||||
await expect(
|
||||
consumePresentation(api.forEntity('component:default/test')),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
entityRef: 'component:default/test',
|
||||
entity: undefined,
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
{
|
||||
entityRef: 'component:default/test',
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
etag: 'something',
|
||||
name: 'test',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test | service',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
// use cached entity, immediately return full presentation
|
||||
await expect(
|
||||
consumePresentation(api.forEntity('component:default/test')),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
entityRef: 'component:default/test',
|
||||
entity: {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
etag: 'something',
|
||||
name: 'test',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
primaryTitle: 'test',
|
||||
secondaryTitle: 'component:default/test | service',
|
||||
Icon: expect.anything(),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(1);
|
||||
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityRefs: ['component:default/test'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function consumePresentation(
|
||||
presentation: EntityRefPresentation,
|
||||
): Promise<EntityRefPresentationSnapshot[]> {
|
||||
const result: EntityRefPresentationSnapshot[] = [];
|
||||
const { snapshot, update$ } = presentation;
|
||||
|
||||
result.push(snapshot);
|
||||
|
||||
if (update$) {
|
||||
await new Promise<void>(resolve => {
|
||||
const sub = update$.subscribe({
|
||||
next: newSnapshot => {
|
||||
result.push(newSnapshot);
|
||||
},
|
||||
complete: () => {
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
CatalogApi,
|
||||
EntityPresentationApi,
|
||||
EntityRefPresentation,
|
||||
EntityRefPresentationSnapshot,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import DataLoader from 'dataloader';
|
||||
import ExpiryMap from 'expiry-map';
|
||||
import uniq from 'lodash/uniq';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import {
|
||||
DEFAULT_BATCH_DELAY,
|
||||
DEFAULT_CACHE_TTL,
|
||||
DEFAULT_ENTITY_FIELDS,
|
||||
createDefaultRenderer,
|
||||
} from './defaults';
|
||||
import { durationToMs } from './util';
|
||||
|
||||
/**
|
||||
* A custom renderer for the {@link DefaultEntityPresentationApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface DefaultEntityPresentationApiRenderer {
|
||||
/**
|
||||
* An extra set of fields to request for entities from the catalog API.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* You may want to specify this to get additional entity fields. The smaller
|
||||
* the set of fields, the more efficient requests will be to the catalog
|
||||
* backend.
|
||||
*
|
||||
* The default set of fields is: apiVersion, kind, the scalar metadata fields
|
||||
* (uid, etag, name, namespace, title, description), spec.type, and
|
||||
* spec.profile.
|
||||
*
|
||||
* This field is ignored if async is set to false.
|
||||
*/
|
||||
extraFields?: string[];
|
||||
|
||||
/**
|
||||
* Whether to request the entity from the catalog API asynchronously.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If this is set to true, entity data will be streamed in from the catalog
|
||||
* whenever needed, and the render function may be called more than once:
|
||||
* first when no entity data existed (or with old cached data), and then again
|
||||
* at a later point when data is loaded from the catalog that proved to be
|
||||
* different from the old one.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
async?: boolean;
|
||||
|
||||
/**
|
||||
* The actual render function.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This function may be called multiple times.
|
||||
*
|
||||
* The loading flag signals that the framework MAY be trying to load more
|
||||
* entity data from the catalog and call the render function again, if it
|
||||
* succeeds. In some cases you may want to render a loading state in that
|
||||
* case.
|
||||
*
|
||||
* The entity may or may not be given. If the caller of the presentation API
|
||||
* did present an entity upfront, then that's what will be passed in here.
|
||||
* Otherwise, it may be a server-side entity that either comes from a local
|
||||
* cache or directly from the server.
|
||||
*
|
||||
* In either case, the renderer should return a presentation that is the most
|
||||
* useful possible for the end user, given the data that is available.
|
||||
*/
|
||||
render: (options: {
|
||||
entityRef: string;
|
||||
loading: boolean;
|
||||
entity: Entity | undefined;
|
||||
context: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
};
|
||||
}) => {
|
||||
snapshot: Omit<EntityRefPresentationSnapshot, 'entityRef' | 'entity'>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the {@link DefaultEntityPresentationApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface DefaultEntityPresentationApiOptions {
|
||||
/**
|
||||
* The catalog API to use. If you want to use any asynchronous features, you
|
||||
* must supply one.
|
||||
*/
|
||||
catalogApi?: CatalogApi;
|
||||
|
||||
/**
|
||||
* When to expire entities that have been loaded from the catalog API and
|
||||
* cached for a while.
|
||||
*
|
||||
* @defaultValue 30 seconds
|
||||
* @remarks
|
||||
*
|
||||
* The higher this value, the lower the load on the catalog API, but also the
|
||||
* higher the risk of users seeing stale data.
|
||||
*/
|
||||
cacheTtl?: HumanDuration;
|
||||
|
||||
/**
|
||||
* For how long to wait before sending a batch of entity references to the
|
||||
* catalog API.
|
||||
*
|
||||
* @defaultValue 50 milliseconds
|
||||
* @remarks
|
||||
*
|
||||
* The higher this value, the greater the chance of batching up requests from
|
||||
* across a page, but also the longer the lag time before displaying accurate
|
||||
* information.
|
||||
*/
|
||||
batchDelay?: HumanDuration;
|
||||
|
||||
/**
|
||||
* A custom renderer, if any.
|
||||
*/
|
||||
renderer?: DefaultEntityPresentationApiRenderer;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
updatedAt: number;
|
||||
entity: Entity | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of the {@link @backstage/plugin-catalog-react#EntityPresentationApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class DefaultEntityPresentationApi implements EntityPresentationApi {
|
||||
/**
|
||||
* Creates a new presentation API that does not reach out to the catalog.
|
||||
*/
|
||||
static createLocal(): EntityPresentationApi {
|
||||
return new DefaultEntityPresentationApi({
|
||||
renderer: createDefaultRenderer({ async: false }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new presentation API that calls out to the catalog as needed to
|
||||
* get additional information about entities.
|
||||
*/
|
||||
static create(
|
||||
options: DefaultEntityPresentationApiOptions,
|
||||
): EntityPresentationApi {
|
||||
return new DefaultEntityPresentationApi(options);
|
||||
}
|
||||
|
||||
// This cache holds on to all entity data ever loaded, no matter how old. Each
|
||||
// entry is tagged with a timestamp of when it was inserted. We use this map
|
||||
// to be able to always render SOME data even though the information is old.
|
||||
// Entities change very rarely, so it's likely that the rendered information
|
||||
// was perfectly fine in the first place.
|
||||
readonly #cache: Map<string, CacheEntry>;
|
||||
readonly #cacheTtlMs: number;
|
||||
readonly #loader: DataLoader<string, Entity | undefined> | undefined;
|
||||
readonly #renderer: DefaultEntityPresentationApiRenderer;
|
||||
|
||||
private constructor(options: DefaultEntityPresentationApiOptions) {
|
||||
const cacheTtl = options.cacheTtl ?? DEFAULT_CACHE_TTL;
|
||||
const batchDelay = options.batchDelay ?? DEFAULT_BATCH_DELAY;
|
||||
const renderer = options.renderer ?? createDefaultRenderer({ async: true });
|
||||
|
||||
if (renderer.async) {
|
||||
if (!options.catalogApi) {
|
||||
throw new TypeError(`Asynchronous rendering requires a catalog API`);
|
||||
}
|
||||
this.#loader = this.#createLoader({
|
||||
cacheTtl,
|
||||
batchDelay,
|
||||
renderer,
|
||||
catalogApi: options.catalogApi,
|
||||
});
|
||||
}
|
||||
|
||||
this.#cacheTtlMs = durationToMs(cacheTtl);
|
||||
this.#cache = new Map();
|
||||
this.#renderer = renderer;
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/plugin-catalog-react#EntityPresentationApi.forEntity} */
|
||||
forEntity(
|
||||
entityOrRef: Entity | string,
|
||||
context?: {
|
||||
defaultKind?: string;
|
||||
defaultNamespace?: string;
|
||||
},
|
||||
): EntityRefPresentation {
|
||||
const { entityRef, entity, needsLoad } =
|
||||
this.#getEntityForInitialRender(entityOrRef);
|
||||
|
||||
let rendered: Omit<EntityRefPresentationSnapshot, 'entityRef' | 'entity'>;
|
||||
try {
|
||||
const output = this.#renderer.render({
|
||||
entityRef: entityRef,
|
||||
loading: needsLoad,
|
||||
entity: entity,
|
||||
context: context || {},
|
||||
});
|
||||
rendered = output.snapshot;
|
||||
} catch {
|
||||
// This is what gets presented if the renderer throws an error
|
||||
rendered = {
|
||||
primaryTitle: entityRef,
|
||||
};
|
||||
}
|
||||
|
||||
const observable = !needsLoad
|
||||
? undefined
|
||||
: new ObservableImpl<EntityRefPresentationSnapshot>(subscriber => {
|
||||
let aborted = false;
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => this.#loader?.load(entityRef))
|
||||
.then(newEntity => {
|
||||
if (
|
||||
!aborted &&
|
||||
newEntity &&
|
||||
newEntity.metadata.etag !== entity?.metadata.etag
|
||||
) {
|
||||
const output = this.#renderer.render({
|
||||
entityRef: entityRef,
|
||||
loading: false,
|
||||
entity: newEntity,
|
||||
context: context || {},
|
||||
});
|
||||
subscriber.next({
|
||||
...output.snapshot,
|
||||
entityRef: entityRef,
|
||||
entity: newEntity,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Intentionally ignored - we do not propagate errors to the
|
||||
// observable here. The presentation API should be error free and
|
||||
// always return SOMETHING that makes sense to render, and we have
|
||||
// already ensured above that the initial snapshot was that.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!aborted) {
|
||||
subscriber.complete();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
...rendered,
|
||||
entityRef: entityRef,
|
||||
entity: entity,
|
||||
},
|
||||
update$: observable,
|
||||
};
|
||||
}
|
||||
|
||||
#getEntityForInitialRender(entityOrRef: Entity | string): {
|
||||
entity: Entity | undefined;
|
||||
entityRef: string;
|
||||
needsLoad: boolean;
|
||||
} {
|
||||
// If we were given an entity in the first place, we use it for a single
|
||||
// pass of rendering and assume that it's up to date and not partial (i.e.
|
||||
// we expect that it wasn't fetched in such a way that the required fields
|
||||
// of the renderer were excluded)
|
||||
if (typeof entityOrRef !== 'string') {
|
||||
return {
|
||||
entity: entityOrRef,
|
||||
entityRef: stringifyEntityRef(entityOrRef),
|
||||
needsLoad: false,
|
||||
};
|
||||
}
|
||||
|
||||
const cached = this.#cache.get(entityOrRef);
|
||||
const cachedEntity: Entity | undefined = cached?.entity;
|
||||
const cacheNeedsUpdate =
|
||||
!cached || Date.now() - cached.updatedAt > this.#cacheTtlMs;
|
||||
const needsLoad =
|
||||
cacheNeedsUpdate &&
|
||||
this.#renderer.async !== false &&
|
||||
this.#loader !== undefined;
|
||||
|
||||
return {
|
||||
entity: cachedEntity,
|
||||
entityRef: entityOrRef,
|
||||
needsLoad,
|
||||
};
|
||||
}
|
||||
|
||||
#createLoader(options: {
|
||||
catalogApi: CatalogApi;
|
||||
cacheTtl: HumanDuration;
|
||||
batchDelay: HumanDuration;
|
||||
renderer: DefaultEntityPresentationApiRenderer;
|
||||
}): DataLoader<string, Entity | undefined> {
|
||||
const cacheTtlMs = durationToMs(options.cacheTtl);
|
||||
const batchDelayMs = durationToMs(options.batchDelay);
|
||||
|
||||
const entityFields = uniq(
|
||||
[DEFAULT_ENTITY_FIELDS, options.renderer?.extraFields ?? []].flat(),
|
||||
);
|
||||
|
||||
return new DataLoader(
|
||||
async (entityRefs: readonly string[]) => {
|
||||
const { items } = await options.catalogApi!.getEntitiesByRefs({
|
||||
entityRefs: entityRefs as string[],
|
||||
fields: [...entityFields],
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
entityRefs.forEach((entityRef, index) => {
|
||||
this.#cache.set(entityRef, {
|
||||
updatedAt: now,
|
||||
entity: items[index],
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
{
|
||||
name: DefaultEntityPresentationApi.name,
|
||||
// This cache is the one that the data loader uses internally for
|
||||
// memoizing requests; essentially what it achieves is that multiple
|
||||
// requests for the same entity ref will be batched up into a single
|
||||
// request and then the resulting promises are held on to. We put an
|
||||
// expiring map here, which makes it so that it re-fetches data with the
|
||||
// expiry cadence of that map. Otherwise it would only fetch a given ref
|
||||
// once and then never try again. This cache does therefore not fulfill
|
||||
// the same purpose as the one that is in the root of the class.
|
||||
cacheMap: new ExpiryMap(cacheTtlMs),
|
||||
maxBatchSize: 100,
|
||||
batchScheduleFn: batchDelayMs
|
||||
? cb => setTimeout(cb, batchDelayMs)
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { defaultEntityPresentation } from '@backstage/plugin-catalog-react';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import ApartmentIcon from '@material-ui/icons/Apartment';
|
||||
import BusinessIcon from '@material-ui/icons/Business';
|
||||
import ExtensionIcon from '@material-ui/icons/Extension';
|
||||
import HelpIcon from '@material-ui/icons/Help';
|
||||
import LibraryAddIcon from '@material-ui/icons/LibraryAdd';
|
||||
import LocationOnIcon from '@material-ui/icons/LocationOn';
|
||||
import MemoryIcon from '@material-ui/icons/Memory';
|
||||
import PeopleIcon from '@material-ui/icons/People';
|
||||
import PersonIcon from '@material-ui/icons/Person';
|
||||
import { DefaultEntityPresentationApiRenderer } from './DefaultEntityPresentationApi';
|
||||
|
||||
export const DEFAULT_CACHE_TTL: HumanDuration = { seconds: 30 };
|
||||
|
||||
export const DEFAULT_BATCH_DELAY: HumanDuration = { milliseconds: 50 };
|
||||
|
||||
export const DEFAULT_ENTITY_FIELDS: string[] = [
|
||||
'apiVersion',
|
||||
'kind',
|
||||
'metadata.uid',
|
||||
'metadata.etag',
|
||||
'metadata.name',
|
||||
'metadata.namespace',
|
||||
'metadata.title',
|
||||
'metadata.description',
|
||||
'spec.type',
|
||||
'spec.profile',
|
||||
];
|
||||
|
||||
export const UNKNOWN_KIND_ICON: IconComponent = HelpIcon;
|
||||
|
||||
export const DEFAULT_ICONS: Record<string, IconComponent> = {
|
||||
api: ExtensionIcon,
|
||||
component: MemoryIcon,
|
||||
system: BusinessIcon,
|
||||
domain: ApartmentIcon,
|
||||
location: LocationOnIcon,
|
||||
user: PersonIcon,
|
||||
group: PeopleIcon,
|
||||
template: LibraryAddIcon,
|
||||
};
|
||||
|
||||
export function createDefaultRenderer(options: {
|
||||
async: boolean;
|
||||
}): DefaultEntityPresentationApiRenderer {
|
||||
return {
|
||||
async: options.async,
|
||||
|
||||
render: ({ entityRef, entity, context }) => {
|
||||
const presentation = defaultEntityPresentation(
|
||||
entity || entityRef,
|
||||
context,
|
||||
);
|
||||
return {
|
||||
snapshot: presentation,
|
||||
loadEntity: options.async,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
DefaultEntityPresentationApi,
|
||||
type DefaultEntityPresentationApiOptions,
|
||||
type DefaultEntityPresentationApiRenderer,
|
||||
} from './DefaultEntityPresentationApi';
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
|
||||
export function durationToMs(duration: HumanDuration): number {
|
||||
const {
|
||||
years = 0,
|
||||
months = 0,
|
||||
weeks = 0,
|
||||
days = 0,
|
||||
hours = 0,
|
||||
minutes = 0,
|
||||
seconds = 0,
|
||||
milliseconds = 0,
|
||||
} = duration;
|
||||
|
||||
const totalDays = years * 365 + months * 30 + weeks * 7 + days;
|
||||
const totalHours = totalDays * 24 + hours;
|
||||
const totalMinutes = totalHours * 60 + minutes;
|
||||
const totalSeconds = totalMinutes * 60 + seconds;
|
||||
const totalMilliseconds = totalSeconds * 1000 + milliseconds;
|
||||
|
||||
return totalMilliseconds;
|
||||
}
|
||||
@@ -14,4 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './EntityPresentationApi';
|
||||
export * from './StarredEntitiesApi';
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Entity,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PART_OF,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
CodeSnippet,
|
||||
@@ -203,9 +204,13 @@ export const CatalogTable = (props: CatalogTableProps) => {
|
||||
return {
|
||||
entity,
|
||||
resolved: {
|
||||
// This name is here for backwards compatibility mostly; the
|
||||
// presentation of refs in the table should in general be handled with
|
||||
// EntityRefLink / EntityName components
|
||||
name: humanizeEntityRef(entity, {
|
||||
defaultKind: 'Component',
|
||||
}),
|
||||
entityRef: stringifyEntityRef(entity),
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => humanizeEntityRef(r, { defaultKind: 'group' }))
|
||||
.join(', '),
|
||||
|
||||
@@ -43,7 +43,7 @@ export const columnFactories = Object.freeze({
|
||||
|
||||
return {
|
||||
title: 'Name',
|
||||
field: 'resolved.name',
|
||||
field: 'resolved.entityRef',
|
||||
highlight: true,
|
||||
customSort({ entity: entity1 }, { entity: entity2 }) {
|
||||
// TODO: We could implement this more efficiently by comparing field by field.
|
||||
@@ -54,7 +54,6 @@ export const columnFactories = Object.freeze({
|
||||
<EntityRefLink
|
||||
entityRef={entity}
|
||||
defaultKind={options?.defaultKind || 'Component'}
|
||||
title={entity.metadata?.title}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -20,7 +20,11 @@ import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
|
||||
export interface CatalogTableRow {
|
||||
entity: Entity;
|
||||
resolved: {
|
||||
// This name is here for backwards compatibility mostly; the presentation of
|
||||
// refs in the table should in general be handled with EntityRefLink /
|
||||
// EntityName components
|
||||
name: string;
|
||||
entityRef: string;
|
||||
partOfSystemRelationTitle?: string;
|
||||
partOfSystemRelations: CompoundEntityRef[];
|
||||
ownedByRelationsTitle?: string;
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('EntityLayout', () => {
|
||||
kind: 'MyKind',
|
||||
metadata: {
|
||||
name: 'my-entity',
|
||||
namespace: 'default',
|
||||
title: 'My Entity',
|
||||
},
|
||||
} as Entity;
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useRouteRefParams,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
EntityDisplayName,
|
||||
EntityRefLinks,
|
||||
entityRouteRef,
|
||||
FavoriteEntity,
|
||||
@@ -78,7 +79,11 @@ function EntityLayoutTitle(props: {
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
>
|
||||
{title}
|
||||
{entity ? (
|
||||
<EntityDisplayName entityRef={entity} variant="simple" />
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Box>
|
||||
{entity && <FavoriteEntity entity={entity} />}
|
||||
</Box>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CatalogClient } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
catalogApiRef,
|
||||
entityPresentationApiRef,
|
||||
entityRouteRef,
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
@@ -52,6 +53,7 @@ import { HasSystemsCardProps } from './components/HasSystemsCard';
|
||||
import { RelatedEntitiesCardProps } from './components/RelatedEntitiesCard';
|
||||
import { CatalogSearchResultListItemProps } from './components/CatalogSearchResultListItem';
|
||||
import { rootRouteRef } from './routes';
|
||||
import { DefaultEntityPresentationApi } from './apis/EntityPresentationApi';
|
||||
|
||||
/** @public */
|
||||
export const catalogPlugin = createPlugin({
|
||||
@@ -72,6 +74,12 @@ export const catalogPlugin = createPlugin({
|
||||
factory: ({ storageApi }) =>
|
||||
new DefaultStarredEntitiesApi({ storageApi }),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: entityPresentationApiRef,
|
||||
deps: { catalogApi: catalogApiRef },
|
||||
factory: ({ catalogApi }) =>
|
||||
DefaultEntityPresentationApi.create({ catalogApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
catalogIndex: rootRouteRef,
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('UserSummary Test', () => {
|
||||
'src',
|
||||
'https://example.com/staff/calum.jpeg',
|
||||
);
|
||||
expect(screen.getByText('examplegroup')).toHaveAttribute(
|
||||
expect(screen.getByText('examplegroup').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/group/examplegroup',
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('<UserSettingsIdentityCard />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('user:default/test-ownership')).toBeInTheDocument();
|
||||
expect(screen.getByText('user:test-ownership')).toBeInTheDocument();
|
||||
expect(screen.getByText('foo:bar/foobar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5872,6 +5872,8 @@ __metadata:
|
||||
"@testing-library/react": ^12.1.3
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
dataloader: ^2.0.0
|
||||
expiry-map: ^2.0.0
|
||||
history: ^5.0.0
|
||||
lodash: ^4.17.21
|
||||
pluralize: ^8.0.0
|
||||
@@ -25825,6 +25827,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"expiry-map@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "expiry-map@npm:2.0.0"
|
||||
dependencies:
|
||||
map-age-cleaner: ^0.2.0
|
||||
checksum: 9be8662e1a5c1084fb6d0ddc5402658dd06101c330454062b2f5efbf1477259d272e54ec16663d7d12a93d08ed510535781c36acb214696c5bc3a690a02a7a9d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"exponential-backoff@npm:^3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "exponential-backoff@npm:3.1.1"
|
||||
@@ -32078,6 +32089,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"map-age-cleaner@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "map-age-cleaner@npm:0.2.0"
|
||||
dependencies:
|
||||
p-defer: ^1.0.0
|
||||
checksum: 13a6810b76b0067efa7f4b0f3dc58b58b4a4b5faa4cae5a0e8d5d59eda04d7074724eee426c9b5890a1d7e14d1e2902a090587acc8e2430198e79ab1556a2dad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"map-cache@npm:^0.2.0":
|
||||
version: 0.2.2
|
||||
resolution: "map-cache@npm:0.2.2"
|
||||
@@ -34515,6 +34535,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-defer@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "p-defer@npm:1.0.0"
|
||||
checksum: 4271b935c27987e7b6f229e5de4cdd335d808465604644cb7b4c4c95bef266735859a93b16415af8a41fd663ee9e3b97a1a2023ca9def613dba1bad2a0da0c7b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-filter@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "p-filter@npm:2.1.0"
|
||||
|
||||
Reference in New Issue
Block a user