Added for easier consumption

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-05-02 15:59:06 +02:00
parent 9910c6babc
commit 8834dafd47
7 changed files with 117 additions and 64 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-react': minor
'@backstage/plugin-catalog': minor
---
Updated the presentation API to return a promise, in addition to the snapshot and observable that were there before. This makes it much easier to consume the API in a non-React context.
+1
View File
@@ -460,6 +460,7 @@ export type EntityRefLinksProps<
// @public
export interface EntityRefPresentation {
promise: Promise<EntityRefPresentationSnapshot>;
snapshot: EntityRefPresentationSnapshot;
update$?: Observable<EntityRefPresentationSnapshot>;
}
@@ -109,6 +109,10 @@ export interface EntityRefPresentation {
* elsewhere.
*/
update$?: Observable<EntityRefPresentationSnapshot>;
/**
* A promise that resolves to a usable entity presentation.
*/
promise: Promise<EntityRefPresentationSnapshot>;
}
/**
@@ -61,7 +61,8 @@ export function useEntityPresentation(
const presentation = useMemo<EntityRefPresentation>(
() => {
if (!entityPresentationApi) {
return { snapshot: defaultEntityPresentation(entityOrRef, context) };
const fallback = defaultEntityPresentation(entityOrRef, context);
return { snapshot: fallback, promise: Promise.resolve(fallback) };
}
return entityPresentationApi.forEntity(
@@ -73,6 +73,10 @@ describe('<EntityDisplayName />', () => {
update$: new ObservableImpl(subscriber => {
promise.then(value => subscriber.next(value));
}),
promise: Promise.resolve({
entityRef: 'component:default/foo',
primaryTitle: 'foo',
}),
} as EntityRefPresentation);
await renderInTestApp(
@@ -23,10 +23,11 @@ import {
import { DefaultEntityPresentationApi } from './DefaultEntityPresentationApi';
describe('DefaultEntityPresentationApi', () => {
it('works in local mode', () => {
it('works in local mode', async () => {
const api = DefaultEntityPresentationApi.createLocal();
expect(api.forEntity('component:default/test')).toEqual({
let presentation = api.forEntity('component:default/test');
expect(presentation).toEqual({
snapshot: {
entityRef: 'component:default/test',
entity: undefined,
@@ -35,11 +36,14 @@ describe('DefaultEntityPresentationApi', () => {
Icon: expect.anything(),
},
update$: undefined,
promise: expect.any(Promise),
});
await expect(presentation.promise).resolves.toEqual(presentation.snapshot);
expect(
api.forEntity('component:default/test', { defaultKind: 'Other' }),
).toEqual({
presentation = api.forEntity('component:default/test', {
defaultKind: 'Other',
});
expect(presentation).toEqual({
snapshot: {
entityRef: 'component:default/test',
entity: undefined,
@@ -48,13 +52,14 @@ describe('DefaultEntityPresentationApi', () => {
Icon: expect.anything(),
},
update$: undefined,
promise: expect.any(Promise),
});
await expect(presentation.promise).resolves.toEqual(presentation.snapshot);
expect(
api.forEntity('component:default/test', {
defaultNamespace: 'other',
}),
).toEqual({
presentation = api.forEntity('component:default/test', {
defaultNamespace: 'other',
});
expect(presentation).toEqual({
snapshot: {
entityRef: 'component:default/test',
entity: undefined,
@@ -63,7 +68,9 @@ describe('DefaultEntityPresentationApi', () => {
Icon: expect.anything(),
},
update$: undefined,
promise: expect.any(Promise),
});
await expect(presentation.promise).resolves.toEqual(presentation.snapshot);
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -77,7 +84,8 @@ describe('DefaultEntityPresentationApi', () => {
},
};
expect(api.forEntity(entity)).toEqual({
presentation = api.forEntity(entity);
expect(presentation).toEqual({
snapshot: {
entityRef: 'component:default/test',
primaryTitle: 'test',
@@ -85,7 +93,9 @@ describe('DefaultEntityPresentationApi', () => {
Icon: expect.anything(),
},
update$: undefined,
promise: expect.any(Promise),
});
await expect(presentation.promise).resolves.toEqual(presentation.snapshot);
});
it('works in catalog mode', async () => {
@@ -114,34 +124,38 @@ describe('DefaultEntityPresentationApi', () => {
});
// return simple presentation, call catalog, return full presentation
await expect(
consumePresentation(api.forEntity('component:default/test')),
).resolves.toEqual([
let presentation = api.forEntity('component:default/test');
let expected: EntityRefPresentationSnapshot = {
entityRef: 'component:default/test',
primaryTitle: 'test',
secondaryTitle: 'component:default/test | service',
Icon: expect.anything(),
};
await expect(consumePresentation(presentation)).resolves.toEqual([
// first the dummy snapshot
{
entityRef: 'component:default/test',
primaryTitle: 'test',
secondaryTitle: 'component:default/test',
Icon: expect.anything(),
},
{
entityRef: 'component:default/test',
primaryTitle: 'test',
secondaryTitle: 'component:default/test | service',
Icon: expect.anything(),
},
expected,
]);
await expect(presentation.promise).resolves.toEqual(expected);
// use cached entity, immediately return full presentation
await expect(
consumePresentation(api.forEntity('component:default/test')),
).resolves.toEqual([
{
entityRef: 'component:default/test',
primaryTitle: 'test',
secondaryTitle: 'component:default/test | service',
Icon: expect.anything(),
},
presentation = api.forEntity('component:default/test');
expected = {
entityRef: 'component:default/test',
primaryTitle: 'test',
secondaryTitle: 'component:default/test | service',
Icon: expect.anything(),
};
expect(presentation.snapshot).toEqual(expected);
await expect(consumePresentation(presentation)).resolves.toEqual([
expected,
]);
await expect(presentation.promise).resolves.toEqual(presentation.snapshot);
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(1);
expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledWith(
@@ -260,47 +260,70 @@ export class DefaultEntityPresentationApi implements EntityPresentationApi {
};
}
if (!needsLoad) {
return {
snapshot: initialSnapshot,
promise: Promise.resolve(initialSnapshot),
};
}
const loadingPromise = Promise.resolve().then(() =>
this.#loader?.load(entityRef),
);
// And then the following snapshot
const observable = !needsLoad
? undefined
: new ObservableImpl<EntityRefPresentationSnapshot>(subscriber => {
let aborted = false;
const observable = 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 updatedSnapshot = render({
loading: false,
entity: newEntity,
});
subscriber.next(updatedSnapshot);
}
})
.catch(() => {
// Intentionally ignored - we do not propagate errors to the
// observable here. The presentation API should be error free and
// always return SOMETHING that makes sense to render, and we have
// already ensured above that the initial snapshot was that.
})
.finally(() => {
if (!aborted) {
subscriber.complete();
}
});
loadingPromise
.then(newEntity => {
if (
!aborted &&
newEntity &&
newEntity.metadata.etag !== entity?.metadata.etag
) {
const updatedSnapshot = render({
loading: false,
entity: newEntity,
});
subscriber.next(updatedSnapshot);
}
})
.catch(() => {
// Intentionally ignored - we do not propagate errors to the
// observable here. The presentation API should be error free and
// always return SOMETHING that makes sense to render, and we have
// already ensured above that the initial snapshot was that.
})
.finally(() => {
if (!aborted) {
subscriber.complete();
}
});
return () => {
aborted = true;
};
});
return () => {
aborted = true;
};
},
);
const promise = loadingPromise
.then(newEntity => {
if (newEntity && newEntity.metadata.etag !== entity?.metadata.etag) {
return render({
loading: false,
entity: newEntity,
});
}
return initialSnapshot;
})
.catch(() => initialSnapshot);
return {
snapshot: initialSnapshot,
update$: observable,
promise: promise,
};
}