[Embeddable Refactor] Publish phase events (#175245)

Makes the Legacy Embeddable API properly implement the `publishesPhaseEvents` interface.
This commit is contained in:
Devon Thomson 2024-01-23 13:29:07 -05:00 committed by GitHub
parent d3c51c45eb
commit 290bc8b359
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 97 additions and 11 deletions

View file

@ -18,11 +18,11 @@ export {
type CanAccessViewMode,
} from './interfaces/can_access_view_mode';
export {
apiFiresPhaseEvents,
type FiresPhaseEvents,
apiPublishesPhaseEvents,
type PublishesPhaseEvents,
type PhaseEvent,
type PhaseEventType,
} from './interfaces/fires_phase_events';
} from './interfaces/publishes_phase_events';
export { hasEditCapabilities, type HasEditCapabilities } from './interfaces/has_edit_capabilities';
export { apiHasParentApi, type HasParentApi } from './interfaces/has_parent_api';
export {

View file

@ -20,10 +20,12 @@ export interface PhaseEvent {
timeToEvent: number;
}
export interface FiresPhaseEvents {
onPhaseChange: PublishingSubject<PhaseEvent>;
export interface PublishesPhaseEvents {
onPhaseChange: PublishingSubject<PhaseEvent | undefined>;
}
export const apiFiresPhaseEvents = (unknownApi: null | unknown): unknownApi is FiresPhaseEvents => {
return Boolean(unknownApi && (unknownApi as FiresPhaseEvents)?.onPhaseChange !== undefined);
export const apiPublishesPhaseEvents = (
unknownApi: null | unknown
): unknownApi is PublishesPhaseEvents => {
return Boolean(unknownApi && (unknownApi as PublishesPhaseEvents)?.onPhaseChange !== undefined);
};

View file

@ -10,8 +10,10 @@ import { DataView } from '@kbn/data-views-plugin/common';
import { AggregateQuery, compareFilters, Filter, Query, TimeRange } from '@kbn/es-query';
import type { ErrorLike } from '@kbn/expressions-plugin/common';
import { i18n } from '@kbn/i18n';
import { PhaseEvent, PhaseEventType } from '@kbn/presentation-publishing';
import deepEqual from 'fast-deep-equal';
import { BehaviorSubject, Subscription } from 'rxjs';
import { isNil } from 'lodash';
import { BehaviorSubject, map, Subscription, distinct } from 'rxjs';
import { embeddableStart } from '../../../kibana_services';
import { isFilterableEmbeddable } from '../../filterable_embeddable';
import {
@ -40,6 +42,18 @@ function isVisualizeEmbeddable(
return embeddable.type === 'visualization';
}
const getEventStatus = (output: EmbeddableOutput): PhaseEventType => {
if (!isNil(output.error)) {
return 'error';
} else if (output.rendered === true) {
return 'rendered';
} else if (output.loading === false) {
return 'loaded';
} else {
return 'loading';
}
};
export const legacyEmbeddableToApi = (
embeddable: CommonLegacyEmbeddable
): { api: Omit<LegacyEmbeddableAPI, 'type' | 'getInspectorAdapters'>; destroyAPI: () => void } => {
@ -66,6 +80,42 @@ export const legacyEmbeddableToApi = (
});
const isEditingEnabled = () => canEditEmbeddable(embeddable);
/**
* Performance tracking
*/
const onPhaseChange = new BehaviorSubject<PhaseEvent | undefined>(undefined);
let loadingStartTime = 0;
subscriptions.add(
embeddable
.getOutput$()
.pipe(
// Map loaded event properties
map((output) => {
if (output.loading === true) {
loadingStartTime = performance.now();
}
return {
id: embeddable.id,
status: getEventStatus(output),
error: output.error,
};
}),
// Dedupe
distinct((output) => loadingStartTime + output.id + output.status + !!output.error),
// Map loaded event properties
map((output): PhaseEvent => {
return {
...output,
timeToEvent: performance.now() - loadingStartTime,
};
})
)
.subscribe((statusOutput) => {
onPhaseChange.next(statusOutput);
})
);
/**
* Publish state for Presentation panel
*/
@ -155,6 +205,8 @@ export const legacyEmbeddableToApi = (
dataLoading,
blockingError,
onPhaseChange,
onEdit,
isEditingEnabled,
getTypeDisplayName,

View file

@ -43,6 +43,17 @@ class OutputTestEmbeddable extends Embeddable<EmbeddableInput, Output> {
reload() {}
}
class PhaseTestEmbeddable extends Embeddable<EmbeddableInput, EmbeddableOutput> {
public readonly type = 'phaseTest';
constructor() {
super({ id: 'phaseTest', viewMode: ViewMode.VIEW }, {});
}
public reportsEmbeddableLoad(): boolean {
return true;
}
reload() {}
}
test('Embeddable calls input subscribers when changed', (done) => {
const hello = new ContactCardEmbeddable(
{ id: '123', firstName: 'Brienne', lastName: 'Tarth' },
@ -104,6 +115,21 @@ test('updating output state retains instance information', async () => {
expect(outputTest.getOutput().testClass).toBeInstanceOf(TestClass);
});
test('fires phase events when output changes', async () => {
const phaseEventTest = new PhaseTestEmbeddable();
let phaseEventCount = 0;
phaseEventTest.onPhaseChange.subscribe((event) => {
if (event) {
phaseEventCount++;
}
});
expect(phaseEventCount).toBe(1); // loading is true by default which fires an event.
phaseEventTest.updateOutput({ loading: false });
expect(phaseEventCount).toBe(2);
phaseEventTest.updateOutput({ rendered: true });
expect(phaseEventCount).toBe(3);
});
test('updated$ called after reload and batches input/output changes', async () => {
const hello = new ContactCardEmbeddable(
{ id: '123', firstName: 'Brienne', lastName: 'Tarth' },

View file

@ -126,6 +126,7 @@ export abstract class Embeddable<
dataLoading: this.dataLoading,
localFilters: this.localFilters,
blockingError: this.blockingError,
onPhaseChange: this.onPhaseChange,
setPanelTitle: this.setPanelTitle,
linkToLibrary: this.linkToLibrary,
hidePanelTitle: this.hidePanelTitle,
@ -164,6 +165,7 @@ export abstract class Embeddable<
public panelTitle: LegacyEmbeddableAPI['panelTitle'];
public dataLoading: LegacyEmbeddableAPI['dataLoading'];
public localFilters: LegacyEmbeddableAPI['localFilters'];
public onPhaseChange: LegacyEmbeddableAPI['onPhaseChange'];
public linkToLibrary: LegacyEmbeddableAPI['linkToLibrary'];
public blockingError: LegacyEmbeddableAPI['blockingError'];
public setPanelTitle: LegacyEmbeddableAPI['setPanelTitle'];

View file

@ -22,6 +22,7 @@ import {
PublishesViewMode,
PublishesWritablePanelDescription,
PublishesWritablePanelTitle,
PublishesPhaseEvents,
} from '@kbn/presentation-publishing';
import { Observable } from 'rxjs';
import { EmbeddableInput } from '../../../common/types';
@ -38,6 +39,7 @@ export type { EmbeddableInput };
*/
export type LegacyEmbeddableAPI = HasType &
HasUniqueId &
PublishesPhaseEvents &
PublishesViewMode &
PublishesDataViews &
HasEditCapabilities &

View file

@ -9,7 +9,7 @@
import { EuiFlexGroup, EuiPanel, htmlIdGenerator } from '@elastic/eui';
import { PanelLoader } from '@kbn/panel-loader';
import {
apiFiresPhaseEvents,
apiPublishesPhaseEvents,
apiHasParentApi,
apiPublishesViewMode,
useBatchedPublishingSubjects,
@ -82,8 +82,10 @@ export const PresentationPanelInternal = <
useEffect(() => {
let subscription: Subscription;
if (api && onPanelStatusChange && apiFiresPhaseEvents(api)) {
subscription = api.onPhaseChange.subscribe((phase) => onPanelStatusChange(phase));
if (api && onPanelStatusChange && apiPublishesPhaseEvents(api)) {
subscription = api.onPhaseChange.subscribe((phase) => {
if (phase) onPanelStatusChange(phase);
});
}
return () => subscription?.unsubscribe();
}, [api, onPanelStatusChange]);