[embeddable rebuild][control group] implement PresentationContainer API (#188346)

This commit is contained in:
Nathan Reese 2024-07-17 14:51:49 -06:00 committed by GitHub
parent 6137f8185c
commit f48b5b4b36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 253 additions and 111 deletions

View file

@ -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<boolean>(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<Filter[] | undefined>([]);
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
@ -108,19 +103,16 @@ export const getControlGroupEmbeddableFactory = (services: {
undefined
);
const controlsInOrder$ = new BehaviorSubject<Array<{ id: string; type: string }>>(
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 }) => (
<ControlRenderer
key={id}
maybeId={id}
uuid={id}
type={type}
getParentApi={() => api}
onApiAvailable={(controlApi) => {
children$.next({
...children$.getValue(),
[id]: controlApi,
});
controlsManager.setControlApi(id, controlApi);
}}
/>
))}

View file

@ -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',
]);
});
});

View file

@ -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<Array<{ id: string; type: string }>>(
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<DefaultControlApi | undefined> {
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<DefaultControlState>,
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<Array<{ id: string; type: string }>>,
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<DefaultControlState>) => {
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<ControlPanelState>,
};
}

View file

@ -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<ControlGroupSerializedState> => {
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,
};
};

View file

@ -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<StateType, ApiType>(type);
const buildApi = (