Add an entityPresentationApiRef

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-04-15 16:26:09 +02:00
parent ec1ca01848
commit 1e5b7d993a
42 changed files with 2024 additions and 455 deletions
+17
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': minor
---
`IconComponent` can now have a `fontSize` of `inherit`, which is useful for in-line icons.
+10
View File
@@ -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.
+2 -2
View File
@@ -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';
}
>;
+2 -2
View File
@@ -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';
}
>;
+75 -15
View File
@@ -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;
}
@@ -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;
}
+1
View File
@@ -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;
+47
View File
@@ -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 });
+2
View File
@@ -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;
}
+1
View File
@@ -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>
+8
View File
@@ -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();
});
});
+27
View File
@@ -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"