diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_phase_events.ts b/packages/presentation/presentation_publishing/interfaces/publishes_phase_events.ts index 72dbf70393a90..967cbf2508d9a 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_phase_events.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_phase_events.ts @@ -21,11 +21,11 @@ export interface PhaseEvent { } export interface PublishesPhaseEvents { - onPhaseChange: PublishingSubject; + phase$: PublishingSubject; } export const apiPublishesPhaseEvents = ( unknownApi: null | unknown ): unknownApi is PublishesPhaseEvents => { - return Boolean(unknownApi && (unknownApi as PublishesPhaseEvents)?.onPhaseChange !== undefined); + return Boolean(unknownApi && (unknownApi as PublishesPhaseEvents)?.phase$ !== undefined); }; diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx index b0413b9fa3c46..583a867351b3b 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid_item.tsx @@ -103,6 +103,7 @@ export const Item = React.forwardRef( showBorder: useMargins, showNotifications: true, showShadow: false, + onPanelStatusChange, }; // render React embeddable @@ -123,7 +124,6 @@ export const Item = React.forwardRef( container.untilEmbeddableLoaded(id)} {...panelProps} /> diff --git a/src/plugins/embeddable/public/lib/embeddables/compatibility/legacy_embeddable_to_api.ts b/src/plugins/embeddable/public/lib/embeddables/compatibility/legacy_embeddable_to_api.ts index 8253fabe9a8f3..8385f9010e7e2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/compatibility/legacy_embeddable_to_api.ts +++ b/src/plugins/embeddable/public/lib/embeddables/compatibility/legacy_embeddable_to_api.ts @@ -103,7 +103,7 @@ export const legacyEmbeddableToApi = ( /** * Performance tracking */ - const onPhaseChange = new BehaviorSubject(undefined); + const phase$ = new BehaviorSubject(undefined); let loadingStartTime = 0; subscriptions.add( @@ -132,7 +132,7 @@ export const legacyEmbeddableToApi = ( }) ) .subscribe((statusOutput) => { - onPhaseChange.next(statusOutput); + phase$.next(statusOutput); }) ); @@ -252,7 +252,7 @@ export const legacyEmbeddableToApi = ( dataLoading, blockingError, - onPhaseChange, + phase$, onEdit, isEditingEnabled, diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx index 3ff28fe7d5608..2269328503ba4 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.test.tsx @@ -118,7 +118,7 @@ test('updating output state retains instance information', async () => { test('fires phase events when output changes', async () => { const phaseEventTest = new PhaseTestEmbeddable(); let phaseEventCount = 0; - phaseEventTest.onPhaseChange.subscribe((event) => { + phaseEventTest.phase$.subscribe((event) => { if (event) { phaseEventCount++; } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index fb6a093d54817..d27fd62148a27 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -127,7 +127,7 @@ export abstract class Embeddable< dataLoading: this.dataLoading, filters$: this.filters$, blockingError: this.blockingError, - onPhaseChange: this.onPhaseChange, + phase$: this.phase$, setPanelTitle: this.setPanelTitle, linkToLibrary: this.linkToLibrary, hidePanelTitle: this.hidePanelTitle, @@ -168,7 +168,7 @@ export abstract class Embeddable< public panelTitle: LegacyEmbeddableAPI['panelTitle']; public dataLoading: LegacyEmbeddableAPI['dataLoading']; public filters$: LegacyEmbeddableAPI['filters$']; - public onPhaseChange: LegacyEmbeddableAPI['onPhaseChange']; + public phase$: LegacyEmbeddableAPI['phase$']; public linkToLibrary: LegacyEmbeddableAPI['linkToLibrary']; public blockingError: LegacyEmbeddableAPI['blockingError']; public setPanelTitle: LegacyEmbeddableAPI['setPanelTitle']; diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx index 84cfa78e983b0..e8ba94e61a125 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx @@ -7,7 +7,7 @@ */ import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; import { setStubKibanaServices as setupPresentationPanelServices } from '@kbn/presentation-panel-plugin/public/mocks'; -import { render, waitFor, screen } from '@testing-library/react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; @@ -15,36 +15,36 @@ import { registerReactEmbeddableFactory } from './react_embeddable_registry'; import { ReactEmbeddableRenderer } from './react_embeddable_renderer'; import { ReactEmbeddableFactory } from './types'; -describe('react embeddable renderer', () => { - const testEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { - type: 'test', - deserializeState: jest.fn().mockImplementation((state) => state.rawState), - buildEmbeddable: async (state, registerApi) => { - const api = registerApi( - { - serializeState: () => ({ - rawState: { - name: state.name, - bork: state.bork, - }, - }), - }, - { - name: [new BehaviorSubject(state.name), () => {}], - bork: [new BehaviorSubject(state.bork), () => {}], - } - ); - return { - Component: () => ( -
- SUPER TEST COMPONENT, name: {state.name} bork: {state.bork} -
- ), - api, - }; - }, - }; +const testEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { + type: 'test', + deserializeState: jest.fn().mockImplementation((state) => state.rawState), + buildEmbeddable: async (state, registerApi) => { + const api = registerApi( + { + serializeState: () => ({ + rawState: { + name: state.name, + bork: state.bork, + }, + }), + }, + { + name: [new BehaviorSubject(state.name), () => {}], + bork: [new BehaviorSubject(state.bork), () => {}], + } + ); + return { + Component: () => ( +
+ SUPER TEST COMPONENT, name: {state.name} bork: {state.bork} +
+ ), + api, + }; + }, +}; +describe('react embeddable renderer', () => { const getTestEmbeddableFactory = async () => { return testEmbeddableFactory; }; @@ -185,6 +185,7 @@ describe('react embeddable renderer', () => { serializeState: expect.any(Function), resetUnsavedChanges: expect.any(Function), snapshotRuntimeState: expect.any(Function), + phase$: expect.any(Object), }) ); }); @@ -209,3 +210,104 @@ describe('react embeddable renderer', () => { ); }); }); + +describe('reactEmbeddable phase events', () => { + it('publishes rendered phase immediately when dataLoading is not defined', async () => { + const immediateLoadEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { + ...testEmbeddableFactory, + type: 'immediateLoad', + }; + registerReactEmbeddableFactory('immediateLoad', () => + Promise.resolve(immediateLoadEmbeddableFactory) + ); + setupPresentationPanelServices(); + + const renderedEvent = jest.fn(); + render( + { + api.phase$.subscribe((phase) => { + if (phase?.status === 'rendered') { + renderedEvent(); + } + }); + }} + getParentApi={() => ({ + getSerializedStateForChild: () => ({ + rawState: { name: 'Kuni Garu' }, + }), + })} + /> + ); + await waitFor(() => expect(renderedEvent).toHaveBeenCalled()); + }); + + it('publishes rendered phase event when dataLoading is complete', async () => { + const dataLoadingEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { + ...testEmbeddableFactory, + type: 'loadClicker', + buildEmbeddable: async (state, registerApi) => { + const dataLoading = new BehaviorSubject(true); + const api = registerApi( + { + serializeState: () => ({ + rawState: { + name: state.name, + bork: state.bork, + }, + }), + dataLoading, + }, + { + name: [new BehaviorSubject(state.name), () => {}], + bork: [new BehaviorSubject(state.bork), () => {}], + } + ); + return { + Component: () => ( + <> +
+ SUPER TEST COMPONENT, name: {state.name} bork: {state.bork} +
+ + + ), + api, + }; + }, + }; + registerReactEmbeddableFactory('loadClicker', () => + Promise.resolve(dataLoadingEmbeddableFactory) + ); + setupPresentationPanelServices(); + + const phaseFn = jest.fn(); + render( + { + api.phase$.subscribe((phase) => { + phaseFn(phase); + }); + }} + getParentApi={() => ({ + getSerializedStateForChild: () => ({ + rawState: { name: 'Kuni Garu' }, + }), + })} + /> + ); + await waitFor(() => { + expect(phaseFn).toHaveBeenCalledWith(expect.objectContaining({ status: 'loading' })); + }); + await fireEvent.click(screen.getByTestId('clickToStopLoading')); + await waitFor(() => { + expect(phaseFn).toHaveBeenCalledWith(expect.objectContaining({ status: 'rendered' })); + }); + }); +}); diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx index 98a7a42244bb4..509e5a758866d 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -8,9 +8,14 @@ import { HasSerializedChildState, SerializedPanelState } from '@kbn/presentation-containers'; import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public'; -import { ComparatorDefinition, StateComparators } from '@kbn/presentation-publishing'; +import { + apiPublishesDataLoading, + ComparatorDefinition, + PhaseEvent, + StateComparators, +} from '@kbn/presentation-publishing'; import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; -import { combineLatest, debounceTime, skip, switchMap } from 'rxjs'; +import { BehaviorSubject, combineLatest, debounceTime, skip, Subscription, switchMap } from 'rxjs'; import { v4 as generateId } from 'uuid'; import { getReactEmbeddableFactory } from './react_embeddable_registry'; import { initializeReactEmbeddableState } from './react_embeddable_state'; @@ -58,13 +63,32 @@ export const ReactEmbeddableRenderer = < onAnyStateChange?: (state: SerializedPanelState) => void; }) => { const cleanupFunction = useRef<(() => void) | null>(null); + const firstLoadCompleteTime = useRef(null); const componentPromise = useMemo( - () => - (async () => { + () => { + const uuid = maybeId ?? generateId(); + + /** + * Phase tracking instrumentation for telemetry + */ + const phase$ = new BehaviorSubject(undefined); + const embeddableStartTime = performance.now(); + const reportPhaseChange = (loading: boolean) => { + if (firstLoadCompleteTime.current === null) { + firstLoadCompleteTime.current = performance.now(); + } + const duration = firstLoadCompleteTime.current - embeddableStartTime; + phase$.next({ id: uuid, status: loading ? 'loading' : 'rendered', timeToEvent: duration }); + }; + + /** + * Build the embeddable promise + */ + return (async () => { const parentApi = getParentApi(); - const uuid = maybeId ?? generateId(); const factory = await getReactEmbeddableFactory(type); + const subscriptions = new Subscription(); const { initialState, startStateDiffing } = await initializeReactEmbeddableState< SerializedState, @@ -84,23 +108,25 @@ export const ReactEmbeddableRenderer = < const comparatorDefinitions: Array< ComparatorDefinition > = Object.values(comparators); - combineLatest(comparatorDefinitions.map((comparator) => comparator[0])) - .pipe( - skip(1), - debounceTime(ON_STATE_CHANGE_DEBOUNCE), - switchMap(() => { - const isAsync = - apiRegistration.serializeState.prototype?.name === 'AsyncFunction'; - return isAsync - ? (apiRegistration.serializeState() as Promise< - SerializedPanelState - >) - : Promise.resolve(apiRegistration.serializeState()); + subscriptions.add( + combineLatest(comparatorDefinitions.map((comparator) => comparator[0])) + .pipe( + skip(1), + debounceTime(ON_STATE_CHANGE_DEBOUNCE), + switchMap(() => { + const isAsync = + apiRegistration.serializeState.prototype?.name === 'AsyncFunction'; + return isAsync + ? (apiRegistration.serializeState() as Promise< + SerializedPanelState + >) + : Promise.resolve(apiRegistration.serializeState()); + }) + ) + .subscribe((serializedState) => { + onAnyStateChange(serializedState); }) - ) - .subscribe((serializedState) => { - onAnyStateChange(serializedState); - }); + ); } const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } = @@ -108,13 +134,17 @@ export const ReactEmbeddableRenderer = < const fullApi = { ...apiRegistration, uuid, + phase$, parentApi, unsavedChanges, type: factory.type, resetUnsavedChanges, snapshotRuntimeState, } as unknown as Api; - cleanupFunction.current = () => cleanup(); + cleanupFunction.current = () => { + subscriptions.unsubscribe(); + cleanup(); + }; onApiAvailable?.(fullApi); return fullApi; }; @@ -126,13 +156,22 @@ export const ReactEmbeddableRenderer = < parentApi ); + if (apiPublishesDataLoading(api)) { + subscriptions.add( + api.dataLoading.subscribe((loading) => reportPhaseChange(Boolean(loading))) + ); + } else { + reportPhaseChange(false); + } + return React.forwardRef((_, ref) => { // expose the api into the imperative handle useImperativeHandle(ref, () => api, []); return ; }); - })(), + })(); + }, /** * Disabling exhaustive deps because we do not want to re-fetch the component * from the embeddable registry unless the type changes. diff --git a/src/plugins/embeddable/public/react_embeddable_system/types.ts b/src/plugins/embeddable/public/react_embeddable_system/types.ts index f0ff899f5df69..64514ac854b52 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/types.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/types.ts @@ -11,7 +11,12 @@ import { SerializedPanelState, } from '@kbn/presentation-containers'; import { DefaultPresentationPanelApi } from '@kbn/presentation-panel-plugin/public/panel_component/types'; -import { HasType, PublishesUnsavedChanges, StateComparators } from '@kbn/presentation-publishing'; +import { + HasType, + PublishesPhaseEvents, + PublishesUnsavedChanges, + StateComparators, +} from '@kbn/presentation-publishing'; import { MaybePromise } from '@kbn/utility-types'; import React from 'react'; @@ -25,6 +30,7 @@ export interface DefaultEmbeddableApi< RuntimeState extends object = SerializedState > extends DefaultPresentationPanelApi, HasType, + PublishesPhaseEvents, PublishesUnsavedChanges, HasSerializableState, HasSnapshottableState {} @@ -38,7 +44,13 @@ export type ReactEmbeddableApiRegistration< Api extends DefaultEmbeddableApi = DefaultEmbeddableApi > = Omit< Api, - 'uuid' | 'parent' | 'type' | 'unsavedChanges' | 'resetUnsavedChanges' | 'snapshotRuntimeState' + | 'uuid' + | 'parent' + | 'type' + | 'unsavedChanges' + | 'resetUnsavedChanges' + | 'snapshotRuntimeState' + | 'phase$' >; /** diff --git a/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.tsx b/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.tsx index 2d46bca73d3b8..d910d2b063afc 100644 --- a/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.tsx +++ b/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.tsx @@ -84,7 +84,7 @@ export const PresentationPanelInternal = < useEffect(() => { let subscription: Subscription; if (api && onPanelStatusChange && apiPublishesPhaseEvents(api)) { - subscription = api.onPhaseChange.subscribe((phase) => { + subscription = api.phase$.subscribe((phase) => { if (phase) onPanelStatusChange(phase); }); }