diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index e990458531e1a..12e24526d2eb2 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -31,6 +31,7 @@ import { FilterDebuggerEmbeddableFactory, FilterDebuggerEmbeddableFactoryDefinition, } from './filter_debugger'; +import { registerMarkdownEditorEmbeddable } from './react_embeddables/eui_markdown_react_embeddable'; export interface EmbeddableExamplesSetupDependencies { embeddable: EmbeddableSetup; @@ -53,6 +54,8 @@ export interface EmbeddableExamplesStart { factories: ExampleEmbeddableFactories; } +registerMarkdownEditorEmbeddable(); + export class EmbeddableExamplesPlugin implements Plugin< diff --git a/examples/embeddable_examples/public/react_embeddables/eui_markdown_react_embeddable.tsx b/examples/embeddable_examples/public/react_embeddables/eui_markdown_react_embeddable.tsx new file mode 100644 index 0000000000000..b525181f307a9 --- /dev/null +++ b/examples/embeddable_examples/public/react_embeddables/eui_markdown_react_embeddable.tsx @@ -0,0 +1,143 @@ +/* + * 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 { EuiMarkdownEditor, EuiMarkdownFormat } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { + ReactEmbeddableFactory, + RegisterReactEmbeddable, + registerReactEmbeddableFactory, + useReactEmbeddableApiHandle, + initializeReactEmbeddableUuid, + initializeReactEmbeddableTitles, + SerializedReactEmbeddableTitles, + DefaultEmbeddableApi, + useReactEmbeddableUnsavedChanges, +} from '@kbn/embeddable-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { useInheritedViewMode, useStateFromPublishingSubject } from '@kbn/presentation-publishing'; +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; + +// ----------------------------------------------------------------------------- +// Types for this embeddable +// ----------------------------------------------------------------------------- +type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & { + content: string; +}; + +type MarkdownEditorApi = DefaultEmbeddableApi; + +const type = 'euiMarkdown'; + +// ----------------------------------------------------------------------------- +// Define the Embeddable Factory +// ----------------------------------------------------------------------------- +const markdownEmbeddableFactory: ReactEmbeddableFactory< + MarkdownEditorSerializedState, + MarkdownEditorApi +> = { + // ----------------------------------------------------------------------------- + // Deserialize function + // ----------------------------------------------------------------------------- + deserializeState: (state) => { + // We could run migrations here. + // We should inject references here. References are given as state.references + + return state.rawState as MarkdownEditorSerializedState; + }, + + // ----------------------------------------------------------------------------- + // Register the Embeddable component + // ----------------------------------------------------------------------------- + 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); + + /** + * 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. + */ + + 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 + ); + + // get state for rendering + const content = useStateFromPublishingSubject(contentSubject); + const viewMode = useInheritedViewMode(thisApi) ?? 'view'; + + return viewMode === 'edit' ? ( + contentSubject.next(value)} + aria-label={i18n.translate('dashboard.test.markdownEditor.ariaLabel', { + defaultMessage: 'Dashboard markdown editor', + })} + height="full" + /> + ) : ( + + {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 +// ----------------------------------------------------------------------------- +export const registerMarkdownEditorEmbeddable = () => + registerReactEmbeddableFactory(type, markdownEmbeddableFactory); diff --git a/examples/embeddable_examples/tsconfig.json b/examples/embeddable_examples/tsconfig.json index 22a42db537daf..0f22cccc94483 100644 --- a/examples/embeddable_examples/tsconfig.json +++ b/examples/embeddable_examples/tsconfig.json @@ -11,15 +11,15 @@ "server/**/*.ts", "../../typings/**/*" ], - "exclude": [ - "target/**/*", - ], + "exclude": ["target/**/*"], "kbn_references": [ "@kbn/core", "@kbn/kibana-utils-plugin", "@kbn/ui-actions-plugin", "@kbn/embeddable-plugin", + "@kbn/presentation-publishing", + "@kbn/ui-theme", "@kbn/i18n", - "@kbn/es-query", + "@kbn/es-query" ] } diff --git a/packages/presentation/presentation_containers/index.ts b/packages/presentation/presentation_containers/index.ts index eae0e6c7f4f3a..47c8d8783a663 100644 --- a/packages/presentation/presentation_containers/index.ts +++ b/packages/presentation/presentation_containers/index.ts @@ -20,3 +20,9 @@ export { type PresentationContainer, } from './interfaces/presentation_container'; export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays'; +export { type SerializedPanelState } from './interfaces/serialized_state'; +export { + type PublishesLastSavedState, + apiPublishesLastSavedState, + getLastSavedStateSubjectForChild, +} from './interfaces/last_saved_state'; diff --git a/packages/presentation/presentation_containers/interfaces/last_saved_state.ts b/packages/presentation/presentation_containers/interfaces/last_saved_state.ts new file mode 100644 index 0000000000000..0c7e83aef7bf3 --- /dev/null +++ b/packages/presentation/presentation_containers/interfaces/last_saved_state.ts @@ -0,0 +1,51 @@ +/* + * 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 { PublishingSubject } from '@kbn/presentation-publishing'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { SerializedPanelState } from './serialized_state'; + +export interface PublishesLastSavedState { + lastSavedState: Subject; // a notification that the last saved state has changed + getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined; +} + +export const apiPublishesLastSavedState = (api: unknown): api is PublishesLastSavedState => { + return Boolean( + api && + (api as PublishesLastSavedState).lastSavedState && + (api as PublishesLastSavedState).getLastSavedStateForChild + ); +}; + +export const getLastSavedStateSubjectForChild = ( + parentApi: unknown, + childId: string, + deserializer?: (state: SerializedPanelState) => StateType +): PublishingSubject | undefined => { + if (!parentApi) return; + const fetchUnsavedChanges = (): StateType | undefined => { + if (!apiPublishesLastSavedState(parentApi)) return; + const rawLastSavedState = parentApi.getLastSavedStateForChild(childId); + if (rawLastSavedState === undefined) return; + return deserializer + ? deserializer(rawLastSavedState) + : (rawLastSavedState.rawState as StateType); + }; + + const lastSavedStateForChild = new BehaviorSubject(fetchUnsavedChanges()); + if (!apiPublishesLastSavedState(parentApi)) return; + parentApi.lastSavedState + .pipe( + map(() => fetchUnsavedChanges()), + filter((rawLastSavedState) => rawLastSavedState !== undefined) + ) + .subscribe(lastSavedStateForChild); + return lastSavedStateForChild; +}; diff --git a/packages/presentation/presentation_containers/interfaces/panel_management.ts b/packages/presentation/presentation_containers/interfaces/panel_management.ts index feb752ea81655..9408b6b756694 100644 --- a/packages/presentation/presentation_containers/interfaces/panel_management.ts +++ b/packages/presentation/presentation_containers/interfaces/panel_management.ts @@ -34,6 +34,4 @@ export const apiCanExpandPanels = (unknownApi: unknown | null): unknownApi is Ca * Gets this API's expanded panel state as a reactive variable which will cause re-renders on change. */ export const useExpandedPanelId = (api: Partial | undefined) => - useStateFromPublishingSubject( - apiCanExpandPanels(api) ? api.expandedPanelId : undefined - ); + useStateFromPublishingSubject(apiCanExpandPanels(api) ? api.expandedPanelId : undefined); diff --git a/packages/presentation/presentation_containers/interfaces/presentation_container.ts b/packages/presentation/presentation_containers/interfaces/presentation_container.ts index 960d728630bc4..c5c558dfe6227 100644 --- a/packages/presentation/presentation_containers/interfaces/presentation_container.ts +++ b/packages/presentation/presentation_containers/interfaces/presentation_container.ts @@ -7,21 +7,32 @@ */ import { apiHasParentApi, PublishesViewMode } from '@kbn/presentation-publishing'; +import { PublishesLastSavedState } from './last_saved_state'; export interface PanelPackage { panelType: string; initialState: unknown; } -export interface PresentationContainer extends Partial { - removePanel: (panelId: string) => void; - canRemovePanels?: () => boolean; - replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise; -} + +export type PresentationContainer = Partial & + PublishesLastSavedState & { + registerPanelApi: ( + panelId: string, + panelApi: ApiType + ) => void; + removePanel: (panelId: string) => void; + canRemovePanels?: () => boolean; + replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise; + }; export const apiIsPresentationContainer = ( unknownApi: unknown | null ): unknownApi is PresentationContainer => { - return Boolean((unknownApi as PresentationContainer)?.removePanel !== undefined); + return Boolean( + (unknownApi as PresentationContainer)?.removePanel !== undefined && + (unknownApi as PresentationContainer)?.registerPanelApi !== undefined && + (unknownApi as PresentationContainer)?.replacePanel !== undefined + ); }; export const getContainerParentFromAPI = ( diff --git a/packages/presentation/presentation_containers/interfaces/serialized_state.ts b/packages/presentation/presentation_containers/interfaces/serialized_state.ts new file mode 100644 index 0000000000000..6b24471d34c75 --- /dev/null +++ b/packages/presentation/presentation_containers/interfaces/serialized_state.ts @@ -0,0 +1,19 @@ +/* + * 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 type { SavedObjectReference } from '@kbn/core-saved-objects-api-server'; + +/** + * A package containing the serialized Embeddable state, with references extracted. When saving Embeddables using any + * strategy, this is the format that should be used. + */ +export interface SerializedPanelState { + references?: SavedObjectReference[]; + rawState: RawStateType; + version?: string; +} diff --git a/packages/presentation/presentation_containers/mocks.ts b/packages/presentation/presentation_containers/mocks.ts new file mode 100644 index 0000000000000..5ac45fc6049c2 --- /dev/null +++ b/packages/presentation/presentation_containers/mocks.ts @@ -0,0 +1,20 @@ +/* + * 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 { Subject } from 'rxjs'; +import { PresentationContainer } from './interfaces/presentation_container'; + +export const getMockPresentationContainer = (): PresentationContainer => { + return { + registerPanelApi: jest.fn(), + removePanel: jest.fn(), + replacePanel: jest.fn(), + lastSavedState: new Subject(), + getLastSavedStateForChild: jest.fn(), + }; +}; diff --git a/packages/presentation/presentation_containers/tsconfig.json b/packages/presentation/presentation_containers/tsconfig.json index 84f28c7bc60c3..7892712228da2 100644 --- a/packages/presentation/presentation_containers/tsconfig.json +++ b/packages/presentation/presentation_containers/tsconfig.json @@ -6,5 +6,9 @@ }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["target/**/*"], - "kbn_references": ["@kbn/presentation-publishing", "@kbn/core-mount-utils-browser"] + "kbn_references": [ + "@kbn/presentation-publishing", + "@kbn/core-mount-utils-browser", + "@kbn/core-saved-objects-api-server", + ] } diff --git a/packages/presentation/presentation_publishing/index.ts b/packages/presentation/presentation_publishing/index.ts index 21ccddf3273a3..61209d6635729 100644 --- a/packages/presentation/presentation_publishing/index.ts +++ b/packages/presentation/presentation_publishing/index.ts @@ -93,6 +93,11 @@ export { type PublishesWritableViewMode, type ViewMode, } from './interfaces/publishes_view_mode'; +export { + type PublishesUnsavedChanges, + apiPublishesUnsavedChanges, + useUnsavedChanges, +} from './interfaces/publishes_unsaved_changes'; export { useBatchedPublishingSubjects, useStateFromPublishingSubject, diff --git a/packages/presentation/presentation_publishing/interfaces/can_access_view_mode.ts b/packages/presentation/presentation_publishing/interfaces/can_access_view_mode.ts index c838d9f1ee573..48f621ddbccb0 100644 --- a/packages/presentation/presentation_publishing/interfaces/can_access_view_mode.ts +++ b/packages/presentation/presentation_publishing/interfaces/can_access_view_mode.ts @@ -8,7 +8,7 @@ import { useStateFromPublishingSubject } from '../publishing_subject'; import { apiHasParentApi, HasParentApi } from './has_parent_api'; -import { apiPublishesViewMode, PublishesViewMode, ViewMode } from './publishes_view_mode'; +import { apiPublishesViewMode, PublishesViewMode } from './publishes_view_mode'; /** * This API can access a view mode, either its own or from its parent API. @@ -49,6 +49,5 @@ export const getViewModeSubject = (api?: CanAccessViewMode) => { export const useInheritedViewMode = ( api: ApiType | undefined ) => { - const subject = getViewModeSubject(api); - useStateFromPublishingSubject(subject); + return useStateFromPublishingSubject(getViewModeSubject(api)); }; diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_blocking_error.ts b/packages/presentation/presentation_publishing/interfaces/publishes_blocking_error.ts index 578c75f21fe22..fac7d8dda9568 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_blocking_error.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_blocking_error.ts @@ -22,6 +22,4 @@ export const apiPublishesBlockingError = ( * Gets this API's fatal error as a reactive variable which will cause re-renders on change. */ export const useBlockingError = (api: Partial | undefined) => - useStateFromPublishingSubject( - api?.blockingError - ); + useStateFromPublishingSubject(api?.blockingError); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_data_loading.ts b/packages/presentation/presentation_publishing/interfaces/publishes_data_loading.ts index 6f988167807f3..a7cedcac6d34d 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_data_loading.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_data_loading.ts @@ -22,6 +22,4 @@ export const apiPublishesDataLoading = ( * Gets this API's data loading state as a reactive variable which will cause re-renders on change. */ export const useDataLoading = (api: Partial | undefined) => - useStateFromPublishingSubject( - apiPublishesDataLoading(api) ? api.dataLoading : undefined - ); + useStateFromPublishingSubject(apiPublishesDataLoading(api) ? api.dataLoading : undefined); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_data_views.ts b/packages/presentation/presentation_publishing/interfaces/publishes_data_views.ts index b83037a52c1e0..3daf0412d93d3 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_data_views.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_data_views.ts @@ -23,6 +23,4 @@ export const apiPublishesDataViews = ( * Gets this API's data views as a reactive variable which will cause re-renders on change. */ export const useDataViews = (api: Partial | undefined) => - useStateFromPublishingSubject( - apiPublishesDataViews(api) ? api.dataViews : undefined - ); + useStateFromPublishingSubject(apiPublishesDataViews(api) ? api.dataViews : undefined); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_disabled_action_ids.ts b/packages/presentation/presentation_publishing/interfaces/publishes_disabled_action_ids.ts index 69f76070f369e..05a1ac6ac143c 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_disabled_action_ids.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_disabled_action_ids.ts @@ -29,7 +29,4 @@ export const apiPublishesDisabledActionIds = ( * Gets this API's disabled action IDs as a reactive variable which will cause re-renders on change. */ export const useDisabledActionIds = (api: Partial | undefined) => - useStateFromPublishingSubject< - string[] | undefined, - PublishesDisabledActionIds['disabledActionIds'] - >(api?.disabledActionIds); + useStateFromPublishingSubject(api?.disabledActionIds); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_local_unified_search.ts b/packages/presentation/presentation_publishing/interfaces/publishes_local_unified_search.ts index c5f163206d81d..623c107d47e8c 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_local_unified_search.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_local_unified_search.ts @@ -62,16 +62,16 @@ export const apiPublishesWritableLocalUnifiedSearch = ( * A hook that gets this API's local time range as a reactive variable which will cause re-renders on change. */ export const useLocalTimeRange = (api: Partial | undefined) => - useStateFromPublishingSubject(api?.localTimeRange); + useStateFromPublishingSubject(api?.localTimeRange); /** * A hook that gets this API's local filters as a reactive variable which will cause re-renders on change. */ export const useLocalFilters = (api: Partial | undefined) => - useStateFromPublishingSubject(api?.localFilters); + useStateFromPublishingSubject(api?.localFilters); /** * A hook that gets this API's local query as a reactive variable which will cause re-renders on change. */ export const useLocalQuery = (api: Partial | undefined) => - useStateFromPublishingSubject(api?.localQuery); + useStateFromPublishingSubject(api?.localQuery); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_panel_description.ts b/packages/presentation/presentation_publishing/interfaces/publishes_panel_description.ts index 1f12c35217c2e..fb48b7a6228fa 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_panel_description.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_panel_description.ts @@ -39,15 +39,10 @@ export const apiPublishesWritablePanelDescription = ( * A hook that gets this API's panel description as a reactive variable which will cause re-renders on change. */ export const usePanelDescription = (api: Partial | undefined) => - useStateFromPublishingSubject( - api?.panelDescription - ); + useStateFromPublishingSubject(api?.panelDescription); /** * A hook that gets this API's default panel description as a reactive variable which will cause re-renders on change. */ export const useDefaultPanelDescription = (api: Partial | undefined) => - useStateFromPublishingSubject< - string | undefined, - PublishesPanelDescription['defaultPanelDescription'] - >(api?.defaultPanelDescription); + useStateFromPublishingSubject(api?.defaultPanelDescription); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_panel_title.ts b/packages/presentation/presentation_publishing/interfaces/publishes_panel_title.ts index b2be97926fb5a..b1b10fb1c742a 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_panel_title.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_panel_title.ts @@ -17,7 +17,6 @@ export interface PublishesPanelTitle { export type PublishesWritablePanelTitle = PublishesPanelTitle & { setPanelTitle: (newTitle: string | undefined) => void; setHidePanelTitle: (hide: boolean | undefined) => void; - setDefaultPanelTitle?: (newDefaultTitle: string | undefined) => void; }; export const apiPublishesPanelTitle = ( @@ -46,16 +45,16 @@ export const apiPublishesWritablePanelTitle = ( * A hook that gets this API's panel title as a reactive variable which will cause re-renders on change. */ export const usePanelTitle = (api: Partial | undefined) => - useStateFromPublishingSubject(api?.panelTitle); + useStateFromPublishingSubject(api?.panelTitle); /** * A hook that gets this API's hide panel title setting as a reactive variable which will cause re-renders on change. */ export const useHidePanelTitle = (api: Partial | undefined) => - useStateFromPublishingSubject(api?.hidePanelTitle); + useStateFromPublishingSubject(api?.hidePanelTitle); /** * A hook that gets this API's default title as a reactive variable which will cause re-renders on change. */ export const useDefaultPanelTitle = (api: Partial | undefined) => - useStateFromPublishingSubject(api?.defaultPanelTitle); + useStateFromPublishingSubject(api?.defaultPanelTitle); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_saved_object_id.ts b/packages/presentation/presentation_publishing/interfaces/publishes_saved_object_id.ts index 3df33706612b6..9bf527ca8a8e9 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_saved_object_id.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_saved_object_id.ts @@ -28,4 +28,4 @@ export const apiPublishesSavedObjectId = ( * A hook that gets this API's saved object ID as a reactive variable which will cause re-renders on change. */ export const useSavedObjectId = (api: PublishesSavedObjectId | undefined) => - useStateFromPublishingSubject(api?.savedObjectId); + useStateFromPublishingSubject(api?.savedObjectId); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts b/packages/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts new file mode 100644 index 0000000000000..9c43a82ff9038 --- /dev/null +++ b/packages/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts @@ -0,0 +1,28 @@ +/* + * 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 { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject'; + +export interface PublishesUnsavedChanges { + unsavedChanges: PublishingSubject; + resetUnsavedChanges: () => void; +} + +export const apiPublishesUnsavedChanges = (api: unknown): api is PublishesUnsavedChanges => { + return Boolean( + api && + (api as PublishesUnsavedChanges).unsavedChanges && + (api as PublishesUnsavedChanges).resetUnsavedChanges + ); +}; + +/** + * A hook that gets this API's unsaved changes as a reactive variable which will cause re-renders on change. + */ +export const useUnsavedChanges = (api: PublishesUnsavedChanges | undefined) => + useStateFromPublishingSubject(api?.unsavedChanges); diff --git a/packages/presentation/presentation_publishing/interfaces/publishes_view_mode.ts b/packages/presentation/presentation_publishing/interfaces/publishes_view_mode.ts index 6e19c170a0222..80633f90fc00e 100644 --- a/packages/presentation/presentation_publishing/interfaces/publishes_view_mode.ts +++ b/packages/presentation/presentation_publishing/interfaces/publishes_view_mode.ts @@ -52,4 +52,4 @@ export const useViewMode = < ApiType extends Partial = Partial >( api: ApiType | undefined -) => useStateFromPublishingSubject(api?.viewMode); +) => useStateFromPublishingSubject(api?.viewMode); diff --git a/packages/presentation/presentation_publishing/publishing_subject/index.ts b/packages/presentation/presentation_publishing/publishing_subject/index.ts index f75455e64a68e..5dbd2eb95579a 100644 --- a/packages/presentation/presentation_publishing/publishing_subject/index.ts +++ b/packages/presentation/presentation_publishing/publishing_subject/index.ts @@ -7,8 +7,9 @@ */ export { useBatchedPublishingSubjects } from './publishing_batcher'; -export { - useStateFromPublishingSubject, - usePublishingSubject, - type PublishingSubject, -} from './publishing_subject'; +export { useStateFromPublishingSubject, usePublishingSubject } from './publishing_subject'; +export type { + PublishingSubject, + ValueFromPublishingSubject, + UnwrapPublishingSubjectTuple, +} from './types'; diff --git a/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts b/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts index a4620e8aa2996..2c3cbe32be4a9 100644 --- a/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts +++ b/packages/presentation/presentation_publishing/publishing_subject/publishing_batcher.ts @@ -8,39 +8,18 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { combineLatest } from 'rxjs'; -import { debounceTime, filter } from 'rxjs/operators'; -import { PublishingSubject } from './publishing_subject'; +import { debounceTime } from 'rxjs/operators'; +import { AnyPublishingSubject, PublishingSubject, UnwrapPublishingSubjectTuple } from './types'; -// Usage of any required here. We want to subscribe to the subject no matter the type. -type AnyValue = any; -type AnyPublishingSubject = PublishingSubject; - -interface PublishingSubjectCollection { - [key: string]: AnyPublishingSubject | undefined; -} - -interface RequiredPublishingSubjectCollection { - [key: string]: AnyPublishingSubject; -} - -type PublishingSubjectBatchResult = { - [SubjectKey in keyof SubjectsType]?: SubjectsType[SubjectKey] extends - | PublishingSubject - | undefined - ? ValueType - : never; -}; - -const hasSubjectsObjectChanged = ( - subjectsA: PublishingSubjectCollection, - subjectsB: PublishingSubjectCollection +const hasSubjectsArrayChanged = ( + subjectsA: AnyPublishingSubject[], + subjectsB: AnyPublishingSubject[] ) => { - const subjectKeysA = Object.keys(subjectsA); - const subjectKeysB = Object.keys(subjectsB); - if (subjectKeysA.length !== subjectKeysB.length) return true; + if (subjectsA.length !== subjectsB.length) return true; - for (const key of subjectKeysA) { - if (Boolean(subjectsA[key]) !== Boolean(subjectsB[key])) return true; + for (let i = 0; i < subjectsA.length; i++) { + // here we only compare if the subjects are both either defined or undefined. + if (Boolean(subjectsA[i]) !== Boolean(subjectsB[i])) return true; } return false; }; @@ -49,21 +28,15 @@ const hasSubjectsObjectChanged = ( * Batches the latest values of multiple publishing subjects into a single object. Use this to avoid unnecessary re-renders. * You should avoid using this hook with subjects that your component pushes values to on user interaction, as it can cause a slight delay. */ -export const useBatchedPublishingSubjects = ( - subjects: SubjectsType -): PublishingSubjectBatchResult => { +export const useBatchedPublishingSubjects = ( + ...subjects: [...SubjectsType] +): UnwrapPublishingSubjectTuple => { /** * memoize and deep diff subjects to avoid rebuilding the subscription when the subjects are the same. */ - const previousSubjects = useRef(null); - + const previousSubjects = useRef(subjects); const subjectsToUse = useMemo(() => { - if (!previousSubjects.current && !Object.values(subjects).some((subject) => Boolean(subject))) { - // if the previous subjects were null and none of the new subjects are defined, return null to avoid building the subscription. - return null; - } - - if (!hasSubjectsObjectChanged(previousSubjects.current ?? {}, subjects)) { + if (!hasSubjectsArrayChanged(previousSubjects.current ?? [], subjects)) { return previousSubjects.current; } previousSubjects.current = subjects; @@ -71,54 +44,51 @@ export const useBatchedPublishingSubjects = { - if (!subjectsToUse) return {}; - const definedSubjectsMap: RequiredPublishingSubjectCollection = - Object.keys(subjectsToUse).reduce((acc, key) => { - if (Boolean(subjectsToUse[key])) acc[key] = subjectsToUse[key] as AnyPublishingSubject; - return acc; - }, {} as RequiredPublishingSubjectCollection) ?? {}; - - return { - definedKeys: Object.keys(definedSubjectsMap ?? {}) as Array, - definedSubjects: Object.values(definedSubjectsMap) ?? [], - }; - }, [subjectsToUse]); - - const [latestPublishedValues, setLatestPublishedValues] = useState< - PublishingSubjectBatchResult - >(() => { - if (!definedKeys?.length || !definedSubjects?.length) return {}; - const nextResult: PublishingSubjectBatchResult = {}; - for (let keyIndex = 0; keyIndex < definedKeys.length; keyIndex++) { - nextResult[definedKeys[keyIndex]] = definedSubjects[keyIndex].value ?? undefined; - } - return nextResult; - }); + const initialSubjectValues = useMemo( + () => unwrapPublishingSubjectArray(subjectsToUse), + [subjectsToUse] + ); + const [latestPublishedValues, setLatestPublishedValues] = + useState>(initialSubjectValues); /** * Subscribe to all subjects and update the latest values when any of them change. */ useEffect(() => { - if (!definedSubjects?.length || !definedKeys?.length) return; + const definedSubjects: Array> = []; + const definedSubjectIndices: number[] = []; + + for (let i = 0; i < subjectsToUse.length; i++) { + if (!subjectsToUse[i]) continue; + definedSubjects.push(subjectsToUse[i] as PublishingSubject); + definedSubjectIndices.push(i); + } + if (definedSubjects.length === 0) return; const subscription = combineLatest(definedSubjects) - .pipe( - // debounce latest state for 0ms to flush all in-flight changes - debounceTime(0), - filter((changes) => changes.length > 0) - ) - .subscribe((latestValues) => { - const nextResult: PublishingSubjectBatchResult = {}; - for (let keyIndex = 0; keyIndex < definedKeys.length; keyIndex++) { - nextResult[definedKeys[keyIndex]] = latestValues[keyIndex] ?? undefined; - } - setLatestPublishedValues(nextResult); + .pipe(debounceTime(0)) + .subscribe((values) => { + setLatestPublishedValues((lastPublishedValues) => { + const newLatestPublishedValues: UnwrapPublishingSubjectTuple = [ + ...lastPublishedValues, + ] as UnwrapPublishingSubjectTuple; + for (let i = 0; i < values.length; i++) { + newLatestPublishedValues[definedSubjectIndices[i]] = values[i] as never; + } + return newLatestPublishedValues; + }); }); - return () => subscription.unsubscribe(); - }, [definedKeys, definedSubjects]); + }, [subjectsToUse]); return latestPublishedValues; }; + +const unwrapPublishingSubjectArray = ( + subjects: T +): UnwrapPublishingSubjectTuple => { + return subjects.map( + (subject) => subject?.getValue?.() ?? undefined + ) as UnwrapPublishingSubjectTuple; +}; diff --git a/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx b/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx index deac166cc4686..7e84d2593d392 100644 --- a/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx +++ b/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.test.tsx @@ -42,12 +42,12 @@ describe('useBatchedPublishingSubjects', () => { test('should render once when all state changes are in click handler (react batch)', async () => { let renderCount = 0; function Component() { - const value1 = useStateFromPublishingSubject(subject1); - const value2 = useStateFromPublishingSubject(subject2); - const value3 = useStateFromPublishingSubject(subject3); - const value4 = useStateFromPublishingSubject(subject4); - const value5 = useStateFromPublishingSubject(subject5); - const value6 = useStateFromPublishingSubject(subject6); + const value1 = useStateFromPublishingSubject(subject1); + const value2 = useStateFromPublishingSubject(subject2); + const value3 = useStateFromPublishingSubject(subject3); + const value4 = useStateFromPublishingSubject(subject4); + const value5 = useStateFromPublishingSubject(subject5); + const value6 = useStateFromPublishingSubject(subject6); renderCount++; return ( @@ -76,14 +76,14 @@ describe('useBatchedPublishingSubjects', () => { test('should batch state updates when using useBatchedPublishingSubjects', async () => { let renderCount = 0; function Component() { - const { value1, value2, value3, value4, value5, value6 } = useBatchedPublishingSubjects({ - value1: subject1, - value2: subject2, - value3: subject3, - value4: subject4, - value5: subject5, - value6: subject6, - }); + const [value1, value2, value3, value4, value5, value6] = useBatchedPublishingSubjects( + subject1, + subject2, + subject3, + subject4, + subject5, + subject6 + ); renderCount++; return ( @@ -117,12 +117,12 @@ describe('useBatchedPublishingSubjects', () => { test('should render for each state update outside of click handler', async () => { let renderCount = 0; function Component() { - const value1 = useStateFromPublishingSubject(subject1); - const value2 = useStateFromPublishingSubject(subject2); - const value3 = useStateFromPublishingSubject(subject3); - const value4 = useStateFromPublishingSubject(subject4); - const value5 = useStateFromPublishingSubject(subject5); - const value6 = useStateFromPublishingSubject(subject6); + const value1 = useStateFromPublishingSubject(subject1); + const value2 = useStateFromPublishingSubject(subject2); + const value3 = useStateFromPublishingSubject(subject3); + const value4 = useStateFromPublishingSubject(subject4); + const value5 = useStateFromPublishingSubject(subject5); + const value6 = useStateFromPublishingSubject(subject6); renderCount++; return ( diff --git a/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.ts b/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.ts index 104282e4e91b9..c1dfa43683f91 100644 --- a/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.ts +++ b/packages/presentation/presentation_publishing/publishing_subject/publishing_subject.ts @@ -8,16 +8,7 @@ import { useEffect, useMemo, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; - -/** - * A publishing subject is a RxJS subject that can be used to listen to value changes, but does not allow pushing values via the Next method. - */ -export type PublishingSubject = Omit, 'next'>; - -/** - * A utility type that makes a type optional if another passed in type is optional. - */ -type OptionalIfOptional = undefined extends TestType ? Type | undefined : Type; +import { PublishingSubject, ValueFromPublishingSubject } from './types'; /** * Declares a publishing subject, allowing external code to subscribe to react state changes. @@ -41,18 +32,15 @@ export const usePublishingSubject = ( * @param subject Publishing subject. */ export const useStateFromPublishingSubject = < - ValueType extends unknown = unknown, - SubjectType extends PublishingSubject | undefined = - | PublishingSubject - | undefined + SubjectType extends PublishingSubject | undefined = PublishingSubject | undefined >( - subject?: SubjectType -): OptionalIfOptional => { - const [value, setValue] = useState(subject?.getValue()); + subject: SubjectType +): ValueFromPublishingSubject => { + const [value, setValue] = useState>(subject?.getValue()); useEffect(() => { if (!subject) return; const subscription = subject.subscribe((newValue) => setValue(newValue)); return () => subscription.unsubscribe(); }, [subject]); - return value as OptionalIfOptional; + return value; }; diff --git a/packages/presentation/presentation_publishing/publishing_subject/types.ts b/packages/presentation/presentation_publishing/publishing_subject/types.ts new file mode 100644 index 0000000000000..77b3cd4f645b1 --- /dev/null +++ b/packages/presentation/presentation_publishing/publishing_subject/types.ts @@ -0,0 +1,34 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; + +/** + * A publishing subject is a RxJS subject that can be used to listen to value changes, but does not allow pushing values via the Next method. + */ +export type PublishingSubject = Omit, 'next'>; + +// Usage of any required here. We want to build functionalities that can work with a publishing subject of any type. +type AnyValue = any; + +export type AnyPublishingSubject = PublishingSubject | undefined; + +export type ValueFromPublishingSubject< + T extends PublishingSubject | undefined = PublishingSubject | undefined +> = T extends PublishingSubject + ? ValueType + : T extends undefined + ? undefined + : never; + +export type UnwrapPublishingSubjectTuple = T extends [ + infer Head extends AnyPublishingSubject, + ...infer Tail extends AnyPublishingSubject[] +] + ? [ValueFromPublishingSubject, ...UnwrapPublishingSubjectTuple] + : []; diff --git a/packages/presentation/presentation_publishing/publishing_utils.ts b/packages/presentation/presentation_publishing/publishing_utils.ts deleted file mode 100644 index 37949a7d63ba6..0000000000000 --- a/packages/presentation/presentation_publishing/publishing_utils.ts +++ /dev/null @@ -1,27 +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 { useImperativeHandle, useMemo } from 'react'; - -/** - * Publishes 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 useApiPublisher = ( - api: ApiType, - ref: React.ForwardedRef -) => { - const publishApi = useMemo( - () => api, - // disabling exhaustive deps because the API should be created once and never change. - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - useImperativeHandle(ref, () => publishApi); -}; diff --git a/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx index 281d1cf6efdb9..2eef7b6cdbaf6 100644 --- a/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/copy_to_dashboard_modal.tsx @@ -48,9 +48,9 @@ export function CopyToDashboardModal({ api, closeModal }: CopyToDashboardModalPr const dashboardId = api.parentApi.savedObjectId.value; - const onSubmit = useCallback(() => { + const onSubmit = useCallback(async () => { const dashboard = api.parentApi; - const panelToCopy = dashboard.getDashboardPanelFromId(api.uuid); + const panelToCopy = await dashboard.getDashboardPanelFromId(api.uuid); if (!panelToCopy) { throw new PanelNotFoundError(); diff --git a/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx index 0fd864c8aa151..0e8a4ea4e9fbd 100644 --- a/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx +++ b/src/plugins/dashboard/public/dashboard_actions/replace_panel_action.test.tsx @@ -7,6 +7,7 @@ */ import { PublishesViewMode, ViewMode } from '@kbn/presentation-publishing'; +import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; import { BehaviorSubject } from 'rxjs'; import { ReplacePanelSOFinder } from '.'; import { ReplacePanelAction, ReplacePanelActionApi } from './replace_panel_action'; @@ -28,10 +29,7 @@ describe('replace panel action', () => { embeddable: { uuid: 'superId', viewMode: new BehaviorSubject('edit'), - parentApi: { - removePanel: jest.fn(), - replacePanel: jest.fn(), - }, + parentApi: getMockPresentationContainer(), }, }; }); 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 1dc5ac0b9751b..164555b92a176 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 @@ -6,12 +6,17 @@ * Side Public License, v 1. */ -import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; +import React, { useState, useRef, useEffect, useLayoutEffect, useMemo } from 'react'; import { EuiLoadingChart } from '@elastic/eui'; import classNames from 'classnames'; import { PhaseEvent } from '@kbn/presentation-publishing'; -import { EmbeddablePanel, ViewMode } from '@kbn/embeddable-plugin/public'; +import { + ReactEmbeddableRenderer, + EmbeddablePanel, + reactEmbeddableRegistryHasKey, + ViewMode, +} from '@kbn/embeddable-plugin/public'; import { css } from '@emotion/react'; import { DashboardPanelState } from '../../../../common'; @@ -52,6 +57,7 @@ export const Item = React.forwardRef( const container = useDashboardContainer(); const scrollToPanelId = container.select((state) => state.componentState.scrollToPanelId); const highlightPanelId = container.select((state) => state.componentState.highlightPanelId); + const panel = container.select((state) => state.explicitInput.panels[id]); const expandPanel = expandedPanelId !== undefined && expandedPanelId === id; const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id; @@ -94,6 +100,33 @@ export const Item = React.forwardRef( ` : css``; + const renderedEmbeddable = useMemo(() => { + if (reactEmbeddableRegistryHasKey(type)) { + return ( + + ); + } + return ( + container.untilEmbeddableLoaded(id)} + /> + ); + }, [container, id, index, onPanelStatusChange, type, panel]); + + // render legacy embeddable + return (
( > {isRenderable ? ( <> - container.untilEmbeddableLoaded(id)} - /> + {renderedEmbeddable} {children} ) : ( diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts index ad19962603985..256d03681447d 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.test.ts @@ -143,15 +143,13 @@ test('Duplicates a non RefOrVal embeddable by value', async () => { }); test('Gets a unique title from the dashboard', async () => { - expect(await incrementPanelTitle(byRefOrValEmbeddable, '')).toEqual(''); + expect(await incrementPanelTitle(container, '')).toEqual(''); container.getPanelTitles = jest.fn().mockImplementation(() => { return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle']; }); - expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testUniqueTitle')).toEqual( - 'testUniqueTitle (copy)' - ); - expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual( + expect(await incrementPanelTitle(container, 'testUniqueTitle')).toEqual('testUniqueTitle (copy)'); + expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( 'testDuplicateTitle (copy 1)' ); @@ -160,20 +158,20 @@ test('Gets a unique title from the dashboard', async () => { Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`) ); }); - expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual( + expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( 'testDuplicateTitle (copy 40)' ); - expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual( + expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( 'testDuplicateTitle (copy 40)' ); container.getPanelTitles = jest.fn().mockImplementation(() => { return ['testDuplicateTitle (copy 100)']; }); - expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual( + expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual( 'testDuplicateTitle (copy 101)' ); - expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual( + expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual( 'testDuplicateTitle (copy 101)' ); }); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts index a05565a9e09cb..9dc32bf94c54f 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/api/duplicate_dashboard_panel.ts @@ -7,12 +7,11 @@ */ import { - EmbeddableInput, - IEmbeddable, isReferenceOrValueEmbeddable, PanelNotFoundError, - PanelState, + reactEmbeddableRegistryHasKey, } from '@kbn/embeddable-plugin/public'; +import { apiPublishesPanelTitle } from '@kbn/presentation-publishing'; import { filter, map, max } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { DashboardPanelState } from '../../../../common'; @@ -21,37 +20,69 @@ import { pluginServices } from '../../../services/plugin_services'; import { placeClonePanel } from '../../component/panel_placement'; import { DashboardContainer } from '../dashboard_container'; -export async function duplicateDashboardPanel(this: DashboardContainer, idToDuplicate: string) { - const panelToClone = this.getInput().panels[idToDuplicate] as DashboardPanelState; - const embeddable = this.getChild(idToDuplicate); - if (!panelToClone || !embeddable) { - throw new PanelNotFoundError(); - } +const duplicateLegacyInput = async ( + dashboard: DashboardContainer, + panelToClone: DashboardPanelState, + idToDuplicate: string +) => { + const embeddable = dashboard.getChild(idToDuplicate); + if (!panelToClone || !embeddable) throw new PanelNotFoundError(); - // duplicate panel input - const duplicatedPanelState: PanelState = await (async () => { - const newTitle = await incrementPanelTitle(embeddable, embeddable.getTitle() || ''); - const id = uuidv4(); - if (isReferenceOrValueEmbeddable(embeddable)) { - return { - type: embeddable.type, - explicitInput: { - ...(await embeddable.getInputAsValueType()), - hidePanelTitles: panelToClone.explicitInput.hidePanelTitles, - ...(newTitle ? { title: newTitle } : {}), - id, - }, - }; - } + const newTitle = await incrementPanelTitle(dashboard, embeddable.getTitle() || ''); + const id = uuidv4(); + if (isReferenceOrValueEmbeddable(embeddable)) { return { type: embeddable.type, explicitInput: { - ...panelToClone.explicitInput, - title: newTitle, + ...(await embeddable.getInputAsValueType()), + hidePanelTitles: panelToClone.explicitInput.hidePanelTitles, + ...(newTitle ? { title: newTitle } : {}), id, }, }; - })(); + } + return { + type: embeddable.type, + explicitInput: { + ...panelToClone.explicitInput, + title: newTitle, + id, + }, + }; +}; + +const duplicateReactEmbeddableInput = async ( + dashboard: DashboardContainer, + panelToClone: DashboardPanelState, + idToDuplicate: string +) => { + const child = dashboard.reactEmbeddableChildren.value[idToDuplicate]; + if (!child) throw new PanelNotFoundError(); + + const lastTitle = apiPublishesPanelTitle(child) + ? child.panelTitle.value ?? child.defaultPanelTitle?.value ?? '' + : ''; + const newTitle = await incrementPanelTitle(dashboard, lastTitle); + const id = uuidv4(); + const serializedState = await child.serializeState(); + return { + type: panelToClone.type, + explicitInput: { + ...panelToClone.explicitInput, + ...serializedState.rawState, + title: newTitle, + id, + }, + }; +}; + +export async function duplicateDashboardPanel(this: DashboardContainer, idToDuplicate: string) { + const panelToClone = this.getInput().panels[idToDuplicate] as DashboardPanelState; + + const duplicatedPanelState = reactEmbeddableRegistryHasKey(panelToClone.type) + ? await duplicateReactEmbeddableInput(this, panelToClone, idToDuplicate) + : await duplicateLegacyInput(this, panelToClone, idToDuplicate); + pluginServices.getServices().notifications.toasts.addSuccess({ title: dashboardClonePanelActionStrings.getSuccessMessage(), 'data-test-subj': 'addObjectToContainerSuccess', @@ -80,14 +111,13 @@ export async function duplicateDashboardPanel(this: DashboardContainer, idToDupl }); } -export const incrementPanelTitle = async (embeddable: IEmbeddable, rawTitle: string) => { +export const incrementPanelTitle = async (dashboard: DashboardContainer, rawTitle: string) => { if (rawTitle === '') return ''; const clonedTag = dashboardClonePanelActionStrings.getClonedTag(); const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g'); const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g'); const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim(); - const dashboard: DashboardContainer = embeddable.getRoot() as DashboardContainer; const similarTitles = filter(await dashboard.getPanelTitles(), (title: string) => { return title.startsWith(baseTitle); }); 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 26c52cb429fd3..66c52f14a0002 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 @@ -6,13 +6,14 @@ * Side Public License, v 1. */ +import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; +import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; +import { 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'; import { batch } from 'react-redux'; - -import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; -import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; - import { DashboardContainerInput } from '../../../../common'; import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_POST_TIME } from '../../../dashboard_constants'; import { @@ -25,6 +26,33 @@ import { DashboardContainer } from '../dashboard_container'; import { extractTitleAndCount } from './lib/extract_title_and_count'; import { DashboardSaveModal } from './overlays/save_modal'; +const serializeAllPanelState = async ( + dashboard: DashboardContainer +): Promise => { + 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 saveResults = await Promise.all(reactEmbeddableSavePromises); + for (const { serializedState, uuid } of saveResults) { + panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid }; + } + return panels; +}; + export function runSaveAs(this: DashboardContainer) { const { data: { @@ -82,18 +110,20 @@ export function runSaveAs(this: DashboardContainer) { // do not save if title is duplicate and is unconfirmed return {}; } - - const lastSavedInput: DashboardContainerInput = { + const nextPanels = await serializeAllPanelState(this); + const dashboardStateToSave: DashboardContainerInput = { ...currentState, + panels: nextPanels, ...stateFromSaveModal, }; - let stateToSave: SavedDashboardInput = lastSavedInput; + let stateToSave: SavedDashboardInput = dashboardStateToSave; let persistableControlGroupInput: PersistableControlGroupInput | undefined; if (this.controlGroup) { persistableControlGroupInput = this.controlGroup.getPersistableInput(); stateToSave = { ...stateToSave, controlGroupInput: persistableControlGroupInput }; } const beforeAddTime = window.performance.now(); + const saveResult = await saveDashboardState({ currentState: stateToSave, saveOptions, @@ -112,7 +142,8 @@ export function runSaveAs(this: DashboardContainer) { if (saveResult.id) { batch(() => { this.dispatch.setStateFromSaveModal(stateFromSaveModal); - this.dispatch.setLastSavedInput(lastSavedInput); + this.dispatch.setLastSavedInput(dashboardStateToSave); + this.lastSavedState.next(); if (this.controlGroup && persistableControlGroupInput) { this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput); } @@ -153,7 +184,8 @@ export async function runQuickSave(this: DashboardContainer) { if (managed) return; - let stateToSave: SavedDashboardInput = currentState; + const nextPanels = await serializeAllPanelState(this); + let stateToSave: SavedDashboardInput = { ...currentState, panels: nextPanels }; let persistableControlGroupInput: PersistableControlGroupInput | undefined; if (this.controlGroup) { persistableControlGroupInput = this.controlGroup.getPersistableInput(); @@ -167,6 +199,7 @@ export async function runQuickSave(this: DashboardContainer) { }); this.dispatch.setLastSavedInput(currentState); + this.lastSavedState.next(); if (this.controlGroup && persistableControlGroupInput) { this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 6fc13af8a7ca9..9621b8f01476c 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -5,10 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { cloneDeep, identity, omit, pickBy } from 'lodash'; -import { BehaviorSubject, combineLatestWith, distinctUntilChanged, map, Subject } from 'rxjs'; -import { v4 } from 'uuid'; - import { ControlGroupInput, CONTROL_GROUP_TYPE, @@ -21,10 +17,17 @@ import { type ControlGroupContainer, } from '@kbn/controls-plugin/public'; import { GlobalQueryStateFromUrl, syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public'; -import { EmbeddableFactory, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; +import { + EmbeddableFactory, + isErrorEmbeddable, + reactEmbeddableRegistryHasKey, + ViewMode, +} from '@kbn/embeddable-plugin/public'; import { TimeRange } from '@kbn/es-query'; import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public'; - +import { cloneDeep, identity, omit, pickBy } from 'lodash'; +import { Subject } from 'rxjs'; +import { v4 } from 'uuid'; import { DashboardContainerInput, DashboardPanelState } from '../../../../common'; import { DEFAULT_DASHBOARD_INPUT, @@ -38,6 +41,7 @@ import { } from '../../../services/dashboard_content_management/types'; import { pluginServices } from '../../../services/plugin_services'; import { panelPlacementStrategies } from '../../component/panel_placement/place_new_panel_strategies'; +import { startDiffingDashboardState } from '../../state/diffing/dashboard_diffing_integration'; import { DashboardPublicState } from '../../types'; import { DashboardContainer } from '../dashboard_container'; import { DashboardCreationOptions } from '../dashboard_container_factory'; @@ -100,7 +104,7 @@ export const createDashboard = async ( const { input, searchSessionId } = initializeResult; // -------------------------------------------------------------------------------------- - // Build and return the dashboard container. + // Build the dashboard container. // -------------------------------------------------------------------------------------- const initialComponentState: DashboardPublicState = { lastSavedInput: omit(savedObjectResult?.dashboardInput, 'controlGroupInput') ?? { @@ -124,6 +128,14 @@ export const createDashboard = async ( creationOptions, initialComponentState ); + + // -------------------------------------------------------------------------------------- + // Start the diffing integration after all other integrations are set up. + // -------------------------------------------------------------------------------------- + untilDashboardReady().then((container) => { + startDiffingDashboardState.bind(container)(creationOptions); + }); + dashboardContainerReady$.next(dashboardContainer); return dashboardContainer; }; @@ -353,6 +365,9 @@ export const initializeDashboard = async ({ [newPanelState.explicitInput.id]: newPanelState, }, }); + if (reactEmbeddableRegistryHasKey(incomingEmbeddable.type)) { + return { id: embeddableId }; + } return await container.untilEmbeddableLoaded(embeddableId); })(); @@ -451,49 +466,5 @@ export const initializeDashboard = async ({ setTimeout(() => dashboard.dispatch.setAnimatePanelTransforms(true), 500) ); - // -------------------------------------------------------------------------------------- - // Start diffing subscription to keep track of unsaved changes - // -------------------------------------------------------------------------------------- - untilDashboardReady().then((dashboard) => { - // subscription that handles the unsaved changes badge - dashboard.integrationSubscriptions.add( - dashboard.hasUnsavedChanges - .pipe( - combineLatestWith( - dashboard.controlGroup?.unsavedChanges.pipe( - map((unsavedControlchanges) => Boolean(unsavedControlchanges)) - ) ?? new BehaviorSubject(false) - ), - distinctUntilChanged( - ( - [dashboardHasChanges1, controlHasChanges1], - [dashboardHasChanges2, controlHasChanges2] - ) => - (dashboardHasChanges1 || controlHasChanges1) === - (dashboardHasChanges2 || controlHasChanges2) - ) - ) - .subscribe(([dashboardHasChanges, controlGroupHasChanges]) => { - dashboard.dispatch.setHasUnsavedChanges(dashboardHasChanges || controlGroupHasChanges); - }) - ); - - // subscription that handles backing up the unsaved changes to the session storage - dashboard.integrationSubscriptions.add( - dashboard.backupUnsavedChanges - .pipe( - combineLatestWith( - dashboard.controlGroup?.unsavedChanges ?? new BehaviorSubject(undefined) - ) - ) - .subscribe(([dashboardChanges, controlGroupChanges]) => { - dashboardBackup.setState(dashboard.getDashboardSavedObjectId(), { - ...dashboardChanges, - controlGroupInput: controlGroupChanges, - }); - }) - ); - }); - return { input: initialDashboardInput, searchSessionId: initialSearchSessionId }; }; 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 163b1a7b4d08f..bf1bf12e65756 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -11,7 +11,8 @@ import React, { createContext, useContext } from 'react'; import ReactDOM from 'react-dom'; import { batch } from 'react-redux'; import { BehaviorSubject, Subject, Subscription } from 'rxjs'; - +import { map, distinctUntilChanged } from 'rxjs/operators'; +import deepEqual from 'fast-deep-equal'; import type { ControlGroupContainer } from '@kbn/controls-plugin/public'; import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public'; import { RefreshInterval } from '@kbn/data-plugin/public'; @@ -19,6 +20,10 @@ import type { DataView } from '@kbn/data-views-plugin/public'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { Container, + DefaultEmbeddableApi, + PanelNotFoundError, + ReactEmbeddableParentContext, + reactEmbeddableRegistryHasKey, ViewMode, type EmbeddableFactory, type EmbeddableInput, @@ -43,7 +48,7 @@ import { placePanel } from '../component/panel_placement'; import { DashboardViewport } from '../component/viewport/dashboard_viewport'; import { DashboardExternallyAccessibleApi } from '../external_api/dashboard_api'; import { dashboardContainerReducers } from '../state/dashboard_container_reducers'; -import { startDiffingDashboardState } from '../state/diffing/dashboard_diffing_integration'; +import { getDiffingMiddleware } from '../state/diffing/dashboard_diffing_integration'; import { DashboardPublicState, DashboardReduxState, @@ -65,7 +70,6 @@ import { dashboardTypeDisplayLowercase, dashboardTypeDisplayName, } from './dashboard_container_factory'; -import { SavedDashboardInput } from '../../services/dashboard_content_management/types'; export interface InheritedChildInput { filters: Filter[]; @@ -108,15 +112,13 @@ export class DashboardContainer public getState: DashboardReduxEmbeddableTools['getState']; public dispatch: DashboardReduxEmbeddableTools['dispatch']; public onStateChange: DashboardReduxEmbeddableTools['onStateChange']; + public anyReducerRun: Subject = new Subject(); public integrationSubscriptions: Subscription = new Subscription(); public publishingSubscription: Subscription = new Subscription(); public diffingSubscription: Subscription = new Subscription(); public controlGroup?: ControlGroupContainer; - public hasUnsavedChanges: BehaviorSubject; - public backupUnsavedChanges: BehaviorSubject | undefined>; - public searchSessionId?: string; public locator?: Pick, 'navigate' | 'getRedirectUrl'>; @@ -140,6 +142,10 @@ export class DashboardContainer private chrome; private customBranding; + // new embeddable framework + public reactEmbeddableChildren: BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }> = + new BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }>({}); + constructor( initialInput: DashboardContainerInput, reduxToolsPackage: ReduxToolsPackage, @@ -177,11 +183,7 @@ export class DashboardContainer this.dashboardCreationStartTime = dashboardCreationStartTime; // start diffing dashboard state - this.hasUnsavedChanges = new BehaviorSubject(false); - this.backupUnsavedChanges = new BehaviorSubject | undefined>( - undefined - ); - const diffingMiddleware = startDiffingDashboardState.bind(this)(creationOptions); + const diffingMiddleware = getDiffingMiddleware.bind(this)(); // build redux embeddable tools const reduxTools = reduxToolsPackage.createReduxEmbeddableTools< @@ -214,6 +216,7 @@ export class DashboardContainer this.expandedPanelId.next(this.getExpandedPanelId()); }) ); + this.startAuditingReactEmbeddableChildren(); } public getAppContext() { @@ -278,7 +281,9 @@ export class DashboardContainer > - + + + @@ -391,7 +396,21 @@ export class DashboardContainer return newId; } - public getDashboardPanelFromId = (panelId: string) => this.getInput().panels[panelId]; + public getDashboardPanelFromId = async (panelId: string) => { + const panel = this.getInput().panels[panelId]; + if (reactEmbeddableRegistryHasKey(panel.type)) { + const child = this.reactEmbeddableChildren.value[panelId]; + if (!child) throw new PanelNotFoundError(); + const serialized = await child.serializeState(); + return { + type: panel.type, + explicitInput: { ...panel.explicitInput, ...serialized.rawState }, + gridData: panel.gridData, + version: serialized.version, + }; + } + return panel; + }; public expandPanel = (panelId?: string) => { this.setExpandedPanelId(panelId); @@ -435,6 +454,7 @@ export class DashboardContainer if (timeRange) timeFilterService.setTime(timeRange); if (refreshInterval) timeFilterService.setRefreshInterval(refreshInterval); } + this.resetAllReactEmbeddables(); } public navigateToDashboard = async ( @@ -537,14 +557,20 @@ export class DashboardContainer public async getPanelTitles(): Promise { const titles: string[] = []; - const ids: string[] = Object.keys(this.getInput().panels); - for (const panelId of ids) { - await this.untilEmbeddableLoaded(panelId); - const child: IEmbeddable = this.getChild(panelId); - const title = child.getTitle(); - if (title) { - titles.push(title); - } + for (const [id, panel] of Object.entries(this.getInput().panels)) { + const title = await (async () => { + if (reactEmbeddableRegistryHasKey(panel.type)) { + return ( + this.reactEmbeddableChildren.value[id]?.panelTitle?.value ?? + this.reactEmbeddableChildren.value[id]?.defaultPanelTitle?.value + ); + } + await this.untilEmbeddableLoaded(id); + const child: IEmbeddable = this.getChild(id); + if (!child) return undefined; + return child.getTitle(); + })(); + if (title) titles.push(title); } return titles; } @@ -589,4 +615,75 @@ export class DashboardContainer public setFocusedPanelId = (id: string | undefined) => { this.dispatch.setFocusedPanelId(id); }; + + // ------------------------------------------------------------------------------------------------------ + // React Embeddable system + // ------------------------------------------------------------------------------------------------------ + public registerPanelApi = (id: string, api: ApiType) => { + this.reactEmbeddableChildren.next({ + ...this.reactEmbeddableChildren.value, + [id]: api as DefaultEmbeddableApi, + }); + }; + + public getLastSavedStateForChild = (childId: string) => { + const { + componentState: { + lastSavedInput: { panels }, + }, + } = this.getState(); + const panel: DashboardPanelState | undefined = panels[childId]; + + // TODO Embeddable refactor. References here + return { rawState: panel?.explicitInput, version: panel?.version, references: [] }; + }; + + public removePanel(id: string) { + const type = this.getInput().panels[id]?.type; + this.removeEmbeddable(id); + if (reactEmbeddableRegistryHasKey(type)) { + const { [id]: childToRemove, ...otherChildren } = this.reactEmbeddableChildren.value; + this.reactEmbeddableChildren.next(otherChildren); + } + } + + public startAuditingReactEmbeddableChildren = () => { + const auditChildren = () => { + const currentChildren = this.reactEmbeddableChildren.value; + let panelsChanged = false; + for (const panelId of Object.keys(currentChildren)) { + if (!this.getInput().panels[panelId]) { + delete currentChildren[panelId]; + panelsChanged = true; + } + } + if (panelsChanged) this.reactEmbeddableChildren.next(currentChildren); + }; + + // audit children when panels change + this.publishingSubscription.add( + this.getInput$() + .pipe( + map(() => Object.keys(this.getInput().panels)), + distinctUntilChanged(deepEqual) + ) + .subscribe(() => auditChildren()) + ); + auditChildren(); + }; + + public resetAllReactEmbeddables = () => { + let resetChangedPanelCount = false; + const currentChildren = this.reactEmbeddableChildren.value; + for (const panelId of Object.keys(currentChildren)) { + if (this.getInput().panels[panelId]) { + currentChildren[panelId].resetUnsavedChanges(); + } else { + // if reset resulted in panel removal, we need to update the list of children + delete currentChildren[panelId]; + resetChangedPanelCount = true; + } + } + if (resetChangedPanelCount) this.reactEmbeddableChildren.next(currentChildren); + }; } diff --git a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_api.ts b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_api.ts index 0a9dc0e5ca56c..42915c0aa3979 100644 --- a/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_api.ts +++ b/src/plugins/dashboard/public/dashboard_container/external_api/dashboard_api.ts @@ -34,7 +34,7 @@ export interface DashboardPluginInternalFunctions { * A temporary backdoor to allow some actions access to the Dashboard panels. This should eventually be replaced with a generic version * on the PresentationContainer interface. */ - getDashboardPanelFromId: (id: string) => DashboardPanelState; + getDashboardPanelFromId: (id: string) => Promise; /** * A temporary backdoor to allow the filters notification popover to get the data views directly from the dashboard container diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts index 4816b261f373f..acfb95b6745e9 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts @@ -6,16 +6,17 @@ * Side Public License, v 1. */ -import fastIsEqual from 'fast-deep-equal'; - -import { shouldRefreshFilterCompareOptions } from '@kbn/embeddable-plugin/public'; +import { + reactEmbeddableRegistryHasKey, + shouldRefreshFilterCompareOptions, +} from '@kbn/embeddable-plugin/public'; import { compareFilters, COMPARE_ALL_OPTIONS, isFilterPinned, onlyDisabledFiltersChanged, } from '@kbn/es-query'; - +import fastIsEqual from 'fast-deep-equal'; import { DashboardContainerInput } from '../../../../common'; import { DashboardContainer } from '../../embeddable/dashboard_container'; import { DashboardContainerInputWithoutId } from '../../types'; @@ -82,7 +83,11 @@ export const unsavedChangesDiffingFunctions: DashboardDiffFunctions = { (panel) => new Promise((resolve, reject) => { const embeddableId = panel.explicitInput.id; - if (!embeddableId) reject(); + if (!embeddableId || reactEmbeddableRegistryHasKey(panel.type)) { + // if this is a new style embeddable, it will handle its own diffing. + reject(); + return; + } try { container.untilEmbeddableLoaded(embeddableId).then((embeddable) => embeddable diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts index 31e99e213fb25..d0ba41e47074a 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts @@ -5,20 +5,23 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { omit } from 'lodash'; +import { PersistableControlGroupInput } from '@kbn/controls-plugin/common'; +import deepEqual from 'fast-deep-equal'; +import { cloneDeep, omit } from 'lodash'; import { AnyAction, Middleware } from 'redux'; -import { debounceTime, Observable, startWith, Subject, switchMap } from 'rxjs'; - +import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { DashboardContainer, DashboardCreationOptions } from '../..'; +import { DashboardContainerInput } from '../../../../common'; +import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; +import { pluginServices } from '../../../services/plugin_services'; +import { dashboardContainerReducers } from '../dashboard_container_reducers'; import { isKeyEqual, isKeyEqualAsync, shouldRefreshDiffingFunctions, unsavedChangesDiffingFunctions, } from './dashboard_diffing_functions'; -import { DashboardContainerInput } from '../../../../common'; -import { DashboardContainer, DashboardCreationOptions } from '../..'; -import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; -import { dashboardContainerReducers } from '../dashboard_container_reducers'; /** * An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input @@ -75,45 +78,9 @@ const sessionChangeKeys: Array> = ]; /** - * Does an initial diff between @param initialInput and @param initialLastSavedInput, and creates a middleware - * which listens to the redux store and pushes updates to the `hasUnsavedChanges` and `backupUnsavedChanges` behaviour - * subjects so that the corresponding subscriptions can dispatch updates as necessary + * build middleware that fires an event any time a reducer that could cause unsaved changes is run */ -export function startDiffingDashboardState( - this: DashboardContainer, - creationOptions?: DashboardCreationOptions -) { - const checkForUnsavedChangesSubject$ = new Subject(); - this.diffingSubscription.add( - checkForUnsavedChangesSubject$ - .pipe( - startWith(null), - debounceTime(CHANGE_CHECK_DEBOUNCE), - switchMap(() => { - return new Observable((observer) => { - const { - explicitInput: currentInput, - componentState: { lastSavedInput }, - } = this.getState(); - getUnsavedChanges - .bind(this)(lastSavedInput, currentInput) - .then((unsavedChanges) => { - if (observer.closed) return; - const validUnsavedChanges = omit(unsavedChanges, keysNotConsideredUnsavedChanges); - const hasChanges = Object.keys(validUnsavedChanges).length > 0; - this.hasUnsavedChanges.next(hasChanges); - - if (creationOptions?.useSessionStorageIntegration) { - this.backupUnsavedChanges.next( - omit(unsavedChanges, keysToOmitFromSessionStorage) - ); - } - }); - }); - }) - ) - .subscribe() - ); +export function getDiffingMiddleware(this: DashboardContainer) { const diffingMiddleware: Middleware = (store) => (next) => (action) => { const dispatchedActionName = action.type.split('/')?.[1]; if ( @@ -121,18 +88,105 @@ export function startDiffingDashboardState( dispatchedActionName !== 'updateEmbeddableReduxOutput' && // ignore any generic output updates. !reducersToIgnore.includes(dispatchedActionName) ) { - checkForUnsavedChangesSubject$.next(null); + this.anyReducerRun.next(null); } next(action); }; return diffingMiddleware; } +/** + * Does an initial diff between @param initialInput and @param initialLastSavedInput, and creates a middleware + * which listens to the redux store and pushes updates to the `hasUnsavedChanges` and `backupUnsavedChanges` behaviour + * subjects so that the corresponding subscriptions can dispatch updates as necessary + */ +export function startDiffingDashboardState( + this: DashboardContainer, + creationOptions?: DashboardCreationOptions +) { + /** + * Create an observable stream of unsaved changes from all react embeddable children + */ + const reactEmbeddableUnsavedChanges = this.reactEmbeddableChildren.pipe( + map((children) => Object.keys(children)), + distinctUntilChanged(deepEqual), + debounceTime(CHANGE_CHECK_DEBOUNCE), + + // children may change, so make sure we subscribe/unsubscribe with switchMap + switchMap((newChildIds: string[]) => { + if (newChildIds.length === 0) return of([]); + return combineLatest( + newChildIds.map((childId) => + this.reactEmbeddableChildren.value[childId].unsavedChanges.pipe( + map((unsavedChanges) => { + return { childId, unsavedChanges }; + }) + ) + ) + ); + }), + map((children) => children.filter((child) => Boolean(child.unsavedChanges))) + ); + + /** + * Create an observable stream that checks for unsaved changes in the Dashboard state + * and the state of all of its legacy embeddable children. + */ + const dashboardUnsavedChanges = this.anyReducerRun.pipe( + startWith(null), + debounceTime(CHANGE_CHECK_DEBOUNCE), + switchMap(() => { + return new Observable>((observer) => { + const { + explicitInput: currentInput, + componentState: { lastSavedInput }, + } = this.getState(); + getDashboardUnsavedChanges + .bind(this)(lastSavedInput, currentInput) + .then((unsavedChanges) => { + if (observer.closed) return; + observer.next(unsavedChanges); + }); + }); + }) + ); + + /** + * Combine unsaved changes from all sources together. Set unsaved changes state and backup unsaved changes when any of the sources emit. + */ + this.diffingSubscription.add( + combineLatest([ + dashboardUnsavedChanges, + reactEmbeddableUnsavedChanges, + this.controlGroup?.unsavedChanges ?? + (of(undefined) as Observable), + ]).subscribe(([dashboardChanges, reactEmbeddableChanges, controlGroupChanges]) => { + // calculate unsaved changes + const hasUnsavedChanges = + Object.keys(omit(dashboardChanges, keysNotConsideredUnsavedChanges)).length > 0 || + reactEmbeddableChanges.length > 0 || + controlGroupChanges !== undefined; + if (hasUnsavedChanges !== this.getState().componentState.hasUnsavedChanges) { + this.dispatch.setHasUnsavedChanges(hasUnsavedChanges); + } + + // backup unsaved changes if configured to do so + if (creationOptions?.useSessionStorageIntegration) { + backupUnsavedChanges.bind(this)( + dashboardChanges, + reactEmbeddableChanges, + controlGroupChanges + ); + } + }) + ); +} + /** * Does a shallow diff between @param lastInput and @param input and * @returns an object out of the keys which are different. */ -export async function getUnsavedChanges( +export async function getDashboardUnsavedChanges( this: DashboardContainer, lastInput: DashboardContainerInput, input: DashboardContainerInput @@ -198,3 +252,40 @@ export function getShouldRefresh( } return false; } + +function backupUnsavedChanges( + this: DashboardContainer, + dashboardChanges: Partial, + reactEmbeddableChanges: Array<{ + childId: string; + unsavedChanges: object | undefined; + }>, + controlGroupChanges: PersistableControlGroupInput | undefined +) { + const { dashboardBackup } = pluginServices.getServices(); + + // apply all unsaved state from react embeddables to the unsaved changes object. + let hasAnyReactEmbeddableUnsavedChanges = false; + const currentPanels = cloneDeep(dashboardChanges.panels ?? this.getInput().panels); + for (const { childId, unsavedChanges: childUnsavedChanges } of reactEmbeddableChanges) { + if (!childUnsavedChanges) continue; + const panelStateToBackup = { + ...currentPanels[childId], + ...(dashboardChanges.panels?.[childId] ?? {}), + explicitInput: { + ...currentPanels[childId]?.explicitInput, + ...(dashboardChanges.panels?.[childId]?.explicitInput ?? {}), + ...childUnsavedChanges, + }, + }; + hasAnyReactEmbeddableUnsavedChanges = true; + currentPanels[childId] = panelStateToBackup; + } + const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage); + + dashboardBackup.setState(this.getDashboardSavedObjectId(), { + ...dashboardStateToBackup, + panels: hasAnyReactEmbeddableUnsavedChanges ? currentPanels : dashboardChanges.panels, + controlGroupInput: controlGroupChanges, + }); +} diff --git a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts index 9d42d496df25f..adc4de15d38af 100644 --- a/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts +++ b/src/plugins/dashboard/public/services/dashboard_content_management/lib/migrate_dashboard_input.ts @@ -9,9 +9,9 @@ import { ControlGroupInput } from '@kbn/controls-plugin/common'; import { EmbeddableFactoryNotFoundError, + reactEmbeddableRegistryHasKey, runEmbeddableFactoryMigrations, } from '@kbn/embeddable-plugin/public'; - import { DashboardContainerInput, DashboardPanelState } from '../../../../common'; import { type DashboardEmbeddableService } from '../../embeddable/types'; import { SavedDashboardInput } from '../types'; @@ -51,7 +51,13 @@ export const migrateDashboardInput = ( }); } const migratedPanels: DashboardContainerInput['panels'] = {}; - Object.entries(dashboardInput.panels).forEach(([id, panel]) => { + for (const [id, panel] of Object.entries(dashboardInput.panels)) { + // if the panel type is registered in the new embeddable system, we do not need to run migrations for it. + if (reactEmbeddableRegistryHasKey(panel.type)) { + migratedPanels[id] = panel; + continue; + } + const factory = embeddable.getEmbeddableFactory(panel.type); if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); // run last saved migrations for by value panels only. @@ -67,7 +73,7 @@ export const migrateDashboardInput = ( panel.explicitInput.version = factory.latestVersion; } migratedPanels[id] = panel; - }); + } dashboardInput.panels = migratedPanels; return { dashboardInput, anyMigrationRun }; }; diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index c63f8c50b0630..371ec81c3d2cf 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -75,7 +75,7 @@ "@kbn/presentation-panel-plugin", "@kbn/content-management-table-list-view-common", "@kbn/shared-ux-utility", - "@kbn/managed-content-badge" + "@kbn/managed-content-badge", ], "exclude": ["target/**/*"] } diff --git a/src/plugins/embeddable/public/embeddable_panel/types.ts b/src/plugins/embeddable/public/embeddable_panel/types.ts index eecf412d75835..8463e830aa771 100644 --- a/src/plugins/embeddable/public/embeddable_panel/types.ts +++ b/src/plugins/embeddable/public/embeddable_panel/types.ts @@ -10,8 +10,6 @@ import { PresentationPanelProps } from '@kbn/presentation-panel-plugin/public'; import { MaybePromise } from '@kbn/utility-types'; import { ReactNode } from 'react'; import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../lib'; -import { LegacyEmbeddableAPI } from '../lib/embeddables/i_embeddable'; -import { EmbeddableComponent } from '../registry/types'; export type LegacyCompatibleEmbeddable = IEmbeddable< EmbeddableInput, @@ -26,5 +24,3 @@ export type EmbeddablePanelProps = Omit & { export type UnwrappedEmbeddablePanelProps = Omit & { embeddable: LegacyCompatibleEmbeddable; }; - -export type LegacyEmbeddableCompatibilityComponent = EmbeddableComponent; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 5563696fdde18..5f9254ae11748 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -92,8 +92,26 @@ export type { EmbeddableStartDependencies, } from './plugin'; export type { EnhancementRegistryDefinition } from './types'; -export type { EmbeddableComponentFactory } from './registry/types'; -export { CreateEmbeddableComponent } from './registry/create_embeddable_component'; + +export { + ReactEmbeddableRenderer, + reactEmbeddableRegistryHasKey, + RegisterReactEmbeddable, + registerReactEmbeddableFactory, + useReactEmbeddableApiHandle, + type DefaultEmbeddableApi, + type ReactEmbeddable, + type ReactEmbeddableFactory, + type ReactEmbeddableRegistration, + type ReactEmbeddableTitlesApi, + type SerializedReactEmbeddableTitles, + ReactEmbeddableParentContext, + useReactEmbeddableParentApi, + useReactEmbeddableUnsavedChanges, + initializeReactEmbeddableUuid, + initializeReactEmbeddableTitles, + serializeReactEmbeddableTitles, +} from './react_embeddable_system'; export function plugin(initializerContext: PluginInitializerContext) { return new EmbeddablePublicPlugin(initializerContext); diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 65f7a70f6afbc..d039bd5b80f89 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -8,7 +8,7 @@ import deepEqual from 'fast-deep-equal'; import { isEqual, xor } from 'lodash'; -import { EMPTY, merge, Subscription } from 'rxjs'; +import { EMPTY, merge, Subject, Subscription } from 'rxjs'; import { catchError, combineLatestWith, @@ -21,7 +21,11 @@ import { } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; -import { PresentationContainer, PanelPackage } from '@kbn/presentation-containers'; +import { + PresentationContainer, + PanelPackage, + SerializedPanelState, +} from '@kbn/presentation-containers'; import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable'; import { EmbeddableStart } from '../../plugin'; @@ -42,6 +46,7 @@ import { IContainer, PanelState, } from './i_container'; +import { reactEmbeddableRegistryHasKey } from '../../react_embeddable_system'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -61,6 +66,12 @@ export abstract class Container< private subscription: Subscription | undefined; private readonly anyChildOutputChange$; + public lastSavedState: Subject = new Subject(); + public getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined = () => + undefined; + + public registerPanelApi = (id: string, api: ApiType) => {}; + constructor( input: TContainerInput, output: TContainerOutput, @@ -492,6 +503,17 @@ export abstract class Container< } private async onPanelAdded(panel: PanelState) { + // do nothing if this panel's type is in the new Embeddable registry. + if (reactEmbeddableRegistryHasKey(panel.type)) { + this.updateOutput({ + embeddableLoaded: { + ...this.output.embeddableLoaded, + [panel.explicitInput.id]: true, + }, + } as Partial); + return; + } + this.updateOutput({ embeddableLoaded: { ...this.output.embeddableLoaded, diff --git a/src/plugins/embeddable/public/react_embeddable_system/index.ts b/src/plugins/embeddable/public/react_embeddable_system/index.ts new file mode 100644 index 0000000000000..4bfd9e7efa2cf --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/index.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +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'; +export { + initializeReactEmbeddableTitles, + serializeReactEmbeddableTitles, + type ReactEmbeddableTitlesApi, + type SerializedReactEmbeddableTitles, +} from './react_embeddable_titles'; +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 new file mode 100644 index 0000000000000..4ec729119d87e --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 new file mode 100644 index 0000000000000..896a9fb05468b --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_api.ts @@ -0,0 +1,74 @@ +/* + * 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 new file mode 100644 index 0000000000000..11d7a58fa0890 --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { + registerReactEmbeddableFactory, + reactEmbeddableRegistryHasKey, + getReactEmbeddableFactory, +} from './react_embeddable_registry'; +import { ReactEmbeddableFactory } from './types'; + +describe('react embeddable registry', () => { + const testEmbeddableFactory: ReactEmbeddableFactory = { + deserializeState: jest.fn(), + getComponent: jest.fn(), + }; + + it('throws an error if requested embeddable factory type is not registered', () => { + expect(() => getReactEmbeddableFactory('notRegistered')).toThrowErrorMatchingInlineSnapshot( + `"No embeddable factory found for type: notRegistered"` + ); + }); + + it('can register and get an embeddable factory', () => { + registerReactEmbeddableFactory('test', testEmbeddableFactory); + expect(getReactEmbeddableFactory('test')).toBe(testEmbeddableFactory); + }); + + it('can check if a factory is registered', () => { + expect(reactEmbeddableRegistryHasKey('test')).toBe(true); + expect(reactEmbeddableRegistryHasKey('notRegistered')).toBe(false); + }); +}); 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 new file mode 100644 index 0000000000000..2f014b84c07ab --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_registry.ts @@ -0,0 +1,61 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + DefaultEmbeddableApi, + ReactEmbeddable, + ReactEmbeddableFactory, + ReactEmbeddableRegistration, +} from './types'; + +const registry: { [key: string]: ReactEmbeddableFactory } = {}; + +export const registerReactEmbeddableFactory = < + StateType extends unknown = unknown, + APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi +>( + key: string, + factory: ReactEmbeddableFactory +) => { + if (registry[key] !== undefined) + throw new Error( + i18n.translate('embeddableApi.reactEmbeddable.factoryAlreadyExistsError', { + defaultMessage: 'An embeddable factory for for type: {key} is already registered.', + values: { key }, + }) + ); + registry[key] = factory; +}; + +export const reactEmbeddableRegistryHasKey = (key: string) => registry[key] !== undefined; + +export const getReactEmbeddableFactory = < + StateType extends unknown = unknown, + ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi +>( + key: string +): ReactEmbeddableFactory => { + if (registry[key] === undefined) + throw new Error( + i18n.translate('embeddableApi.reactEmbeddable.factoryNotFoundError', { + defaultMessage: 'No embeddable factory found for type: {key}', + values: { key }, + }) + ); + 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 new file mode 100644 index 0000000000000..376923de108d1 --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx @@ -0,0 +1,40 @@ +/* + * 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, screen } from '@testing-library/react'; + +import React from 'react'; +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
; + }), + }; + + beforeAll(() => { + registerReactEmbeddableFactory('test', testEmbeddableFactory); + }); + + it('deserializes given state', () => { + render(); + expect(testEmbeddableFactory.deserializeState).toHaveBeenCalledWith({ + rawState: { blorp: 'blorp?' }, + }); + }); + + it('renders the given component once it resolves', () => { + render(); + waitFor(() => { + expect(screen.findByText('SUPER TEST COMPONENT')).toBeInTheDocument(); + }); + }); +}); 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 new file mode 100644 index 0000000000000..80b43cde5dd00 --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -0,0 +1,42 @@ +/* + * 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 { SerializedPanelState } from '@kbn/presentation-containers'; +import { PresentationPanel } from '@kbn/presentation-panel-plugin/public'; +import React, { useMemo } from 'react'; +import { getReactEmbeddableFactory } from './react_embeddable_registry'; + +/** + * 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, + type, + state, +}: { + uuid?: string; + type: string; + state: SerializedPanelState; +}) => { + const componentPromise = useMemo( + () => + (async () => { + const factory = getReactEmbeddableFactory(type); + return await factory.getComponent(factory.deserializeState(state), uuid); + })(), + /** + * Disabling exhaustive deps because we do not want to re-fetch the component + * from the embeddable registry unless the type changes. + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + [type] + ); + return ; +}; diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_titles.test.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_titles.test.ts new file mode 100644 index 0000000000000..be240cafb7ea6 --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_titles.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { + initializeReactEmbeddableTitles, + SerializedReactEmbeddableTitles, +} from './react_embeddable_titles'; + +describe('react embeddable titles', () => { + const rawState: SerializedReactEmbeddableTitles = { + title: 'very cool title', + description: 'less cool description', + hidePanelTitles: false, + }; + + it('should initialize publishing subjects with the provided rawState', () => { + const { titlesApi } = initializeReactEmbeddableTitles(rawState); + expect(titlesApi.panelTitle.value).toBe(rawState.title); + expect(titlesApi.panelDescription.value).toBe(rawState.description); + expect(titlesApi.hidePanelTitle.value).toBe(rawState.hidePanelTitles); + }); + + it('should update publishing subject values when set functions are called', () => { + const { titlesApi } = initializeReactEmbeddableTitles(rawState); + + titlesApi.setPanelTitle('even cooler title'); + titlesApi.setPanelDescription('super uncool description'); + titlesApi.setHidePanelTitle(true); + + expect(titlesApi.panelTitle.value).toEqual('even cooler title'); + expect(titlesApi.panelDescription.value).toEqual('super uncool description'); + expect(titlesApi.hidePanelTitle.value).toBe(true); + }); + + it('should correctly serialize current state', () => { + const { serializeTitles, titlesApi } = initializeReactEmbeddableTitles(rawState); + titlesApi.setPanelTitle('UH OH, A TITLE'); + + const serializedTitles = serializeTitles(); + expect(serializedTitles).toMatchInlineSnapshot(` + Object { + "description": "less cool description", + "hidePanelTitles": false, + "title": "UH OH, A TITLE", + } + `); + }); + + it('should return the correct set of comparators', () => { + const { titleComparators } = initializeReactEmbeddableTitles(rawState); + + expect(titleComparators.title).toBeDefined(); + expect(titleComparators.description).toBeDefined(); + expect(titleComparators.hidePanelTitles).toBeDefined(); + }); + + it('should correctly compare hidePanelTitles with custom comparator', () => { + const { titleComparators } = initializeReactEmbeddableTitles(rawState); + + expect(titleComparators.hidePanelTitles![2]!(true, false)).toBe(false); + expect(titleComparators.hidePanelTitles![2]!(undefined, false)).toBe(true); + expect(titleComparators.hidePanelTitles![2]!(true, undefined)).toBe(false); + }); +}); diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_titles.ts b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_titles.ts new file mode 100644 index 0000000000000..3b65b962694e5 --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_titles.ts @@ -0,0 +1,70 @@ +/* + * 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 { + PublishesWritablePanelDescription, + PublishesWritablePanelTitle, +} from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; +import { EmbeddableStateComparators } from './types'; + +export interface SerializedReactEmbeddableTitles { + title?: string; + description?: string; + hidePanelTitles?: boolean; +} + +export type ReactEmbeddableTitlesApi = PublishesWritablePanelTitle & + PublishesWritablePanelDescription; + +export const initializeReactEmbeddableTitles = ( + rawState: SerializedReactEmbeddableTitles +): { + titlesApi: ReactEmbeddableTitlesApi; + titleComparators: EmbeddableStateComparators; + serializeTitles: () => SerializedReactEmbeddableTitles; +} => { + const panelTitle = new BehaviorSubject(rawState.title); + const panelDescription = new BehaviorSubject(rawState.description); + const hidePanelTitle = new BehaviorSubject(rawState.hidePanelTitles); + + const setPanelTitle = (value: string | undefined) => panelTitle.next(value); + const setHidePanelTitle = (value: boolean | undefined) => hidePanelTitle.next(value); + const setPanelDescription = (value: string | undefined) => panelDescription.next(value); + + const titleComparators: EmbeddableStateComparators = { + title: [panelTitle, setPanelTitle], + description: [panelDescription, setPanelDescription], + hidePanelTitles: [hidePanelTitle, setHidePanelTitle, (a, b) => Boolean(a) === Boolean(b)], + }; + + const titlesApi = { + panelTitle, + hidePanelTitle, + setPanelTitle, + setHidePanelTitle, + panelDescription, + setPanelDescription, + }; + + return { + serializeTitles: () => serializeReactEmbeddableTitles(titlesApi), + titleComparators, + titlesApi, + }; +}; + +export const serializeReactEmbeddableTitles = ( + titlesApi: ReactEmbeddableTitlesApi +): SerializedReactEmbeddableTitles => { + return { + title: titlesApi.panelTitle.value, + hidePanelTitles: titlesApi.hidePanelTitle.value, + description: titlesApi.panelDescription.value, + }; +}; 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 new file mode 100644 index 0000000000000..cdc123f0dc004 --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.test.tsx @@ -0,0 +1,205 @@ +/* + * 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 { + PresentationContainer, + PublishesLastSavedState, + 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 { BehaviorSubject, Subject } from 'rxjs'; +import { ReactEmbeddableParentContext } from './react_embeddable_api'; +import { useReactEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes'; +import { EmbeddableStateComparators, ReactEmbeddableFactory } from './types'; + +interface SuperTestStateType { + name: string; + age: number; + tagline: string; +} + +describe('react embeddable unsaved changes', () => { + let initialState: SuperTestStateType; + let lastSavedState: SuperTestStateType; + let comparators: EmbeddableStateComparators; + let deserializeState: (state: SerializedPanelState) => SuperTestStateType; + let parentApi: (PresentationContainer & PublishesLastSavedState) | null; + + beforeEach(() => { + initialState = { + name: 'Sir Testsalot', + age: 42, + tagline: 'A glutton for testing!', + }; + lastSavedState = { + name: 'Sir Testsalot', + age: 42, + tagline: 'A glutton for testing!', + }; + }); + + const initializeDefaultComparators = () => { + const nameSubject = new BehaviorSubject(initialState.name); + const ageSubject = new BehaviorSubject(initialState.age); + const taglineSubject = new BehaviorSubject(initialState.tagline); + const defaultComparators: EmbeddableStateComparators = { + name: [nameSubject, jest.fn((nextName) => nameSubject.next(nextName))], + age: [ageSubject, jest.fn((nextAge) => ageSubject.next(nextAge))], + tagline: [taglineSubject, jest.fn((nextTagline) => taglineSubject.next(nextTagline))], + }; + return defaultComparators; + }; + + const renderTestComponent = async ( + customComparators?: EmbeddableStateComparators + ) => { + comparators = customComparators ?? initializeDefaultComparators(); + deserializeState = jest.fn((state) => state.rawState as SuperTestStateType); + + parentApi = { + ...getMockPresentationContainer(), + 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; + }; + + 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(); + expect(unsavedChangesApi).toBeDefined(); + expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); + }); + + it('runs factory deserialize function on last saved state', async () => { + await renderTestComponent(); + 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(); + 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(); + 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(); + expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); + + comparators.tagline[1]('Testing is my speciality!'); + await waitFor(() => { + expect(unsavedChangesApi.unsavedChanges.value).toEqual({ + tagline: 'Testing is my speciality!', + }); + }); + }); + + it('should detect unsaved changes when last saved state changes during the lifetime of the component', async () => { + const unsavedChangesApi = await renderTestComponent(); + expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); + + lastSavedState.tagline = 'Some other tagline'; + parentApi?.lastSavedState.next(); + await waitFor(() => { + expect(unsavedChangesApi.unsavedChanges.value).toEqual({ + // we expect `A glutton for testing!` here because that is the current state of the component. + tagline: 'A glutton for testing!', + }); + }); + }); + + it('should reset unsaved changes, calling given setters with last saved values. This should remove all unsaved state', async () => { + const unsavedChangesApi = await renderTestComponent(); + expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); + + comparators.tagline[1]('Testing is my speciality!'); + await waitFor(() => { + expect(unsavedChangesApi.unsavedChanges.value).toEqual({ + tagline: 'Testing is my speciality!', + }); + }); + + unsavedChangesApi.resetUnsavedChanges(); + expect(comparators.tagline[1]).toHaveBeenCalledWith('A glutton for testing!'); + await waitFor(() => { + expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined); + }); + }); + + it('uses a custom comparator when supplied', async () => { + lastSavedState.age = 20; + initialState.age = 50; + const ageSubject = new BehaviorSubject(initialState.age); + const customComparators: EmbeddableStateComparators = { + ...initializeDefaultComparators(), + age: [ + ageSubject, + jest.fn((nextAge) => ageSubject.next(nextAge)), + (lastAge, currentAge) => lastAge?.toString().length === currentAge?.toString().length, + ], + }; + + const unsavedChangesApi = await renderTestComponent(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); + + comparators.age[1](101); + + await waitFor(() => { + // here we expect there to be unsaved changes, because now the latest state has three digits. + expect(unsavedChangesApi.unsavedChanges.value).toEqual({ + age: 101, + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..1e760b55f748a --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_unsaved_changes.ts @@ -0,0 +1,133 @@ +/* + * 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 { getLastSavedStateSubjectForChild } 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'; + +const defaultComparator = (a: T, b: T) => a === b; + +const getInitialValuesFromComparators = ( + comparators: EmbeddableStateComparators, + comparatorKeys: Array +) => { + const initialValues: Partial = {}; + for (const key of comparatorKeys) { + const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject + initialValues[key] = comparatorSubject?.value; + } + return initialValues; +}; + +const runComparators = ( + comparators: EmbeddableStateComparators, + comparatorKeys: Array, + lastSavedState: StateType | undefined, + latestState: Partial +) => { + if (!lastSavedState) { + // if the parent API provides last saved state, but it's empty for this panel, all of our latest state is unsaved. + return latestState; + } + const latestChanges: Partial = {}; + for (const key of comparatorKeys) { + const customComparator = comparators[key]?.[2]; // 2nd element of the tuple is the custom comparator + const comparator = customComparator ?? defaultComparator; + if (!comparator(lastSavedState?.[key], latestState[key], lastSavedState, latestState)) { + latestChanges[key] = latestState[key]; + } + } + return Object.keys(latestChanges).length > 0 ? latestChanges : undefined; +}; + +export const useReactEmbeddableUnsavedChanges = ( + uuid: string, + factory: ReactEmbeddableFactory, + comparators: EmbeddableStateComparators +) => { + 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 + }, []); + + /** + * 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 + [] + ); + + 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 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]); + } + + // disable exhaustive deps because the comparators must be static + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { unsavedChanges, resetUnsavedChanges }; +}; diff --git a/src/plugins/embeddable/public/react_embeddable_system/types.ts b/src/plugins/embeddable/public/react_embeddable_system/types.ts new file mode 100644 index 0000000000000..1a8c78476a963 --- /dev/null +++ b/src/plugins/embeddable/public/react_embeddable_system/types.ts @@ -0,0 +1,60 @@ +/* + * 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 { 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'; + +export type ReactEmbeddableRegistration< + ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi +> = (ref: React.ForwardedRef) => ReactElement | null; + +/** + * The default embeddable API that all Embeddables must implement. + * + * Before adding anything to this interface, please be certain that it belongs in *every* embeddable. + */ +export type DefaultEmbeddableApi = DefaultPresentationPanelApi & + PublishesUnsavedChanges & { + serializeState: () => Promise; + }; + +export type ReactEmbeddable = + React.ForwardRefExoticComponent>; + +export interface ReactEmbeddableFactory< + StateType extends unknown = unknown, + APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi +> { + getComponent: (initialState: StateType, maybeId?: string) => Promise>; + deserializeState: (state: SerializedPanelState) => StateType; + latestVersion?: string; +} + +export type StateTypeFromFactory> = + F extends ReactEmbeddableFactory ? S : never; + +/** + * State comparators + */ +export type EmbeddableComparatorFunction = ( + last: StateType[KeyType] | undefined, + current: StateType[KeyType] | undefined, + lastState?: Partial, + currentState?: Partial +) => boolean; + +export type EmbeddableComparatorDefinition = [ + PublishingSubject, + (value: StateType[KeyType]) => void, + EmbeddableComparatorFunction? +]; + +export type EmbeddableStateComparators = { + [KeyType in keyof StateType]: EmbeddableComparatorDefinition; +}; diff --git a/src/plugins/embeddable/public/registry/create_embeddable_component.ts b/src/plugins/embeddable/public/registry/create_embeddable_component.ts deleted file mode 100644 index 59cb3b174f85a..0000000000000 --- a/src/plugins/embeddable/public/registry/create_embeddable_component.ts +++ /dev/null @@ -1,17 +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 React from 'react'; -import { EmbeddableComponent } from './types'; - -export const CreateEmbeddableComponent: ( - component: ( - ref: React.ForwardedRef - ) => React.ReactElement> | null -) => EmbeddableComponent = (component) => - React.forwardRef((_, apiRef) => component(apiRef)); diff --git a/src/plugins/embeddable/public/registry/types.ts b/src/plugins/embeddable/public/registry/types.ts deleted file mode 100644 index 0846b3fb746de..0000000000000 --- a/src/plugins/embeddable/public/registry/types.ts +++ /dev/null @@ -1,18 +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. - */ - -export type EmbeddableComponent = - React.ForwardRefExoticComponent>; - -export interface EmbeddableComponentFactory< - StateType extends unknown = unknown, - APIType extends unknown = unknown -> { - getComponent: (initialState: StateType) => Promise>; - deserializeState: (state: unknown) => StateType; -} diff --git a/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/customize_panel_action.tsx b/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/customize_panel_action.tsx index e31ff54f21eb6..017203f961a08 100644 --- a/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/customize_panel_action.tsx +++ b/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/customize_panel_action.tsx @@ -11,6 +11,7 @@ import { apiCanAccessViewMode, apiPublishesDataViews, apiPublishesLocalUnifiedSearch, + apiPublishesPanelTitle, CanAccessViewMode, EmbeddableApiContext, getInheritedViewMode, @@ -26,9 +27,9 @@ import { openCustomizePanelFlyout } from './open_customize_panel'; export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL'; export type CustomizePanelActionApi = CanAccessViewMode & - PublishesDataViews & Partial< - PublishesWritableLocalUnifiedSearch & + PublishesDataViews & + PublishesWritableLocalUnifiedSearch & PublishesWritablePanelDescription & PublishesWritablePanelTitle & HasParentApi @@ -37,7 +38,7 @@ export type CustomizePanelActionApi = CanAccessViewMode & export const isApiCompatibleWithCustomizePanelAction = ( api: unknown | null ): api is CustomizePanelActionApi => - Boolean(apiCanAccessViewMode(api) && apiPublishesDataViews(api)); + apiCanAccessViewMode(api) && (apiPublishesDataViews(api) || apiPublishesPanelTitle(api)); export class CustomizePanelAction implements Action { public type = ACTION_CUSTOMIZE_PANEL; diff --git a/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/filters_details.tsx b/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/filters_details.tsx index 6c6e0cadf17e1..8f29e4fd4aa5d 100644 --- a/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/filters_details.tsx +++ b/src/plugins/presentation_panel/public/panel_actions/customize_panel_action/filters_details.tsx @@ -37,7 +37,7 @@ interface FiltersDetailsProps { export function FiltersDetails({ editMode, api }: FiltersDetailsProps) { const [queryString, setQueryString] = useState(''); const [queryLanguage, setQueryLanguage] = useState<'sql' | 'esql' | undefined>(); - const dataViews = api.dataViews.value ?? []; + const dataViews = api.dataViews?.value ?? []; const filters = useMemo(() => api.localFilters?.value ?? [], [api]); diff --git a/src/plugins/presentation_panel/public/panel_actions/remove_panel_action/remove_panel_action.test.tsx b/src/plugins/presentation_panel/public/panel_actions/remove_panel_action/remove_panel_action.test.tsx index 4312d0993eaf3..d6163faa87b72 100644 --- a/src/plugins/presentation_panel/public/panel_actions/remove_panel_action/remove_panel_action.test.tsx +++ b/src/plugins/presentation_panel/public/panel_actions/remove_panel_action/remove_panel_action.test.tsx @@ -9,6 +9,7 @@ import { ViewMode } from '@kbn/presentation-publishing'; import { BehaviorSubject } from 'rxjs'; import { RemovePanelAction, RemovePanelActionApi } from './remove_panel_action'; +import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; describe('Remove panel action', () => { let action: RemovePanelAction; @@ -20,11 +21,7 @@ describe('Remove panel action', () => { embeddable: { uuid: 'superId', viewMode: new BehaviorSubject('edit'), - parentApi: { - removePanel: jest.fn(), - canRemovePanels: jest.fn().mockReturnValue(true), - replacePanel: jest.fn(), - }, + parentApi: getMockPresentationContainer(), }, }; }); diff --git a/src/plugins/presentation_panel/public/panel_component/panel_header/presentation_panel_context_menu.tsx b/src/plugins/presentation_panel/public/panel_component/panel_header/presentation_panel_context_menu.tsx index 0a75ab48506e2..ff9f7766baadf 100644 --- a/src/plugins/presentation_panel/public/panel_component/panel_header/presentation_panel_context_menu.tsx +++ b/src/plugins/presentation_panel/public/panel_component/panel_header/presentation_panel_context_menu.tsx @@ -43,8 +43,8 @@ export const PresentationPanelContextMenu = ({ const [isContextMenuOpen, setIsContextMenuOpen] = useState(undefined); const [contextMenuPanels, setContextMenuPanels] = useState([]); - const { title, parentViewMode } = useBatchedPublishingSubjects({ - title: api.panelTitle, + const [title, parentViewMode] = useBatchedPublishingSubjects( + api.panelTitle, /** * View mode changes often have the biggest influence over which actions will be compatible, @@ -52,8 +52,8 @@ export const PresentationPanelContextMenu = ({ * actions should eventually all be Frequent Compatibility Change Actions which can track their * own dependencies. */ - parentViewMode: getViewModeSubject(api), - }); + getViewModeSubject(api) + ); useEffect(() => { /** 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 107756e7b7951..116dca0169f9b 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 @@ -8,6 +8,7 @@ import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { DataView } from '@kbn/data-views-plugin/common'; +import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks'; import { PublishesDataViews, PublishesViewMode, ViewMode } from '@kbn/presentation-publishing'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -221,9 +222,8 @@ describe('Presentation panel', () => { panelTitle: new BehaviorSubject('SUPER TITLE'), viewMode: new BehaviorSubject('view'), parentApi: { - removePanel: jest.fn(), - replacePanel: jest.fn(), viewMode: new BehaviorSubject('view'), + ...getMockPresentationContainer(), }, }; await renderPresentationPanel({ api }); 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 8af8e22cbeede..5259aedac0cd5 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 @@ -47,27 +47,25 @@ export const PresentationPanelInternal = < if (apiHasParentApi(api) && apiPublishesViewMode(api.parentApi)) return api.parentApi.viewMode; })(); - const { - rawViewMode, + const [ + dataLoading, blockingError, panelTitle, - dataLoading, hidePanelTitle, panelDescription, defaultPanelTitle, + rawViewMode, parentHidePanelTitle, - } = useBatchedPublishingSubjects({ - dataLoading: api?.dataLoading, - blockingError: api?.blockingError, - - panelTitle: api?.panelTitle, - hidePanelTitle: api?.hidePanelTitle, - panelDescription: api?.panelDescription, - defaultPanelTitle: api?.defaultPanelTitle, - - rawViewMode: viewModeSubject, - parentHidePanelTitle: api?.parentApi?.hidePanelTitle, - }); + ] = useBatchedPublishingSubjects( + api?.dataLoading, + api?.blockingError, + api?.panelTitle, + api?.hidePanelTitle, + api?.panelDescription, + api?.defaultPanelTitle, + viewModeSubject, + api?.parentApi?.hidePanelTitle + ); const viewMode = rawViewMode ?? 'view'; const [initialLoadComplete, setInitialLoadComplete] = useState(!dataLoading); diff --git a/src/plugins/presentation_panel/public/panel_component/types.ts b/src/plugins/presentation_panel/public/panel_component/types.ts index 25ef639929235..654626e5959d6 100644 --- a/src/plugins/presentation_panel/public/panel_component/types.ts +++ b/src/plugins/presentation_panel/public/panel_component/types.ts @@ -56,6 +56,10 @@ export interface PresentationPanelInternalProps< index?: number; } +/** + * 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 & diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index 5307d2aaa77fe..5bd16597c0b54 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -68,6 +68,7 @@ export class FlyoutCreateDrilldownAction implements Action { } public async isCompatible(context: EmbeddableContext) { + if (!context.embeddable?.getInput) return false; const isEditMode = context.embeddable.getInput().viewMode === 'edit'; return isEditMode && this.isEmbeddableCompatible(context); } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index a97cce9ecf75c..aed94cd1c4adc 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -46,6 +46,7 @@ export class FlyoutEditDrilldownAction implements Action { public readonly MenuItem = MenuItem as any; public async isCompatible({ embeddable }: EmbeddableContext) { + if (!embeddable?.getInput) return false; if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; if (!isEnhancedEmbeddable(embeddable)) return false; return embeddable.enhancements.dynamicActions.state.get().events.length > 0; diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts index 9b1c013f0946e..8c1de7cf56b34 100644 --- a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts +++ b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts @@ -46,7 +46,7 @@ export class PanelNotificationsAction implements ActionDefinition { + if (!embeddable?.getInput) return false; if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; return this.getEventCount(embeddable) > 0; }; diff --git a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts index aa59b85850e3f..9e738dea11ed7 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/edit_action_helpers.ts @@ -24,6 +24,7 @@ interface Context { } export async function isEditActionCompatible(embeddable: IEmbeddable) { + if (!embeddable?.getInput) return false; // display the action only if dashboard is on editable mode const inDashboardEditMode = embeddable.getInput().viewMode === 'edit'; return Boolean(isLensEmbeddable(embeddable) && embeddable.getIsEditable() && inDashboardEditMode);