mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Embeddables Rebuild] Publish Phase Events (#184445)
allows Dashboards with React Embeddables to properly fire phase events and report telemetry.
This commit is contained in:
parent
b34ff5203c
commit
81d3e1a4be
9 changed files with 218 additions and 65 deletions
|
@ -21,11 +21,11 @@ export interface PhaseEvent {
|
|||
}
|
||||
|
||||
export interface PublishesPhaseEvents {
|
||||
onPhaseChange: PublishingSubject<PhaseEvent | undefined>;
|
||||
phase$: PublishingSubject<PhaseEvent | undefined>;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -103,6 +103,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
|
|||
showBorder: useMargins,
|
||||
showNotifications: true,
|
||||
showShadow: false,
|
||||
onPanelStatusChange,
|
||||
};
|
||||
|
||||
// render React embeddable
|
||||
|
@ -123,7 +124,6 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
|
|||
<EmbeddablePanel
|
||||
key={type}
|
||||
index={index}
|
||||
onPanelStatusChange={onPanelStatusChange}
|
||||
embeddable={() => container.untilEmbeddableLoaded(id)}
|
||||
{...panelProps}
|
||||
/>
|
||||
|
|
|
@ -103,7 +103,7 @@ export const legacyEmbeddableToApi = (
|
|||
/**
|
||||
* Performance tracking
|
||||
*/
|
||||
const onPhaseChange = new BehaviorSubject<PhaseEvent | undefined>(undefined);
|
||||
const phase$ = new BehaviorSubject<PhaseEvent | undefined>(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,
|
||||
|
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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<string>(state.name), () => {}],
|
||||
bork: [new BehaviorSubject<string>(state.bork), () => {}],
|
||||
}
|
||||
);
|
||||
return {
|
||||
Component: () => (
|
||||
<div data-test-subj="superTestEmbeddable">
|
||||
SUPER TEST COMPONENT, name: {state.name} bork: {state.bork}
|
||||
</div>
|
||||
),
|
||||
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<string>(state.name), () => {}],
|
||||
bork: [new BehaviorSubject<string>(state.bork), () => {}],
|
||||
}
|
||||
);
|
||||
return {
|
||||
Component: () => (
|
||||
<div data-test-subj="superTestEmbeddable">
|
||||
SUPER TEST COMPONENT, name: {state.name} bork: {state.bork}
|
||||
</div>
|
||||
),
|
||||
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(
|
||||
<ReactEmbeddableRenderer
|
||||
type={'test'}
|
||||
maybeId={'12345'}
|
||||
onApiAvailable={(api) => {
|
||||
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<boolean | undefined>(true);
|
||||
const api = registerApi(
|
||||
{
|
||||
serializeState: () => ({
|
||||
rawState: {
|
||||
name: state.name,
|
||||
bork: state.bork,
|
||||
},
|
||||
}),
|
||||
dataLoading,
|
||||
},
|
||||
{
|
||||
name: [new BehaviorSubject<string>(state.name), () => {}],
|
||||
bork: [new BehaviorSubject<string>(state.bork), () => {}],
|
||||
}
|
||||
);
|
||||
return {
|
||||
Component: () => (
|
||||
<>
|
||||
<div data-test-subj="superTestEmbeddable">
|
||||
SUPER TEST COMPONENT, name: {state.name} bork: {state.bork}
|
||||
</div>
|
||||
<button data-test-subj="clickToStopLoading" onClick={() => dataLoading.next(false)}>
|
||||
Done loading
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
api,
|
||||
};
|
||||
},
|
||||
};
|
||||
registerReactEmbeddableFactory('loadClicker', () =>
|
||||
Promise.resolve(dataLoadingEmbeddableFactory)
|
||||
);
|
||||
setupPresentationPanelServices();
|
||||
|
||||
const phaseFn = jest.fn();
|
||||
render(
|
||||
<ReactEmbeddableRenderer
|
||||
type={'loadClicker'}
|
||||
maybeId={'12345'}
|
||||
onApiAvailable={(api) => {
|
||||
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' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<SerializedState>) => void;
|
||||
}) => {
|
||||
const cleanupFunction = useRef<(() => void) | null>(null);
|
||||
const firstLoadCompleteTime = useRef<number | null>(null);
|
||||
|
||||
const componentPromise = useMemo(
|
||||
() =>
|
||||
(async () => {
|
||||
() => {
|
||||
const uuid = maybeId ?? generateId();
|
||||
|
||||
/**
|
||||
* Phase tracking instrumentation for telemetry
|
||||
*/
|
||||
const phase$ = new BehaviorSubject<PhaseEvent | undefined>(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<SerializedState, Api, RuntimeState>(type);
|
||||
const subscriptions = new Subscription();
|
||||
|
||||
const { initialState, startStateDiffing } = await initializeReactEmbeddableState<
|
||||
SerializedState,
|
||||
|
@ -84,23 +108,25 @@ export const ReactEmbeddableRenderer = <
|
|||
const comparatorDefinitions: Array<
|
||||
ComparatorDefinition<RuntimeState, keyof RuntimeState>
|
||||
> = 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<SerializedState>
|
||||
>)
|
||||
: 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<SerializedState>
|
||||
>)
|
||||
: 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<typeof api>((_, ref) => {
|
||||
// expose the api into the imperative handle
|
||||
useImperativeHandle(ref, () => api, []);
|
||||
|
||||
return <Component />;
|
||||
});
|
||||
})(),
|
||||
})();
|
||||
},
|
||||
/**
|
||||
* Disabling exhaustive deps because we do not want to re-fetch the component
|
||||
* from the embeddable registry unless the type changes.
|
||||
|
|
|
@ -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<SerializedState>,
|
||||
HasSnapshottableState<RuntimeState> {}
|
||||
|
@ -38,7 +44,13 @@ export type ReactEmbeddableApiRegistration<
|
|||
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>
|
||||
> = Omit<
|
||||
Api,
|
||||
'uuid' | 'parent' | 'type' | 'unsavedChanges' | 'resetUnsavedChanges' | 'snapshotRuntimeState'
|
||||
| 'uuid'
|
||||
| 'parent'
|
||||
| 'type'
|
||||
| 'unsavedChanges'
|
||||
| 'resetUnsavedChanges'
|
||||
| 'snapshotRuntimeState'
|
||||
| 'phase$'
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue