Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const PresentationContainerExample = ({ uiActions }: { uiActions: UiActio
<EuiFlexItem grow={false}>
<TopNav
onSave={componentApi.onSave}
resetUnsavedChanges={pageApi.resetUnsavedChanges}
onReset={componentApi.onReset}
hasUnsavedChanges$={pageApi.hasUnsavedChanges$}
/>
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { PublishesUnsavedChanges } from '@kbn/presentation-publishing';

interface Props {
onSave: () => Promise<void>;
resetUnsavedChanges: PublishesUnsavedChanges['resetUnsavedChanges'];
onReset: () => void;
hasUnsavedChanges$: PublishesUnsavedChanges['hasUnsavedChanges$'];
}

Expand All @@ -40,7 +40,7 @@ export function TopNav(props: Props) {
<EuiBadge color="warning">Unsaved changes</EuiBadge>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty disabled={isSaving} onClick={props.resetUnsavedChanges}>
<EuiButtonEmpty disabled={isSaving} onClick={props.onReset}>
Reset
</EuiButtonEmpty>
</EuiFlexItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import type {
import {
apiHasSerializableState,
apiPublishesDataLoading,
apiPublishesUnsavedChanges,
childrenUnsavedChanges$,
combineCompatibleChildrenApis,
} from '@kbn/presentation-publishing';
Expand Down Expand Up @@ -156,6 +155,31 @@ export function getPageApi() {
unsavedChangesSessionStorage.save(serializePage());
});

const applySerializedState = (state?: PageState) => {
const nextState = state ?? initialState;
timeRange$.next(nextState.timeRange);
const { layout: nextLayout, childState: nextChildState } = deserializePanels(nextState.panels);
layout$.next(nextLayout);
currentChildState = nextChildState;
let childrenModified = false;
const currentChildren = { ...children$.value };
for (const uuid of Object.keys(currentChildren)) {
const existsInNextLayout = nextLayout.some(({ id }) => id === uuid);
if (existsInNextLayout) {
const child = currentChildren[uuid];
if (apiHasSerializableState(child)) {
void child.applySerializedState(nextChildState[uuid]);
}
} else {
// if reset resulted in panel removal, we need to update the list of children
delete currentChildren[uuid];
delete currentChildState[uuid];
childrenModified = true;
}
}
if (childrenModified) children$.next(currentChildren);
};

return {
cleanUp: () => {
childrenDataLoadingSubscripiton.unsubscribe();
Expand All @@ -176,6 +200,9 @@ export function getPageApi() {
lastSavedStateSessionStorage.save(serializedPage);
unsavedChangesSessionStorage.clear();
},
onReset: () => {
applySerializedState(lastSavedState$.value);
},
layout$,
setChild: (id: string, api: unknown) => {
children$.next({
Expand Down Expand Up @@ -219,30 +246,8 @@ export function getPageApi() {
lastSavedStateForChild$: (panelId: string) =>
lastSavedState$.pipe(map(() => getLastSavedStateForChild(panelId))),
getLastSavedStateForChild,
resetUnsavedChanges: () => {
const lastSavedState = lastSavedState$.value;
timeRange$.next(lastSavedState.timeRange);
const { layout: lastSavedLayout, childState: lastSavedChildState } = deserializePanels(
lastSavedState.panels
);
layout$.next(lastSavedLayout);
currentChildState = lastSavedChildState;
let childrenModified = false;
const currentChildren = { ...children$.value };
for (const uuid of Object.keys(currentChildren)) {
const existsInLastSavedLayout = lastSavedLayout.some(({ id }) => id === uuid);
if (existsInLastSavedLayout) {
const child = currentChildren[uuid];
if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges();
} else {
// if reset resulted in panel removal, we need to update the list of children
delete currentChildren[uuid];
delete currentChildState[uuid];
childrenModified = true;
}
}
if (childrenModified) children$.next(currentChildren);
},
serializeState: serializePage,
applySerializedState,
timeRange$,
hasUnsavedChanges$,
} as PageApi,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ export const StateManagementExample = ({ uiActions }: { uiActions: UiActionsStar
<EuiButtonEmpty
disabled={!bookApi}
onClick={() => {
bookApi?.resetUnsavedChanges();
bookApi?.applySerializedState(
parentApi.getLastSavedStateForChild(BOOK_EMBEDDABLE_ID) as BookEmbeddableState
);
}}
>
Reset
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,15 @@ export const getFieldListFactory = (
})
);

function serializeState() {
function serializeState(): FieldListSerializedState {
const { dataViews: selectedDataViews, ...rest } = fieldListStateManager.getLatestState();
return {
...titleManager.getLatestState(),
...rest,
};
}

const unsavedChangesApi = initializeUnsavedChanges({
const unsavedChangesApi = initializeUnsavedChanges<FieldListSerializedState>({
uuid,
parentApi,
serializeState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const factory: EmbeddableFactory<{}, Api> = {
buildEmbeddable: async ({ finalizeApi, parentApi }) => {
const api = finalizeApi({
serializeState: () => ({}),
applySerializedState: () => undefined,
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const mockOptionsListFactory: EmbeddableFactory<{ type: typeof OPTIONS_LIST_CONT
serializeState: () => ({
type: OPTIONS_LIST_CONTROL,
}),
applySerializedState: () => undefined,
});
return {
Component: () => <div data-test-subj="optionsListControl">Options list control</div>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type EmbeddableFactory,
} from '@kbn/embeddable-plugin/public/react_embeddable_system';
import type { Filter } from '@kbn/es-query';
import type { PublishesUnsavedChanges } from '@kbn/presentation-publishing';
import type { HasSerializableState } from '@kbn/presentation-publishing';
import { act, render, waitFor } from '@testing-library/react';

import { BehaviorSubject } from 'rxjs';
Expand Down Expand Up @@ -44,13 +44,13 @@ const getTestEmbeddableFactory = () =>
serializeState: () => ({
selection: initialState.selection,
}),
applySerializedState: jest.fn(),
});
return {
Component: () => <div data-test-subj="testControl">{initialState.selection}</div>,
api: {
...api,
hasUnsavedChanges$: new BehaviorSubject(false),
resetUnsavedChanges: jest.fn(),
},
};
},
Expand Down Expand Up @@ -87,7 +87,7 @@ describe('control group renderer', () => {
return { component, api: controlGroupApi! as ControlGroupRendererApi };
};

test('calling `updateInput` forces each child to be reset', async () => {
test('calling `updateInput` applies the updated child state', async () => {
const { api } = await mountControlGroupRenderer({
getCreationOptions: jest.fn().mockResolvedValue({
initialState: {
Expand All @@ -99,9 +99,9 @@ describe('control group renderer', () => {
},
}),
});
const resetSpy = jest.spyOn(
api.children$.getValue().test as PublishesUnsavedChanges,
'resetUnsavedChanges'
const applySpy = jest.spyOn(
api.children$.getValue().test as HasSerializableState,
'applySerializedState'
);
act(() =>
api.updateInput({
Expand All @@ -114,7 +114,10 @@ describe('control group renderer', () => {
})
);

expect(resetSpy).toBeCalledTimes(1);
expect(applySpy).toBeCalledWith({
type: 'testControl',
selection: 'test selection',
});
});

test('filter changes are dispatched to control parent API if they are different', async () => {
Expand Down
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
mbondyra marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { ControlsRenderer, type ControlsRendererParentApi } from '@kbn/controls-
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
apiPublishesUnsavedChanges,
apiHasSerializableState,
useSearchApi,
type EmbeddableApiContext,
type ViewMode,
Expand Down Expand Up @@ -172,17 +172,15 @@ export const ControlGroupRenderer = ({
getInput$: () => input$,
getInput: () => input$.value,
updateInput: (newInput: Partial<ControlGroupRuntimeState>) => {
/** Set the last saved state to the new input and then reset each child to this state */
const newState = lastSavedState$Ref.current.getValue();
const newState = { ...lastSavedState$Ref.current.getValue() };
Object.entries(newInput.initialChildControlState ?? {}).forEach(([id, control]) => {
newState[id] = {
...lastSavedState$Ref.current.value[id],
...control,
};
});
lastSavedState$Ref.current.next(newState);
asyncForEach(Object.values(parentApi.children$.getValue()), async (child) => {
if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges();
asyncForEach(Object.entries(parentApi.children$.getValue()), async ([id, child]) => {
if (apiHasSerializableState(child)) child.applySerializedState(newState[id]);
});
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const createControlApi = (
uuid,
type,
serializeState: () => state,
applySerializedState: () => undefined,
});

describe('getEsqlControls', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@ describe('childrenUnsavedChanges$', () => {
const child1Api = {
uuid: 'child1',
hasUnsavedChanges$: new BehaviorSubject<boolean>(false),
resetUnsavedChanges: () => undefined,
};
const child2Api = {
uuid: 'child2',
hasUnsavedChanges$: new BehaviorSubject<boolean>(false),
resetUnsavedChanges: () => undefined,
};
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
const onFireMock = jest.fn();
Expand Down Expand Up @@ -118,7 +116,6 @@ describe('childrenUnsavedChanges$', () => {
child3: {
uuid: 'child3',
hasUnsavedChanges$: new BehaviorSubject<boolean>(true),
resetUnsavedChanges: () => undefined,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type { Observable } from 'rxjs';
import type { MaybePromise } from '@kbn/utility-types';
import { combineLatestWith, debounceTime, map, of } from 'rxjs';
import type { HasSerializableState } from '../../has_serializable_state';
import type { PublishesUnsavedChanges } from '../../publishes_unsaved_changes';
import { type StateComparators, areComparatorsEqual } from '../../../state_manager';
import { getTitle } from '../../titles/publishes_title';
Expand All @@ -33,13 +34,17 @@ export const initializeUnsavedChanges = <StateType extends object = object>({
serializeState: () => StateType;
getComparators: () => StateComparators<StateType>;
defaultState?: Partial<StateType>;
onReset: (lastSavedPanelState?: StateType) => MaybePromise<void>;
onReset?: (lastSavedPanelState?: StateType) => MaybePromise<void>;
checkRefEquality?: boolean;
}): PublishesUnsavedChanges => {
}): PublishesUnsavedChanges & Pick<HasSerializableState<StateType>, 'applySerializedState'> => {
const applySerializedState = async (state?: StateType) => {
await onReset?.(state);
};

if (!apiHasLastSavedChildState<StateType>(parentApi)) {
return {
applySerializedState,
hasUnsavedChanges$: of(false),
resetUnsavedChanges: () => Promise.resolve(),
};
}

Expand Down Expand Up @@ -67,10 +72,5 @@ export const initializeUnsavedChanges = <StateType extends object = object>({
})
);

const resetUnsavedChanges = async () => {
const lastSavedState = parentApi.getLastSavedStateForChild(uuid);
await onReset(lastSavedState);
};

return { hasUnsavedChanges$, resetUnsavedChanges };
return { applySerializedState, hasUnsavedChanges$ };
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { MaybePromise } from '@kbn/utility-types';

export interface HasSerializableState<SerializedState extends object = object> {
/**
* Serializes all state into a format that can be saved into
* some external store. The opposite of `deserialize` in the {@link ReactEmbeddableFactory}
*/
serializeState: () => SerializedState;

/**
* Applies a serialized state snapshot owned by the parent container.
*/
applySerializedState: (state?: SerializedState) => MaybePromise<void>;
}

export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => {
return Boolean((api as HasSerializableState)?.serializeState);
return Boolean(
(api as HasSerializableState)?.serializeState &&
(api as HasSerializableState)?.applySerializedState
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { MaybePromise } from '@kbn/utility-types';
import type { Observable } from 'rxjs';

export interface PublishesUnsavedChanges {
hasUnsavedChanges$: Observable<boolean>; // Observable rather than publishingSubject because it should be derived.
resetUnsavedChanges: () => MaybePromise<void>;
}

export const apiPublishesUnsavedChanges = (api: unknown): api is PublishesUnsavedChanges => {
return Boolean(
api &&
(api as PublishesUnsavedChanges).hasUnsavedChanges$ &&
(api as PublishesUnsavedChanges).resetUnsavedChanges
);
return Boolean(api && (api as PublishesUnsavedChanges).hasUnsavedChanges$);
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ import {
} from 'rxjs';

import { OPTIONS_LIST_CONTROL, DEFAULT_DSL_OPTIONS_LIST_STATE } from '@kbn/controls-constants';
import type {
OptionsListSelection,
OptionsListControlState,
OptionsListDSLControlState,
} from '@kbn/controls-schemas';
import type { OptionsListSelection, OptionsListControlState } from '@kbn/controls-schemas';
import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import {
apiHasPinnedPanels,
Expand Down Expand Up @@ -239,7 +235,7 @@ export const getOptionsListControlFactory = (): EmbeddableFactory<
}
);

function serializeState(): OptionsListDSLControlState {
function serializeState(): OptionsListControlState {
return {
...dataControlManager.getLatestState(),
...selectionsManager.getLatestState(),
Expand All @@ -250,7 +246,7 @@ export const getOptionsListControlFactory = (): EmbeddableFactory<
};
}

const unsavedChangesApi = initializeUnsavedChanges<OptionsListDSLControlState>({
const unsavedChangesApi = initializeUnsavedChanges<OptionsListControlState>({
uuid,
parentApi,
serializeState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe('TimeSliderControlApi', () => {
expect(new Date(api.appliedTimeslice$.value![1]).toISOString()).toEqual(
'2024-06-09T18:00:00.000Z'
);
await api.resetUnsavedChanges();
await api.applySerializedState(controlState);

await new Promise((resolve) => setTimeout(resolve, 0));
expect(new Date(api.appliedTimeslice$.value![0]).toISOString()).toEqual(
Expand Down
Loading
Loading