diff --git a/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx index c699ca5799967..f1aeac5d04dce 100644 --- a/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/eui_markdown/eui_markdown_react_embeddable.tsx @@ -10,12 +10,8 @@ import { EuiMarkdownEditor, EuiMarkdownFormat } from '@elastic/eui'; import { css } from '@emotion/react'; import { initializeReactEmbeddableTitles, - initializeReactEmbeddableUuid, ReactEmbeddableFactory, - RegisterReactEmbeddable, registerReactEmbeddableFactory, - useReactEmbeddableApiHandle, - useReactEmbeddableUnsavedChanges, } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { useInheritedViewMode, useStateFromPublishingSubject } from '@kbn/presentation-publishing'; @@ -25,70 +21,64 @@ import { BehaviorSubject } from 'rxjs'; import { EUI_MARKDOWN_ID } from './constants'; import { MarkdownEditorSerializedState, MarkdownEditorApi } from './types'; -export const registerMarkdownEditorEmbeddable = () => { - const markdownEmbeddableFactory: ReactEmbeddableFactory< - MarkdownEditorSerializedState, - MarkdownEditorApi - > = { - deserializeState: (state) => { - /** - * Here we can run migrations and inject references. - */ - return state.rawState as MarkdownEditorSerializedState; - }, - getComponent: async (state, maybeId) => { - /** - * initialize state (source of truth) - */ - const uuid = initializeReactEmbeddableUuid(maybeId); - const { titlesApi, titleComparators, serializeTitles } = - initializeReactEmbeddableTitles(state); - const contentSubject = new BehaviorSubject(state.content); +const markdownEmbeddableFactory: ReactEmbeddableFactory< + MarkdownEditorSerializedState, + MarkdownEditorApi +> = { + type: EUI_MARKDOWN_ID, + deserializeState: (state) => { + /** + * Here we can run clientside migrations and inject references. + */ + return state.rawState as MarkdownEditorSerializedState; + }, + /** + * The buildEmbeddable function is async so you can async import the component or load a saved + * object here. The loading will be handed gracefully by the Presentation Container. + */ + buildEmbeddable: async (state, buildApi) => { + /** + * initialize state (source of truth) + */ + const { titlesApi, titleComparators, serializeTitles } = initializeReactEmbeddableTitles(state); + const content$ = new BehaviorSubject(state.content); + + /** + * Register the API for this embeddable. This API will be published into the imperative handle + * of the React component. Methods on this API will be exposed to siblings, to registered actions + * and to the parent api. + */ + const api = buildApi( + { + ...titlesApi, + serializeState: () => { + return { + rawState: { + ...serializeTitles(), + content: content$.getValue(), + }, + }; + }, + }, /** - * getComponent is async so you can async import the component or load a saved object here. - * the loading will be handed gracefully by the Presentation Container. + * Provide state comparators. Each comparator is 3 element tuple: + * 1) current value (publishing subject) + * 2) setter, allowing parent to reset value + * 3) optional comparator which provides logic to diff lasted stored value and current value */ + { + content: [content$, (value) => content$.next(value)], + ...titleComparators, + } + ); - return RegisterReactEmbeddable((apiRef) => { - /** - * Unsaved changes logic is handled automatically by this hook. You only need to provide - * a subject, setter, and optional state comparator for each key in your state type. - */ - const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges( - uuid, - markdownEmbeddableFactory, - { - content: [contentSubject, (value) => contentSubject.next(value)], - ...titleComparators, - } - ); - - /** - * Publish the API. This is what gets forwarded to the Actions framework, and to whatever the - * parent of this embeddable is. - */ - const thisApi = useReactEmbeddableApiHandle( - { - ...titlesApi, - unsavedChanges, - resetUnsavedChanges, - serializeState: async () => { - return { - rawState: { - ...serializeTitles(), - content: contentSubject.getValue(), - }, - }; - }, - }, - apiRef, - uuid - ); - + return { + api, + Component: () => { // get state for rendering - const content = useStateFromPublishingSubject(contentSubject); - const viewMode = useInheritedViewMode(thisApi) ?? 'view'; + const content = useStateFromPublishingSubject(content$); + const viewMode = useInheritedViewMode(api) ?? 'view'; return viewMode === 'edit' ? ( { width: 100%; `} value={content ?? ''} - onChange={(value) => contentSubject.next(value)} + onChange={(value) => content$.next(value)} aria-label={i18n.translate('embeddableExamples.euiMarkdownEditor.ariaLabel', { defaultMessage: 'Dashboard markdown editor', })} @@ -105,20 +95,21 @@ export const registerMarkdownEditorEmbeddable = () => { ) : ( {content ?? ''} ); - }); - }, - }; - - /** - * Register the defined Embeddable Factory - notice that this isn't defined - * on the plugin. Instead, it's a simple imported function. I.E to register an - * embeddable, you only need the embeddable plugin in your requiredBundles - */ - registerReactEmbeddableFactory(EUI_MARKDOWN_ID, markdownEmbeddableFactory); + }, + }; + }, }; + +/** + * Register the defined Embeddable Factory - notice that this isn't defined + * on the plugin. Instead, it's a simple imported function. I.E to register an + * embeddable, you only need the embeddable plugin in your requiredBundles + */ +export const registerMarkdownEditorEmbeddable = () => + registerReactEmbeddableFactory(markdownEmbeddableFactory); diff --git a/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts b/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts index 1e594ff61ba68..c8fd99d842333 100644 --- a/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts +++ b/examples/embeddable_examples/public/react_embeddables/eui_markdown/types.ts @@ -15,4 +15,4 @@ export type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & { content: string; }; -export type MarkdownEditorApi = DefaultEmbeddableApi; +export type MarkdownEditorApi = DefaultEmbeddableApi; diff --git a/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx index 38122c7393c84..ccc40ad7067a7 100644 --- a/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx +++ b/examples/embeddable_examples/public/react_embeddables/field_list/field_list_react_embeddable.tsx @@ -12,19 +12,15 @@ import { ChartsPluginStart } from '@kbn/charts-plugin/public'; import { Reference } from '@kbn/content-management-utils'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/common'; import { DataViewsPublicPluginStart, DATA_VIEW_SAVED_OBJECT_TYPE, - type DataView, } from '@kbn/data-views-plugin/public'; import { initializeReactEmbeddableTitles, - initializeReactEmbeddableUuid, ReactEmbeddableFactory, - RegisterReactEmbeddable, registerReactEmbeddableFactory, - useReactEmbeddableApiHandle, - useReactEmbeddableUnsavedChanges, } from '@kbn/embeddable-plugin/public'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { i18n } from '@kbn/i18n'; @@ -37,7 +33,7 @@ import { } from '@kbn/unified-field-list'; import { cloneDeep } from 'lodash'; import React, { useEffect, useState } from 'react'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { FIELD_LIST_DATA_VIEW_REF_NAME, FIELD_LIST_ID } from './constants'; import { FieldListApi, FieldListSerializedStateState } from './types'; @@ -72,150 +68,164 @@ export const registerFieldListFactory = ( FieldListSerializedStateState, FieldListApi > = { + type: FIELD_LIST_ID, deserializeState: (state) => { const serializedState = cloneDeep(state.rawState) as FieldListSerializedStateState; // inject the reference const dataViewIdRef = state.references?.find( (ref) => ref.name === FIELD_LIST_DATA_VIEW_REF_NAME ); - if (dataViewIdRef && serializedState) { + // if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this) + if (dataViewIdRef && serializedState && !serializedState.dataViewId) { serializedState.dataViewId = dataViewIdRef?.id; } return serializedState; }, - getComponent: async (initialState, maybeId) => { - const uuid = initializeReactEmbeddableUuid(maybeId); + buildEmbeddable: async (initialState, buildApi) => { + const subscriptions = new Subscription(); const { titlesApi, titleComparators, serializeTitles } = initializeReactEmbeddableTitles(initialState); const allDataViews = await dataViews.getIdsWithTitle(); - const selectedDataViewId$ = new BehaviorSubject( initialState.dataViewId ?? (await dataViews.getDefaultDataView())?.id ); + + // transform data view ID into data views array. + const getDataViews = async (id?: string) => { + return id ? [await dataViews.get(id)] : undefined; + }; + const dataViews$ = new BehaviorSubject( + await getDataViews(initialState.dataViewId) + ); + subscriptions.add( + selectedDataViewId$.subscribe(async (id) => dataViews$.next(await getDataViews(id))) + ); + const selectedFieldNames$ = new BehaviorSubject( initialState.selectedFieldNames ); - return RegisterReactEmbeddable((apiRef) => { - const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges( - uuid, - fieldListEmbeddableFactory, - { - dataViewId: [selectedDataViewId$, (value) => selectedDataViewId$.next(value)], - selectedFieldNames: [ - selectedFieldNames$, - (value) => selectedFieldNames$.next(value), - (a, b) => { - return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? ''); + const api = buildApi( + { + ...titlesApi, + serializeState: () => { + const dataViewId = selectedDataViewId$.getValue(); + const references: Reference[] = dataViewId + ? [ + { + type: DATA_VIEW_SAVED_OBJECT_TYPE, + name: FIELD_LIST_DATA_VIEW_REF_NAME, + id: dataViewId, + }, + ] + : []; + return { + rawState: { + ...serializeTitles(), + // here we skip serializing the dataViewId, because the reference contains that information. + selectedFieldNames: selectedFieldNames$.getValue(), }, - ], - ...titleComparators, - } - ); - - useReactEmbeddableApiHandle( - { - ...titlesApi, - unsavedChanges, - resetUnsavedChanges, - serializeState: async () => { - const dataViewId = selectedDataViewId$.getValue(); - const references: Reference[] = dataViewId - ? [ - { - type: DATA_VIEW_SAVED_OBJECT_TYPE, - name: FIELD_LIST_DATA_VIEW_REF_NAME, - id: dataViewId, - }, - ] - : []; - return { - rawState: { - ...serializeTitles(), - // here we skip serializing the dataViewId, because the reference contains that information. - selectedFieldNames: selectedFieldNames$.getValue(), - }, - references, - }; - }, + references, + }; }, - apiRef, - uuid - ); + }, + { + ...titleComparators, + dataViewId: [selectedDataViewId$, (value) => selectedDataViewId$.next(value)], + selectedFieldNames: [ + selectedFieldNames$, + (value) => selectedFieldNames$.next(value), + (a, b) => { + return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? ''); + }, + ], + } + ); - const [selectedDataViewId, selectedFieldNames] = useBatchedPublishingSubjects( - selectedDataViewId$, - selectedFieldNames$ - ); + return { + api, + Component: () => { + const [selectedDataViewId, selectedFieldNames] = useBatchedPublishingSubjects( + selectedDataViewId$, + selectedFieldNames$ + ); - const [selectedDataView, setSelectedDataView] = useState(undefined); + const [selectedDataView, setSelectedDataView] = useState(undefined); - useEffect(() => { - if (!selectedDataViewId) return; - let mounted = true; - (async () => { - const dataView = await dataViews.get(selectedDataViewId); - if (!mounted) return; - setSelectedDataView(dataView); - })(); - return () => { - mounted = false; - }; - }, [selectedDataViewId]); + useEffect(() => { + if (!selectedDataViewId) return; + let mounted = true; + (async () => { + const dataView = await dataViews.get(selectedDataViewId); + if (!mounted) return; + setSelectedDataView(dataView); + })(); + return () => { + mounted = false; + }; + }, [selectedDataViewId]); - return ( - - - { - selectedDataViewId$.next(nextSelection); - }} - trigger={{ - label: - selectedDataView?.getName() ?? - i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', { - defaultMessage: 'Please select a data view', - }), - }} - /> - - - {selectedDataView ? ( - - selectedFieldNames$.next([ - ...(selectedFieldNames$.getValue() ?? []), - field.name, - ]) - } - onRemoveFieldFromWorkspace={(field) => { - selectedFieldNames$.next( - (selectedFieldNames$.getValue() ?? []).filter((name) => name !== field.name) - ); + // On destroy + useEffect(() => { + return () => { + subscriptions.unsubscribe(); + }; + }, []); + + return ( + + + { + selectedDataViewId$.next(nextSelection); + }} + trigger={{ + label: + selectedDataView?.getName() ?? + i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', { + defaultMessage: 'Please select a data view', + }), }} /> - ) : null} - - - ); - }); + + + {selectedDataView ? ( + + selectedFieldNames$.next([ + ...(selectedFieldNames$.getValue() ?? []), + field.name, + ]) + } + onRemoveFieldFromWorkspace={(field) => { + selectedFieldNames$.next( + (selectedFieldNames$.getValue() ?? []).filter((name) => name !== field.name) + ); + }} + /> + ) : null} + + + ); + }, + }; }, }; - registerReactEmbeddableFactory(FIELD_LIST_ID, fieldListEmbeddableFactory); + registerReactEmbeddableFactory(fieldListEmbeddableFactory); }; diff --git a/packages/presentation/presentation_containers/index.ts b/packages/presentation/presentation_containers/index.ts index 47c8d8783a663..7d5c9c435c98f 100644 --- a/packages/presentation/presentation_containers/index.ts +++ b/packages/presentation/presentation_containers/index.ts @@ -20,7 +20,11 @@ export { type PresentationContainer, } from './interfaces/presentation_container'; export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays'; -export { type SerializedPanelState } from './interfaces/serialized_state'; +export { + type SerializedPanelState, + type HasSerializableState, + apiHasSerializableState, +} from './interfaces/serialized_state'; export { type PublishesLastSavedState, apiPublishesLastSavedState, diff --git a/packages/presentation/presentation_containers/interfaces/serialized_state.ts b/packages/presentation/presentation_containers/interfaces/serialized_state.ts index 87d51580ca6dd..56fa2066e80fa 100644 --- a/packages/presentation/presentation_containers/interfaces/serialized_state.ts +++ b/packages/presentation/presentation_containers/interfaces/serialized_state.ts @@ -17,3 +17,11 @@ export interface SerializedPanelState { rawState: RawStateType; version?: string; } + +export interface HasSerializableState { + serializeState: () => SerializedPanelState; +} + +export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => { + return Boolean((api as HasSerializableState)?.serializeState); +}; 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 948b9f7bc6332..7bbb29c3013e6 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 @@ -105,9 +105,10 @@ export const Item = React.forwardRef( if (reactEmbeddableRegistryHasKey(type)) { return ( ); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx index 5e4f129564614..d311cc005793b 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/run_save_functions.tsx @@ -14,7 +14,6 @@ import { isReferenceOrValueEmbeddable, reactEmbeddableRegistryHasKey, } from '@kbn/embeddable-plugin/public'; -import { SerializedPanelState } from '@kbn/presentation-containers'; import { showSaveModal } from '@kbn/saved-objects-plugin/public'; import { cloneDeep } from 'lodash'; import React from 'react'; @@ -36,28 +35,16 @@ const serializeAllPanelState = async ( dashboard: DashboardContainer ): Promise<{ panels: DashboardContainerInput['panels']; references: Reference[] }> => { const references: Reference[] = []; - const reactEmbeddableSavePromises: Array< - Promise<{ serializedState: SerializedPanelState; uuid: string }> - > = []; const panels = cloneDeep(dashboard.getInput().panels); for (const [uuid, panel] of Object.entries(panels)) { if (!reactEmbeddableRegistryHasKey(panel.type)) continue; const api = dashboard.reactEmbeddableChildren.value[uuid]; if (api) { - reactEmbeddableSavePromises.push( - new Promise((resolve) => { - api.serializeState().then((serializedState) => { - resolve({ serializedState, uuid }); - }); - }) - ); + const serializedState = api.serializeState(); + panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid }; + references.push(...prefixReferencesFromPanel(uuid, serializedState.references ?? [])); } } - const saveResults = await Promise.all(reactEmbeddableSavePromises); - for (const { serializedState, uuid } of saveResults) { - panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid }; - references.push(...prefixReferencesFromPanel(uuid, serializedState.references ?? [])); - } return { panels, references }; }; diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 22eb43994911c..90b4fb3094274 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -20,7 +20,6 @@ import { EmbeddableFactoryNotFoundError, isExplicitInputWithAttributes, PanelNotFoundError, - ReactEmbeddableParentContext, reactEmbeddableRegistryHasKey, ViewMode, type EmbeddableFactory, @@ -304,9 +303,7 @@ export class DashboardContainer > - - - + diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 2eec70d591db3..3214e18aeff26 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -96,21 +96,15 @@ export type { EnhancementRegistryDefinition } from './types'; export { ReactEmbeddableRenderer, reactEmbeddableRegistryHasKey, - RegisterReactEmbeddable, registerReactEmbeddableFactory, - useReactEmbeddableApiHandle, type DefaultEmbeddableApi, - type ReactEmbeddable, type ReactEmbeddableFactory, type ReactEmbeddableRegistration, type ReactEmbeddableTitlesApi, type SerializedReactEmbeddableTitles, - ReactEmbeddableParentContext, - useReactEmbeddableParentApi, - useReactEmbeddableUnsavedChanges, - initializeReactEmbeddableUuid, initializeReactEmbeddableTitles, serializeReactEmbeddableTitles, + startTrackingEmbeddableUnsavedChanges, } from './react_embeddable_system'; export { registerSavedObjectToPanelMethod } from './registry/saved_object_to_panel_methods'; diff --git a/src/plugins/embeddable/public/react_embeddable_system/index.ts b/src/plugins/embeddable/public/react_embeddable_system/index.ts index 4bfd9e7efa2cf..b48289b7c4127 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/index.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/index.ts @@ -6,16 +6,8 @@ * Side Public License, v 1. */ -export { - useReactEmbeddableApiHandle, - initializeReactEmbeddableUuid, - ReactEmbeddableParentContext, - useReactEmbeddableParentApi, -} from './react_embeddable_api'; -export { useReactEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes'; export { reactEmbeddableRegistryHasKey, - RegisterReactEmbeddable, registerReactEmbeddableFactory, } from './react_embeddable_registry'; export { ReactEmbeddableRenderer } from './react_embeddable_renderer'; @@ -25,9 +17,9 @@ export { type ReactEmbeddableTitlesApi, type SerializedReactEmbeddableTitles, } from './react_embeddable_titles'; +export { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes'; export type { DefaultEmbeddableApi, - ReactEmbeddable, ReactEmbeddableFactory, ReactEmbeddableRegistration, } from './types'; diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.test.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.test.tsx deleted file mode 100644 index 4ec729119d87e..0000000000000 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { render, waitFor } from '@testing-library/react'; -import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; -import { renderHook } from '@testing-library/react-hooks'; -import React from 'react'; -import { BehaviorSubject } from 'rxjs'; -import { useReactEmbeddableApiHandle, ReactEmbeddableParentContext } from './react_embeddable_api'; -import { DefaultEmbeddableApi } from './types'; - -describe('react embeddable api', () => { - const defaultApi = { - unsavedChanges: new BehaviorSubject(undefined), - resetUnsavedChanges: jest.fn(), - serializeState: jest.fn().mockReturnValue({ bork: 'borkbork' }), - }; - - const parentApi = getMockPresentationContainer(); - - const TestComponent = React.forwardRef((_, ref) => { - useReactEmbeddableApiHandle(defaultApi, ref, '123'); - return
; - }); - - it('returns the given API', () => { - const { result } = renderHook(() => - useReactEmbeddableApiHandle 'bork' }>( - { - ...defaultApi, - bork: jest.fn().mockReturnValue('bork'), - }, - {} as any, - 'superBork' - ) - ); - - expect(result.current.bork()).toEqual('bork'); - expect(result.current.serializeState()).toEqual({ bork: 'borkbork' }); - }); - - it('publishes the API into the provided ref', async () => { - const ref = React.createRef(); - renderHook(() => useReactEmbeddableApiHandle(defaultApi, ref, '123')); - await waitFor(() => expect(ref.current).toBeDefined()); - expect(ref.current?.serializeState); - expect(ref.current?.serializeState()).toEqual({ bork: 'borkbork' }); - }); - - it('publishes the API into an imperative handle', async () => { - const ref = React.createRef(); - render(); - await waitFor(() => expect(ref.current).toBeDefined()); - expect(ref.current?.serializeState); - expect(ref.current?.serializeState()).toEqual({ bork: 'borkbork' }); - }); - - it('returns an API with a parent when rendered inside a parent context', async () => { - const ref = React.createRef(); - render( - - - - ); - await waitFor(() => expect(ref.current).toBeDefined()); - expect(ref.current?.serializeState); - expect(ref.current?.serializeState()).toEqual({ bork: 'borkbork' }); - - expect(ref.current?.parentApi?.getLastSavedStateForChild).toBeDefined(); - expect(ref.current?.parentApi?.registerPanelApi).toBeDefined(); - }); - - it('calls registerPanelApi on its parent', async () => { - const ref = React.createRef(); - render( - - - - ); - expect(parentApi?.registerPanelApi).toHaveBeenCalledWith('123', expect.any(Object)); - }); -}); diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.ts deleted file mode 100644 index 896a9fb05468b..0000000000000 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { apiIsPresentationContainer, PresentationContainer } from '@kbn/presentation-containers'; -import { createContext, useContext, useImperativeHandle, useMemo } from 'react'; -import { v4 as generateId } from 'uuid'; -import { DefaultEmbeddableApi } from './types'; - -/** - * Pushes any API to the passed in ref. Note that any API passed in will not be rebuilt on - * subsequent renders, so it does not support reactive variables. Instead, pass in setter functions - * and publishing subjects to allow other components to listen to changes. - */ -export const useReactEmbeddableApiHandle = < - ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi ->( - apiToRegister: Omit, - ref: React.ForwardedRef, - uuid: string -) => { - const { parentApi } = useReactEmbeddableParentContext() ?? {}; - - /** - * Publish the api for this embeddable. - */ - const thisApi = useMemo( - () => { - const api = { - ...apiToRegister, - uuid, - - // allow this embeddable access to its parent - parentApi, - } as ApiType; - // register this api with its parent - if (parentApi && apiIsPresentationContainer(parentApi)) - parentApi.registerPanelApi(uuid, api); - return api; - }, - // disabling exhaustive deps because the API should only be rebuilt when the uuid changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - [uuid] - ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useImperativeHandle(ref, () => thisApi, [uuid]); - - return thisApi; -}; - -export const initializeReactEmbeddableUuid = (maybeId?: string) => maybeId ?? generateId(); - -/** - * Parenting - */ -interface ReactEmbeddableParentContext { - parentApi?: PresentationContainer; -} - -export const ReactEmbeddableParentContext = createContext( - null -); -export const useReactEmbeddableParentApi = (): unknown | null => { - return useContext(ReactEmbeddableParentContext)?.parentApi; -}; - -export const useReactEmbeddableParentContext = (): ReactEmbeddableParentContext | null => { - return useContext(ReactEmbeddableParentContext); -}; diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx index 11d7a58fa0890..8ffb5500979dd 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx @@ -15,8 +15,9 @@ import { ReactEmbeddableFactory } from './types'; describe('react embeddable registry', () => { const testEmbeddableFactory: ReactEmbeddableFactory = { + type: 'test', deserializeState: jest.fn(), - getComponent: jest.fn(), + buildEmbeddable: jest.fn(), }; it('throws an error if requested embeddable factory type is not registered', () => { @@ -26,7 +27,7 @@ describe('react embeddable registry', () => { }); it('can register and get an embeddable factory', () => { - registerReactEmbeddableFactory('test', testEmbeddableFactory); + registerReactEmbeddableFactory(testEmbeddableFactory); expect(getReactEmbeddableFactory('test')).toBe(testEmbeddableFactory); }); diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.ts index 2f014b84c07ab..964cba236e31b 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.ts @@ -7,38 +7,31 @@ */ import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { - DefaultEmbeddableApi, - ReactEmbeddable, - ReactEmbeddableFactory, - ReactEmbeddableRegistration, -} from './types'; +import { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types'; const registry: { [key: string]: ReactEmbeddableFactory } = {}; export const registerReactEmbeddableFactory = < - StateType extends unknown = unknown, - APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi + StateType extends object = object, + APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi >( - key: string, factory: ReactEmbeddableFactory ) => { - if (registry[key] !== undefined) + if (registry[factory.type] !== undefined) throw new Error( i18n.translate('embeddableApi.reactEmbeddable.factoryAlreadyExistsError', { defaultMessage: 'An embeddable factory for for type: {key} is already registered.', - values: { key }, + values: { key: factory.type }, }) ); - registry[key] = factory; + registry[factory.type] = factory; }; export const reactEmbeddableRegistryHasKey = (key: string) => registry[key] !== undefined; export const getReactEmbeddableFactory = < - StateType extends unknown = unknown, - ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi + StateType extends object = object, + ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi >( key: string ): ReactEmbeddableFactory => { @@ -51,11 +44,3 @@ export const getReactEmbeddableFactory = < ); return registry[key]; }; - -/** - * A helper function which transforms a component into an Embeddable component by forwarding a ref which - * should be used with `useEmbeddableApiHandle` to expose an API for your component. - */ -export const RegisterReactEmbeddable: ( - component: ReactEmbeddableRegistration -) => ReactEmbeddable = (component) => React.forwardRef((_, apiRef) => component(apiRef)); 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 376923de108d1..d2afc6fbb29e9 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 @@ -5,36 +5,122 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; import { render, waitFor, screen } from '@testing-library/react'; import React from 'react'; +import { BehaviorSubject } from 'rxjs'; 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 }> = { - deserializeState: jest.fn(), - getComponent: jest.fn().mockResolvedValue(() => { - return
SUPER TEST COMPONENT
; - }), + 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, + }; + }, }; beforeAll(() => { - registerReactEmbeddableFactory('test', testEmbeddableFactory); + registerReactEmbeddableFactory(testEmbeddableFactory); }); it('deserializes given state', () => { - render(); + render(); expect(testEmbeddableFactory.deserializeState).toHaveBeenCalledWith({ - rawState: { blorp: 'blorp?' }, + rawState: { bork: 'blorp?' }, }); }); + it('builds the embeddable', () => { + const buildEmbeddableSpy = jest.spyOn(testEmbeddableFactory, 'buildEmbeddable'); + render(); + expect(buildEmbeddableSpy).toHaveBeenCalledWith({ bork: 'blorp?' }, expect.any(Function)); + }); + it('renders the given component once it resolves', () => { - render(); + render(); waitFor(() => { - expect(screen.findByText('SUPER TEST COMPONENT')).toBeInTheDocument(); + expect(screen.findByText('SUPER TEST COMPONENT, name: Kuni Garu')).toBeInTheDocument(); + }); + }); + + it('publishes the API into the provided callback', async () => { + const onApiAvailable = jest.fn(); + render( + + ); + await waitFor(() => + expect(onApiAvailable).toHaveBeenCalledWith({ + type: 'test', + uuid: '12345', + parentApi: undefined, + unsavedChanges: expect.any(Object), + serializeState: expect.any(Function), + resetUnsavedChanges: expect.any(Function), + }) + ); + }); + + it('initializes a new ID when one is not given', async () => { + const onApiAvailable = jest.fn(); + render( + + ); + await waitFor(() => + expect(onApiAvailable).toHaveBeenCalledWith( + expect.objectContaining({ uuid: expect.any(String) }) + ) + ); + }); + + it('registers the API with the parent API', async () => { + const onApiAvailable = jest.fn(); + const parentApi = getMockPresentationContainer(); + render( + + ); + await waitFor(() => { + expect(onApiAvailable).toHaveBeenCalledWith(expect.objectContaining({ parentApi })); + expect(parentApi.registerPanelApi).toHaveBeenCalledWith('12345', expect.any(Object)); }); }); }); 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 80b43cde5dd00..64340dc2d05bc 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 @@ -6,30 +6,92 @@ * Side Public License, v 1. */ -import { SerializedPanelState } from '@kbn/presentation-containers'; +import { + apiIsPresentationContainer, + PresentationContainer, + SerializedPanelState, +} from '@kbn/presentation-containers'; import { PresentationPanel } from '@kbn/presentation-panel-plugin/public'; -import React, { useMemo } from 'react'; +import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; +import { v4 as generateId } from 'uuid'; import { getReactEmbeddableFactory } from './react_embeddable_registry'; +import { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes'; +import { + DefaultEmbeddableApi, + EmbeddableStateComparators, + ReactEmbeddableApiRegistration, + ReactEmbeddableFactory, +} from './types'; /** * Renders a component from the React Embeddable registry into a Presentation Panel. * * TODO: Rename this to simply `Embeddable` when the legacy Embeddable system is removed. */ -export const ReactEmbeddableRenderer = ({ - uuid, +export const ReactEmbeddableRenderer = < + StateType extends object = object, + ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi +>({ + maybeId, type, state, + parentApi, + onApiAvailable, }: { - uuid?: string; + maybeId?: string; type: string; - state: SerializedPanelState; + state: SerializedPanelState; + parentApi?: PresentationContainer; + onApiAvailable?: (api: ApiType) => void; }) => { + const cleanupFunction = useRef<(() => void) | null>(null); + const componentPromise = useMemo( () => (async () => { - const factory = getReactEmbeddableFactory(type); - return await factory.getComponent(factory.deserializeState(state), uuid); + const factory = getReactEmbeddableFactory(type) as ReactEmbeddableFactory< + StateType, + ApiType + >; + const registerApi = ( + apiRegistration: ReactEmbeddableApiRegistration, + comparators: EmbeddableStateComparators + ) => { + const uuid = maybeId ?? generateId(); + const { unsavedChanges, resetUnsavedChanges, cleanup } = + startTrackingEmbeddableUnsavedChanges( + uuid, + parentApi, + comparators, + factory.deserializeState + ); + const fullApi = { + ...apiRegistration, + uuid, + parentApi, + unsavedChanges, + resetUnsavedChanges, + type: factory.type, + } as unknown as ApiType; + if (parentApi && apiIsPresentationContainer(parentApi)) { + parentApi.registerPanelApi(uuid, fullApi); + } + cleanupFunction.current = () => cleanup(); + onApiAvailable?.(fullApi); + return fullApi; + }; + + const { api, Component } = await factory.buildEmbeddable( + factory.deserializeState(state), + registerApi + ); + + 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 @@ -38,5 +100,12 @@ export const ReactEmbeddableRenderer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [type] ); + + useEffect(() => { + return () => { + cleanupFunction.current?.(); + }; + }, []); + return ; }; diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.test.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.test.tsx index cdc123f0dc004..be3ecb4645fae 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.test.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.test.tsx @@ -12,13 +12,10 @@ import { SerializedPanelState, } from '@kbn/presentation-containers'; import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; -import { PublishesUnsavedChanges } from '@kbn/presentation-publishing'; -import { render, waitFor } from '@testing-library/react'; -import React, { useImperativeHandle } from 'react'; +import { waitFor } from '@testing-library/react'; import { BehaviorSubject, Subject } from 'rxjs'; -import { ReactEmbeddableParentContext } from './react_embeddable_api'; -import { useReactEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes'; -import { EmbeddableStateComparators, ReactEmbeddableFactory } from './types'; +import { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes'; +import { EmbeddableStateComparators } from './types'; interface SuperTestStateType { name: string; @@ -58,7 +55,7 @@ describe('react embeddable unsaved changes', () => { return defaultComparators; }; - const renderTestComponent = async ( + const startTrackingUnsavedChanges = ( customComparators?: EmbeddableStateComparators ) => { comparators = customComparators ?? initializeDefaultComparators(); @@ -69,70 +66,36 @@ describe('react embeddable unsaved changes', () => { getLastSavedStateForChild: () => ({ rawState: lastSavedState }), lastSavedState: new Subject(), }; - - let apiToReturn: PublishesUnsavedChanges | null = null; - const TestComponent = React.forwardRef((props, ref) => { - const unsavedChangesApi = useReactEmbeddableUnsavedChanges( - 'someId', - { deserializeState } as ReactEmbeddableFactory, - comparators - ); - useImperativeHandle(ref, () => unsavedChangesApi); - - return
A Test Component
; - }); - - const componentElement = ( - { - apiToReturn = outApi; - }} - /> - ); - - if (parentApi) { - render( - - {componentElement} - - ); - } else { - render(componentElement); - } - - await waitFor(() => { - expect(apiToReturn).toBeDefined(); - }); - return apiToReturn as unknown as PublishesUnsavedChanges; + return startTrackingEmbeddableUnsavedChanges('id', parentApi, comparators, deserializeState); }; it('should return undefined unsaved changes when used without a parent context to provide the last saved state', async () => { parentApi = null; - const unsavedChangesApi = await renderTestComponent(); + const unsavedChangesApi = startTrackingUnsavedChanges(); expect(unsavedChangesApi).toBeDefined(); expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); }); it('runs factory deserialize function on last saved state', async () => { - await renderTestComponent(); + startTrackingUnsavedChanges(); expect(deserializeState).toHaveBeenCalledWith({ rawState: lastSavedState }); }); it('should return unsaved changes subject initialized to undefined when no unsaved changes are detected', async () => { - const unsavedChangesApi = await renderTestComponent(); + const unsavedChangesApi = startTrackingUnsavedChanges(); expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); }); it('should return unsaved changes subject initialized with diff when unsaved changes are detected', async () => { initialState.tagline = 'Testing is my speciality!'; - const unsavedChangesApi = await renderTestComponent(); + const unsavedChangesApi = startTrackingUnsavedChanges(); expect(unsavedChangesApi.unsavedChanges.value).toEqual({ tagline: 'Testing is my speciality!', }); }); it('should detect unsaved changes when state changes during the lifetime of the component', async () => { - const unsavedChangesApi = await renderTestComponent(); + const unsavedChangesApi = startTrackingUnsavedChanges(); expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); comparators.tagline[1]('Testing is my speciality!'); @@ -144,7 +107,7 @@ describe('react embeddable unsaved changes', () => { }); it('should detect unsaved changes when last saved state changes during the lifetime of the component', async () => { - const unsavedChangesApi = await renderTestComponent(); + const unsavedChangesApi = startTrackingUnsavedChanges(); expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); lastSavedState.tagline = 'Some other tagline'; @@ -158,7 +121,7 @@ describe('react embeddable unsaved changes', () => { }); it('should reset unsaved changes, calling given setters with last saved values. This should remove all unsaved state', async () => { - const unsavedChangesApi = await renderTestComponent(); + const unsavedChangesApi = startTrackingUnsavedChanges(); expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); comparators.tagline[1]('Testing is my speciality!'); @@ -188,7 +151,7 @@ describe('react embeddable unsaved changes', () => { ], }; - const unsavedChangesApi = await renderTestComponent(customComparators); + const unsavedChangesApi = startTrackingUnsavedChanges(customComparators); // here we expect there to be no unsaved changes, both unsaved state and last saved state have two digits. expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.ts index 1e760b55f748a..b717c47737ad6 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.ts @@ -6,13 +6,15 @@ * Side Public License, v 1. */ -import { getLastSavedStateSubjectForChild } from '@kbn/presentation-containers'; +import { + getLastSavedStateSubjectForChild, + PresentationContainer, + SerializedPanelState, +} from '@kbn/presentation-containers'; import { PublishingSubject } from '@kbn/presentation-publishing'; -import { useCallback, useEffect, useMemo } from 'react'; import { BehaviorSubject, combineLatest } from 'rxjs'; import { combineLatestWith, debounceTime, map } from 'rxjs/operators'; -import { useReactEmbeddableParentContext } from './react_embeddable_api'; -import { EmbeddableStateComparators, ReactEmbeddableFactory } from './types'; +import { EmbeddableStateComparators } from './types'; const defaultComparator = (a: T, b: T) => a === b; @@ -28,6 +30,14 @@ const getInitialValuesFromComparators = ( return initialValues; }; +const getDefaultDiffingApi = () => { + return { + unsavedChanges: new BehaviorSubject(undefined), + resetUnsavedChanges: () => {}, + cleanup: () => {}, + }; +}; + const runComparators = ( comparators: EmbeddableStateComparators, comparatorKeys: Array, @@ -49,85 +59,64 @@ const runComparators = ( return Object.keys(latestChanges).length > 0 ? latestChanges : undefined; }; -export const useReactEmbeddableUnsavedChanges = ( +export const startTrackingEmbeddableUnsavedChanges = ( uuid: string, - factory: ReactEmbeddableFactory, - comparators: EmbeddableStateComparators + parentApi: PresentationContainer | undefined, + comparators: EmbeddableStateComparators, + deserializeState: (state: SerializedPanelState) => StateType ) => { - const { parentApi } = useReactEmbeddableParentContext() ?? {}; - const lastSavedStateSubject = useMemo( - () => getLastSavedStateSubjectForChild(parentApi, uuid, factory.deserializeState), - [factory.deserializeState, parentApi, uuid] - ); - - const { comparatorSubjects, comparatorKeys } = useMemo(() => { - const subjects: Array> = []; - const keys: Array = []; - for (const key of Object.keys(comparators) as Array) { - const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject - subjects.push(comparatorSubject as PublishingSubject); - keys.push(key); - } - return { comparatorKeys: keys, comparatorSubjects: subjects }; - // disable exhaustive deps because the comparators must be static - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (Object.keys(comparators).length === 0) return getDefaultDiffingApi(); - /** - * set up unsaved changes subject, running an initial diff. If the parent API cannot provide - * last saved state, we return undefined. - */ - const unsavedChanges = useMemo( - () => - new BehaviorSubject | undefined>( - lastSavedStateSubject - ? runComparators( - comparators, - comparatorKeys, - lastSavedStateSubject?.getValue(), - getInitialValuesFromComparators(comparators, comparatorKeys) - ) - : undefined - ), - // disable exhaustive deps because the comparators must be static - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + const lastSavedStateSubject = getLastSavedStateSubjectForChild( + parentApi, + uuid, + deserializeState ); + if (!lastSavedStateSubject) return getDefaultDiffingApi(); - useEffect(() => { - if (!lastSavedStateSubject) return; - // subscribe to last saved state subject and all state comparators - const subscription = combineLatest(comparatorSubjects) - .pipe( - debounceTime(100), - map((latestStates) => - comparatorKeys.reduce((acc, key, index) => { - acc[key] = latestStates[index] as StateType[typeof key]; - return acc; - }, {} as Partial) - ), - combineLatestWith(lastSavedStateSubject) - ) - .subscribe(([latestStates, lastSavedState]) => { - unsavedChanges.next( - runComparators(comparators, comparatorKeys, lastSavedState, latestStates) - ); - }); - return () => subscription.unsubscribe(); - // disable exhaustive deps because the comparators must be static - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const comparatorSubjects: Array> = []; + const comparatorKeys: Array = []; + for (const key of Object.keys(comparators) as Array) { + const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject + comparatorSubjects.push(comparatorSubject as PublishingSubject); + comparatorKeys.push(key); + } - const resetUnsavedChanges = useCallback(() => { - const lastSaved = lastSavedStateSubject?.getValue(); - for (const key of comparatorKeys) { - const setter = comparators[key][1]; // setter function is the 1st element of the tuple - setter(lastSaved?.[key] as StateType[typeof key]); - } + const unsavedChanges = new BehaviorSubject | undefined>( + runComparators( + comparators, + comparatorKeys, + lastSavedStateSubject?.getValue(), + getInitialValuesFromComparators(comparators, comparatorKeys) + ) + ); - // disable exhaustive deps because the comparators must be static - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const subscription = combineLatest(comparatorSubjects) + .pipe( + debounceTime(100), + map((latestStates) => + comparatorKeys.reduce((acc, key, index) => { + acc[key] = latestStates[index] as StateType[typeof key]; + return acc; + }, {} as Partial) + ), + combineLatestWith(lastSavedStateSubject) + ) + .subscribe(([latestStates, lastSavedState]) => { + unsavedChanges.next( + runComparators(comparators, comparatorKeys, lastSavedState, latestStates) + ); + }); - return { unsavedChanges, resetUnsavedChanges }; + return { + unsavedChanges, + resetUnsavedChanges: () => { + const lastSaved = lastSavedStateSubject?.getValue(); + for (const key of comparatorKeys) { + const setter = comparators[key][1]; // setter function is the 1st element of the tuple + setter(lastSaved?.[key] as StateType[typeof key]); + } + }, + cleanup: () => subscription.unsubscribe(), + }; }; diff --git a/src/plugins/embeddable/public/react_embeddable_system/types.ts b/src/plugins/embeddable/public/react_embeddable_system/types.ts index 1a8c78476a963..247c2f891b718 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/types.ts +++ b/src/plugins/embeddable/public/react_embeddable_system/types.ts @@ -5,10 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { SerializedPanelState } from '@kbn/presentation-containers'; +import { HasSerializableState, SerializedPanelState } from '@kbn/presentation-containers'; import { DefaultPresentationPanelApi } from '@kbn/presentation-panel-plugin/public/panel_component/types'; -import { PublishesUnsavedChanges, PublishingSubject } from '@kbn/presentation-publishing'; -import { ReactElement } from 'react'; +import { HasType, PublishesUnsavedChanges, PublishingSubject } from '@kbn/presentation-publishing'; +import React, { ReactElement } from 'react'; export type ReactEmbeddableRegistration< ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi @@ -19,26 +19,33 @@ export type ReactEmbeddableRegistration< * * Before adding anything to this interface, please be certain that it belongs in *every* embeddable. */ -export type DefaultEmbeddableApi = DefaultPresentationPanelApi & - PublishesUnsavedChanges & { - serializeState: () => Promise; - }; +export interface DefaultEmbeddableApi + extends DefaultPresentationPanelApi, + HasType, + PublishesUnsavedChanges, + HasSerializableState {} -export type ReactEmbeddable = - React.ForwardRefExoticComponent>; +export type ReactEmbeddableApiRegistration< + StateType extends object = object, + ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi +> = Omit; export interface ReactEmbeddableFactory< - StateType extends unknown = unknown, - APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi + StateType extends object = object, + ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi > { - getComponent: (initialState: StateType, maybeId?: string) => Promise>; - deserializeState: (state: SerializedPanelState) => StateType; + type: string; latestVersion?: string; + deserializeState: (state: SerializedPanelState) => StateType; + buildEmbeddable: ( + initialState: StateType, + buildApi: ( + apiRegistration: ReactEmbeddableApiRegistration, + comparators: EmbeddableStateComparators + ) => ApiType + ) => Promise<{ Component: React.FC<{}>; api: ApiType }>; } -export type StateTypeFromFactory> = - F extends ReactEmbeddableFactory ? S : never; - /** * State comparators */ diff --git a/src/plugins/presentation_panel/public/mocks.tsx b/src/plugins/presentation_panel/public/mocks.tsx index 8d55026619ec0..816d369bda5dc 100644 --- a/src/plugins/presentation_panel/public/mocks.tsx +++ b/src/plugins/presentation_panel/public/mocks.tsx @@ -38,7 +38,7 @@ export const getMockPresentationPanelCompatibleComponent = < ): Promise => Promise.resolve( React.forwardRef((_, apiRef) => { - useImperativeHandle(apiRef, () => api ?? {}); + useImperativeHandle(apiRef, () => api ?? { uuid: 'test' }); return (
This is a test component
); diff --git a/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.test.tsx b/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.test.tsx index f937dfe643dce..8386535a7ebeb 100644 --- a/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.test.tsx +++ b/src/plugins/presentation_panel/public/panel_component/presentation_panel_internal.test.tsx @@ -49,6 +49,7 @@ describe('Presentation panel', () => { it('renders a blocking error when one is present', async () => { const api: DefaultPresentationPanelApi = { + uuid: 'test', blockingError: new BehaviorSubject(new Error('UH OH')), }; render(); @@ -64,7 +65,7 @@ describe('Presentation panel', () => { function getComponent(api?: DefaultPresentationPanelApi): Promise { return Promise.resolve( React.forwardRef((_, apiRef) => { - useImperativeHandle(apiRef, () => api ?? {}); + useImperativeHandle(apiRef, () => api ?? { uuid: 'test' }); return ; }) ); @@ -88,6 +89,7 @@ describe('Presentation panel', () => { it('gets compatible actions for the given API', async () => { const api: DefaultPresentationPanelApi = { + uuid: 'test', panelTitle: new BehaviorSubject('superTest'), }; await renderPresentationPanel({ api }); @@ -112,6 +114,7 @@ describe('Presentation panel', () => { it('does not show actions which are disabled by the API', async () => { const api: DefaultPresentationPanelApi = { + uuid: 'test', disabledActionIds: new BehaviorSubject(['actionA']), }; const getActions = jest.fn().mockReturnValue([mockAction('actionA'), mockAction('actionB')]); @@ -156,6 +159,7 @@ describe('Presentation panel', () => { describe('titles', () => { it('renders the panel title from the api', async () => { const api: DefaultPresentationPanelApi = { + uuid: 'test', panelTitle: new BehaviorSubject('SUPER TITLE'), }; await renderPresentationPanel({ api }); @@ -166,6 +170,7 @@ describe('Presentation panel', () => { it('renders an info icon when the api provides a panel description', async () => { const api: DefaultPresentationPanelApi = { + uuid: 'test', panelTitle: new BehaviorSubject('SUPER TITLE'), panelDescription: new BehaviorSubject('SUPER DESCRIPTION'), }; @@ -177,6 +182,7 @@ describe('Presentation panel', () => { it('does not render a title when in view mode when the provided title is blank', async () => { const api: DefaultPresentationPanelApi & PublishesViewMode = { + uuid: 'test', panelTitle: new BehaviorSubject(''), viewMode: new BehaviorSubject('view'), }; @@ -188,6 +194,7 @@ describe('Presentation panel', () => { it('renders a placeholder title when in edit mode and the provided title is blank', async () => { const api: DefaultPresentationPanelApi & PublishesDataViews & PublishesViewMode = { + uuid: 'test', panelTitle: new BehaviorSubject(''), viewMode: new BehaviorSubject('edit'), dataViews: new BehaviorSubject([]), @@ -202,6 +209,7 @@ describe('Presentation panel', () => { const spy = jest.spyOn(openCustomizePanel, 'openCustomizePanelFlyout'); const api: DefaultPresentationPanelApi & PublishesDataViews & PublishesViewMode = { + uuid: 'test', panelTitle: new BehaviorSubject('TITLE'), viewMode: new BehaviorSubject('edit'), dataViews: new BehaviorSubject([]), @@ -218,6 +226,7 @@ describe('Presentation panel', () => { it('does not show title customize link in view mode', async () => { const api: DefaultPresentationPanelApi & PublishesDataViews & PublishesViewMode = { + uuid: 'test', panelTitle: new BehaviorSubject('SUPER TITLE'), viewMode: new BehaviorSubject('view'), dataViews: new BehaviorSubject([]), @@ -231,6 +240,7 @@ describe('Presentation panel', () => { it('hides title when API hide title option is true', async () => { const api: DefaultPresentationPanelApi & PublishesViewMode = { + uuid: 'test', panelTitle: new BehaviorSubject('SUPER TITLE'), hidePanelTitle: new BehaviorSubject(true), viewMode: new BehaviorSubject('view'), @@ -241,6 +251,7 @@ describe('Presentation panel', () => { it('hides title when parent hide title option is true', async () => { const api: DefaultPresentationPanelApi & PublishesViewMode = { + uuid: 'test', panelTitle: new BehaviorSubject('SUPER TITLE'), viewMode: new BehaviorSubject('view'), parentApi: { diff --git a/src/plugins/presentation_panel/public/panel_component/types.ts b/src/plugins/presentation_panel/public/panel_component/types.ts index aaee39d43939a..61f6eaca887f5 100644 --- a/src/plugins/presentation_panel/public/panel_component/types.ts +++ b/src/plugins/presentation_panel/public/panel_component/types.ts @@ -60,18 +60,19 @@ export interface PresentationPanelInternalProps< * The API that any component passed to the `Component` prop of `PresentationPanel` should implement. * Everything in this API is Partial because it is valid for a component to implement none of these methods. */ -export type DefaultPresentationPanelApi = Partial< - HasUniqueId & - PublishesPanelTitle & - PublishesDataLoading & - PublishesBlockingError & - PublishesPanelDescription & - PublishesDisabledActionIds & - HasParentApi< - PresentationContainer & - Partial & PublishesViewMode> - > ->; +export interface DefaultPresentationPanelApi + extends HasUniqueId, + Partial< + PublishesPanelTitle & + PublishesDataLoading & + PublishesBlockingError & + PublishesPanelDescription & + PublishesDisabledActionIds & + HasParentApi< + PresentationContainer & + Partial & PublishesViewMode> + > + > {} export type PresentationPanelProps< ApiType extends DefaultPresentationPanelApi = DefaultPresentationPanelApi,