diff --git a/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx b/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx index 1ce98f9b68824..b3b2dc48062ee 100644 --- a/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx +++ b/examples/embeddable_examples/public/react_embeddables/eui_markdown/create_eui_markdown_action.tsx @@ -36,7 +36,7 @@ export const registerCreateEuiMarkdownAction = (uiActions: UiActionsStart) => { embeddable.addNewPanel( { panelType: EUI_MARKDOWN_ID, - initialState: { content: '# hello world!' }, + serializedState: { rawState: { content: '# hello world!' } }, }, true ); diff --git a/src/platform/packages/shared/presentation/presentation_containers/index.ts b/src/platform/packages/shared/presentation/presentation_containers/index.ts index 4ddbe46329f4d..9fc07b911b0ec 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/index.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/index.ts @@ -9,17 +9,12 @@ export { apiCanAddNewPanel, type CanAddNewPanel } from './interfaces/can_add_new_panel'; export { - apiHasRuntimeChildState, apiHasSerializedChildState, - type HasRuntimeChildState, + apiHasLastSavedChildState, + type HasLastSavedChildState, type HasSerializedChildState, } from './interfaces/child_state'; export { childrenUnsavedChanges$ } from './interfaces/unsaved_changes/children_unsaved_changes'; -export { initializeUnsavedChanges } from './interfaces/unsaved_changes/initialize_unsaved_changes'; -export { - apiHasSaveNotification, - type HasSaveNotification, -} from './interfaces/has_save_notification'; export { apiCanDuplicatePanels, apiCanExpandPanels, diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts index 4245b48904314..d91895ec5f65e 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/child_state.ts @@ -8,6 +8,7 @@ */ import { SerializedPanelState } from '@kbn/presentation-publishing'; +import { Subject } from 'rxjs'; export interface HasSerializedChildState { getSerializedStateForChild: ( @@ -15,23 +16,22 @@ export interface HasSerializedChildState SerializedPanelState | undefined; } -/** - * @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state. - */ -export interface HasRuntimeChildState { - getRuntimeStateForChild: (childId: string) => Partial | undefined; -} - export const apiHasSerializedChildState = ( api: unknown ): api is HasSerializedChildState => { return Boolean(api && (api as HasSerializedChildState).getSerializedStateForChild); }; -/** - * @deprecated Use `HasSerializedChildState` instead. All interactions between the container and the child should use the serialized state. - */ -export const apiHasRuntimeChildState = ( + +export const apiHasLastSavedChildState = ( api: unknown -): api is HasRuntimeChildState => { - return Boolean(api && (api as HasRuntimeChildState).getRuntimeStateForChild); +): api is HasLastSavedChildState => { + return ( + Boolean(api && (api as HasLastSavedChildState).getLastSavedStateForChild) && + Boolean(api && (api as HasLastSavedChildState).saveNotification$) + ); }; + +export interface HasLastSavedChildState { + getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined; + saveNotification$: Subject; // a notification that state has been saved +} diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/has_save_notification.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/has_save_notification.ts deleted file mode 100644 index 21dc7f3aa9798..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/has_save_notification.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { Subject } from 'rxjs'; - -export interface HasSaveNotification { - saveNotification$: Subject; // a notification that state has been saved -} - -export const apiHasSaveNotification = (api: unknown): api is HasSaveNotification => { - return Boolean(api && (api as HasSaveNotification).saveNotification$); -}; diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts index 7b76260aad188..c657fc41ee98f 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/presentation_container.ts @@ -16,21 +16,13 @@ import { import { BehaviorSubject, combineLatest, isObservable, map, Observable, of, switchMap } from 'rxjs'; import { apiCanAddNewPanel, CanAddNewPanel } from './can_add_new_panel'; -export interface PanelPackage< - SerializedStateType extends object = object, - RuntimeStateType extends object = object -> { +export interface PanelPackage { panelType: string; /** * The serialized state of this panel. */ - serializedState?: SerializedPanelState; - - /** - * The runtime state of this panel. @deprecated Use `serializedState` instead. - */ - initialState?: RuntimeStateType; + serializedState: SerializedPanelState; } export interface PresentationContainer extends CanAddNewPanel { diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts index 2e1fdd53c622c..c0a498a06bd66 100644 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts +++ b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/children_unsaved_changes.ts @@ -9,15 +9,22 @@ import { combineLatest, debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs'; import deepEqual from 'fast-deep-equal'; -import { apiPublishesUnsavedChanges, PublishesUnsavedChanges } from '@kbn/presentation-publishing'; -import { PresentationContainer } from '../presentation_container'; +import { + apiHasUniqueId, + apiPublishesUnsavedChanges, + HasUniqueId, + PublishesUnsavedChanges, + PublishingSubject, +} from '@kbn/presentation-publishing'; export const DEBOUNCE_TIME = 100; /** * Create an observable stream of unsaved changes from all react embeddable children */ -export function childrenUnsavedChanges$(children$: PresentationContainer['children$']) { +export function childrenUnsavedChanges$( + children$: PublishingSubject<{ [key: string]: Api }> +) { return children$.pipe( map((children) => Object.keys(children)), distinctUntilChanged(deepEqual), @@ -25,27 +32,20 @@ export function childrenUnsavedChanges$(children$: PresentationContainer['childr // children may change, so make sure we subscribe/unsubscribe with switchMap switchMap((newChildIds: string[]) => { if (newChildIds.length === 0) return of([]); - const childrenThatPublishUnsavedChanges = Object.entries(children$.value).filter( - ([childId, child]) => apiPublishesUnsavedChanges(child) - ) as Array<[string, PublishesUnsavedChanges]>; + const childrenThatPublishUnsavedChanges = Object.values(children$.value).filter( + (child) => apiPublishesUnsavedChanges(child) && apiHasUniqueId(child) + ) as Array; return childrenThatPublishUnsavedChanges.length === 0 ? of([]) : combineLatest( - childrenThatPublishUnsavedChanges.map(([childId, child]) => - child.unsavedChanges$.pipe(map((unsavedChanges) => ({ childId, unsavedChanges }))) + childrenThatPublishUnsavedChanges.map((child) => + child.hasUnsavedChanges$.pipe( + map((hasUnsavedChanges) => ({ uuid: child.uuid, hasUnsavedChanges })) + ) ) ); }), - debounceTime(DEBOUNCE_TIME), - map((unsavedChildStates) => { - const unsavedChildrenState: { [key: string]: object } = {}; - unsavedChildStates.forEach(({ childId, unsavedChanges }) => { - if (unsavedChanges) { - unsavedChildrenState[childId] = unsavedChanges; - } - }); - return Object.keys(unsavedChildrenState).length ? unsavedChildrenState : undefined; - }) + debounceTime(DEBOUNCE_TIME) ); } diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts deleted file mode 100644 index 84ec84ef601c2..0000000000000 --- a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.ts +++ /dev/null @@ -1,118 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - BehaviorSubject, - combineLatest, - combineLatestWith, - debounceTime, - map, - Subscription, -} from 'rxjs'; -import { - getInitialValuesFromComparators, - PublishesUnsavedChanges, - PublishingSubject, - runComparators, - StateComparators, - HasSnapshottableState, -} from '@kbn/presentation-publishing'; -import { apiHasSaveNotification } from '../has_save_notification'; - -export const COMPARATOR_SUBJECTS_DEBOUNCE = 100; - -export const initializeUnsavedChanges = ( - initialLastSavedState: RuntimeState, - parentApi: unknown, - comparators: StateComparators -) => { - const subscriptions: Subscription[] = []; - const lastSavedState$ = new BehaviorSubject(initialLastSavedState); - - const snapshotRuntimeState = () => { - const comparatorKeys = Object.keys(comparators) as Array; - const snapshot = {} as RuntimeState; - comparatorKeys.forEach((key) => { - const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject - snapshot[key] = comparatorSubject.value as RuntimeState[typeof key]; - }); - return snapshot; - }; - - if (apiHasSaveNotification(parentApi)) { - subscriptions.push( - // any time the parent saves, the current state becomes the last saved state... - parentApi.saveNotification$.subscribe(() => { - lastSavedState$.next(snapshotRuntimeState()); - }) - ); - } - - const comparatorSubjects: Array> = []; - const comparatorKeys: Array = []; // index maps comparator subject to comparator key - for (const key of Object.keys(comparators) as Array) { - const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject - comparatorSubjects.push(comparatorSubject as PublishingSubject); - comparatorKeys.push(key); - } - - const unsavedChanges$ = new BehaviorSubject | undefined>( - runComparators( - comparators, - comparatorKeys, - lastSavedState$.getValue() as RuntimeState, - getInitialValuesFromComparators(comparators, comparatorKeys) - ) - ); - - subscriptions.push( - combineLatest(comparatorSubjects) - .pipe( - debounceTime(COMPARATOR_SUBJECTS_DEBOUNCE), - map((latestStates) => - comparatorKeys.reduce((acc, key, index) => { - acc[key] = latestStates[index] as RuntimeState[typeof key]; - return acc; - }, {} as Partial) - ), - combineLatestWith(lastSavedState$) - ) - .subscribe(([latestState, lastSavedState]) => { - unsavedChanges$.next( - runComparators(comparators, comparatorKeys, lastSavedState, latestState) - ); - }) - ); - - return { - api: { - unsavedChanges$, - resetUnsavedChanges: () => { - const lastSaved = lastSavedState$.getValue(); - - // Do not reset to undefined or empty last saved state - // Temporary fix for https://github.com/elastic/kibana/issues/201627 - // TODO remove when architecture fix resolves issue. - if (comparatorKeys.length && (!lastSaved || Object.keys(lastSaved).length === 0)) { - return false; - } - - for (const key of comparatorKeys) { - const setter = comparators[key][1]; // setter function is the 1st element of the tuple - setter(lastSaved?.[key] as RuntimeState[typeof key]); - } - return true; - }, - snapshotRuntimeState, - } as PublishesUnsavedChanges & HasSnapshottableState, - cleanup: () => { - subscriptions.forEach((subscription) => subscription.unsubscribe()); - }, - }; -}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/comparators/diff_comparators.ts b/src/platform/packages/shared/presentation/presentation_publishing/comparators/diff_comparators.ts new file mode 100644 index 0000000000000..339412be44966 --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_publishing/comparators/diff_comparators.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Observable, combineLatest, combineLatestWith, debounceTime, map } from 'rxjs'; +import { StateComparators } from './types'; +import { PublishingSubject } from '../publishing_subject'; +import { runComparators } from './state_comparators'; + +export const COMPARATOR_SUBJECTS_DEBOUNCE = 100; + +export const diffComparators$ = ( + lastSavedState$: PublishingSubject, + comparators: StateComparators +): Observable | undefined> => { + const comparatorKeys = Object.keys(comparators) as Array; + const comparatorSubjects = comparatorKeys.map((key) => comparators[key][0]); // 0th element of tuple is the subject + + return combineLatest(comparatorSubjects).pipe( + debounceTime(COMPARATOR_SUBJECTS_DEBOUNCE), + map((latestStates) => + comparatorKeys.reduce((acc, key, index) => { + acc[key] = latestStates[index] as RuntimeState[typeof key]; + return acc; + }, {} as Partial) + ), + combineLatestWith(lastSavedState$), + map(([latestState, lastSavedState]) => + runComparators(comparators, comparatorKeys, lastSavedState, latestState) + ) + ); +}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/comparators/index.ts b/src/platform/packages/shared/presentation/presentation_publishing/comparators/index.ts index 1b58fdad9ba2f..2efd39610ab2e 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/comparators/index.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/comparators/index.ts @@ -8,5 +8,6 @@ */ export type { ComparatorFunction, ComparatorDefinition, StateComparators } from './types'; -export { getInitialValuesFromComparators, runComparators } from './state_comparators'; +export { diffComparators$ } from './diff_comparators'; export { getUnchangingComparator } from './fallback_comparator'; +export { latestComparatorValues$, runComparators } from './state_comparators'; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/comparators/state_comparators.ts b/src/platform/packages/shared/presentation/presentation_publishing/comparators/state_comparators.ts index a2185185a3a9e..43246d1a456f7 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/comparators/state_comparators.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/comparators/state_comparators.ts @@ -7,7 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { combineLatest } from 'rxjs'; import { StateComparators } from './types'; +import { PublishingSubject } from '../publishing_subject'; const defaultComparator = (a: T, b: T) => a === b; @@ -23,6 +25,16 @@ export const getInitialValuesFromComparators = ( + comparators: StateComparators +) => { + const comparatorSubjects: Array> = []; + for (const key of Object.keys(comparators) as Array) { + comparatorSubjects.push(comparators[key][0]); // 0th element of tuple is the subject + } + return combineLatest(comparatorSubjects); +}; + export const runComparators = ( comparators: StateComparators, comparatorKeys: Array, diff --git a/src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts b/src/platform/packages/shared/presentation/presentation_publishing/has_unsaved_changes/initialize_has_unsaved_changes.test.ts similarity index 100% rename from src/platform/packages/shared/presentation/presentation_containers/interfaces/unsaved_changes/initialize_unsaved_changes.test.ts rename to src/platform/packages/shared/presentation/presentation_publishing/has_unsaved_changes/initialize_has_unsaved_changes.test.ts diff --git a/src/platform/packages/shared/presentation/presentation_publishing/has_unsaved_changes/initialize_has_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_publishing/has_unsaved_changes/initialize_has_unsaved_changes.ts new file mode 100644 index 0000000000000..6cd42306ec43e --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_publishing/has_unsaved_changes/initialize_has_unsaved_changes.ts @@ -0,0 +1,140 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + apiHasLastSavedChildState, + type HasLastSavedChildState, +} from '@kbn/presentation-containers'; +import { MaybePromise } from '@kbn/utility-types'; +import deepEqual from 'fast-deep-equal'; +import { BehaviorSubject, combineLatestWith, debounceTime, Subscription } from 'rxjs'; +import { + apiHasSerializableState, + apiHasSerializedStateComparator, + HasSnapshottableState, + latestComparatorValues$, + PublishesUnsavedChanges, + SerializedPanelState, + StateComparators, +} from '..'; + +export const COMPARATOR_SUBJECTS_DEBOUNCE = 100; + +const serializedStateComparator = ( + api: unknown, + a?: SerializedState, + b?: SerializedState +) => { + if (Boolean(a) !== Boolean(b)) return false; + + // clone via serialization to deeply remove undefined values + const stateA = JSON.parse(JSON.stringify(a ?? {})) as SerializedState; + const stateB = JSON.parse(JSON.stringify(b ?? {})) as SerializedState; + + const comparatorFunction = apiHasSerializedStateComparator(api) + ? api.isSerializedStateEqual + : deepEqual; + return comparatorFunction(stateA, stateB); +}; + +export const initializeHasUnsavedChanges = < + SerializedState extends object = object, + RuntimeState extends object = SerializedState +>( + uuid: string, + comparators: StateComparators, + api: unknown, + parentApi: unknown, + deserializeState: (state: SerializedPanelState) => MaybePromise +) => { + const snapshotRuntimeState = () => { + const comparatorKeys = Object.keys(comparators) as Array; + const snapshot = {} as RuntimeState; + comparatorKeys.forEach((key) => { + const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject + snapshot[key] = comparatorSubject.value as RuntimeState[typeof key]; + }); + return snapshot; + }; + + if ( + !parentApi || + !apiHasLastSavedChildState(parentApi) || + !apiHasSerializableState(api) + ) { + return { + api: { + hasUnsavedChanges$: new BehaviorSubject(false), + resetUnsavedChanges: () => {}, + snapshotRuntimeState, + } as PublishesUnsavedChanges & HasSnapshottableState, + cleanup: () => {}, + }; + } + const subscriptions: Subscription[] = []; + + /** + * Set up a subject that refreshes the last saved state from the parent any time + * the parent saves. + */ + const getLastSavedState = () => { + return (parentApi as HasLastSavedChildState).getLastSavedStateForChild(uuid); + }; + const lastSavedState$ = new BehaviorSubject | undefined>( + getLastSavedState() + ); + subscriptions.push( + // any time the parent saves, refresh the last saved state... + parentApi.saveNotification$.subscribe(() => { + lastSavedState$.next(getLastSavedState()); + }) + ); + + /** + * set up hasUnsavedChanges$. It should recalculate whether this API has unsaved changes any time the + * last saved state or the runtime state changes. + */ + const compareState = () => { + const isEqual = serializedStateComparator( + api, + lastSavedState$.getValue()?.rawState, + api.serializeState().rawState + ); + if (!isEqual) { + console.log((api as any).type, ' has unsaved changes'); + } + return isEqual; + }; + const hasUnsavedChanges$ = new BehaviorSubject(!compareState()); + subscriptions.push( + latestComparatorValues$(comparators) + .pipe(debounceTime(COMPARATOR_SUBJECTS_DEBOUNCE), combineLatestWith(lastSavedState$)) + .subscribe(() => hasUnsavedChanges$.next(!compareState())) + ); + + return { + api: { + hasUnsavedChanges$, + resetUnsavedChanges: () => { + const lastSavedState = lastSavedState$.getValue(); + if (!lastSavedState) return; // early return because if the parent does not have last saved state for this panel it will be removed. + (async () => { + const resetRuntimeState = await deserializeState(lastSavedState); + for (const key of Object.keys(comparators) as Array) { + comparators[key][1](resetRuntimeState[key]); + } + })(); + }, + snapshotRuntimeState, + } as PublishesUnsavedChanges & HasSnapshottableState, + cleanup: () => { + subscriptions.forEach((subscription) => subscription.unsubscribe()); + }, + }; +}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/index.ts b/src/platform/packages/shared/presentation/presentation_publishing/index.ts index 98296801619e4..79e1b473311ae 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/index.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/index.ts @@ -10,9 +10,10 @@ export { isEmbeddableApiContext, type EmbeddableApiContext } from './embeddable_api_context'; export { - getInitialValuesFromComparators, - getUnchangingComparator, runComparators, + diffComparators$, + latestComparatorValues$, + getUnchangingComparator, type ComparatorDefinition, type ComparatorFunction, type StateComparators, @@ -79,6 +80,10 @@ export { type HasSnapshottableState, type SerializedPanelState, } from './interfaces/has_serializable_state'; +export { + apiHasSerializedStateComparator, + type HasSerializedStateComparator, +} from './interfaces/has_serialized_state_comparator'; export { apiHasSupportedTriggers, type HasSupportedTriggers, @@ -147,8 +152,8 @@ export { export { initializeTitleManager, stateHasTitles, - type TitlesApi, type SerializedTitles, + type TitlesApi, } from './interfaces/titles/title_manager'; export { useBatchedOptionalPublishingSubjects, @@ -157,3 +162,4 @@ export { useStateFromPublishingSubject, type PublishingSubject, } from './publishing_subject'; +export { initializeHasUnsavedChanges } from './has_unsaved_changes/initialize_has_unsaved_changes'; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts index f6144d78ae19f..6578af22b9741 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serializable_state.ts @@ -18,16 +18,18 @@ export interface SerializedPanelState { rawState: RawStateType; } -export interface HasSerializableState { +export interface HasSerializableState { /** * Serializes all state into a format that can be saved into * some external store. The opposite of `deserialize` in the {@link ReactEmbeddableFactory} */ - serializeState: () => SerializedPanelState; + serializeState: () => SerializedPanelState; } -export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => { - return Boolean((api as HasSerializableState)?.serializeState); +export const apiHasSerializableState = ( + api: unknown | null +): api is HasSerializableState => { + return Boolean((api as HasSerializableState)?.serializeState); }; /** diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serialized_state_comparator.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serialized_state_comparator.ts new file mode 100644 index 0000000000000..01c04a8b21abe --- /dev/null +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/has_serialized_state_comparator.ts @@ -0,0 +1,21 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export interface HasSerializedStateComparator { + /** + * Compares two versions of the serialized state of this API and returns whether they are equal. + */ + isSerializedStateEqual: (a?: SerializedState, b?: SerializedState) => boolean; +} + +export const apiHasSerializedStateComparator = ( + api: unknown | null +): api is HasSerializedStateComparator => { + return Boolean((api as HasSerializedStateComparator)?.isSerializedStateEqual); +}; diff --git a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts index 919cc8c00ce80..1fd16c2e92e22 100644 --- a/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts +++ b/src/platform/packages/shared/presentation/presentation_publishing/interfaces/publishes_unsaved_changes.ts @@ -9,15 +9,15 @@ import { PublishingSubject } from '../publishing_subject'; -export interface PublishesUnsavedChanges { - unsavedChanges$: PublishingSubject | undefined>; - resetUnsavedChanges: () => boolean; +export interface PublishesUnsavedChanges { + hasUnsavedChanges$: PublishingSubject; + resetUnsavedChanges: (() => void) | (() => Promise); } export const apiPublishesUnsavedChanges = (api: unknown): api is PublishesUnsavedChanges => { return Boolean( api && - (api as PublishesUnsavedChanges).unsavedChanges$ && + (api as PublishesUnsavedChanges).hasUnsavedChanges$ && (api as PublishesUnsavedChanges).resetUnsavedChanges ); }; diff --git a/src/platform/plugins/shared/controls/common/constants.ts b/src/platform/plugins/shared/controls/common/constants.ts index d1f88567a3c6e..071eafc40046d 100644 --- a/src/platform/plugins/shared/controls/common/constants.ts +++ b/src/platform/plugins/shared/controls/common/constants.ts @@ -10,6 +10,8 @@ import { ControlGroupChainingSystem } from './control_group'; import { ControlLabelPosition, ControlWidth } from './types'; +export const CONTROL_GROUP_STATIC_ID = 'controlGroup'; + export const CONTROL_WIDTH_OPTIONS = { SMALL: 'small', MEDIUM: 'medium', LARGE: 'large' } as const; export const CONTROL_LABEL_POSITION_OPTIONS = { ONE_LINE: 'oneLine', TWO_LINE: 'twoLine' } as const; export const CONTROL_CHAINING_OPTIONS = { NONE: 'NONE', HIERARCHICAL: 'HIERARCHICAL' } as const; diff --git a/src/platform/plugins/shared/controls/common/index.ts b/src/platform/plugins/shared/controls/common/index.ts index 1fd51fd92be9b..51288ce5c1730 100644 --- a/src/platform/plugins/shared/controls/common/index.ts +++ b/src/platform/plugins/shared/controls/common/index.ts @@ -17,6 +17,7 @@ export type { } from './types'; export { + CONTROL_GROUP_STATIC_ID, DEFAULT_CONTROL_CHAINING, DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_LABEL_POSITION, diff --git a/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx b/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx index 181160fae4d13..8aca2dbaad0d9 100644 --- a/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/components/control_renderer.tsx @@ -7,12 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { StateComparators, initializeHasUnsavedChanges } from '@kbn/presentation-publishing'; import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; - -import { initializeUnsavedChanges } from '@kbn/presentation-containers'; -import { StateComparators } from '@kbn/presentation-publishing'; - import type { DefaultControlState } from '../../../common'; import { getControlFactory } from '../../control_factory_registry'; import type { ControlApiRegistration, DefaultControlApi } from '../../controls/types'; @@ -55,10 +52,12 @@ export const ControlRenderer = < apiRegistration: ControlApiRegistration, comparators: StateComparators ): ApiType => { - const unsavedChanges = initializeUnsavedChanges( - parentApi.getLastSavedControlState(uuid) as StateType, + const unsavedChanges = initializeHasUnsavedChanges( + uuid, + comparators, + apiRegistration, parentApi, - comparators + (state) => state.rawState ); cleanupFunction.current = () => unsavedChanges.cleanup(); diff --git a/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts b/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts index 1331afc4e8f12..83788c2c390a6 100644 --- a/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts +++ b/src/platform/plugins/shared/controls/public/control_group/control_group_unsaved_changes_api.ts @@ -7,23 +7,39 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { omit } from 'lodash'; -import { combineLatest, map } from 'rxjs'; - import { + BehaviorSubject, + Subject, + combineLatest, + combineLatestWith, + debounceTime, + map, +} from 'rxjs'; +import { + apiHasLastSavedChildState, childrenUnsavedChanges$, - initializeUnsavedChanges, + HasLastSavedChildState, type PresentationContainer, } from '@kbn/presentation-containers'; +import deepEqual from 'fast-deep-equal'; import { apiPublishesUnsavedChanges, + SerializedPanelState, type PublishesUnsavedChanges, type StateComparators, + latestComparatorValues$, } from '@kbn/presentation-publishing'; - -import type { ControlGroupRuntimeState, ControlPanelsState } from '../../common'; +import { + CONTROL_GROUP_STATIC_ID, + type ControlGroupRuntimeState, + type ControlGroupSerializedState, + type ControlPanelsState, + DefaultControlState, +} from '../../common'; import { apiPublishesAsyncFilters } from '../controls/data_controls/publishes_async_filters'; import { getControlsInOrder, type ControlsInOrder } from './init_controls_manager'; +import { deserializeControlGroup } from './utils/serialization_utils'; +import { CHANGE_CHECK_DEBOUNCE } from '../constants'; export type ControlGroupComparatorState = Pick< ControlGroupRuntimeState, @@ -32,46 +48,109 @@ export type ControlGroupComparatorState = Pick< controlsInOrder: ControlsInOrder; }; +const compareControlGroupSerializedState = ( + currentState: SerializedPanelState, + lastSavedState?: SerializedPanelState +) => { + // clone via serialization to deeply remove undefined values + const { controls: currentControls, ...currentStateToCompare } = JSON.parse( + JSON.stringify(currentState.rawState ?? {}) + ) as ControlGroupSerializedState; + const { controls: lastControls, ...lastSavedStateToCompare } = JSON.parse( + JSON.stringify(lastSavedState?.rawState ?? {}) + ) as ControlGroupSerializedState; + return !deepEqual(currentStateToCompare, lastSavedStateToCompare); +}; + export function initializeControlGroupUnsavedChanges( applySelections: () => void, children$: PresentationContainer['children$'], comparators: StateComparators, - snapshotControlsRuntimeState: () => ControlPanelsState, - resetControlsUnsavedChanges: () => void, + resetControlsUnsavedChanges: (lastSavedChildControlsState: ControlPanelsState) => void, parentApi: unknown, - lastSavedRuntimeState: ControlGroupRuntimeState -) { - const controlGroupUnsavedChanges = initializeUnsavedChanges( - { - autoApplySelections: lastSavedRuntimeState.autoApplySelections, - chainingSystem: lastSavedRuntimeState.chainingSystem, - controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState), - ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings, - labelPosition: lastSavedRuntimeState.labelPosition, - }, - parentApi, - comparators + serializeState: () => SerializedPanelState +): { + api: PublishesUnsavedChanges & HasLastSavedChildState; + cleanup: () => void; +} { + if (!apiHasLastSavedChildState(parentApi)) { + return { + api: { + resetUnsavedChanges: () => {}, + saveNotification$: new Subject(), + hasUnsavedChanges$: new BehaviorSubject(false), + getLastSavedStateForChild: (uuid: string) => undefined, + }, + cleanup: () => {}, + }; + } + + const getLastSavedState = () => parentApi.getLastSavedStateForChild(CONTROL_GROUP_STATIC_ID); + const initialLastSavedState = getLastSavedState(); + let lastSavedRuntimeState: ControlGroupRuntimeState | undefined = initialLastSavedState + ? deserializeControlGroup(initialLastSavedState) + : undefined; + const saveNotification$ = new Subject(); + const controlGroupLastSavedState$ = new BehaviorSubject< + SerializedPanelState | undefined + >(initialLastSavedState); + const subscriptions = parentApi.saveNotification$.subscribe(() => { + const lastSavedSerializedState = getLastSavedState(); + controlGroupLastSavedState$.next(lastSavedSerializedState); + if (lastSavedSerializedState) { + lastSavedRuntimeState = deserializeControlGroup(lastSavedSerializedState); + } + saveNotification$.next(); + }); + + const hasUnsavedChanges$ = new BehaviorSubject( + compareControlGroupSerializedState(serializeState(), getLastSavedState()) + ); + + const controlsChildrenHaveUnsavedChanges$ = childrenUnsavedChanges$(children$).pipe( + map((childUnsavedChanges) => childUnsavedChanges.some((change) => change.hasUnsavedChanges)) + ); + + const controlGroupHasUnsavedChanges$ = latestComparatorValues$(comparators).pipe( + map(() => serializeState()), + combineLatestWith(controlGroupLastSavedState$), + map(([currentState, lastSavedState]) => + compareControlGroupSerializedState(currentState, lastSavedState) + ) + ); + + subscriptions.add( + combineLatest([controlGroupHasUnsavedChanges$, controlsChildrenHaveUnsavedChanges$]) + .pipe( + debounceTime(CHANGE_CHECK_DEBOUNCE), + map( + ([controlGroupHasUnsavedChanges, controlsChildrenHaveUnsavedChanges]) => + controlGroupHasUnsavedChanges || controlsChildrenHaveUnsavedChanges + ) + ) + .subscribe((hasUnsavedChanges) => hasUnsavedChanges$.next(hasUnsavedChanges)) ); return { api: { - unsavedChanges$: combineLatest([ - controlGroupUnsavedChanges.api.unsavedChanges$, - childrenUnsavedChanges$(children$), - ]).pipe( - map(([unsavedControlGroupState, unsavedControlsState]) => { - const unsavedChanges: Partial = unsavedControlGroupState - ? omit(unsavedControlGroupState, 'controlsInOrder') - : {}; - if (unsavedControlsState || unsavedControlGroupState?.controlsInOrder) { - unsavedChanges.initialChildControlState = snapshotControlsRuntimeState(); - } - return Object.keys(unsavedChanges).length ? unsavedChanges : undefined; - }) - ), - asyncResetUnsavedChanges: async () => { - controlGroupUnsavedChanges.api.resetUnsavedChanges(); - resetControlsUnsavedChanges(); + hasUnsavedChanges$, + resetUnsavedChanges: async () => { + if (!lastSavedRuntimeState || !hasUnsavedChanges$.value) return; + const lastSavedComparatorState: ControlGroupComparatorState = { + autoApplySelections: lastSavedRuntimeState.autoApplySelections, + chainingSystem: lastSavedRuntimeState.chainingSystem, + controlsInOrder: getControlsInOrder(lastSavedRuntimeState.initialChildControlState), + ignoreParentSettings: lastSavedRuntimeState.ignoreParentSettings, + labelPosition: lastSavedRuntimeState.labelPosition, + }; + for (const key of Object.keys(comparators) as Array) { + const setter = comparators[key][1] as ( + value: ControlGroupComparatorState[keyof ControlGroupComparatorState] + ) => void; + setter(lastSavedComparatorState[key]); + } + + resetControlsUnsavedChanges(lastSavedRuntimeState.initialChildControlState); const filtersReadyPromises: Array> = []; Object.values(children$.value).forEach((controlApi) => { @@ -82,13 +161,20 @@ export function initializeControlGroupUnsavedChanges( }); await Promise.all(filtersReadyPromises); - if (!comparators.autoApplySelections[0].value) { applySelections(); } }, - } as Pick & { - asyncResetUnsavedChanges: () => Promise; + saveNotification$, + getLastSavedStateForChild: (uuid: string) => { + const { type, order, id, enhancements, ...rawState } = (lastSavedRuntimeState + ?.initialChildControlState[uuid] ?? {}) as ControlPanelsState & { + enhancements?: object; // controls may have an enhancements key which throws off the diffing + id?: string; // controls may have an id key which throws off the diffing + }; + return rawState ? { rawState } : undefined; + }, }, + cleanup: () => subscriptions.unsubscribe(), }; } diff --git a/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx b/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx index ef2c83d39a3e1..49dfc87aaacd9 100644 --- a/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx +++ b/src/platform/plugins/shared/controls/public/control_group/get_control_group_factory.tsx @@ -12,10 +12,7 @@ import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public'; import type { ESQLControlVariable } from '@kbn/esql-types'; import { PublishesESQLVariable, apiPublishesESQLVariable } from '@kbn/esql-types'; import { i18n } from '@kbn/i18n'; -import { - apiHasSaveNotification, - combineCompatibleChildrenApis, -} from '@kbn/presentation-containers'; +import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { PublishesDataViews, apiPublishesDataViews, @@ -30,7 +27,6 @@ import type { ControlGroupRuntimeState, ControlGroupSerializedState, ControlLabelPosition, - ControlPanelsState, ParentIgnoreSettings, } from '../../common'; import { @@ -57,14 +53,7 @@ export const getControlGroupEmbeddableFactory = () => { > = { type: CONTROL_GROUP_TYPE, deserializeState: (state) => deserializeControlGroup(state), - buildEmbeddable: async ( - initialRuntimeState, - buildApi, - uuid, - parentApi, - setApi, - lastSavedRuntimeState - ) => { + buildEmbeddable: async (initialRuntimeState, buildApi, uuid, parentApi, setApi) => { const { labelPosition: initialLabelPosition, chainingSystem, @@ -74,13 +63,7 @@ export const getControlGroupEmbeddableFactory = () => { const autoApplySelections$ = new BehaviorSubject(autoApplySelections); const defaultDataViewId = await dataViewsService.getDefaultId(); - const lastSavedControlsState$ = new BehaviorSubject( - lastSavedRuntimeState.initialChildControlState - ); - const controlsManager = initControlsManager( - initialRuntimeState.initialChildControlState, - lastSavedControlsState$ - ); + const controlsManager = initControlsManager(initialRuntimeState.initialChildControlState); const selectionsManager = initSelectionsManager({ ...controlsManager.api, autoApplySelections$, @@ -99,6 +82,20 @@ export const getControlGroupEmbeddableFactory = () => { const allowExpensiveQueries$ = new BehaviorSubject(true); const disabledActionIds$ = new BehaviorSubject(undefined); + const serializeState = () => { + const { controls, references } = controlsManager.serializeControls(); + return { + rawState: { + chainingSystem: chainingSystem$.getValue(), + labelPosition: labelPosition$.getValue(), + autoApplySelections: autoApplySelections$.getValue(), + ignoreParentSettings: ignoreParentSettings$.getValue(), + controls, + }, + references, + }; + }; + const unsavedChanges = initializeControlGroupUnsavedChanges( selectionsManager.applySelections, controlsManager.api.children$, @@ -123,10 +120,9 @@ export const getControlGroupEmbeddableFactory = () => { (next: ControlLabelPosition) => labelPosition$.next(next), ], }, - controlsManager.snapshotControlsRuntimeState, controlsManager.resetControlsUnsavedChanges, parentApi, - lastSavedRuntimeState + serializeState ); const api = setApi({ @@ -181,33 +177,20 @@ export const getControlGroupEmbeddableFactory = () => { onSave: ({ type: controlType, state: initialState }) => { controlsManager.api.addNewPanel({ panelType: controlType, - initialState: settings?.controlStateTransform - ? settings.controlStateTransform(initialState, controlType) - : initialState, + serializedState: { + rawState: settings?.controlStateTransform + ? settings.controlStateTransform(initialState, controlType) + : initialState, + }, }); settings?.onSave?.(); }, controlGroupApi: api, }); }, - serializeState: () => { - const { controls, references } = controlsManager.serializeControls(); - return { - rawState: { - chainingSystem: chainingSystem$.getValue(), - labelPosition: labelPosition$.getValue(), - autoApplySelections: autoApplySelections$.getValue(), - ignoreParentSettings: ignoreParentSettings$.getValue(), - controls, - }, - references, - }; - }, + serializeState, dataViews$, labelPosition: labelPosition$, - saveNotification$: apiHasSaveNotification(parentApi) - ? parentApi.saveNotification$ - : undefined, reload$: apiPublishesReload(parentApi) ? parentApi.reload$ : undefined, /** Public getters */ @@ -216,9 +199,6 @@ export const getControlGroupEmbeddableFactory = () => { defaultMessage: 'Controls', }), getEditorConfig: () => initialRuntimeState.editorConfig, - getLastSavedControlState: (controlUuid: string) => { - return lastSavedRuntimeState.initialChildControlState[controlUuid] ?? {}; - }, /** Public setters */ setDisabledActionIds: (ids) => disabledActionIds$.next(ids), @@ -241,20 +221,6 @@ export const getControlGroupEmbeddableFactory = () => { esqlVariables$.next(newESQLVariables); }); - const saveNotificationSubscription = apiHasSaveNotification(parentApi) - ? parentApi.saveNotification$.subscribe(() => { - lastSavedControlsState$.next(controlsManager.snapshotControlsRuntimeState()); - - if ( - typeof autoApplySelections$.value === 'boolean' && - !autoApplySelections$.value && - selectionsManager.hasUnappliedSelections$.value - ) { - selectionsManager.applySelections(); - } - }) - : undefined; - return { api, Component: () => { @@ -283,10 +249,10 @@ export const getControlGroupEmbeddableFactory = () => { fetchAllowExpensiveQueries(); // no need to await - don't want to block anything waiting for this return () => { + unsavedChanges.cleanup(); selectionsManager.cleanup(); childrenDataViewsSubscription.unsubscribe(); childrenESQLVariablesSubscription.unsubscribe(); - saveNotificationSubscription?.unsubscribe(); }; }, []); diff --git a/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts b/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts index cd8c2bea4f907..edd5197675eee 100644 --- a/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts +++ b/src/platform/plugins/shared/controls/public/control_group/init_controls_manager.ts @@ -18,9 +18,9 @@ import type { PresentationContainer, } from '@kbn/presentation-containers'; import { + apiHasSnapshottableState, type PublishingSubject, type StateComparators, - apiHasSnapshottableState, } from '@kbn/presentation-publishing'; import { BehaviorSubject, first, merge } from 'rxjs'; import type { @@ -49,19 +49,10 @@ export function getControlsInOrder(initialControlPanelsState: ControlPanelsState .map(({ id, type }) => ({ id, type })); // filter out `order` } -export function initControlsManager( - /** - * Composed from last saved controls state and previous sessions's unsaved changes to controls state - */ - initialControlsState: ControlPanelsState, - /** - * Observable that publishes last saved controls state only - */ - lastSavedControlsState$: PublishingSubject -) { +export function initControlsManager(initialControlsState: ControlPanelsState) { const initialControlIds = Object.keys(initialControlsState); const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); - let currentControlsState: { [panelId: string]: DefaultControlState } = { + let currentControlsState: ControlPanelsState = { ...initialControlsState, }; const controlsInOrder$ = new BehaviorSubject( @@ -103,17 +94,18 @@ export function initControlsManager( } async function addNewPanel( - { panelType, initialState }: PanelPackage<{}, DefaultControlState>, + { panelType, serializedState }: PanelPackage, index: number ) { - if ((initialState as DefaultDataControlState)?.dataViewId) { - lastUsedDataViewId$.next((initialState as DefaultDataControlState).dataViewId); + const { rawState } = serializedState; + if ((rawState as DefaultDataControlState)?.dataViewId) { + lastUsedDataViewId$.next((rawState as DefaultDataControlState).dataViewId); } - if (initialState?.width) { - lastUsedWidth$.next(initialState.width); + if (rawState?.width) { + lastUsedWidth$.next(rawState.width); } - if (typeof initialState?.grow === 'boolean') { - lastUsedGrow$.next(initialState.grow); + if (typeof rawState?.grow === 'boolean') { + lastUsedGrow$.next(rawState.grow); } const id = generateId(); @@ -123,7 +115,7 @@ export function initControlsManager( type: panelType, }); controlsInOrder$.next(nextControlsInOrder); - currentControlsState[id] = initialState ?? {}; + currentControlsState[id] = { ...rawState, type: panelType, order: index } ?? {}; return await untilControlLoaded(id); } @@ -199,9 +191,9 @@ export function initControlsManager( }); return controlsRuntimeState; }, - resetControlsUnsavedChanges: () => { + resetControlsUnsavedChanges: (lastSavedChildControlsState: ControlPanelsState) => { currentControlsState = { - ...lastSavedControlsState$.value, + ...lastSavedChildControlsState, }; const nextControlsInOrder = getControlsInOrder(currentControlsState as ControlPanelsState); controlsInOrder$.next(nextControlsInOrder); @@ -222,8 +214,8 @@ export function initControlsManager( }, api: { getSerializedStateForChild: (childId: string) => { - const controlPanelState = currentControlsState[childId]; - return controlPanelState ? { rawState: controlPanelState } : undefined; + const { type, order, ...rawState } = currentControlsState[childId]; + return rawState ? { rawState } : undefined; }, children$: children$ as PublishingSubject<{ [key: string]: DefaultControlApi; diff --git a/src/platform/plugins/shared/controls/public/control_group/types.ts b/src/platform/plugins/shared/controls/public/control_group/types.ts index 477b1fddaa878..f25d420e690e2 100644 --- a/src/platform/plugins/shared/controls/public/control_group/types.ts +++ b/src/platform/plugins/shared/controls/public/control_group/types.ts @@ -13,7 +13,7 @@ import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import { PublishesESQLVariables } from '@kbn/esql-types'; import { Filter } from '@kbn/es-query'; import { - HasSaveNotification, + HasLastSavedChildState, HasSerializedChildState, PresentationContainer, } from '@kbn/presentation-containers'; @@ -54,17 +54,17 @@ export type ControlGroupApi = PresentationContainer & PublishesDataViews & PublishesESQLVariables & HasSerializedChildState & + HasLastSavedChildState & HasEditCapabilities & - Pick, 'unsavedChanges$'> & PublishesTimeslice & PublishesDisabledActionIds & - Partial & HasSaveNotification & PublishesReload> & { + PublishesUnsavedChanges & + Partial & PublishesReload> & { allowExpensiveQueries$: PublishingSubject; autoApplySelections$: PublishingSubject; ignoreParentSettings$: PublishingSubject; labelPosition: PublishingSubject; - asyncResetUnsavedChanges: () => Promise; controlFetch$: (controlUuid: string) => Observable; openAddDataControlFlyout: (options?: { controlStateTransform?: ControlStateTransform; @@ -74,7 +74,6 @@ export type ControlGroupApi = PresentationContainer & /** Public getters */ getEditorConfig: () => ControlGroupEditorConfig | undefined; - getLastSavedControlState: (controlUuid: string) => object; /** Public setters */ setChainingSystem: (chainingSystem: ControlGroupChainingSystem) => void; diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts index 87415dff252b0..ed76200439a71 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/constants.ts @@ -10,6 +10,7 @@ import type { OptionsListSortingType, OptionsListSearchTechnique, + OptionsListControlState, } from '../../../../common/options_list'; export const DEFAULT_SEARCH_TECHNIQUE: OptionsListSearchTechnique = 'prefix'; @@ -20,3 +21,22 @@ export const OPTIONS_LIST_DEFAULT_SORT: OptionsListSortingType = { export const MIN_OPTIONS_LIST_REQUEST_SIZE = 10; export const MAX_OPTIONS_LIST_REQUEST_SIZE = 1000; + +export const OPTIONS_LIST_DEFAULTS = { + sort: OPTIONS_LIST_DEFAULT_SORT, + searchTechnique: DEFAULT_SEARCH_TECHNIQUE, + selectedOptions: [], +}; + +export const OPTIONS_LIST_KEYS_TO_COMPARE: Array = [ + 'searchTechnique', + 'selectedOptions', + 'existsSelected', + 'runPastTimeout', + 'singleSelect', + 'dataViewId', + 'fieldName', + 'exclude', + 'title', + 'sort', +]; diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx index 75ac6b1d9e039..ba2a39bf799f0 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/get_options_list_control_factory.tsx @@ -19,10 +19,10 @@ import { skip, Subject, } from 'rxjs'; - +import { pick } from 'lodash'; +import deepEqual from 'fast-deep-equal'; import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter, Filter } from '@kbn/es-query'; import { PublishingSubject, useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; - import { OPTIONS_LIST_CONTROL } from '../../../../common'; import type { OptionsListControlState, @@ -41,6 +41,8 @@ import { DEFAULT_SEARCH_TECHNIQUE, MIN_OPTIONS_LIST_REQUEST_SIZE, OPTIONS_LIST_DEFAULT_SORT, + OPTIONS_LIST_DEFAULTS, + OPTIONS_LIST_KEYS_TO_COMPARE, } from './constants'; import { fetchAndValidate$ } from './fetch_and_validate'; import { OptionsListControlContext } from './options_list_context_provider'; @@ -270,6 +272,13 @@ export const getOptionsListControlFactory = (): DataControlFactory< ...dataControl.api, dataLoading$, getTypeDisplayName: OptionsListStrings.control.getDisplayName, + isSerializedStateEqual: (a, b) => { + const isEqual = deepEqual( + { ...OPTIONS_LIST_DEFAULTS, ...pick(a, OPTIONS_LIST_KEYS_TO_COMPARE) }, + { ...OPTIONS_LIST_DEFAULTS, ...pick(b, OPTIONS_LIST_KEYS_TO_COMPARE) } + ); + return isEqual; + }, serializeState: () => { const { rawState: dataControlState, references } = dataControl.serialize(); return { diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_control_selections.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_control_selections.ts index 94d46c1d59a84..2a08318db05ee 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_control_selections.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/options_list_control_selections.ts @@ -25,10 +25,10 @@ export function initializeOptionsListSelections( const selectedOptionsComparatorFunction = ( a: OptionsListSelection[] | undefined, b: OptionsListSelection[] | undefined - ) => deepEqual(a ?? [], b ?? []); - function setSelectedOptions(next: OptionsListSelection[] | undefined) { - if (!selectedOptionsComparatorFunction(selectedOptions$.value, next)) { - selectedOptions$.next(next); + ) => deepEqual(a, b); + function setSelectedOptions(nextSelectedOptions: OptionsListSelection[] | undefined) { + if (!selectedOptionsComparatorFunction(selectedOptions$.value, nextSelectedOptions)) { + selectedOptions$.next(nextSelectedOptions); onSelectionChange(); } } diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts index 1b82d7e8b06dd..f0741e5f2a9de 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/options_list_control/types.ts @@ -9,7 +9,7 @@ import { Subject } from 'rxjs'; -import type { PublishingSubject } from '@kbn/presentation-publishing'; +import type { HasSerializedStateComparator, PublishingSubject } from '@kbn/presentation-publishing'; import type { OptionsListControlState, OptionsListDisplaySettings, @@ -18,9 +18,10 @@ import type { } from '../../../../common/options_list'; import type { DataControlApi } from '../types'; -export type OptionsListControlApi = DataControlApi & { - setSelectedOptions: (options: OptionsListSelection[] | undefined) => void; -}; +export type OptionsListControlApi = DataControlApi & + HasSerializedStateComparator & { + setSelectedOptions: (options: OptionsListSelection[] | undefined) => void; + }; export interface OptionsListComponentState extends Omit { diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx index c91e2aa34b11e..c3fd1dc39e8d5 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState } from 'react'; import { BehaviorSubject, combineLatest, debounceTime, map, skip } from 'rxjs'; - +import deepEqual from 'fast-deep-equal'; import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; import { Filter, RangeFilterParams, buildRangeFilter } from '@kbn/es-query'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; @@ -25,6 +25,8 @@ import { initializeRangeControlSelections } from './range_control_selections'; import { RangeSliderStrings } from './range_slider_strings'; import type { RangesliderControlApi, RangesliderControlState } from './types'; +const DEFAULT_STEP = 1; + export const getRangesliderControlFactory = (): DataControlFactory< RangesliderControlState, RangesliderControlApi @@ -64,7 +66,7 @@ export const getRangesliderControlFactory = (): DataControlFactory< const loadingMinMax$ = new BehaviorSubject(false); const loadingHasNoResults$ = new BehaviorSubject(false); const dataLoading$ = new BehaviorSubject(undefined); - const step$ = new BehaviorSubject(initialState.step ?? 1); + const step$ = new BehaviorSubject(initialState.step ?? DEFAULT_STEP); const dataControl = initializeDataControl>( uuid, @@ -86,6 +88,10 @@ export const getRangesliderControlFactory = (): DataControlFactory< { ...dataControl.api, dataLoading$, + isSerializedStateEqual: (a, b) => { + const defaults = { step: DEFAULT_STEP }; + return deepEqual({ ...defaults, ...a }, { ...defaults, ...b }); + }, getTypeDisplayName: RangeSliderStrings.control.getDisplayName, serializeState: () => { const { rawState: dataControlState, references } = dataControl.serialize(); diff --git a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/types.ts b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/types.ts index bbbf75e5730b5..8de2b618f4de1 100644 --- a/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/types.ts +++ b/src/platform/plugins/shared/controls/public/controls/data_controls/range_slider/types.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { HasSerializedStateComparator } from '@kbn/presentation-publishing'; import type { DefaultDataControlState } from '../../../../common'; import type { DataControlApi } from '../types'; @@ -17,4 +18,5 @@ export interface RangesliderControlState extends DefaultDataControlState { step?: number; } -export type RangesliderControlApi = DataControlApi; +export type RangesliderControlApi = DataControlApi & + HasSerializedStateComparator; diff --git a/src/platform/plugins/shared/dashboard/common/lib/dashboard_panel_converters.ts b/src/platform/plugins/shared/dashboard/common/lib/dashboard_panel_converters.ts index d0f96e43ec1bb..fdd47ad592f39 100644 --- a/src/platform/plugins/shared/dashboard/common/lib/dashboard_panel_converters.ts +++ b/src/platform/plugins/shared/dashboard/common/lib/dashboard_panel_converters.ts @@ -22,13 +22,11 @@ import { export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): DashboardPanelMap => { const panelsMap: DashboardPanelMap = {}; panels?.forEach((panel, idx) => { - const panelIndex = panel.panelIndex ?? String(idx); panelsMap![panel.panelIndex ?? String(idx)] = { type: panel.type, gridData: panel.gridData, panelRefName: panel.panelRefName, explicitInput: { - id: panelIndex, ...(panel.id !== undefined && { savedObjectId: panel.id }), ...(panel.title !== undefined && { title: panel.title }), ...panel.panelConfig, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts index bda7c13f9f5bd..3ecaf56374779 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_loading_manager.ts @@ -7,16 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { BehaviorSubject, debounceTime, first, map } from 'rxjs'; +import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; +import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { PublishesDataLoading, PublishingSubject, apiPublishesDataLoading, } from '@kbn/presentation-publishing'; -import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; +import { BehaviorSubject, debounceTime, first, map } from 'rxjs'; export function initializeDataLoadingManager( - children$: PublishingSubject<{ [key: string]: unknown }> + children$: PublishingSubject<{ [key: string]: DefaultEmbeddableApi }> ) { const dataLoading$ = new BehaviorSubject(undefined); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts index ba3032c656c0d..9417788fc0ce2 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/data_views_manager.ts @@ -7,23 +7,22 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { uniqBy } from 'lodash'; -import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; - +import { ControlGroupApi } from '@kbn/controls-plugin/public'; import { DataView } from '@kbn/data-views-plugin/common'; +import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public'; import { combineCompatibleChildrenApis } from '@kbn/presentation-containers'; import { apiPublishesDataViews, PublishesDataViews, PublishingSubject, } from '@kbn/presentation-publishing'; - -import { ControlGroupApi } from '@kbn/controls-plugin/public'; +import { uniqBy } from 'lodash'; +import { BehaviorSubject, combineLatest, Observable, of, switchMap } from 'rxjs'; import { dataService } from '../services/kibana_services'; export function initializeDataViewsManager( controlGroupApi$: PublishingSubject, - children$: PublishingSubject<{ [key: string]: unknown }> + children$: PublishingSubject<{ [key: string]: DefaultEmbeddableApi }> ) { const dataViews$ = new BehaviorSubject([]); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts index c5c1b4b8ea378..8447bac379085 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/get_dashboard_api.ts @@ -11,7 +11,6 @@ import type { Reference } from '@kbn/content-management-utils'; import { ControlGroupApi, ControlGroupSerializedState } from '@kbn/controls-plugin/public'; import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public'; import { StateComparators } from '@kbn/presentation-publishing'; -import { omit } from 'lodash'; import { BehaviorSubject, debounceTime, merge } from 'rxjs'; import { v4 } from 'uuid'; import { @@ -19,12 +18,10 @@ import { getReferencesForPanelId, } from '../../common/dashboard_container/persistable_state/dashboard_container_references'; import { DASHBOARD_APP_ID } from '../plugin_constants'; -import { PANELS_CONTROL_GROUP_KEY } from '../services/dashboard_backup_service'; import { getDashboardContentManagementService } from '../services/dashboard_content_management_service'; import { LoadDashboardReturn } from '../services/dashboard_content_management_service/types'; import { initializeDataLoadingManager } from './data_loading_manager'; import { initializeDataViewsManager } from './data_views_manager'; -import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state'; import { getSerializedState } from './get_serialized_state'; import { openSaveModal } from './save_modal/open_save_modal'; import { initializePanelsManager } from './panels_manager'; @@ -39,24 +36,36 @@ import { DashboardCreationOptions, DashboardInternalApi, DashboardState, - UnsavedPanelState, } from './types'; import { initializeUnifiedSearchManager } from './unified_search_manager'; import { initializeUnsavedChangesManager } from './unsaved_changes_manager'; import { initializeViewModeManager } from './view_mode_manager'; +const defaultControlGroupState: ControlGroupSerializedState = { + autoApplySelections: true, + chainingSystem: 'HIERARCHICAL', + controls: [], + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, + labelPosition: 'oneLine', +}; + export function getDashboardApi({ creationOptions, incomingEmbeddable, initialState, - initialPanelsRuntimeState, savedObjectResult, savedObjectId, + lastSavedDashboardState, }: { creationOptions?: DashboardCreationOptions; + lastSavedDashboardState?: DashboardState; incomingEmbeddable?: EmbeddablePackageState | undefined; initialState: DashboardState; - initialPanelsRuntimeState?: UnsavedPanelState; savedObjectResult?: LoadDashboardReturn; savedObjectId?: string; }) { @@ -87,7 +96,6 @@ export function getDashboardApi({ const panelsManager = initializePanelsManager( incomingEmbeddable, initialState.panels, - initialPanelsRuntimeState ?? {}, trackPanel, getPanelReferences, pushPanelReferences @@ -109,9 +117,8 @@ export function getDashboardApi({ const unsavedChangesManager = initializeUnsavedChangesManager({ creationOptions, controlGroupApi$, - lastSavedState: omit(savedObjectResult?.dashboardInput, 'controlGroupInput') ?? { - ...DEFAULT_DASHBOARD_STATE, - }, + getPanelReferences, + lastSavedState: lastSavedDashboardState, panelsManager, savedObjectId$, settingsManager, @@ -246,29 +253,19 @@ export function getDashboardApi({ internalApi: { ...panelsManager.internalApi, ...unifiedSearchManager.internalApi, + getLastSavedStateForControlGroup: () => { + const lastSavedState = unsavedChangesManager.internalApi.getLastSavedState(); + return { + rawState: lastSavedState?.controlGroupInput ?? defaultControlGroupState, + references: getReferencesForControls(lastSavedState?.references ?? []), + }; + }, getSerializedStateForControlGroup: () => { return { - rawState: savedObjectResult?.dashboardInput?.controlGroupInput - ? savedObjectResult.dashboardInput.controlGroupInput - : ({ - autoApplySelections: true, - chainingSystem: 'HIERARCHICAL', - controls: [], - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - ignoreValidations: false, - }, - labelPosition: 'oneLine', - showApplySelections: false, - } as ControlGroupSerializedState), + rawState: initialState.controlGroupInput ?? defaultControlGroupState, references: getReferencesForControls(references$.value ?? []), }; }, - getRuntimeStateForControlGroup: () => { - return panelsManager!.api.getRuntimeStateForChild(PANELS_CONTROL_GROUP_KEY); - }, setControlGroupApi: (controlGroupApi: ControlGroupApi) => controlGroupApi$.next(controlGroupApi), } as DashboardInternalApi, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts index ff4ddba50139b..528f52cb52ab2 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/load_dashboard_api.ts @@ -8,18 +8,15 @@ */ import { ContentInsightsClient } from '@kbn/content-management-content-insights-public'; -import { DashboardPanelMap } from '../../common'; +import { cloneDeep } from 'lodash'; +import { getDashboardBackupService } from '../services/dashboard_backup_service'; import { getDashboardContentManagementService } from '../services/dashboard_content_management_service'; -import { DashboardCreationOptions, DashboardState, UnsavedPanelState } from './types'; -import { getDashboardApi } from './get_dashboard_api'; -import { startQueryPerformanceTracking } from './performance/query_performance_tracking'; import { coreServices } from '../services/kibana_services'; import { logger } from '../services/logger'; -import { - PANELS_CONTROL_GROUP_KEY, - getDashboardBackupService, -} from '../services/dashboard_backup_service'; import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state'; +import { getDashboardApi } from './get_dashboard_api'; +import { startQueryPerformanceTracking } from './performance/query_performance_tracking'; +import { DashboardCreationOptions, DashboardState } from './types'; export async function loadDashboardApi({ getCreationOptions, @@ -51,19 +48,29 @@ export async function loadDashboardApi({ // Combine saved object state and session storage state // -------------------------------------------------------------------------------------- const dashboardBackupState = getDashboardBackupService().getState(savedObjectResult.dashboardId); - const initialPanelsRuntimeState: UnsavedPanelState = creationOptions?.useSessionStorageIntegration - ? dashboardBackupState?.panels ?? {} - : {}; const sessionStorageInput = ((): Partial | undefined => { if (!creationOptions?.useSessionStorageIntegration) return; - return dashboardBackupState?.dashboardState; + return dashboardBackupState; })(); - const combinedSessionState: DashboardState = { + const lastSavedDashboardState: DashboardState = cloneDeep({ ...DEFAULT_DASHBOARD_STATE, ...(savedObjectResult?.dashboardInput ?? {}), + references: savedObjectResult?.references, + }); + + const combinedSessionState: DashboardState = { + ...lastSavedDashboardState, ...sessionStorageInput, + panels: { + ...lastSavedDashboardState.panels, + + /** + * Panels are spread from the session storage input because only panels which have changed are backed up there. + */ + ...sessionStorageInput?.panels, + }, }; combinedSessionState.references = sessionStorageInput?.references?.length ? sessionStorageInput?.references @@ -73,32 +80,7 @@ export async function loadDashboardApi({ // Combine state with overrides. // -------------------------------------------------------------------------------------- const overrideState = creationOptions?.getInitialInput?.(); - if (overrideState?.panels) { - const overridePanels: DashboardPanelMap = {}; - for (const [panelId, panel] of Object.entries(overrideState?.panels)) { - overridePanels[panelId] = { - ...panel, - - /** - * here we need to keep the state of the panel that was already in the Dashboard if one exists. - * This is because this state will become the "last saved state" for this panel. - */ - ...(combinedSessionState.panels[panelId] ?? []), - }; - /** - * We also need to add the state of this react embeddable into the runtime state to be restored. - */ - initialPanelsRuntimeState[panelId] = panel.explicitInput; - } - overrideState.panels = overridePanels; - } - // Back up any view mode passed in explicitly. - if (overrideState?.viewMode) { - getDashboardBackupService().storeViewMode(overrideState?.viewMode); - } - if (overrideState?.controlGroupState) { - initialPanelsRuntimeState[PANELS_CONTROL_GROUP_KEY] = overrideState.controlGroupState; - } + if (overrideState?.viewMode) getDashboardBackupService().storeViewMode(overrideState?.viewMode); // -------------------------------------------------------------------------------------- // get dashboard Api @@ -110,7 +92,7 @@ export async function loadDashboardApi({ ...combinedSessionState, ...overrideState, }, - initialPanelsRuntimeState, + lastSavedDashboardState, savedObjectResult, savedObjectId, }); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts index 80545b1a432b4..55d3cedfe5d7a 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/panels_manager.ts @@ -23,11 +23,9 @@ import { StateComparators, apiHasLibraryTransforms, apiPublishesTitle, - apiPublishesUnsavedChanges, apiHasSerializableState, getTitle, } from '@kbn/presentation-publishing'; -import { i18n } from '@kbn/i18n'; import { coreServices, usageCollectionService } from '../services/kibana_services'; import { DashboardPanelMap, DashboardPanelState, prefixReferencesFromPanel } from '../../common'; import type { initializeTrackPanel } from './track_panel'; @@ -36,7 +34,7 @@ import { runPanelPlacementStrategy } from '../panel_placement/place_new_panel_st import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../common/content_management'; import { DASHBOARD_UI_METRIC_ID } from '../utils/telemetry_constants'; import { getDashboardPanelPlacementSetting } from '../panel_placement/panel_placement_registry'; -import { DashboardState, UnsavedPanelState } from './types'; +import { DashboardState } from './types'; import { arePanelLayoutsEqual } from './are_panel_layouts_equal'; import { dashboardClonePanelActionStrings } from '../dashboard_actions/_dashboard_actions_strings'; import { placeClonePanel } from '../panel_placement/place_clone_panel_strategy'; @@ -45,74 +43,60 @@ import { PanelPlacementStrategy } from '../plugin_constants'; export function initializePanelsManager( incomingEmbeddable: EmbeddablePackageState | undefined, initialPanels: DashboardPanelMap, - initialPanelsRuntimeState: UnsavedPanelState, trackPanel: ReturnType, getReferencesForPanelId: (id: string) => Reference[], pushReferences: (references: Reference[]) => void ) { const children$ = new BehaviorSubject<{ - [key: string]: unknown; + [key: string]: DefaultEmbeddableApi; }>({}); const panels$ = new BehaviorSubject(initialPanels); function setPanels(panels: DashboardPanelMap) { if (panels !== panels$.value) panels$.next(panels); } - let restoredRuntimeState: UnsavedPanelState = initialPanelsRuntimeState; - - function setRuntimeStateForChild(childId: string, state: object) { - restoredRuntimeState[childId] = state; - } // -------------------------------------------------------------------------------------- // Place the incoming embeddable if there is one // -------------------------------------------------------------------------------------- if (incomingEmbeddable) { - const incomingPanelId = incomingEmbeddable.embeddableId ?? v4(); - let incomingPanelState: DashboardPanelState; - if (incomingEmbeddable.embeddableId && Boolean(panels$.value[incomingPanelId])) { - // this embeddable already exists, just update the explicit input. - incomingPanelState = panels$.value[incomingPanelId]; - const sameType = incomingPanelState.type === incomingEmbeddable.type; - - incomingPanelState.type = incomingEmbeddable.type; - setRuntimeStateForChild(incomingPanelId, { - // if the incoming panel is the same type as what was there before we can safely spread the old panel's explicit input - ...(sameType ? incomingPanelState.explicitInput : {}), - - ...incomingEmbeddable.input, - - // maintain hide panel titles setting. - hidePanelTitles: (incomingPanelState.explicitInput as { hidePanelTitles?: boolean }) - .hidePanelTitles, - }); - incomingPanelState.explicitInput = {}; - } else { - // otherwise this incoming embeddable is brand new. - setRuntimeStateForChild(incomingPanelId, incomingEmbeddable.input); + const { serializedState, size, type } = incomingEmbeddable; + const newId = incomingEmbeddable.embeddableId ?? v4(); + const existingPanel: DashboardPanelState | undefined = panels$.value[newId]; + const sameType = existingPanel?.type === type; + + const placeIncomingPanel = () => { const { newPanelPlacement } = runPanelPlacementStrategy( PanelPlacementStrategy.findTopLeftMostOpenSpace, { - width: incomingEmbeddable.size?.width ?? DEFAULT_PANEL_WIDTH, - height: incomingEmbeddable.size?.height ?? DEFAULT_PANEL_HEIGHT, + width: size?.width ?? DEFAULT_PANEL_WIDTH, + height: size?.height ?? DEFAULT_PANEL_HEIGHT, currentPanels: panels$.value, } ); - incomingPanelState = { - explicitInput: {}, - type: incomingEmbeddable.type, - gridData: { - ...newPanelPlacement, - i: incomingPanelId, - }, - }; + return { ...newPanelPlacement, i: newId }; + }; + if (serializedState?.references && serializedState.references.length > 0) { + pushReferences(prefixReferencesFromPanel(newId, serializedState.references ?? [])); } + const gridData = existingPanel ? existingPanel.gridData : placeIncomingPanel(); + const explicitInput = { + ...(sameType ? existingPanel?.explicitInput : {}), + ...serializedState.rawState, + }; + + const incomingPanelState: DashboardPanelState = { + type, + explicitInput, + gridData, + }; + setPanels({ ...panels$.value, - [incomingPanelId]: incomingPanelState, + [newId]: incomingPanelState, }); - trackPanel.setScrollToPanelId(incomingPanelId); - trackPanel.setHighlightPanelId(incomingPanelId); + trackPanel.setScrollToPanelId(newId); + trackPanel.setHighlightPanelId(newId); } async function untilEmbeddableLoaded(id: string): Promise { @@ -169,18 +153,13 @@ export function initializePanelsManager( panelPackage: PanelPackage, displaySuccessMessage?: boolean ) => { - const { panelType: type, serializedState, initialState } = panelPackage; - - usageCollectionService?.reportUiCounter(DASHBOARD_UI_METRIC_ID, METRIC_TYPE.CLICK, type); - + const { panelType: type, serializedState } = panelPackage; const newId = v4(); - const getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting(type); - - const customPlacementSettings = getCustomPlacementSettingFunc - ? await getCustomPlacementSettingFunc(initialState) - : undefined; + usageCollectionService?.reportUiCounter(DASHBOARD_UI_METRIC_ID, METRIC_TYPE.CLICK, type); + // place new panel. + const customPlacementSettings = await getDashboardPanelPlacementSetting(type)?.(); const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy( customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace, { @@ -203,7 +182,6 @@ export function initializePanelsManager( ...serializedState?.rawState, }, }; - if (initialState) setRuntimeStateForChild(newId, initialState); setPanels({ ...otherPanels, [newId]: newPanel }); if (displaySuccessMessage) { @@ -285,9 +263,6 @@ export function initializePanelsManager( references: getReferencesForPanelId(childId), }; }, - getRuntimeStateForChild: (childId: string) => { - return restoredRuntimeState?.[childId]; - }, panels$, removePanel: (id: string) => { const panels = { ...panels$.value }; @@ -311,13 +286,11 @@ export function initializePanelsManager( const oldPanel = panels[idToRemove]; delete panels[idToRemove]; - const { panelType: type, serializedState, initialState } = panelPackage; + const { panelType: type, serializedState } = panelPackage; if (serializedState?.references && serializedState.references.length > 0) { pushReferences(prefixReferencesFromPanel(id, serializedState?.references)); } - if (initialState) setRuntimeStateForChild(id, initialState); - setPanels({ ...panels, [id]: { @@ -337,7 +310,6 @@ export function initializePanelsManager( return id; }, setPanels, - setRuntimeStateForChild, untilEmbeddableLoaded, }, comparators: { @@ -352,22 +324,11 @@ export function initializePanelsManager( }, reset: (lastSavedState: DashboardState) => { setPanels(lastSavedState.panels); - restoredRuntimeState = {}; let resetChangedPanelCount = false; const currentChildren = children$.value; for (const panelId of Object.keys(currentChildren)) { if (panels$.value[panelId]) { - const child = currentChildren[panelId]; - if (apiPublishesUnsavedChanges(child)) { - const success = child.resetUnsavedChanges(); - if (!success) { - coreServices.notifications.toasts.addWarning( - i18n.translate('dashboard.reset.panelError', { - defaultMessage: 'Unable to reset panel changes', - }) - ); - } - } + currentChildren[panelId].resetUnsavedChanges?.(); } else { // if reset resulted in panel removal, we need to update the list of children delete currentChildren[panelId]; @@ -384,12 +345,12 @@ export function initializePanelsManager( const panels = Object.keys(panels$.value).reduce((acc, id) => { const childApi = children$.value[id]; - const serializeResult = apiHasSerializableState(childApi) + const { rawState, references: currentReferences } = apiHasSerializableState(childApi) ? childApi.serializeState() - : { rawState: {} }; - acc[id] = { ...panels$.value[id], explicitInput: { ...serializeResult.rawState, id } }; + : { rawState: {}, references: [] }; + acc[id] = { ...panels$.value[id], explicitInput: rawState }; - references.push(...prefixReferencesFromPanel(id, serializeResult.references ?? [])); + references.push(...prefixReferencesFromPanel(id, currentReferences ?? [])); return acc; }, {} as DashboardPanelMap); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts index 9557cb970a934..75c1da551ff5f 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/types.ts @@ -20,8 +20,7 @@ import { PublishesESQLVariables } from '@kbn/esql-types'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { CanExpandPanels, - HasRuntimeChildState, - HasSaveNotification, + HasLastSavedChildState, HasSerializedChildState, PresentationContainer, PublishesSettings, @@ -29,7 +28,6 @@ import { TracksOverlays, } from '@kbn/presentation-containers'; import { - SerializedPanelState, EmbeddableAppContext, HasAppContext, HasExecutionContext, @@ -38,12 +36,14 @@ import { PublishesDataLoading, PublishesDataViews, PublishesDescription, - PublishesTitle, PublishesSavedObjectId, + PublishesTitle, PublishesUnifiedSearch, + PublishesUnsavedChanges, PublishesViewMode, PublishesWritableViewMode, PublishingSubject, + SerializedPanelState, ViewMode, } from '@kbn/presentation-publishing'; import { PublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload'; @@ -169,8 +169,7 @@ export interface UnsavedPanelState { export type DashboardApi = CanExpandPanels & HasAppContext & HasExecutionContext & - HasRuntimeChildState & - HasSaveNotification & + HasLastSavedChildState & HasSerializedChildState & HasType & HasUniqueId & @@ -187,9 +186,9 @@ export type DashboardApi = CanExpandPanels & PublishesUnifiedSearch & PublishesViewMode & PublishesWritableViewMode & + PublishesUnsavedChanges & TrackContentfulRender & TracksOverlays & { - asyncResetToLastSavedState: () => Promise; controlGroupApi$: PublishingSubject; fullScreenMode$: PublishingSubject; focusedPanelId$: PublishingSubject; @@ -202,7 +201,6 @@ export type DashboardApi = CanExpandPanels & }; getDashboardPanelFromId: (id: string) => DashboardPanelState; hasOverlays$: PublishingSubject; - hasUnsavedChanges$: PublishingSubject; highlightPanel: (panelRef: HTMLDivElement) => void; highlightPanelId$: PublishingSubject; isEmbeddedExternally: boolean; @@ -230,7 +228,7 @@ export type DashboardApi = CanExpandPanels & export interface DashboardInternalApi { controlGroupReload$: Subject; panelsReload$: Subject; - getRuntimeStateForControlGroup: () => object | undefined; + getLastSavedStateForControlGroup: () => SerializedPanelState; getSerializedStateForControlGroup: () => SerializedPanelState; registerChildApi: (api: DefaultEmbeddableApi) => void; setControlGroupApi: (controlGroupApi: ControlGroupApi) => void; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts b/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts index 337e7d86893fe..dfd193acbbf6e 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts +++ b/src/platform/plugins/shared/dashboard/public/dashboard_api/unsaved_changes_manager.ts @@ -7,24 +7,33 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { Reference } from '@kbn/content-management-utils'; import { ControlGroupApi } from '@kbn/controls-plugin/public'; -import { childrenUnsavedChanges$, initializeUnsavedChanges } from '@kbn/presentation-containers'; +import { HasLastSavedChildState, childrenUnsavedChanges$ } from '@kbn/presentation-containers'; import { PublishesSavedObjectId, PublishingSubject, + SerializedPanelState, StateComparators, + diffComparators$, + getUnchangingComparator, } from '@kbn/presentation-publishing'; -import { omit } from 'lodash'; -import { BehaviorSubject, Subject, combineLatest, debounceTime, skipWhile, switchMap } from 'rxjs'; +import { cloneDeep, omit } from 'lodash'; import { - PANELS_CONTROL_GROUP_KEY, - getDashboardBackupService, -} from '../services/dashboard_backup_service'; + BehaviorSubject, + Subject, + combineLatest, + debounceTime, + map, + skipWhile, + switchMap, +} from 'rxjs'; import { initializePanelsManager } from './panels_manager'; import { initializeSettingsManager } from './settings_manager'; import { DashboardCreationOptions, DashboardState } from './types'; import { initializeUnifiedSearchManager } from './unified_search_manager'; import { initializeViewModeManager } from './view_mode_manager'; +import { getDashboardBackupService } from '../services/dashboard_backup_service'; export function initializeUnsavedChangesManager({ creationOptions, @@ -36,10 +45,12 @@ export function initializeUnsavedChangesManager({ viewModeManager, unifiedSearchManager, referencesComparator, + getPanelReferences, }: { + getPanelReferences: (id: string) => Reference[]; creationOptions?: DashboardCreationOptions; controlGroupApi$: PublishingSubject; - lastSavedState: DashboardState; + lastSavedState: DashboardState | undefined; panelsManager: ReturnType; savedObjectId$: PublishesSavedObjectId['savedObjectId$']; settingsManager: ReturnType; @@ -48,35 +59,46 @@ export function initializeUnsavedChangesManager({ referencesComparator: StateComparators>; }) { const hasUnsavedChanges$ = new BehaviorSubject(false); - const lastSavedState$ = new BehaviorSubject(lastSavedState); + const lastSavedState$ = new BehaviorSubject(lastSavedState); const saveNotification$ = new Subject(); - const dashboardUnsavedChanges = initializeUnsavedChanges< - Omit - >( - lastSavedState, - { saveNotification$ }, - { - ...panelsManager.comparators, - ...settingsManager.comparators, - ...viewModeManager.comparators, - ...unifiedSearchManager.comparators, - ...referencesComparator, - } + const dashboardChangesSource$ = diffComparators$(lastSavedState$, { + ...panelsManager.comparators, + ...settingsManager.comparators, + ...viewModeManager.comparators, + ...unifiedSearchManager.comparators, + ...referencesComparator, + + tags: getUnchangingComparator(), + controlGroupInput: getUnchangingComparator(), + controlGroupState: getUnchangingComparator(), + }); + const panelsChangesSource$ = childrenUnsavedChanges$(panelsManager.api.children$).pipe( + map((childrenWithChanges) => { + const changedPanelStates: { [key: string]: SerializedPanelState } = {}; + for (const { uuid, hasUnsavedChanges } of childrenWithChanges) { + if (!hasUnsavedChanges) continue; + const childApi = panelsManager.api.children$.value[uuid]; + if (!childApi) continue; + changedPanelStates[uuid] = childApi.serializeState(); + } + if (Object.keys(changedPanelStates).length === 0) return undefined; + return changedPanelStates; + }) + ); + + const controlGroupChangesSource$ = controlGroupApi$.pipe( + skipWhile((api) => !api), + switchMap((api) => api!.hasUnsavedChanges$) ); const unsavedChangesSubscription = combineLatest([ - dashboardUnsavedChanges.api.unsavedChanges$, - childrenUnsavedChanges$(panelsManager.api.children$), - controlGroupApi$.pipe( - skipWhile((controlGroupApi) => !controlGroupApi), - switchMap((controlGroupApi) => { - return controlGroupApi!.unsavedChanges$; - }) - ), + dashboardChangesSource$, + panelsChangesSource$, + controlGroupChangesSource$, ]) .pipe(debounceTime(0)) - .subscribe(([dashboardChanges, unsavedPanelState, controlGroupChanges]) => { + .subscribe(([dashboardChanges, unsavedPanelState, hasControlGroupChanges]) => { /** * viewMode needs to be stored in session state because its used to exclude 'view' dashboards on the listing page * However, viewMode differences should not trigger unsaved changes notification otherwise, opening a dashboard in @@ -86,50 +108,75 @@ export function initializeUnsavedChangesManager({ const hasDashboardChanges = Object.keys(omit(dashboardChanges ?? {}, ['viewMode', 'references'])).length > 0; const hasUnsavedChanges = - hasDashboardChanges || unsavedPanelState !== undefined || controlGroupChanges !== undefined; + hasDashboardChanges || unsavedPanelState !== undefined || hasControlGroupChanges; if (hasUnsavedChanges !== hasUnsavedChanges$.value) { hasUnsavedChanges$.next(hasUnsavedChanges); } + const allReferences: Reference[] = []; // backup unsaved changes if configured to do so if (creationOptions?.useSessionStorageIntegration) { // Current behaviour expects time range not to be backed up. Revisit this? - const dashboardStateToBackup = omit(dashboardChanges ?? {}, [ + const dashboardStateToBackup: Partial = omit(dashboardChanges ?? {}, [ 'timeRange', 'refreshInterval', ]); - const reactEmbeddableChanges = unsavedPanelState ? { ...unsavedPanelState } : {}; - if (controlGroupChanges) { - reactEmbeddableChanges[PANELS_CONTROL_GROUP_KEY] = controlGroupChanges; + // apply control group changes. + if (hasControlGroupChanges) { + const serializedControlGroupState = controlGroupApi$.value?.serializeState(); + allReferences.concat(serializedControlGroupState?.references ?? []); + dashboardStateToBackup.controlGroupInput = serializedControlGroupState?.rawState; } - getDashboardBackupService().setState( - savedObjectId$.value, - dashboardStateToBackup, - reactEmbeddableChanges - ); + // apply panels changes + if (unsavedPanelState) { + const currentPanels = panelsManager.internalApi.getState().panels; + dashboardStateToBackup.panels = {}; + for (const [uuid, serializedPanelState] of Object.entries(unsavedPanelState)) { + allReferences.concat(serializedPanelState.references ?? []); + dashboardStateToBackup.panels[uuid] = { + ...currentPanels[uuid], + explicitInput: { ...serializedPanelState.rawState, id: uuid }, + }; + } + } + dashboardStateToBackup.references = allReferences; + getDashboardBackupService().setState(savedObjectId$.value, dashboardStateToBackup); } }); + const lastSavedChildStateApi: HasLastSavedChildState = { + saveNotification$, + getLastSavedStateForChild: (uuid: string) => { + if (!lastSavedState$.value?.panels[uuid]) return; + const rawState = lastSavedState$.value?.panels[uuid]?.explicitInput; + + return { + rawState, + references: getPanelReferences(uuid), + }; + }, + }; + return { api: { - asyncResetToLastSavedState: async () => { + resetUnsavedChanges: async () => { + if (!lastSavedState$.value) return; panelsManager.internalApi.reset(lastSavedState$.value); settingsManager.internalApi.reset(lastSavedState$.value); unifiedSearchManager.internalApi.reset(lastSavedState$.value); - await controlGroupApi$.value?.asyncResetUnsavedChanges(); + await controlGroupApi$.value?.resetUnsavedChanges(); }, + ...lastSavedChildStateApi, hasUnsavedChanges$, - saveNotification$, }, cleanup: () => { - dashboardUnsavedChanges.cleanup(); unsavedChangesSubscription.unsubscribe(); }, internalApi: { getLastSavedState: () => lastSavedState$.value, onSave: (savedState: DashboardState) => { - lastSavedState$.next(savedState); + lastSavedState$.next(cloneDeep(savedState)); saveNotification$.next(); }, }, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx index 69279269b1714..f7b111d6ade7d 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/no_data/dashboard_app_no_data.tsx @@ -10,7 +10,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import useAsync from 'react-use/lib/useAsync'; -import { v4 as uuidv4 } from 'uuid'; import { getESQLAdHocDataview, getESQLQueryColumns, @@ -18,7 +17,7 @@ import { getInitialESQLQuery, } from '@kbn/esql-utils'; import { withSuspense } from '@kbn/shared-ux-utility'; -import type { TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import type { LensSerializedState } from '@kbn/lens-plugin/public'; import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils'; import { @@ -33,10 +32,6 @@ import { import { getDashboardBackupService } from '../../services/dashboard_backup_service'; import { getDashboardContentManagementService } from '../../services/dashboard_content_management_service'; -function generateId() { - return uuidv4(); -} - export const DashboardAppNoDataPage = ({ onDataViewCreated, }: { @@ -100,27 +95,26 @@ export const DashboardAppNoDataPage = ({ if (chartSuggestions?.length) { const [suggestion] = chartSuggestions; - const attrs = getLensAttributesFromSuggestion({ - filters: [], - query: { - esql: esqlQuery, - }, - suggestion, - dataView, - }) as TypedLensByValueInput['attributes']; - - const lensEmbeddableInput = { - attributes: attrs, - id: generateId(), - }; - - await embeddableService.getStateTransfer().navigateToWithEmbeddablePackage('dashboards', { - state: { - type: 'lens', - input: lensEmbeddableInput, - }, - path: '#/create', - }); + await embeddableService + .getStateTransfer() + .navigateToWithEmbeddablePackage('dashboards', { + state: { + type: 'lens', + serializedState: { + rawState: { + attributes: getLensAttributesFromSuggestion({ + filters: [], + query: { + esql: esqlQuery, + }, + suggestion, + dataView, + }), + }, + }, + }, + path: '#/create', + }); } } catch (error) { if (error.name !== 'AbortError') { diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 0ddc8614db318..8acffb7687502 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -103,7 +103,7 @@ export const useDashboardMenuItems = ({ } confirmDiscardUnsavedChanges(async () => { setIsResetting(true); - await dashboardApi.asyncResetToLastSavedState(); + await dashboardApi.resetUnsavedChanges(); if (isMounted()) { setIsResetting(false); switchModes?.(); diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx index 35cb077be5bb6..29e40540465aa 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_renderer/viewport/dashboard_viewport.tsx @@ -19,7 +19,7 @@ import { ControlGroupRuntimeState, ControlGroupSerializedState, } from '@kbn/controls-plugin/public'; -import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; +import { CONTROL_GROUP_TYPE, CONTROL_GROUP_STATIC_ID } from '@kbn/controls-plugin/common'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { DashboardGrid } from '../grid'; import { useDashboardApi } from '../../dashboard_api/use_dashboard_api'; @@ -115,16 +115,18 @@ export const DashboardViewport = ({ hidePanelChrome={true} panelProps={{ hideLoader: true }} type={CONTROL_GROUP_TYPE} - maybeId={'control_group'} + maybeId={CONTROL_GROUP_STATIC_ID} getParentApi={() => { return { ...dashboardApi, reload$: dashboardInternalApi.controlGroupReload$, getSerializedStateForChild: dashboardInternalApi.getSerializedStateForControlGroup, - getRuntimeStateForChild: dashboardInternalApi.getRuntimeStateForControlGroup, + getLastSavedStateForChild: dashboardInternalApi.getLastSavedStateForControlGroup, }; }} - onApiAvailable={(api) => dashboardInternalApi.setControlGroupApi(api)} + onApiAvailable={(api) => { + dashboardInternalApi.setControlGroupApi(api); + }} /> ) : null} diff --git a/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts b/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts index 52068d881e9ac..f98371f04fc49 100644 --- a/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts +++ b/src/platform/plugins/shared/dashboard/public/services/dashboard_backup_service.ts @@ -35,17 +35,8 @@ const getPanelsGetError = (message: string) => interface DashboardBackupServiceType { clearState: (id?: string) => void; - getState: (id: string | undefined) => - | { - dashboardState?: Partial; - panels?: UnsavedPanelState; - } - | undefined; - setState: ( - id: string | undefined, - dashboardState: Partial, - panels: UnsavedPanelState - ) => void; + getState: (id: string | undefined) => Partial | undefined; + setState: (id: string | undefined, dashboardState: Partial) => void; getViewMode: () => ViewMode; storeViewMode: (viewMode: ViewMode) => void; getDashboardIdsWithUnsavedChanges: () => string[]; @@ -121,14 +112,9 @@ class DashboardBackupService implements DashboardBackupServiceType { public getState(id = DASHBOARD_PANELS_UNSAVED_ID) { try { - const dashboardState = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[ - this.activeSpaceId - ]?.[id] as Partial | undefined; - const panels = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId]?.[ - id - ] as UnsavedPanelState | undefined; - - return { dashboardState, panels }; + return this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[this.activeSpaceId]?.[id] as + | Partial + | undefined; } catch (e) { coreServices.notifications.toasts.addDanger({ title: getPanelsGetError(e.message), @@ -137,19 +123,11 @@ class DashboardBackupService implements DashboardBackupServiceType { } } - public setState( - id = DASHBOARD_PANELS_UNSAVED_ID, - newState: Partial, - unsavedPanels: UnsavedPanelState - ) { + public setState(id = DASHBOARD_PANELS_UNSAVED_ID, newState: Partial) { try { const dashboardStateStorage = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY) ?? {}; set(dashboardStateStorage, [this.activeSpaceId, id], newState); this.sessionStorage.set(DASHBOARD_STATE_SESSION_KEY, dashboardStateStorage); - - const panelsStorage = this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY) ?? {}; - set(panelsStorage, [this.activeSpaceId, id], unsavedPanels); - this.sessionStorage.set(DASHBOARD_PANELS_SESSION_KEY, panelsStorage, true); } catch (e) { coreServices.notifications.toasts.addDanger({ title: i18n.translate('dashboard.panelStorageError.setError', { @@ -165,23 +143,19 @@ class DashboardBackupService implements DashboardBackupServiceType { try { const dashboardStatesInSpace = this.sessionStorage.get(DASHBOARD_STATE_SESSION_KEY)?.[this.activeSpaceId] ?? {}; - const panelStatesInSpace = - this.sessionStorage.get(DASHBOARD_PANELS_SESSION_KEY)?.[this.activeSpaceId] ?? {}; const dashboardsSet: Set = new Set(); - [...Object.keys(panelStatesInSpace), ...Object.keys(dashboardStatesInSpace)].map( - (dashboardId) => { - if ( - dashboardStatesInSpace[dashboardId].viewMode === 'edit' && - (Object.keys(dashboardStatesInSpace[dashboardId]).some( - (stateKey) => stateKey !== 'viewMode' && stateKey !== 'references' - ) || - Object.keys(panelStatesInSpace?.[dashboardId]).length > 0) + Object.keys(dashboardStatesInSpace).map((dashboardId) => { + if ( + dashboardStatesInSpace[dashboardId].viewMode === 'edit' && + Object.keys(dashboardStatesInSpace[dashboardId]).some( + (stateKey) => stateKey !== 'viewMode' && stateKey !== 'references' ) - dashboardsSet.add(dashboardId); + ) { + dashboardsSet.add(dashboardId); } - ); + }); const dashboardsWithUnsavedChanges = [...dashboardsSet]; /** diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx index 1508ed13f0ef0..e20149aa75a2c 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -7,29 +7,25 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { - apiHasRuntimeChildState, - apiIsPresentationContainer, - HasSerializedChildState, - initializeUnsavedChanges, -} from '@kbn/presentation-containers'; +import { apiIsPresentationContainer, HasSerializedChildState } from '@kbn/presentation-containers'; import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public'; import { - ComparatorDefinition, - StateComparators, - HasSnapshottableState, - SerializedPanelState, + type ComparatorDefinition, + type HasSnapshottableState, + type SerializedPanelState, + type StateComparators, + initializeHasUnsavedChanges, } from '@kbn/presentation-publishing'; import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import { BehaviorSubject, combineLatest, debounceTime, map, skip, Subscription } from 'rxjs'; import { v4 as generateId } from 'uuid'; +import { PhaseTracker } from './phase_tracker'; import { getReactEmbeddableFactory } from './react_embeddable_registry'; import { BuildReactEmbeddableApiRegistration, DefaultEmbeddableApi, SetReactEmbeddableApiRegistration, } from './types'; -import { PhaseTracker } from './phase_tracker'; const ON_STATE_CHANGE_DEBOUNCE = 100; @@ -95,18 +91,11 @@ export const ReactEmbeddableRenderer = < const buildEmbeddable = async () => { const factory = await getReactEmbeddableFactory(type); const serializedState = parentApi.getSerializedStateForChild(uuid); - const lastSavedRuntimeState = serializedState + + const initialRuntimeState = serializedState ? await factory.deserializeState(serializedState) : ({} as RuntimeState); - // If the parent provides runtime state for the child (usually as a state backup or cache), - // we merge it with the last saved runtime state. - const partialRuntimeState = apiHasRuntimeChildState(parentApi) - ? parentApi.getRuntimeStateForChild(uuid) ?? ({} as Partial) - : ({} as Partial); - - const initialRuntimeState = { ...lastSavedRuntimeState, ...partialRuntimeState }; - const setApi = ( apiRegistration: SetReactEmbeddableApiRegistration ) => { @@ -152,11 +141,14 @@ export const ReactEmbeddableRenderer = < }) ); } + (apiRegistration as any).type = type; - const unsavedChanges = initializeUnsavedChanges( - lastSavedRuntimeState, + const unsavedChanges = initializeHasUnsavedChanges( + uuid, + comparators, + apiRegistration, parentApi, - comparators + factory.deserializeState ); const fullApi = setApi({ @@ -177,8 +169,7 @@ export const ReactEmbeddableRenderer = < buildApi, uuid, parentApi, - setApi, - lastSavedRuntimeState + setApi ); phaseTracker.current.trackPhaseEvents(uuid, api); diff --git a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts index db0818a76b826..396256cac454c 100644 --- a/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts +++ b/src/platform/plugins/shared/embeddable/public/react_embeddable_system/types.ts @@ -122,11 +122,6 @@ export interface ReactEmbeddableFactory< uuid: string, parentApi: unknown | undefined, /** `setApi` should be used when the unsaved changes logic in `buildApi` is unnecessary */ - setApi: (api: SetReactEmbeddableApiRegistration) => Api, - /** - * Last saved runtime state. Different from initialRuntimeState in that it does not contain previous sessions's unsaved changes - * Compare with initialRuntimeState to flag unsaved changes on load - */ - lastSavedRuntimeState: RuntimeState + setApi: (api: SetReactEmbeddableApiRegistration) => Api ) => Promise<{ Component: React.FC<{}>; api: Api }>; } diff --git a/src/platform/plugins/shared/embeddable/public/state_transfer/embeddable_state_transfer.ts b/src/platform/plugins/shared/embeddable/public/state_transfer/embeddable_state_transfer.ts index 877f53bb3c7f6..651044eca775d 100644 --- a/src/platform/plugins/shared/embeddable/public/state_transfer/embeddable_state_transfer.ts +++ b/src/platform/plugins/shared/embeddable/public/state_transfer/embeddable_state_transfer.ts @@ -133,14 +133,18 @@ export class EmbeddableStateTransfer { * A wrapper around the {@link ApplicationStart.navigateToApp} method which navigates to the specified appId * with {@link EmbeddablePackageState | embeddable package state} */ - public async navigateToWithEmbeddablePackage( + public async navigateToWithEmbeddablePackage( appId: string, - options?: { path?: string; state: EmbeddablePackageState } + options?: { path?: string; state: EmbeddablePackageState } ): Promise { this.isTransferInProgress = true; - await this.navigateToWithState(appId, EMBEDDABLE_PACKAGE_STATE_KEY, { - ...options, - }); + await this.navigateToWithState>( + appId, + EMBEDDABLE_PACKAGE_STATE_KEY, + { + ...options, + } + ); } private getIncomingState( diff --git a/src/platform/plugins/shared/embeddable/public/state_transfer/types.ts b/src/platform/plugins/shared/embeddable/public/state_transfer/types.ts index 5380b8aae3d6c..603420edebe9b 100644 --- a/src/platform/plugins/shared/embeddable/public/state_transfer/types.ts +++ b/src/platform/plugins/shared/embeddable/public/state_transfer/types.ts @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { SerializedPanelState } from '@kbn/presentation-publishing'; + export const EMBEDDABLE_EDITOR_STATE_KEY = 'embeddable_editor_state'; /** @@ -36,12 +38,9 @@ export const EMBEDDABLE_PACKAGE_STATE_KEY = 'embeddable_package_state'; * A state package that contains all fields necessary to create or update an embeddable by reference or by value in a container. * @public */ -export interface EmbeddablePackageState { +export interface EmbeddablePackageState { type: string; - /** - * For react embeddables, this input must be runtime state. - */ - input: object; + serializedState: SerializedPanelState; embeddableId?: string; size?: { width?: number; @@ -57,7 +56,7 @@ export interface EmbeddablePackageState { export function isEmbeddablePackageState(state: unknown): state is EmbeddablePackageState { return ( ensureFieldOfTypeExists('type', state, 'string') && - ensureFieldOfTypeExists('input', state, 'object') + ensureFieldOfTypeExists('serializedState', state, 'object') ); } diff --git a/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx b/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx index 1d7e0951fa573..c7b2279e20540 100644 --- a/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx +++ b/src/platform/plugins/shared/visualizations/public/visualize_app/utils/get_top_nav_config.tsx @@ -44,6 +44,7 @@ import { VISUALIZE_EDITOR_TRIGGER, AGG_BASED_VISUALIZATION_TRIGGER } from '../.. import { getVizEditorOriginatingAppUrl } from './utils'; import './visualize_navigation.scss'; +import { serializeReferences } from '../../utils/saved_visualization_references'; interface VisualizeCapabilities { createShortUrl: boolean; @@ -183,12 +184,17 @@ export const getTopNavConfig = ( } if (stateTransfer) { + const serializedVis = vis.serialize(); + const { references } = serializeReferences(serializedVis); stateTransfer.navigateToWithEmbeddablePackage(app, { state: { type: VISUALIZE_EMBEDDABLE_TYPE, - input: { - serializedVis: vis.serialize(), - savedObjectId: id, + serializedState: { + rawState: { + serializedVis, + savedObjectId: id, + }, + references, }, embeddableId: saveOptions.copyOnSave ? undefined : embeddableId, searchSessionId: data.search.session.getSessionId(), @@ -245,17 +251,23 @@ export const getTopNavConfig = ( if (!originatingApp) { return; } - - const state = { - input: { - serializedVis: vis.serialize(), + const serializedVis = vis.serialize(); + const { references } = serializeReferences(serializedVis); + + stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { + state: { + serializedState: { + rawState: { + serializedVis: vis.serialize(), + }, + references, + }, + embeddableId, + type: VISUALIZE_EMBEDDABLE_TYPE, + searchSessionId: data.search.session.getSessionId(), }, - embeddableId, - type: VISUALIZE_EMBEDDABLE_TYPE, - searchSessionId: data.search.session.getSessionId(), - }; - - stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { state, path: originatingPath }); + path: originatingPath, + }); }; const navigateToOriginatingApp = () => { @@ -519,23 +531,26 @@ export const getTopNavConfig = ( history.replace(appPath); setActiveUrl(appPath); - const state = { - input: { - serializedVis: { - ...vis.serialize(), - title: newTitle, - description: newDescription, - }, - }, - embeddableId, - type: VISUALIZE_EMBEDDABLE_TYPE, - searchSessionId: data.search.session.getSessionId(), - }; - const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`; + const serializedVis = vis.serialize(); + const { references } = serializeReferences(serializedVis); stateTransfer.navigateToWithEmbeddablePackage('dashboards', { - state, + state: { + serializedState: { + rawState: { + serializedVis: { + ...serializedVis, + title: newTitle, + description: newDescription, + }, + }, + references, + }, + embeddableId, + type: VISUALIZE_EMBEDDABLE_TYPE, + searchSessionId: data.search.session.getSessionId(), + }, path, }); diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx index 7042117098395..9c8c77d81f44b 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/mounter.tsx @@ -10,6 +10,7 @@ import { AppMountParameters, CoreSetup, CoreStart } from '@kbn/core/public'; import { FormattedMessage } from '@kbn/i18n-react'; import { RouteComponentProps } from 'react-router-dom'; import { HashRouter, Routes, Route } from '@kbn/shared-ux-router'; +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { History } from 'history'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; @@ -32,6 +33,7 @@ import { App } from './app'; import { EditorFrameStart, LensTopNavMenuEntryGenerator, VisualizeEditorContext } from '../types'; import { addHelpMenuToAppChrome } from '../help_menu_util'; import { LensPluginStartDependencies } from '../plugin'; +import { extract } from '../../common/embeddable_factory'; import { LENS_EMBEDDABLE_TYPE, LENS_EDIT_BY_VALUE, APP_ID } from '../../common/constants'; import { LensAttributesService } from '../lens_attribute_service'; import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types'; @@ -217,13 +219,17 @@ export async function mountApp( embeddableId = initialContext.embeddableId; } if (stateTransfer && props?.state) { - const { state, isCopied } = props; - stateTransfer.navigateToWithEmbeddablePackage(mergedOriginatingApp, { + const { state: rawState, isCopied } = props; + const { references } = extract(rawState as unknown as EmbeddableStateWithType); + stateTransfer.navigateToWithEmbeddablePackage(mergedOriginatingApp, { path: embeddableEditorIncomingState?.originatingPath, state: { embeddableId: isCopied ? undefined : embeddableId, type: LENS_EMBEDDABLE_TYPE, - input: { ...state, savedObject: state.savedObjectId }, + serializedState: { + references, + rawState, + }, searchSessionId: data.search.session.getSessionId(), }, }); diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/save_modal_container_helpers.ts b/x-pack/platform/plugins/shared/lens/public/app_plugin/save_modal_container_helpers.ts index 44b879c7f27cb..e647499bd8c77 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/save_modal_container_helpers.ts +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/save_modal_container_helpers.ts @@ -5,12 +5,14 @@ * 2.0. */ +import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import type { LensAppServices } from './types'; import { LENS_EMBEDDABLE_TYPE } from '../../common/constants'; import { LensSerializedState } from '../react_embeddable/types'; +import { extract } from '../../common/embeddable_factory'; export const redirectToDashboard = ({ - embeddableInput, + embeddableInput: rawState, dashboardId, originatingApp, getOriginatingPath, @@ -22,17 +24,20 @@ export const redirectToDashboard = ({ getOriginatingPath?: (dashboardId: string) => string | undefined; stateTransfer: LensAppServices['stateTransfer']; }) => { - const state = { - input: embeddableInput, - type: LENS_EMBEDDABLE_TYPE, - }; + const { references } = extract(rawState as unknown as EmbeddableStateWithType); const path = getOriginatingPath?.(dashboardId) ?? (dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`); const appId = originatingApp || 'dashboards'; - stateTransfer.navigateToWithEmbeddablePackage(appId, { - state, + stateTransfer.navigateToWithEmbeddablePackage(appId, { + state: { + type: LENS_EMBEDDABLE_TYPE, + serializedState: { + rawState, + references, + }, + }, path, }); }; diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts index 1fb7556071fcf..7545013884e93 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_integrations.ts @@ -14,7 +14,12 @@ import { noop, omit } from 'lodash'; import type { HasSerializableState } from '@kbn/presentation-publishing'; import { SavedObjectReference } from '@kbn/core/types'; import { emptySerializer, isTextBasedLanguage } from '../helper'; -import type { GetStateType, LensEmbeddableStartServices, LensRuntimeState } from '../types'; +import type { + GetStateType, + LensEmbeddableStartServices, + LensSerializedState, + LensRuntimeState, +} from '../types'; import type { IntegrationCallbacks } from '../types'; function cleanupSerializedState({ @@ -45,7 +50,7 @@ export function initializeIntegrations( | 'updateDataLoading' | 'getTriggerCompatibleActions' > & - HasSerializableState; + HasSerializableState; cleanup: () => void; serialize: () => {}; comparators: {}; diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_state_management.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_state_management.ts index b085f84eb5267..ed51c89cfefa6 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_state_management.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/initializers/initialize_state_management.ts @@ -12,16 +12,24 @@ import { type PublishesSavedObjectId, type StateComparators, type PublishesRendered, + HasSerializedStateComparator, } from '@kbn/presentation-publishing'; import { noop } from 'lodash'; +import deepEqual from 'fast-deep-equal'; import type { DataView } from '@kbn/data-views-plugin/common'; import { BehaviorSubject } from 'rxjs'; -import type { IntegrationCallbacks, LensInternalApi, LensRuntimeState } from '../types'; +import type { + IntegrationCallbacks, + LensInternalApi, + LensRuntimeState, + LensSerializedState, +} from '../types'; import { buildObservableVariable } from '../helper'; import { SharingSavedObjectProps } from '../../types'; export interface StateManagementConfig { api: Pick & + HasSerializedStateComparator & PublishesSavedObjectId & PublishesDataViews & PublishesDataLoading & @@ -66,6 +74,13 @@ export function initializeStateManagement( const [blockingError$] = buildObservableVariable(internalApi.blockingError$); return { api: { + isSerializedStateEqual: (a, b) => { + const isEqual = deepEqual(a, b); + if (!isEqual) { + debugger; + } + return isEqual; + }, updateAttributes: internalApi.updateAttributes, updateSavedObjectId: (newSavedObjectId: LensRuntimeState['savedObjectId']) => savedObjectId$.next(newSavedObjectId), diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts index b068e5e2944b8..9bc98763c9aef 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts @@ -32,6 +32,7 @@ import type { PublishingSubject, SerializedTitles, ViewMode, + HasSerializedStateComparator, } from '@kbn/presentation-publishing'; import type { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public/plugin'; import type { @@ -377,6 +378,8 @@ export interface LensInspectorAdapters { export type LensApi = Simplify< DefaultEmbeddableApi & + // used to determine if the Serialized State has changed. + HasSerializedStateComparator & // This is used by actions to operate the edit action HasEditCapabilities & // for blocking errors leverage the embeddable panel UI diff --git a/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx b/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx index d1f861ace715d..9b1d3b71fe1e4 100644 --- a/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx +++ b/x-pack/platform/plugins/shared/maps/public/react_embeddable/map_react_embeddable.tsx @@ -12,10 +12,12 @@ import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public'; import { ReactEmbeddableFactory, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public'; import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; import { + StateComparators, apiIsOfType, areTriggersDisabled, getUnchangingComparator, initializeTimeRange, + runComparators, initializeTitleManager, useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; @@ -152,6 +154,24 @@ export const mapEmbeddableFactory: ReactEmbeddableFactory< }; } + const allComparators: StateComparators = { + ...timeRange.comparators, + ...titleManager.comparators, + ...(dynamicActionsApi?.dynamicActionsComparator ?? { + enhancements: getUnchangingComparator(), + }), + ...crossPanelActions.comparators, + ...reduxSync.comparators, + attributes: [attributes$, (next: MapAttributes | undefined) => attributes$.next(next)], + mapSettings: [ + mapSettings$, + (next: Partial | undefined) => mapSettings$.next(next), + ], + savedObjectId: [savedObjectId$, (next: string | undefined) => savedObjectId$.next(next)], + // readonly comparators + mapBuffer: getUnchangingComparator(), + }; + api = buildApi( { defaultTitle$, @@ -164,27 +184,17 @@ export const mapEmbeddableFactory: ReactEmbeddableFactory< ...initializeLibraryTransforms(savedMap, serializeState), ...initializeDataViews(savedMap.getStore()), serializeState, + isSerializedStateEqual: (a, b) => { + // since map serialized state and runtime state are the same, we can run through the comparators. + const comparatorKeys = Object.keys(allComparators) as Array; + const diff = runComparators(allComparators, comparatorKeys, b, a ?? {}); + return Boolean(diff); + }, supportedTriggers: () => { return [APPLY_FILTER_TRIGGER, VALUE_CLICK_TRIGGER]; }, }, - { - ...timeRange.comparators, - ...titleManager.comparators, - ...(dynamicActionsApi?.dynamicActionsComparator ?? { - enhancements: getUnchangingComparator(), - }), - ...crossPanelActions.comparators, - ...reduxSync.comparators, - attributes: [attributes$, (next: MapAttributes | undefined) => attributes$.next(next)], - mapSettings: [ - mapSettings$, - (next: Partial | undefined) => mapSettings$.next(next), - ], - savedObjectId: [savedObjectId$, (next: string | undefined) => savedObjectId$.next(next)], - // readonly comparators - mapBuffer: getUnchangingComparator(), - } + allComparators ); const unsubscribeFromFetch = initializeFetch({ diff --git a/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts b/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts index f8538e14e2104..76d6cc8aaa6a6 100644 --- a/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts +++ b/x-pack/platform/plugins/shared/maps/public/react_embeddable/types.ts @@ -11,6 +11,7 @@ import type { HasInspectorAdapters } from '@kbn/inspector-plugin/public'; import type { HasEditCapabilities, HasLibraryTransforms, + HasSerializedStateComparator, HasSupportedTriggers, HasType, PublishesDataLoading, @@ -52,6 +53,7 @@ export type MapSerializedState = SerializedTitles & export type MapRuntimeState = MapSerializedState; export type MapApi = DefaultEmbeddableApi & + HasSerializedStateComparator & HasDynamicActions & Partial & HasInspectorAdapters & diff --git a/x-pack/platform/plugins/shared/maps/public/routes/map_page/saved_map/saved_map.ts b/x-pack/platform/plugins/shared/maps/public/routes/map_page/saved_map/saved_map.ts index 9e5a537de7bac..b024d3d22a0ae 100644 --- a/x-pack/platform/plugins/shared/maps/public/routes/map_page/saved_map/saved_map.ts +++ b/x-pack/platform/plugins/shared/maps/public/routes/map_page/saved_map/saved_map.ts @@ -469,11 +469,11 @@ export class SavedMap { await this._syncAttributesWithStore(); let mapSerializedState: MapSerializedState | undefined; + const { attributes, references } = extractReferences({ + attributes: this._attributes, + }); if (saveByReference) { try { - const { attributes, references } = extractReferences({ - attributes: this._attributes, - }); const savedObjectsTagging = getSavedObjectsTagging(); const tagReferences = savedObjectsTagging && tags ? savedObjectsTagging.ui.updateTagsReferences([], tags) : []; @@ -521,7 +521,7 @@ export class SavedMap { state: { embeddableId: newCopyOnSave ? undefined : this._embeddableId, type: MAP_SAVED_OBJECT_TYPE, - input: mapSerializedState, + serializedState: { rawState: mapSerializedState, references }, }, path: this._originatingPath, }); @@ -530,7 +530,7 @@ export class SavedMap { await this._getStateTransfer().navigateToWithEmbeddablePackage('dashboards', { state: { type: MAP_SAVED_OBJECT_TYPE, - input: mapSerializedState, + serializedState: { rawState: mapSerializedState, references }, }, path: dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`, });