From f48b5b4b36c86cf9264855287bc99753c72d0f2f Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 17 Jul 2024 14:51:49 -0600 Subject: [PATCH] [embeddable rebuild][control group] implement PresentationContainer API (#188346) --- .../get_control_group_factory.tsx | 78 +++------ .../init_controls_manager.test.ts | 68 ++++++++ .../control_group/init_controls_manager.ts | 158 ++++++++++++++++++ .../control_group/serialization_utils.ts | 54 +----- .../react_controls/control_renderer.tsx | 6 +- 5 files changed, 253 insertions(+), 111 deletions(-) create mode 100644 examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts create mode 100644 examples/controls_example/public/react_controls/control_group/init_controls_manager.ts diff --git a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx index 976556b727a51..8814dbd33881b 100644 --- a/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx +++ b/examples/controls_example/public/react_controls/control_group/get_control_group_factory.tsx @@ -8,7 +8,6 @@ import React, { useEffect } from 'react'; import { BehaviorSubject } from 'rxjs'; - import { ControlGroupChainingSystem, ControlWidth, @@ -32,21 +31,20 @@ import { PublishesDataViews, PublishesFilters, PublishesTimeslice, - PublishingSubject, useStateFromPublishingSubject, } from '@kbn/presentation-publishing'; import { EuiFlexGroup } from '@elastic/eui'; import { ControlRenderer } from '../control_renderer'; -import { DefaultControlApi } from '../types'; import { openEditControlGroupFlyout } from './open_edit_control_group_flyout'; -import { deserializeControlGroup, serializeControlGroup } from './serialization_utils'; +import { deserializeControlGroup } from './serialization_utils'; import { ControlGroupApi, ControlGroupRuntimeState, ControlGroupSerializedState, ControlGroupUnsavedChanges, } from './types'; +import { initControlsManager } from './init_controls_manager'; import { controlGroupFetch$, chaining$, controlFetch$ } from './control_fetch'; export const getControlGroupEmbeddableFactory = (services: { @@ -62,7 +60,7 @@ export const getControlGroupEmbeddableFactory = (services: { deserializeState: (state) => deserializeControlGroup(state), buildEmbeddable: async (initialState, buildApi, uuid, parentApi, setApi) => { const { - initialChildControlState: childControlState, + initialChildControlState, defaultControlGrow, defaultControlWidth, labelPosition, @@ -71,12 +69,9 @@ export const getControlGroupEmbeddableFactory = (services: { ignoreParentSettings, } = initialState; + const controlsManager = initControlsManager(initialChildControlState); const autoApplySelections$ = new BehaviorSubject(autoApplySelections); const timeslice$ = new BehaviorSubject<[number, number] | undefined>(undefined); - const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); - function getControlApi(controlUuid: string) { - return children$.value[controlUuid]; - } const filters$ = new BehaviorSubject([]); const dataViews = new BehaviorSubject(undefined); const chainingSystem$ = new BehaviorSubject(chainingSystem); @@ -108,19 +103,16 @@ export const getControlGroupEmbeddableFactory = (services: { undefined ); - const controlsInOrder$ = new BehaviorSubject>( - Object.keys(childControlState) - .map((key) => ({ - id: key, - order: childControlState[key].order, - type: childControlState[key].type, - })) - .sort((a, b) => (a.order > b.order ? 1 : -1)) - ); const api = setApi({ + ...controlsManager.api, controlFetch$: (controlUuid: string) => controlFetch$( - chaining$(controlUuid, chainingSystem$, controlsInOrder$, getControlApi), + chaining$( + controlUuid, + chainingSystem$, + controlsManager.controlsInOrder$, + controlsManager.getControlApi + ), controlGroupFetch$(ignoreParentSettings$, parentApi ? parentApi : {}) ), ignoreParentSettings$, @@ -134,9 +126,6 @@ export const getControlGroupEmbeddableFactory = (services: { return {} as unknown as ControlGroupRuntimeState; }, dataLoading: dataLoading$, - children$: children$ as PublishingSubject<{ - [key: string]: unknown; - }>, onEdit: async () => { openEditControlGroupFlyout( api, @@ -154,34 +143,18 @@ export const getControlGroupEmbeddableFactory = (services: { i18n.translate('controls.controlGroup.displayName', { defaultMessage: 'Controls', }), - getSerializedStateForChild: (childId) => { - return { rawState: childControlState[childId] }; - }, serializeState: () => { - return serializeControlGroup( - children$.getValue(), - controlsInOrder$.getValue().map(({ id }) => id), - { - labelPosition: labelPosition$.getValue(), + const { panelsJSON, references } = controlsManager.serializeControls(); + return { + rawState: { chainingSystem: chainingSystem$.getValue(), - autoApplySelections: autoApplySelections$.getValue(), - ignoreParentSettings: ignoreParentSettings$.getValue(), - } - ); - }, - getPanelCount: () => { - return (Object.keys(children$.getValue()) ?? []).length; - }, - addNewPanel: (panel) => { - // TODO: Add a new child control - return Promise.resolve(undefined); - }, - removePanel: (panelId) => { - // TODO: Remove a child control - }, - replacePanel: async (panelId, newPanel) => { - // TODO: Replace a child control - return Promise.resolve(panelId); + controlStyle: labelPosition$.getValue(), // Rename "labelPosition" to "controlStyle" + showApplySelections: !autoApplySelections$.getValue(), + ignoreParentSettingsJSON: JSON.stringify(ignoreParentSettings$.getValue()), + panelsJSON, + }, + references, + }; }, grow, width, @@ -238,7 +211,7 @@ export const getControlGroupEmbeddableFactory = (services: { return { api, Component: () => { - const controlsInOrder = useStateFromPublishingSubject(controlsInOrder$); + const controlsInOrder = useStateFromPublishingSubject(controlsManager.controlsInOrder$); useEffect(() => { return () => { @@ -253,14 +226,11 @@ export const getControlGroupEmbeddableFactory = (services: { {controlsInOrder.map(({ id, type }) => ( api} onApiAvailable={(controlApi) => { - children$.next({ - ...children$.getValue(), - [id]: controlApi, - }); + controlsManager.setControlApi(id, controlApi); }} /> ))} diff --git a/examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts b/examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts new file mode 100644 index 0000000000000..450d882108892 --- /dev/null +++ b/examples/controls_example/public/react_controls/control_group/init_controls_manager.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DefaultControlApi } from '../types'; +import { initControlsManager } from './init_controls_manager'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('delta'), +})); + +describe('PresentationContainer api', () => { + test('addNewPanel should add control at end of controls', async () => { + const controlsManager = initControlsManager({ + alpha: { type: 'whatever', order: 0 }, + bravo: { type: 'whatever', order: 1 }, + charlie: { type: 'whatever', order: 2 }, + }); + const addNewPanelPromise = controlsManager.api.addNewPanel({ + panelType: 'whatever', + initialState: {}, + }); + controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); + await addNewPanelPromise; + expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([ + 'alpha', + 'bravo', + 'charlie', + 'delta', + ]); + }); + + test('removePanel should remove control', () => { + const controlsManager = initControlsManager({ + alpha: { type: 'whatever', order: 0 }, + bravo: { type: 'whatever', order: 1 }, + charlie: { type: 'whatever', order: 2 }, + }); + controlsManager.api.removePanel('bravo'); + expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([ + 'alpha', + 'charlie', + ]); + }); + + test('replacePanel should replace control', async () => { + const controlsManager = initControlsManager({ + alpha: { type: 'whatever', order: 0 }, + bravo: { type: 'whatever', order: 1 }, + charlie: { type: 'whatever', order: 2 }, + }); + const replacePanelPromise = controlsManager.api.replacePanel('bravo', { + panelType: 'whatever', + initialState: {}, + }); + controlsManager.setControlApi('delta', {} as unknown as DefaultControlApi); + await replacePanelPromise; + expect(controlsManager.controlsInOrder$.value.map((element) => element.id)).toEqual([ + 'alpha', + 'delta', + 'charlie', + ]); + }); +}); diff --git a/examples/controls_example/public/react_controls/control_group/init_controls_manager.ts b/examples/controls_example/public/react_controls/control_group/init_controls_manager.ts new file mode 100644 index 0000000000000..2ba2d4767c07b --- /dev/null +++ b/examples/controls_example/public/react_controls/control_group/init_controls_manager.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { v4 as generateId } from 'uuid'; +import { + HasSerializedChildState, + PanelPackage, + PresentationContainer, +} from '@kbn/presentation-containers'; +import { Reference } from '@kbn/content-management-utils'; +import { BehaviorSubject, merge } from 'rxjs'; +import { PublishingSubject } from '@kbn/presentation-publishing'; +import { omit } from 'lodash'; +import { ControlPanelsState, ControlPanelState } from './types'; +import { DefaultControlApi, DefaultControlState } from '../types'; + +export function initControlsManager(initialControlPanelsState: ControlPanelsState) { + const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({}); + const controlsPanelState: { [panelId: string]: DefaultControlState } = { + ...initialControlPanelsState, + }; + const controlsInOrder$ = new BehaviorSubject>( + Object.keys(initialControlPanelsState) + .map((key) => ({ + id: key, + order: initialControlPanelsState[key].order, + type: initialControlPanelsState[key].type, + })) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + ); + + function untilControlLoaded( + id: string + ): DefaultControlApi | Promise { + if (children$.value[id]) { + return children$.value[id]; + } + + return new Promise((resolve) => { + const subscription = merge(children$, controlsInOrder$).subscribe(() => { + if (children$.value[id]) { + subscription.unsubscribe(); + resolve(children$.value[id]); + return; + } + + // control removed before the control finished loading. + const controlState = controlsInOrder$.value.find((element) => element.id === id); + if (!controlState) { + subscription.unsubscribe(); + resolve(undefined); + } + }); + }); + } + + function getControlApi(controlUuid: string) { + return children$.value[controlUuid]; + } + + async function addNewPanel( + { panelType, initialState }: PanelPackage, + index: number + ) { + const id = generateId(); + const nextControlsInOrder = [...controlsInOrder$.value]; + nextControlsInOrder.splice(index, 0, { + id, + type: panelType, + }); + controlsInOrder$.next(nextControlsInOrder); + controlsPanelState[id] = initialState ?? {}; + return await untilControlLoaded(id); + } + + function removePanel(panelId: string) { + delete controlsPanelState[panelId]; + controlsInOrder$.next(controlsInOrder$.value.filter(({ id }) => id !== panelId)); + children$.next(omit(children$.value, panelId)); + } + + return { + controlsInOrder$: controlsInOrder$ as PublishingSubject>, + getControlApi, + setControlApi: (uuid: string, controlApi: DefaultControlApi) => { + children$.next({ + ...children$.getValue(), + [uuid]: controlApi, + }); + }, + serializeControls: () => { + const references: Reference[] = []; + const explicitInputPanels: { + [panelId: string]: ControlPanelState & { explicitInput: object }; + } = {}; + + controlsInOrder$.getValue().forEach(({ id }, index) => { + const controlApi = getControlApi(id); + if (!controlApi) { + return; + } + + const { + rawState: { grow, width, ...rest }, + references: controlReferences, + } = controlApi.serializeState(); + + if (controlReferences && controlReferences.length > 0) { + references.push(...controlReferences); + } + + explicitInputPanels[id] = { + grow, + order: index, + type: controlApi.type, + width, + /** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */ + explicitInput: rest, + }; + }); + + return { + panelsJSON: JSON.stringify(explicitInputPanels), + references, + }; + }, + api: { + getSerializedStateForChild: (childId: string) => { + const controlPanelState = controlsPanelState[childId]; + return controlPanelState ? { rawState: controlPanelState } : undefined; + }, + children$: children$ as PublishingSubject<{ + [key: string]: DefaultControlApi; + }>, + getPanelCount: () => { + return controlsInOrder$.value.length; + }, + addNewPanel: async (panel: PanelPackage) => { + return addNewPanel(panel, controlsInOrder$.value.length); + }, + removePanel, + replacePanel: async (panelId, newPanel) => { + const index = controlsInOrder$.value.findIndex(({ id }) => id === panelId); + removePanel(panelId); + const controlApi = await addNewPanel( + newPanel, + index >= 0 ? index : controlsInOrder$.value.length + ); + return controlApi ? controlApi.uuid : ''; + }, + } as PresentationContainer & HasSerializedChildState, + }; +} diff --git a/examples/controls_example/public/react_controls/control_group/serialization_utils.ts b/examples/controls_example/public/react_controls/control_group/serialization_utils.ts index 4d0f2eb0f749a..b38b22fc04249 100644 --- a/examples/controls_example/public/react_controls/control_group/serialization_utils.ts +++ b/examples/controls_example/public/react_controls/control_group/serialization_utils.ts @@ -6,11 +6,9 @@ * Side Public License, v 1. */ -import { Reference } from '@kbn/content-management-utils'; import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '@kbn/controls-plugin/common'; import { SerializedPanelState } from '@kbn/presentation-containers'; import { omit } from 'lodash'; -import { DefaultControlApi } from '../types'; import { ControlGroupRuntimeState, ControlGroupSerializedState } from './types'; export const deserializeControlGroup = ( @@ -46,59 +44,9 @@ export const deserializeControlGroup = ( autoApplySelections: typeof state.rawState.showApplySelections === 'boolean' ? !state.rawState.showApplySelections - : false, + : false, // Rename "showApplySelections" to "autoApplySelections" labelPosition: state.rawState.controlStyle, // Rename "controlStyle" to "labelPosition" defaultControlGrow: DEFAULT_CONTROL_GROW, defaultControlWidth: DEFAULT_CONTROL_WIDTH, }; }; - -export const serializeControlGroup = ( - children: { - [key: string]: DefaultControlApi; - }, - idsInOrder: string[], - state: Omit< - ControlGroupRuntimeState, - | 'anyChildHasUnsavedChanges' - | 'defaultControlGrow' - | 'defaultControlWidth' - | 'initialChildControlState' - > -): SerializedPanelState => { - let references: Reference[] = []; - - /** Re-add the `explicitInput` layer on serialize so control group saved object retains shape */ - const explicitInputPanels = Object.keys(children).reduce((prev, panelId) => { - const child: DefaultControlApi = children[panelId]; - const type = child.type; - const { - rawState: { grow, width, ...rest }, - references: childReferences, - } = child.serializeState(); - - if (childReferences && childReferences.length > 0) { - references = [...references, ...childReferences]; - } - - /** - * Note: With legacy control embeddables, `grow` and `width` were duplicated under - * explicit input - this is no longer the case. - */ - return { - ...prev, - [panelId]: { grow, order: idsInOrder.indexOf(panelId), type, width, explicitInput: rest }, - }; - }, {}); - - return { - rawState: { - ...omit(state, ['ignoreParentSettings', 'labelPosition']), - controlStyle: state.labelPosition, // Rename "labelPosition" to "controlStyle" - showApplySelections: !state.autoApplySelections, - ignoreParentSettingsJSON: JSON.stringify(state.ignoreParentSettings), - panelsJSON: JSON.stringify(explicitInputPanels), - }, - references, - }; -}; diff --git a/examples/controls_example/public/react_controls/control_renderer.tsx b/examples/controls_example/public/react_controls/control_renderer.tsx index feea0269ee883..ce8d91ce0fa02 100644 --- a/examples/controls_example/public/react_controls/control_renderer.tsx +++ b/examples/controls_example/public/react_controls/control_renderer.tsx @@ -8,7 +8,6 @@ import React, { useImperativeHandle, useMemo } from 'react'; import { BehaviorSubject } from 'rxjs'; -import { v4 as generateId } from 'uuid'; import { StateComparators } from '@kbn/presentation-publishing'; @@ -25,12 +24,12 @@ export const ControlRenderer = < ApiType extends DefaultControlApi = DefaultControlApi >({ type, - maybeId, + uuid, getParentApi, onApiAvailable, }: { type: string; - maybeId?: string; + uuid: string; getParentApi: () => ControlGroupApi; onApiAvailable?: (api: ApiType) => void; }) => { @@ -38,7 +37,6 @@ export const ControlRenderer = < () => (() => { const parentApi = getParentApi(); - const uuid = maybeId ?? generateId(); const factory = getControlFactory(type); const buildApi = (