diff --git a/package.json b/package.json index e9aea2d2adda3..ebb21d0699b2e 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,9 @@ "yarn": "^1.21.1" }, "dependencies": { + "@dnd-kit/core": "^3.1.1", + "@dnd-kit/sortable": "^4.0.0", + "@dnd-kit/utilities": "^2.0.0", "@babel/runtime": "^7.15.4", "@dnd-kit/core": "^3.1.1", "@dnd-kit/sortable": "^4.0.0", diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts index 59e7a44a83a17..faaa155249949 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { InputControlFactory } from '../types'; import { ControlsService } from '../controls_service'; +import { InputControlFactory } from '../../../services/controls'; import { flightFields, getEuiSelectableOptions } from './flights'; import { OptionsListEmbeddableFactory } from '../control_types/options_list'; diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx index 2a463fece18da..66f1d8b36399e 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx @@ -10,9 +10,14 @@ import React, { useEffect, useMemo } from 'react'; import uuid from 'uuid'; import { decorators } from './decorators'; -import { providers } from '../../../services/storybook'; -import { getControlsServiceStub } from './controls_service_stub'; -import { ControlGroupContainerFactory } from '../control_group/control_group_container_factory'; +import { pluginServices, registry } from '../../../services/storybook'; +import { populateStorybookControlFactories } from './storybook_control_factories'; +import { ControlGroupContainerFactory } from '../control_group/embeddable/control_group_container_factory'; +import { ControlsPanels } from '../control_group/types'; +import { + OptionsListEmbeddableInput, + OPTIONS_LIST_CONTROL, +} from '../control_types/options_list/options_list_embeddable'; export default { title: 'Controls', @@ -20,17 +25,15 @@ export default { decorators, }; -const ControlGroupStoryComponent = () => { +const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels }) => { const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); - providers.overlays.start({}); - const overlays = providers.overlays.getService(); - - const controlsServiceStub = getControlsServiceStub(); + pluginServices.setRegistry(registry.start({})); + populateStorybookControlFactories(pluginServices.getServices().controls); useEffect(() => { (async () => { - const factory = new ControlGroupContainerFactory(controlsServiceStub, overlays); + const factory = new ControlGroupContainerFactory(); const controlGroupContainerEmbeddable = await factory.create({ inheritParentState: { useQuery: false, @@ -38,16 +41,57 @@ const ControlGroupStoryComponent = () => { useTimerange: false, }, controlStyle: 'oneLine', + panels: panels ?? {}, id: uuid.v4(), - panels: {}, }); if (controlGroupContainerEmbeddable && embeddableRoot.current) { controlGroupContainerEmbeddable.render(embeddableRoot.current); } })(); - }, [embeddableRoot, controlsServiceStub, overlays]); + }, [embeddableRoot, panels]); return
; }; -export const ControlGroupStory = () => ; +export const EmptyControlGroupStory = () => ; +export const ConfiguredControlGroupStory = () => ( + +); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts new file mode 100644 index 0000000000000..3048adc74d8c7 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts @@ -0,0 +1,27 @@ +/* + * 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 { flightFields, getEuiSelectableOptions } from './flights'; +import { OptionsListEmbeddableFactory } from '../control_types/options_list'; +import { InputControlFactory, PresentationControlsService } from '../../../services/controls'; + +export const populateStorybookControlFactories = ( + controlsServiceStub: PresentationControlsService +) => { + const optionsListFactoryStub = new OptionsListEmbeddableFactory( + ({ field, search }) => + new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)), + () => Promise.resolve(['demo data flights']), + () => Promise.resolve(flightFields) + ); + + // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory + const optionsListControlFactory = optionsListFactoryStub as unknown as InputControlFactory; + optionsListControlFactory.getDefaultInput = () => ({}); + controlsServiceStub.registerInputControlType(optionsListControlFactory); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_strings.ts b/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_strings.ts deleted file mode 100644 index 5f9e89aa797cb..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_strings.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; - -export const ControlFrameStrings = { - floatingActions: { - getEditButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { - defaultMessage: 'Manage control', - }), - getRemoveButtonTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', { - defaultMessage: 'Remove control', - }), - }, -}; diff --git a/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx similarity index 71% rename from src/plugins/presentation_util/public/components/controls/control_frame/control_frame_component.tsx rename to src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx index 240beea13b0e2..103ce6dd0e27c 100644 --- a/src/plugins/presentation_util/public/components/controls/control_frame/control_frame_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx @@ -15,32 +15,28 @@ import { EuiFormRow, EuiToolTip, } from '@elastic/eui'; -import { ControlGroupContainer } from '../control_group/control_group_container'; -import { useChildEmbeddable } from '../hooks/use_child_embeddable'; -import { ControlStyle } from '../types'; -import { ControlFrameStrings } from './control_frame_strings'; + +import { ControlGroupInput } from '../types'; +import { EditControlButton } from '../editor/edit_control'; +import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; +import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { ControlGroupStrings } from '../control_group_strings'; export interface ControlFrameProps { - container: ControlGroupContainer; customPrepend?: JSX.Element; - controlStyle: ControlStyle; enableActions?: boolean; - onRemove?: () => void; embeddableId: string; - onEdit?: () => void; } -export const ControlFrame = ({ - customPrepend, - enableActions, - embeddableId, - controlStyle, - container, - onRemove, - onEdit, -}: ControlFrameProps) => { +export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: ControlFrameProps) => { const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); - const embeddable = useChildEmbeddable({ container, embeddableId }); + const { + useEmbeddableSelector, + containerActions: { untilEmbeddableLoaded, removeEmbeddable }, + } = useReduxContainerContext(); + const { controlStyle } = useEmbeddableSelector((state) => state); + + const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId }); const [title, setTitle] = useState(); @@ -61,18 +57,13 @@ export const ControlFrame = ({ 'controlFrame--floatingActions-oneLine': !usingTwoLineLayout, })} > - - + + - + removeEmbeddable(embeddableId)} iconType="cross" color="danger" /> diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx index d683c0749d98d..4d5e8bc270e23 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_component.tsx @@ -9,7 +9,7 @@ import '../control_group.scss'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { arrayMove, @@ -29,46 +29,51 @@ import { LayoutMeasuringStrategy, } from '@dnd-kit/core'; +import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../../../services'; import { ControlGroupStrings } from '../control_group_strings'; -import { ControlGroupContainer } from '../control_group_container'; +import { CreateControlButton } from '../editor/create_control'; +import { EditControlGroup } from '../editor/edit_control_group'; +import { forwardAllContext } from '../editor/forward_all_context'; import { ControlClone, SortableControl } from './control_group_sortable_item'; -import { OPTIONS_LIST_CONTROL } from '../../control_types/options_list/options_list_embeddable'; +import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { controlGroupReducers } from '../state/control_group_reducers'; -interface ControlGroupProps { - controlGroupContainer: ControlGroupContainer; -} +export const ControlGroup = () => { + // Presentation Services Context + const { overlays } = pluginServices.getHooks(); + const { openFlyout } = overlays.useService(); -export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => { - const [controlIds, setControlIds] = useState([]); + // Redux embeddable container Context + const reduxContainerContext = useReduxContainerContext< + ControlGroupInput, + typeof controlGroupReducers + >(); + const { + useEmbeddableSelector, + useEmbeddableDispatch, + actions: { setControlOrders }, + } = reduxContainerContext; + const dispatch = useEmbeddableDispatch(); - // sync controlIds every time input panels change - useEffect(() => { - const subscription = controlGroupContainer.getInput$().subscribe(() => { - setControlIds((currentIds) => { - // sync control Ids with panels from container input. - const { panels } = controlGroupContainer.getInput(); - const newIds: string[] = []; - const allIds = [...currentIds, ...Object.keys(panels)]; - allIds.forEach((id) => { - const currentIndex = currentIds.indexOf(id); - if (!panels[id] && currentIndex !== -1) { - currentIds.splice(currentIndex, 1); - } - if (currentIndex === -1 && Boolean(panels[id])) { - newIds.push(id); - } - }); - return [...currentIds, ...newIds]; - }); - }); - return () => subscription.unsubscribe(); - }, [controlGroupContainer]); + // current state + const { panels } = useEmbeddableSelector((state) => state); - const [draggingId, setDraggingId] = useState(null); + const idsInOrder = useMemo( + () => + Object.values(panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .reduce((acc, panel) => { + acc.push(panel.explicitInput.id); + return acc; + }, [] as string[]), + [panels] + ); + const [draggingId, setDraggingId] = useState(null); const draggingIndex = useMemo( - () => (draggingId ? controlIds.indexOf(draggingId) : -1), - [controlIds, draggingId] + () => (draggingId ? idsInOrder.indexOf(draggingId) : -1), + [idsInOrder, draggingId] ); const sensors = useSensors( @@ -78,10 +83,10 @@ export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => { const onDragEnd = ({ over }: DragEndEvent) => { if (over) { - const overIndex = controlIds.indexOf(over.id); + const overIndex = idsInOrder.indexOf(over.id); if (draggingIndex !== overIndex) { const newIndex = overIndex; - setControlIds((currentControlIds) => arrayMove(currentControlIds, draggingIndex, newIndex)); + dispatch(setControlOrders({ ids: arrayMove([...idsInOrder], draggingIndex, newIndex) })); } } setDraggingId(null); @@ -100,36 +105,26 @@ export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => { strategy: LayoutMeasuringStrategy.Always, }} > - + - {controlIds.map((controlId, index) => ( - controlGroupContainer.editControl(controlId)} - onRemove={() => controlGroupContainer.removeEmbeddable(controlId)} - dragInfo={{ index, draggingIndex }} - container={controlGroupContainer} - controlStyle={controlGroupContainer.getInput().controlStyle} - embeddableId={controlId} - width={controlGroupContainer.getInput().panels[controlId].width} - key={controlId} - /> - ))} + {idsInOrder.map( + (controlId, index) => + panels[controlId] && ( + + ) + )} - - {draggingId ? ( - - ) : null} - + {draggingId ? : null} @@ -141,19 +136,15 @@ export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => { iconType="gear" color="text" data-test-subj="inputControlsSortingButton" - onClick={controlGroupContainer.editControlGroup} + onClick={() => + openFlyout(forwardAllContext(, reduxContainerContext)) + } /> - controlGroupContainer.createNewControl(OPTIONS_LIST_CONTROL)} // use popover when there are multiple types of control - /> + diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx index 3ae171a588da4..5c222e3c130b5 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx @@ -12,10 +12,9 @@ import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import classNames from 'classnames'; -import { ControlWidth } from '../../types'; -import { ControlGroupContainer } from '../control_group_container'; -import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; -import { ControlFrame, ControlFrameProps } from '../../control_frame/control_frame_component'; +import { ControlGroupInput } from '../types'; +import { ControlFrame, ControlFrameProps } from './control_frame_component'; +import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; interface DragInfo { isOver?: boolean; @@ -26,7 +25,6 @@ interface DragInfo { export type SortableControlProps = ControlFrameProps & { dragInfo: DragInfo; - width: ControlWidth; }; /** @@ -60,91 +58,67 @@ export const SortableControl = (frameProps: SortableControlProps) => { const SortableControlInner = forwardRef< HTMLButtonElement, SortableControlProps & { style: HTMLAttributes['style'] } ->( - ( - { - embeddableId, - controlStyle, - container, - dragInfo, - onRemove, - onEdit, - style, - width, - ...dragHandleProps - }, - dragHandleRef - ) => { - const { isOver, isDragging, draggingIndex, index } = dragInfo; +>(({ embeddableId, dragInfo, style, ...dragHandleProps }, dragHandleRef) => { + const { isOver, isDragging, draggingIndex, index } = dragInfo; + const { useEmbeddableSelector } = useReduxContainerContext(); + const { panels } = useEmbeddableSelector((state) => state); - const dragHandle = ( - - ); + const width = panels[embeddableId].width; - return ( - (draggingIndex ?? -1), - })} - style={style} - > - - - ); - } -); + const dragHandle = ( + + ); + + return ( + (draggingIndex ?? -1), + })} + style={style} + > + + + ); +}); /** * A simplified clone version of the control which is dragged. This version only shows * the title, because individual controls can be any size, and dragging a wide item * can be quite cumbersome. */ -export const ControlClone = ({ - embeddableId, - container, - width, -}: { - embeddableId: string; - container: ControlGroupContainer; - width: ControlWidth; -}) => { - const embeddable = useChildEmbeddable({ embeddableId, container }); - const layout = container.getInput().controlStyle; +export const ControlClone = ({ draggingId }: { draggingId: string }) => { + const { useEmbeddableSelector } = useReduxContainerContext(); + const { panels, controlStyle } = useEmbeddableSelector((state) => state); + + const width = panels[draggingId].width; + const title = panels[draggingId].explicitInput.title; return ( - {layout === 'twoLine' ? ( - {embeddable?.getInput().title} - ) : undefined} + {controlStyle === 'twoLine' ? {title} : undefined} - {container.getInput().controlStyle === 'oneLine' ? ( - {embeddable?.getInput().title} - ) : undefined} + {controlStyle === 'oneLine' ? {title} : undefined} ); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_container.tsx b/src/plugins/presentation_util/public/components/controls/control_group/control_group_container.tsx deleted file mode 100644 index 03249889dfdea..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_container.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import { cloneDeep } from 'lodash'; - -import { - Container, - EmbeddableFactory, - EmbeddableFactoryNotFoundError, -} from '../../../../../embeddable/public'; -import { - InputControlEmbeddable, - InputControlInput, - InputControlOutput, - IEditableControlFactory, - ControlWidth, -} from '../types'; -import { ControlsService } from '../controls_service'; -import { ControlGroupInput, ControlPanelState } from './types'; -import { ManageControlComponent } from './editor/manage_control'; -import { toMountPoint } from '../../../../../kibana_react/public'; -import { ControlGroup } from './component/control_group_component'; -import { PresentationOverlaysService } from '../../../services/overlays'; -import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from './control_group_constants'; -import { ManageControlGroup } from './editor/manage_control_group_component'; -import { OverlayRef } from '../../../../../../core/public'; -import { ControlGroupStrings } from './control_group_strings'; - -export class ControlGroupContainer extends Container { - public readonly type = CONTROL_GROUP_TYPE; - - private nextControlWidth: ControlWidth = DEFAULT_CONTROL_WIDTH; - - constructor( - initialInput: ControlGroupInput, - private readonly controlsService: ControlsService, - private readonly overlays: PresentationOverlaysService, - parent?: Container - ) { - super(initialInput, { embeddableLoaded: {} }, controlsService.getControlFactory, parent); - this.overlays = overlays; - this.controlsService = controlsService; - } - - protected createNewPanelState( - factory: EmbeddableFactory, - partial: Partial = {} - ): ControlPanelState { - const panelState = super.createNewPanelState(factory, partial); - return { - order: 1, - width: this.nextControlWidth, - ...panelState, - } as ControlPanelState; - } - - protected getInheritedInput(id: string): InputControlInput { - const { filters, query, timeRange, inheritParentState } = this.getInput(); - return { - filters: inheritParentState.useFilters ? filters : undefined, - query: inheritParentState.useQuery ? query : undefined, - timeRange: inheritParentState.useTimerange ? timeRange : undefined, - id, - }; - } - - public createNewControl = async (type: string) => { - const factory = this.controlsService.getControlFactory(type); - if (!factory) throw new EmbeddableFactoryNotFoundError(type); - - const initialInputPromise = new Promise>((resolve, reject) => { - let inputToReturn: Partial = {}; - - const onCancel = (ref: OverlayRef) => { - this.overlays - .openConfirm(ControlGroupStrings.management.discardNewControl.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.discardNewControl.getConfirm(), - cancelButtonText: ControlGroupStrings.management.discardNewControl.getCancel(), - title: ControlGroupStrings.management.discardNewControl.getTitle(), - buttonColor: 'danger', - }) - .then((confirmed) => { - if (confirmed) { - reject(); - ref.close(); - } - }); - }; - - const flyoutInstance = this.overlays.openFlyout( - toMountPoint( - (inputToReturn.title = newTitle)} - updateWidth={(newWidth) => (this.nextControlWidth = newWidth)} - controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ - onChange: (partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }, - })} - onSave={() => { - resolve(inputToReturn); - flyoutInstance.close(); - }} - onCancel={() => onCancel(flyoutInstance)} - /> - ), - { - onClose: (flyout) => onCancel(flyout), - } - ); - }); - initialInputPromise.then( - async (explicitInput) => { - await this.addNewEmbeddable(type, explicitInput); - }, - () => {} // swallow promise rejection because it can be part of normal flow - ); - }; - - public editControl = async (embeddableId: string) => { - const panel = this.getInput().panels[embeddableId]; - const factory = this.getFactory(panel.type); - const embeddable = await this.untilEmbeddableLoaded(embeddableId); - - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - - const initialExplicitInput = cloneDeep(panel.explicitInput); - const initialWidth = panel.width; - - const onCancel = (ref: OverlayRef) => { - this.overlays - .openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), - cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), - title: ControlGroupStrings.management.discardChanges.getTitle(), - buttonColor: 'danger', - }) - .then((confirmed) => { - if (confirmed) { - embeddable.updateInput(initialExplicitInput); - this.updateInput({ - panels: { - ...this.getInput().panels, - [embeddableId]: { ...this.getInput().panels[embeddableId], width: initialWidth }, - }, - }); - ref.close(); - } - }); - }; - - const flyoutInstance = this.overlays.openFlyout( - toMountPoint( - this.removeEmbeddable(embeddableId)} - updateTitle={(newTitle) => embeddable.updateInput({ title: newTitle })} - controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ - onChange: (partialInput) => embeddable.updateInput(partialInput), - initialInput: embeddable.getInput(), - })} - onCancel={() => onCancel(flyoutInstance)} - onSave={() => flyoutInstance.close()} - updateWidth={(newWidth) => - this.updateInput({ - panels: { - ...this.getInput().panels, - [embeddableId]: { ...this.getInput().panels[embeddableId], width: newWidth }, - }, - }) - } - /> - ), - { - onClose: (flyout) => onCancel(flyout), - } - ); - }; - - public editControlGroup = () => { - const flyoutInstance = this.overlays.openFlyout( - toMountPoint( - this.updateInput({ controlStyle: newStyle })} - deleteAllEmbeddables={() => { - this.overlays - .openConfirm(ControlGroupStrings.management.deleteAllControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteAllControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteAllControls.getCancel(), - title: ControlGroupStrings.management.deleteAllControls.getTitle(), - buttonColor: 'danger', - }) - .then((confirmed) => { - if (confirmed) { - Object.keys(this.getInput().panels).forEach((id) => this.removeEmbeddable(id)); - flyoutInstance.close(); - } - }); - }} - setAllPanelWidths={(newWidth) => { - const newPanels = cloneDeep(this.getInput().panels); - Object.values(newPanels).forEach((panel) => (panel.width = newWidth)); - this.updateInput({ panels: { ...newPanels, ...newPanels } }); - }} - panels={this.getInput().panels} - /> - ) - ); - }; - - public render(dom: HTMLElement) { - ReactDOM.render(, dom); - } -} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts index 78e50d8651931..35e490b0ea530 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts @@ -48,13 +48,9 @@ export const ControlGroupStrings = { i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', { defaultMessage: 'Manage controls', }), - getDesignTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.designTitle', { - defaultMessage: 'Design', - }), - getWidthTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.widthTitle', { - defaultMessage: 'Width', + getDefaultWidthTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.defaultWidthTitle', { + defaultMessage: 'Default width', }), getLayoutTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', { @@ -64,23 +60,20 @@ export const ControlGroupStrings = { i18n.translate('presentationUtil.inputControls.controlGroup.management.delete', { defaultMessage: 'Delete control', }), + getSetAllWidthsToDefaultTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.management.setAllWidths', { + defaultMessage: 'Set all widths to default', + }), getDeleteAllButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll', { defaultMessage: 'Delete all', }), controlWidth: { - getChangeAllControlWidthsTitle: () => - i18n.translate( - 'presentationUtil.inputControls.controlGroup.management.layout.changeAllControlWidths', - { - defaultMessage: 'Set width for all controls', - } - ), getWidthSwitchLegend: () => i18n.translate( 'presentationUtil.inputControls.controlGroup.management.layout.controlWidthLegend', { - defaultMessage: 'Change individual control width', + defaultMessage: 'Change control width', } ), getAutoWidthTitle: () => @@ -117,21 +110,31 @@ export const ControlGroupStrings = { defaultMessage: 'Two line layout', }), }, - deleteAllControls: { - getTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.title', { - defaultMessage: 'Delete all?', - }), + deleteControls: { + getDeleteAllTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.management.delete.deleteAllTitle', + { + defaultMessage: 'Delete all controls?', + } + ), + getDeleteTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.management.delete.deleteTitle', + { + defaultMessage: 'Delete control?', + } + ), getSubtitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.sub', { + i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.sub', { defaultMessage: 'Controls are not recoverable once removed.', }), getConfirm: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.confirm', { + i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.confirm', { defaultMessage: 'Delete', }), getCancel: () => - i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.cancel', { + i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.cancel', { defaultMessage: 'Cancel', }), }, @@ -143,7 +146,7 @@ export const ControlGroupStrings = { getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', { defaultMessage: - 'Discard changes to this control? Controls are not recoverable once removed.', + 'Discard changes to this control? Changes are not recoverable once discardsd.', }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', { @@ -161,7 +164,7 @@ export const ControlGroupStrings = { }), getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', { - defaultMessage: 'Discard new control? Controls are not recoverable once removed.', + defaultMessage: 'Discard new control? Controls are not recoverable once discarded.', }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', { @@ -173,4 +176,14 @@ export const ControlGroupStrings = { }), }, }, + floatingActions: { + getEditButtonTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { + defaultMessage: 'Manage control', + }), + getRemoveButtonTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', { + defaultMessage: 'Remove control', + }), + }, }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx similarity index 99% rename from src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control.tsx rename to src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx index 6d80a6e0b31f6..38d8faf37397a 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx @@ -46,7 +46,7 @@ interface ManageControlProps { updateWidth: (newWidth: ControlWidth) => void; } -export const ManageControlComponent = ({ +export const ControlEditor = ({ controlEditorComponent, removeControl, updateTitle, diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx new file mode 100644 index 0000000000000..9f59fe98cc0c1 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/create_control.tsx @@ -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 { + EuiButtonIcon, + EuiButtonIconColor, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, +} from '@elastic/eui'; +import React, { useState, ReactElement } from 'react'; + +import { ControlGroupInput } from '../types'; +import { ControlEditor } from './control_editor'; +import { pluginServices } from '../../../../services'; +import { forwardAllContext } from './forward_all_context'; +import { OverlayRef } from '../../../../../../../core/public'; +import { ControlGroupStrings } from '../control_group_strings'; +import { InputControlInput } from '../../../../services/controls'; +import { DEFAULT_CONTROL_WIDTH } from '../control_group_constants'; +import { ControlWidth, IEditableControlFactory } from '../../types'; +import { controlGroupReducers } from '../state/control_group_reducers'; +import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; +import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; + +export const CreateControlButton = () => { + // Presentation Services Context + const { overlays, controls } = pluginServices.getHooks(); + const { getInputControlTypes, getControlFactory } = controls.useService(); + const { openFlyout, openConfirm } = overlays.useService(); + + // Redux embeddable container Context + const reduxContainerContext = useReduxContainerContext< + ControlGroupInput, + typeof controlGroupReducers + >(); + const { + containerActions: { addNewEmbeddable }, + actions: { setDefaultControlWidth }, + useEmbeddableSelector, + useEmbeddableDispatch, + } = reduxContainerContext; + const dispatch = useEmbeddableDispatch(); + + // current state + const { defaultControlWidth } = useEmbeddableSelector((state) => state); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const createNewControl = async (type: string) => { + const factory = getControlFactory(type); + if (!factory) throw new EmbeddableFactoryNotFoundError(type); + + const initialInputPromise = new Promise>((resolve, reject) => { + let inputToReturn: Partial = {}; + + const onCancel = (ref: OverlayRef) => { + if (Object.keys(inputToReturn).length === 0) { + reject(); + ref.close(); + return; + } + openConfirm(ControlGroupStrings.management.discardNewControl.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.discardNewControl.getConfirm(), + cancelButtonText: ControlGroupStrings.management.discardNewControl.getCancel(), + title: ControlGroupStrings.management.discardNewControl.getTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + reject(); + ref.close(); + } + }); + }; + + const flyoutInstance = openFlyout( + forwardAllContext( + (inputToReturn.title = newTitle)} + updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))} + controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ + onChange: (partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }, + })} + onSave={() => { + resolve(inputToReturn); + flyoutInstance.close(); + }} + onCancel={() => onCancel(flyoutInstance)} + />, + reduxContainerContext + ), + { + onClose: (flyout) => onCancel(flyout), + } + ); + }); + initialInputPromise.then( + async (explicitInput) => { + await addNewEmbeddable(type, explicitInput); + }, + () => {} // swallow promise rejection because it can be part of normal flow + ); + }; + + if (getInputControlTypes().length === 0) return null; + + const commonButtonProps = { + iconType: 'plus', + color: 'text' as EuiButtonIconColor, + 'data-test-subj': 'inputControlsSortingButton', + 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), + }; + + if (getInputControlTypes().length > 1) { + const items: ReactElement[] = []; + getInputControlTypes().forEach((type) => { + const factory = getControlFactory(type); + items.push( + { + setIsPopoverOpen(false); + createNewControl(type); + }} + > + {factory.getDisplayName()} + + ); + }); + const button = setIsPopoverOpen(true)} />; + + return ( + setIsPopoverOpen(false)} + > + + + ); + } + return ( + createNewControl(getInputControlTypes()[0])} + /> + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx new file mode 100644 index 0000000000000..58c59c8f84fe0 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx @@ -0,0 +1,127 @@ +/* + * 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 { isEqual } from 'lodash'; +import { EuiButtonIcon } from '@elastic/eui'; +import React, { useEffect, useRef } from 'react'; + +import { ControlGroupInput } from '../types'; +import { ControlEditor } from './control_editor'; +import { IEditableControlFactory } from '../../types'; +import { pluginServices } from '../../../../services'; +import { forwardAllContext } from './forward_all_context'; +import { OverlayRef } from '../../../../../../../core/public'; +import { ControlGroupStrings } from '../control_group_strings'; +import { controlGroupReducers } from '../state/control_group_reducers'; +import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; +import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; + +export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { + // Presentation Services Context + const { overlays, controls } = pluginServices.getHooks(); + const { getControlFactory } = controls.useService(); + const { openFlyout, openConfirm } = overlays.useService(); + + // Redux embeddable container Context + const reduxContainerContext = useReduxContainerContext< + ControlGroupInput, + typeof controlGroupReducers + >(); + const { + containerActions: { untilEmbeddableLoaded, removeEmbeddable, updateInputForChild }, + actions: { setControlWidth }, + useEmbeddableSelector, + useEmbeddableDispatch, + } = reduxContainerContext; + const dispatch = useEmbeddableDispatch(); + + // current state + const { panels } = useEmbeddableSelector((state) => state); + + // keep up to date ref of latest panel state for comparison when closing editor. + const latestPanelState = useRef(panels[embeddableId]); + useEffect(() => { + latestPanelState.current = panels[embeddableId]; + }, [panels, embeddableId]); + + const editControl = async () => { + const panel = panels[embeddableId]; + const factory = getControlFactory(panel.type); + const embeddable = await untilEmbeddableLoaded(embeddableId); + + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + + let removed = false; + const onCancel = (ref: OverlayRef) => { + if ( + removed || + (isEqual(latestPanelState.current.explicitInput, panel.explicitInput) && + isEqual(latestPanelState.current.width, panel.width)) + ) { + ref.close(); + return; + } + openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), + cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), + title: ControlGroupStrings.management.discardChanges.getTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + updateInputForChild(embeddableId, panel.explicitInput); + dispatch(setControlWidth({ width: panel.width, embeddableId })); + ref.close(); + } + }); + }; + + const flyoutInstance = openFlyout( + forwardAllContext( + { + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + removed = true; + flyoutInstance.close(); + } + }); + }} + updateTitle={(newTitle) => updateInputForChild(embeddableId, { title: newTitle })} + controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ + onChange: (partialInput) => updateInputForChild(embeddableId, partialInput), + initialInput: embeddable.getInput(), + })} + onCancel={() => onCancel(flyoutInstance)} + onSave={() => flyoutInstance.close()} + updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} + />, + reduxContainerContext + ), + { + onClose: (flyout) => onCancel(flyout), + } + ); + }; + + return ( + editControl()} + color="text" + /> + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx new file mode 100644 index 0000000000000..9438091e2fb1d --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { + EuiTitle, + EuiSpacer, + EuiFormRow, + EuiFlexItem, + EuiFlexGroup, + EuiFlyoutBody, + EuiButtonGroup, + EuiButtonEmpty, + EuiFlyoutHeader, +} from '@elastic/eui'; + +import { + CONTROL_LAYOUT_OPTIONS, + CONTROL_WIDTH_OPTIONS, + DEFAULT_CONTROL_WIDTH, +} from '../control_group_constants'; +import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../../../services'; +import { ControlStyle, ControlWidth } from '../../types'; +import { ControlGroupStrings } from '../control_group_strings'; +import { controlGroupReducers } from '../state/control_group_reducers'; +import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; + +export const EditControlGroup = () => { + const { overlays } = pluginServices.getHooks(); + const { openConfirm } = overlays.useService(); + + const { + containerActions, + useEmbeddableSelector, + useEmbeddableDispatch, + actions: { setControlStyle, setAllControlWidths, setDefaultControlWidth }, + } = useReduxContainerContext(); + + const dispatch = useEmbeddableDispatch(); + const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state); + + return ( + <> + + +

{ControlGroupStrings.management.getFlyoutTitle()}

+
+
+ + + + dispatch(setControlStyle(newControlStyle as ControlStyle)) + } + /> + + + + + + + dispatch(setDefaultControlWidth(newWidth as ControlWidth)) + } + /> + + + + dispatch(setAllControlWidths(defaultControlWidth ?? DEFAULT_CONTROL_WIDTH)) + } + aria-label={'delete-all'} + iconType="returnKey" + size="s" + > + {ControlGroupStrings.management.getSetAllWidthsToDefaultTitle()} + + + + + + + + { + if (!containerActions?.removeEmbeddable) return; + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) + Object.keys(panels).forEach((panelId) => + containerActions.removeEmbeddable(panelId) + ); + }); + }} + aria-label={'delete-all'} + iconType="trash" + color="danger" + flush="left" + size="s" + > + {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/forward_all_context.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/forward_all_context.tsx new file mode 100644 index 0000000000000..bb7356c240648 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/forward_all_context.tsx @@ -0,0 +1,36 @@ +/* + * 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 { Provider } from 'react-redux'; +import { ReactElement } from 'react'; +import React from 'react'; + +import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../../../services'; +import { toMountPoint } from '../../../../../../kibana_react/public'; +import { ReduxContainerContextServices } from '../../../redux_embeddables/types'; +import { ReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { getManagedEmbeddablesStore } from '../../../redux_embeddables/generic_embeddable_store'; + +/** + * The overlays service creates its divs outside the flow of the component. This necessitates + * passing all context from the component to the flyout. + */ +export const forwardAllContext = ( + component: ReactElement, + reduxContainerContext: ReduxContainerContextServices +) => { + const PresentationUtilProvider = pluginServices.getContextProvider(); + return toMountPoint( + + + {component} + + + ); +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control_group_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control_group_component.tsx deleted file mode 100644 index e766b16ade13a..0000000000000 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/manage_control_group_component.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import useMount from 'react-use/lib/useMount'; -import React, { useState } from 'react'; -import { - EuiFlyoutHeader, - EuiButtonEmpty, - EuiButtonGroup, - EuiFlyoutBody, - EuiFormRow, - EuiSpacer, - EuiSwitch, - EuiTitle, -} from '@elastic/eui'; - -import { ControlsPanels } from '../types'; -import { ControlStyle, ControlWidth } from '../../types'; -import { ControlGroupStrings } from '../control_group_strings'; -import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from '../control_group_constants'; - -interface ManageControlGroupProps { - panels: ControlsPanels; - controlStyle: ControlStyle; - deleteAllEmbeddables: () => void; - setControlStyle: (style: ControlStyle) => void; - setAllPanelWidths: (newWidth: ControlWidth) => void; -} - -export const ManageControlGroup = ({ - panels, - controlStyle, - setControlStyle, - setAllPanelWidths, - deleteAllEmbeddables, -}: ManageControlGroupProps) => { - const [currentControlStyle, setCurrentControlStyle] = useState(controlStyle); - const [selectedWidth, setSelectedWidth] = useState(); - const [selectionDisplay, setSelectionDisplay] = useState(false); - - useMount(() => { - if (!panels || Object.keys(panels).length === 0) return; - const firstWidth = panels[Object.keys(panels)[0]].width; - if (Object.values(panels).every((panel) => panel.width === firstWidth)) { - setSelectedWidth(firstWidth); - } - }); - - return ( - <> - - -

{ControlGroupStrings.management.getFlyoutTitle()}

-
-
- - - { - setControlStyle(newControlStyle as ControlStyle); - setCurrentControlStyle(newControlStyle as ControlStyle); - }} - /> - - - - setSelectionDisplay(!selectionDisplay)} - /> - - {selectionDisplay ? ( - <> - - { - setAllPanelWidths(newWidth as ControlWidth); - setSelectedWidth(newWidth as ControlWidth); - }} - /> - - ) : undefined} - - - - - {ControlGroupStrings.management.getDeleteAllButtonTitle()} - - - - ); -}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx new file mode 100644 index 0000000000000..a722bed6c07d2 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { + InputControlEmbeddable, + InputControlInput, + InputControlOutput, +} from '../../../../services/controls'; +import { pluginServices } from '../../../../services'; +import { ControlGroupInput, ControlPanelState } from '../types'; +import { ControlGroup } from '../component/control_group_component'; +import { controlGroupReducers } from '../state/control_group_reducers'; +import { Container, EmbeddableFactory } from '../../../../../../embeddable/public'; +import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from '../control_group_constants'; +import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; + +export class ControlGroupContainer extends Container { + public readonly type = CONTROL_GROUP_TYPE; + + constructor(initialInput: ControlGroupInput, parent?: Container) { + super( + initialInput, + { embeddableLoaded: {} }, + pluginServices.getServices().controls.getControlFactory, + parent + ); + } + + protected createNewPanelState( + factory: EmbeddableFactory, + partial: Partial = {} + ): ControlPanelState { + const panelState = super.createNewPanelState(factory, partial); + const highestOrder = Object.values(this.getInput().panels).reduce((highestSoFar, panel) => { + if (panel.order > highestSoFar) highestSoFar = panel.order; + return highestSoFar; + }, 0); + return { + order: highestOrder + 1, + width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH, + ...panelState, + } as ControlPanelState; + } + + protected getInheritedInput(id: string): InputControlInput { + const { filters, query, timeRange, inheritParentState } = this.getInput(); + return { + filters: inheritParentState.useFilters ? filters : undefined, + query: inheritParentState.useQuery ? query : undefined, + timeRange: inheritParentState.useTimerange ? timeRange : undefined, + id, + }; + } + + public render(dom: HTMLElement) { + const PresentationUtilProvider = pluginServices.getContextProvider(); + ReactDOM.render( + + + embeddable={this} + reducers={controlGroupReducers} + > + + + , + dom + ); + } +} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_container_factory.ts b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts similarity index 71% rename from src/plugins/presentation_util/public/components/controls/control_group/control_group_container_factory.ts rename to src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts index 97ef48e6b240c..e50b1c5d734e4 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_container_factory.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/embeddable/control_group_container_factory.ts @@ -20,13 +20,11 @@ import { EmbeddableFactory, EmbeddableFactoryDefinition, ErrorEmbeddable, -} from '../../../../../embeddable/public'; -import { ControlGroupInput } from './types'; -import { ControlsService } from '../controls_service'; -import { ControlGroupStrings } from './control_group_strings'; -import { CONTROL_GROUP_TYPE } from './control_group_constants'; +} from '../../../../../../embeddable/public'; +import { ControlGroupInput } from '../types'; +import { ControlGroupStrings } from '../control_group_strings'; +import { CONTROL_GROUP_TYPE } from '../control_group_constants'; import { ControlGroupContainer } from './control_group_container'; -import { PresentationOverlaysService } from '../../../services/overlays'; export type DashboardContainerFactory = EmbeddableFactory< ControlGroupInput, @@ -38,13 +36,6 @@ export class ControlGroupContainerFactory { public readonly isContainerType = true; public readonly type = CONTROL_GROUP_TYPE; - public readonly controlsService: ControlsService; - private readonly overlays: PresentationOverlaysService; - - constructor(controlsService: ControlsService, overlays: PresentationOverlaysService) { - this.overlays = overlays; - this.controlsService = controlsService; - } public isEditable = async () => false; @@ -67,6 +58,6 @@ export class ControlGroupContainerFactory initialInput: ControlGroupInput, parent?: Container ): Promise => { - return new ControlGroupContainer(initialInput, this.controlsService, this.overlays, parent); + return new ControlGroupContainer(initialInput, parent); }; } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/state/control_group_reducers.ts b/src/plugins/presentation_util/public/components/controls/control_group/state/control_group_reducers.ts new file mode 100644 index 0000000000000..b7c0c62535d4c --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/state/control_group_reducers.ts @@ -0,0 +1,48 @@ +/* + * 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 { PayloadAction } from '@reduxjs/toolkit'; +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { ControlWidth } from '../../types'; +import { ControlGroupInput } from '../types'; + +export const controlGroupReducers = { + setControlStyle: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.controlStyle = action.payload; + }, + setDefaultControlWidth: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.defaultControlWidth = action.payload; + }, + setAllControlWidths: ( + state: WritableDraft, + action: PayloadAction + ) => { + Object.keys(state.panels).forEach((panelId) => (state.panels[panelId].width = action.payload)); + }, + setControlWidth: ( + state: WritableDraft, + action: PayloadAction<{ width: ControlWidth; embeddableId: string }> + ) => { + state.panels[action.payload.embeddableId].width = action.payload.width; + }, + setControlOrders: ( + state: WritableDraft, + action: PayloadAction<{ ids: string[] }> + ) => { + action.payload.ids.forEach((id, index) => { + state.panels[id].order = index; + }); + }, +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/types.ts b/src/plugins/presentation_util/public/components/controls/control_group/types.ts index fb381610711e5..438eee1c461dd 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/types.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/types.ts @@ -7,7 +7,8 @@ */ import { PanelState, EmbeddableInput } from '../../../../../embeddable/public'; -import { ControlStyle, ControlWidth, InputControlInput } from '../types'; +import { InputControlInput } from '../../../services/controls'; +import { ControlStyle, ControlWidth } from '../types'; export interface ControlGroupInput extends EmbeddableInput, @@ -17,6 +18,7 @@ export interface ControlGroupInput useQuery: boolean; useTimerange: boolean; }; + defaultControlWidth?: ControlWidth; controlStyle: ControlStyle; panels: ControlsPanels; } diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx index 93a7b3e353bdf..97a128c3e84eb 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx @@ -16,7 +16,7 @@ import { tap, debounceTime, map, distinctUntilChanged } from 'rxjs/operators'; import { esFilters } from '../../../../../../data/public'; import { OptionsListStrings } from './options_list_strings'; import { Embeddable, IContainer } from '../../../../../../embeddable/public'; -import { InputControlInput, InputControlOutput } from '../../types'; +import { InputControlInput, InputControlOutput } from '../../../../services/controls'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; const toggleAvailableOptions = ( diff --git a/src/plugins/presentation_util/public/components/controls/controls_service.ts b/src/plugins/presentation_util/public/components/controls/controls_service.ts index 4e01f3cf9ab6a..82242946e4563 100644 --- a/src/plugins/presentation_util/public/components/controls/controls_service.ts +++ b/src/plugins/presentation_util/public/components/controls/controls_service.ts @@ -8,12 +8,12 @@ import { EmbeddableFactory } from '../../../../embeddable/public'; import { - ControlTypeRegistry, InputControlEmbeddable, + ControlTypeRegistry, InputControlFactory, - InputControlInput, InputControlOutput, -} from './types'; + InputControlInput, +} from '../../services/controls'; export class ControlsService { private controlsFactoriesMap: ControlTypeRegistry = {}; diff --git a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts index 82b9aa528bf35..c4f700ec059d9 100644 --- a/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts +++ b/src/plugins/presentation_util/public/components/controls/hooks/use_child_embeddable.ts @@ -6,14 +6,13 @@ * Side Public License, v 1. */ import { useEffect, useState } from 'react'; -import { InputControlEmbeddable } from '../types'; -import { IContainer } from '../../../../../embeddable/public'; +import { InputControlEmbeddable } from '../../../services/controls'; export const useChildEmbeddable = ({ - container, + untilEmbeddableLoaded, embeddableId, }: { - container: IContainer; + untilEmbeddableLoaded: (embeddableId: string) => Promise; embeddableId: string; }) => { const [embeddable, setEmbeddable] = useState(); @@ -21,14 +20,14 @@ export const useChildEmbeddable = ({ useEffect(() => { let mounted = true; (async () => { - const newEmbeddable = await container.untilEmbeddableLoaded(embeddableId); + const newEmbeddable = await untilEmbeddableLoaded(embeddableId); if (!mounted) return; setEmbeddable(newEmbeddable); })(); return () => { mounted = false; }; - }, [container, embeddableId]); + }, [untilEmbeddableLoaded, embeddableId]); return embeddable; }; diff --git a/src/plugins/presentation_util/public/components/controls/types.ts b/src/plugins/presentation_util/public/components/controls/types.ts index c94e2957e34ea..0704a601640e6 100644 --- a/src/plugins/presentation_util/public/components/controls/types.ts +++ b/src/plugins/presentation_util/public/components/controls/types.ts @@ -6,47 +6,11 @@ * Side Public License, v 1. */ -import { Filter } from '@kbn/es-query'; -import { Query, TimeRange } from '../../../../data/public'; -import { - EmbeddableFactory, - EmbeddableInput, - EmbeddableOutput, - IEmbeddable, -} from '../../../../embeddable/public'; +import { InputControlInput } from '../../services/controls'; export type ControlWidth = 'auto' | 'small' | 'medium' | 'large'; export type ControlStyle = 'twoLine' | 'oneLine'; -/** - * Control embeddable types - */ -export type InputControlFactory = EmbeddableFactory< - InputControlInput, - InputControlOutput, - InputControlEmbeddable ->; - -export interface ControlTypeRegistry { - [key: string]: InputControlFactory; -} - -export type InputControlInput = EmbeddableInput & { - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - twoLineLayout?: boolean; -}; - -export type InputControlOutput = EmbeddableOutput & { - filters?: Filter[]; -}; - -export type InputControlEmbeddable< - TInputControlEmbeddableInput extends InputControlInput = InputControlInput, - TInputControlEmbeddableOutput extends InputControlOutput = InputControlOutput -> = IEmbeddable; - /** * Control embeddable editor types */ diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts new file mode 100644 index 0000000000000..36ba1fcaa49b9 --- /dev/null +++ b/src/plugins/presentation_util/public/components/redux_embeddables/generic_embeddable_store.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { configureStore, EnhancedStore } from '@reduxjs/toolkit'; +import { combineReducers, Reducer } from 'redux'; + +export interface InjectReducerProps { + key: string; + asyncReducer: Reducer; +} + +type ManagedEmbeddableReduxStore = EnhancedStore & { + asyncReducers: { [key: string]: Reducer }; + injectReducer: (props: InjectReducerProps) => void; +}; +const embeddablesStore = configureStore({ reducer: {} as { [key: string]: Reducer } }); + +const managedEmbeddablesStore = embeddablesStore as ManagedEmbeddableReduxStore; +managedEmbeddablesStore.asyncReducers = {}; + +managedEmbeddablesStore.injectReducer = ({ + key, + asyncReducer, +}: InjectReducerProps) => { + managedEmbeddablesStore.asyncReducers[key] = asyncReducer as Reducer; + managedEmbeddablesStore.replaceReducer( + combineReducers({ ...managedEmbeddablesStore.asyncReducers }) + ); +}; + +/** + * A managed Redux store which can be used with multiple embeddables at once. When a new embeddable is created at runtime, + * all passed in reducers will be made into a slice, then combined into the store using combineReducers. + */ +export const getManagedEmbeddablesStore = () => managedEmbeddablesStore; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts new file mode 100644 index 0000000000000..159230e4de024 --- /dev/null +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_context.ts @@ -0,0 +1,73 @@ +/* + * 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 { createContext, useContext } from 'react'; + +import { + GenericEmbeddableReducers, + ReduxContainerContextServices, + ReduxEmbeddableContextServices, +} from './types'; +import { ContainerInput, EmbeddableInput } from '../../../../embeddable/public'; + +/** + * When creating the context, a generic EmbeddableInput as placeholder is used. This will later be cast to + * the generic type passed in by the useReduxEmbeddableContext or useReduxContainerContext hooks + **/ +export const ReduxEmbeddableContext = createContext< + | ReduxEmbeddableContextServices + | ReduxContainerContextServices + | null +>(null); + +/** + * A typed use context hook for embeddables that are not containers. it @returns an + * ReduxEmbeddableContextServices object typed to the generic inputTypes and ReducerTypes you pass in. + * Note that the reducer type is optional, but will be required to correctly infer the keys and payload + * types of your reducers. use `typeof MyReducers` here to retain them. + */ +export const useReduxEmbeddableContext = < + InputType extends EmbeddableInput = EmbeddableInput, + ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers +>(): ReduxEmbeddableContextServices => { + const context = useContext>( + ReduxEmbeddableContext as unknown as React.Context< + ReduxEmbeddableContextServices + > + ); + if (context == null) { + throw new Error( + 'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.' + ); + } + + return context!; +}; + +/** + * A typed use context hook for embeddable containers. it @returns an + * ReduxContainerContextServices object typed to the generic inputTypes and ReducerTypes you pass in. + * Note that the reducer type is optional, but will be required to correctly infer the keys and payload + * types of your reducers. use `typeof MyReducers` here to retain them. It also includes a containerActions + * key which contains most of the commonly used container operations + */ +export const useReduxContainerContext = < + InputType extends ContainerInput = ContainerInput, + ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers +>(): ReduxContainerContextServices => { + const context = useContext>( + ReduxEmbeddableContext as unknown as React.Context< + ReduxContainerContextServices + > + ); + if (context == null) { + throw new Error( + 'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.' + ); + } + return context!; +}; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx new file mode 100644 index 0000000000000..a4912b5b5f2fc --- /dev/null +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; +import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import { Draft } from 'immer/dist/types/types-external'; +import { isEqual } from 'lodash'; +import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; + +import { + IEmbeddable, + EmbeddableInput, + EmbeddableOutput, + IContainer, +} from '../../../../embeddable/public'; +import { getManagedEmbeddablesStore } from './generic_embeddable_store'; +import { + ReduxContainerContextServices, + ReduxEmbeddableContextServices, + ReduxEmbeddableWrapperProps, +} from './types'; +import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; + +const getDefaultProps = (): Required< + Pick, 'diffInput'> +> => ({ + diffInput: (a, b) => { + const differences: Partial = {}; + const allKeys = [...Object.keys(a), ...Object.keys(b)] as Array; + allKeys.forEach((key) => { + if (!isEqual(a[key], b[key])) differences[key] = a[key]; + }); + return differences; + }, +}); + +const embeddableIsContainer = ( + embeddable: IEmbeddable +): embeddable is IContainer => embeddable.isContainer; + +/** + * Place this wrapper around the react component when rendering an embeddable to automatically set up + * redux for use with the embeddable via the supplied reducers. Any child components can then use ReduxEmbeddableContext + * or ReduxContainerContext to interface with the state of the embeddable. + */ +export const ReduxEmbeddableWrapper = ( + props: PropsWithChildren> +) => { + const { embeddable, reducers, diffInput } = useMemo( + () => ({ ...getDefaultProps(), ...props }), + [props] + ); + + const containerActions: ReduxContainerContextServices['containerActions'] | undefined = + useMemo(() => { + if (embeddableIsContainer(embeddable)) { + return { + untilEmbeddableLoaded: embeddable.untilEmbeddableLoaded.bind(embeddable), + updateInputForChild: embeddable.updateInputForChild.bind(embeddable), + removeEmbeddable: embeddable.removeEmbeddable.bind(embeddable), + addNewEmbeddable: embeddable.addNewEmbeddable.bind(embeddable), + }; + } + return; + }, [embeddable]); + + const reduxEmbeddableContext: ReduxEmbeddableContextServices | ReduxContainerContextServices = + useMemo(() => { + const key = `${embeddable.type}_${embeddable.id}`; + + // A generic reducer used to update redux state when the embeddable input changes + const updateEmbeddableReduxState = ( + state: Draft, + action: PayloadAction> + ) => { + return { ...state, ...action.payload }; + }; + + const slice = createSlice>({ + initialState: embeddable.getInput(), + name: key, + reducers: { ...reducers, updateEmbeddableReduxState }, + }); + const store = getManagedEmbeddablesStore(); + + store.injectReducer({ + key, + asyncReducer: slice.reducer, + }); + + const useEmbeddableSelector: TypedUseSelectorHook = () => + useSelector((state: ReturnType) => state[key]); + + return { + useEmbeddableDispatch: () => useDispatch(), + useEmbeddableSelector, + actions: slice.actions as ReduxEmbeddableContextServices['actions'], + containerActions, + }; + }, [reducers, embeddable, containerActions]); + + return ( + + + + {props.children} + + + + ); +}; + +interface ReduxEmbeddableSyncProps { + diffInput: (a: InputType, b: InputType) => Partial; + embeddable: IEmbeddable; +} + +/** + * This component uses the context from the embeddable wrapper to set up a generic two-way binding between the embeddable input and + * the redux store. a custom diffInput function can be provided, this function should always prioritize input A over input B. + */ +const ReduxEmbeddableSync = ({ + embeddable, + diffInput, + children, +}: PropsWithChildren>) => { + const { + useEmbeddableSelector, + useEmbeddableDispatch, + actions: { updateEmbeddableReduxState }, + } = useReduxEmbeddableContext(); + + const dispatch = useEmbeddableDispatch(); + const currentState = useEmbeddableSelector((state) => state); + const stateRef = useRef(currentState); + + // When Embeddable Input changes, push differences to redux. + useEffect(() => { + embeddable.getInput$().subscribe(() => { + const differences = diffInput(embeddable.getInput(), stateRef.current); + if (differences && Object.keys(differences).length > 0) { + dispatch(updateEmbeddableReduxState(differences)); + } + }); + }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState]); + + // When redux state changes, push differences to Embeddable Input. + useEffect(() => { + stateRef.current = currentState; + const differences = diffInput(currentState, embeddable.getInput()); + if (differences && Object.keys(differences).length > 0) { + embeddable.updateInput(differences); + } + }, [currentState, diffInput, embeddable]); + + return <>{children}; +}; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/types.ts b/src/plugins/presentation_util/public/components/redux_embeddables/types.ts new file mode 100644 index 0000000000000..118b5d340528e --- /dev/null +++ b/src/plugins/presentation_util/public/components/redux_embeddables/types.ts @@ -0,0 +1,62 @@ +/* + * 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 { + ActionCreatorWithPayload, + AnyAction, + CaseReducer, + Dispatch, + PayloadAction, +} from '@reduxjs/toolkit'; +import { TypedUseSelectorHook } from 'react-redux'; +import { + EmbeddableInput, + EmbeddableOutput, + IContainer, + IEmbeddable, +} from '../../../../embeddable/public'; + +export interface GenericEmbeddableReducers { + /** + * PayloadAction of type any is strategic here because we want to allow payloads of any shape in generic reducers. + * This type will be overridden to remove any and be type safe when returned by ReduxEmbeddableContextServices. + */ + [key: string]: CaseReducer>; +} + +export interface ReduxEmbeddableWrapperProps { + embeddable: IEmbeddable; + reducers: GenericEmbeddableReducers; + diffInput?: (a: InputType, b: InputType) => Partial; +} + +/** + * This context allows components underneath the redux embeddable wrapper to get access to the actions, selector, dispatch, and containerActions. + */ +export interface ReduxEmbeddableContextServices< + InputType extends EmbeddableInput = EmbeddableInput, + ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers +> { + actions: { + [Property in keyof ReducerType]: ActionCreatorWithPayload< + Parameters[1]['payload'] + >; + } & { updateEmbeddableReduxState: ActionCreatorWithPayload> }; + useEmbeddableSelector: TypedUseSelectorHook; + useEmbeddableDispatch: () => Dispatch; +} + +export type ReduxContainerContextServices< + InputType extends EmbeddableInput = EmbeddableInput, + ReducerType extends GenericEmbeddableReducers = GenericEmbeddableReducers +> = ReduxEmbeddableContextServices & { + containerActions: Pick< + IContainer, + 'untilEmbeddableLoaded' | 'removeEmbeddable' | 'addNewEmbeddable' | 'updateInputForChild' + >; +}; diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts index 91c461646c280..ddb02ce464e22 100644 --- a/src/plugins/presentation_util/public/mocks.ts +++ b/src/plugins/presentation_util/public/mocks.ts @@ -17,6 +17,7 @@ const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart const startContract: PresentationUtilPluginStart = { ContextProvider: pluginServices.getContextProvider(), labsService: pluginServices.getServices().labs, + controlsService: pluginServices.getServices().controls, }; return startContract; }; diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index f34bd2f1f8afe..f697f1a29eb82 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -39,6 +39,7 @@ export class PresentationUtilPlugin pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); return { ContextProvider: pluginServices.getContextProvider(), + controlsService: pluginServices.getServices().controls, labsService: pluginServices.getServices().labs, }; } diff --git a/src/plugins/presentation_util/public/services/controls.ts b/src/plugins/presentation_util/public/services/controls.ts new file mode 100644 index 0000000000000..197e986381b10 --- /dev/null +++ b/src/plugins/presentation_util/public/services/controls.ts @@ -0,0 +1,85 @@ +/* + * 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 { Filter } from '@kbn/es-query'; +import { Query, TimeRange } from '../../../data/public'; +import { + EmbeddableFactory, + EmbeddableInput, + EmbeddableOutput, + IEmbeddable, +} from '../../../embeddable/public'; + +/** + * Control embeddable types + */ +export type InputControlFactory = EmbeddableFactory< + InputControlInput, + InputControlOutput, + InputControlEmbeddable +>; + +export type InputControlInput = EmbeddableInput & { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + twoLineLayout?: boolean; +}; + +export type InputControlOutput = EmbeddableOutput & { + filters?: Filter[]; +}; + +export type InputControlEmbeddable< + TInputControlEmbeddableInput extends InputControlInput = InputControlInput, + TInputControlEmbeddableOutput extends InputControlOutput = InputControlOutput +> = IEmbeddable; + +export interface ControlTypeRegistry { + [key: string]: InputControlFactory; +} + +export interface PresentationControlsService { + registerInputControlType: (factory: InputControlFactory) => void; + + getControlFactory: < + I extends InputControlInput = InputControlInput, + O extends InputControlOutput = InputControlOutput, + E extends InputControlEmbeddable = InputControlEmbeddable + >( + type: string + ) => EmbeddableFactory; + + getInputControlTypes: () => string[]; +} + +export const getCommonControlsService = () => { + const controlsFactoriesMap: ControlTypeRegistry = {}; + + const registerInputControlType = (factory: InputControlFactory) => { + controlsFactoriesMap[factory.type] = factory; + }; + + const getControlFactory = < + I extends InputControlInput = InputControlInput, + O extends InputControlOutput = InputControlOutput, + E extends InputControlEmbeddable = InputControlEmbeddable + >( + type: string + ) => { + return controlsFactoriesMap[type] as EmbeddableFactory; + }; + + const getInputControlTypes = () => Object.keys(controlsFactoriesMap); + + return { + registerInputControlType, + getControlFactory, + getInputControlTypes, + }; +}; diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts index c622ad82bb888..21012971ca86d 100644 --- a/src/plugins/presentation_util/public/services/index.ts +++ b/src/plugins/presentation_util/public/services/index.ts @@ -13,6 +13,7 @@ import { PresentationDashboardsService } from './dashboards'; import { PresentationLabsService } from './labs'; import { registry as stubRegistry } from './stub'; import { PresentationOverlaysService } from './overlays'; +import { PresentationControlsService } from './controls'; export { PresentationCapabilitiesService } from './capabilities'; export { PresentationDashboardsService } from './dashboards'; @@ -21,6 +22,7 @@ export interface PresentationUtilServices { dashboards: PresentationDashboardsService; capabilities: PresentationCapabilitiesService; overlays: PresentationOverlaysService; + controls: PresentationControlsService; labs: PresentationLabsService; } @@ -31,5 +33,6 @@ export const getStubPluginServices = (): PresentationUtilPluginStart => { return { ContextProvider: pluginServices.getContextProvider(), labsService: pluginServices.getServices().labs, + controlsService: pluginServices.getServices().controls, }; }; diff --git a/src/plugins/presentation_util/public/components/controls/index.ts b/src/plugins/presentation_util/public/services/kibana/controls.ts similarity index 54% rename from src/plugins/presentation_util/public/components/controls/index.ts rename to src/plugins/presentation_util/public/services/kibana/controls.ts index 5c2d5b68ae2e0..e5dc84a3dd645 100644 --- a/src/plugins/presentation_util/public/components/controls/index.ts +++ b/src/plugins/presentation_util/public/services/kibana/controls.ts @@ -5,3 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import { PluginServiceFactory } from '../create'; +import { getCommonControlsService, PresentationControlsService } from '../controls'; + +export type ControlsServiceFactory = PluginServiceFactory; +export const controlsServiceFactory = () => getCommonControlsService(); diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts index 8a9a28606f24b..48c921bff1efd 100644 --- a/src/plugins/presentation_util/public/services/kibana/index.ts +++ b/src/plugins/presentation_util/public/services/kibana/index.ts @@ -18,6 +18,7 @@ import { } from '../create'; import { PresentationUtilPluginStartDeps } from '../../types'; import { PresentationUtilServices } from '..'; +import { controlsServiceFactory } from './controls'; export { capabilitiesServiceFactory } from './capabilities'; export { dashboardsServiceFactory } from './dashboards'; @@ -32,6 +33,7 @@ export const providers: PluginServiceProviders< labs: new PluginServiceProvider(labsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), + controls: new PluginServiceProvider(controlsServiceFactory), }; export const registry = new PluginServiceRegistry< diff --git a/src/plugins/presentation_util/public/services/storybook/controls.ts b/src/plugins/presentation_util/public/services/storybook/controls.ts new file mode 100644 index 0000000000000..e5dc84a3dd645 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/controls.ts @@ -0,0 +1,13 @@ +/* + * 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 { PluginServiceFactory } from '../create'; +import { getCommonControlsService, PresentationControlsService } from '../controls'; + +export type ControlsServiceFactory = PluginServiceFactory; +export const controlsServiceFactory = () => getCommonControlsService(); diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts index 1ce1eb72848c9..9de4934d51300 100644 --- a/src/plugins/presentation_util/public/services/storybook/index.ts +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -6,12 +6,18 @@ * Side Public License, v 1. */ -import { PluginServices, PluginServiceProviders, PluginServiceProvider } from '../create'; +import { + PluginServices, + PluginServiceProviders, + PluginServiceProvider, + PluginServiceRegistry, +} from '../create'; import { dashboardsServiceFactory } from '../stub/dashboards'; import { labsServiceFactory } from './labs'; import { capabilitiesServiceFactory } from './capabilities'; import { PresentationUtilServices } from '..'; import { overlaysServiceFactory } from './overlays'; +import { controlsServiceFactory } from './controls'; export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; export { PresentationUtilServices } from '..'; @@ -27,7 +33,10 @@ export const providers: PluginServiceProviders(); + +export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/presentation_util/public/services/stub/controls.ts b/src/plugins/presentation_util/public/services/stub/controls.ts new file mode 100644 index 0000000000000..e5dc84a3dd645 --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/controls.ts @@ -0,0 +1,13 @@ +/* + * 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 { PluginServiceFactory } from '../create'; +import { getCommonControlsService, PresentationControlsService } from '../controls'; + +export type ControlsServiceFactory = PluginServiceFactory; +export const controlsServiceFactory = () => getCommonControlsService(); diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts index 61dca47427531..35aabdb465b14 100644 --- a/src/plugins/presentation_util/public/services/stub/index.ts +++ b/src/plugins/presentation_util/public/services/stub/index.ts @@ -12,7 +12,7 @@ import { labsServiceFactory } from './labs'; import { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; import { PresentationUtilServices } from '..'; import { overlaysServiceFactory } from './overlays'; - +import { controlsServiceFactory } from './controls'; export { dashboardsServiceFactory } from './dashboards'; export { capabilitiesServiceFactory } from './capabilities'; @@ -20,6 +20,7 @@ export const providers: PluginServiceProviders = { dashboards: new PluginServiceProvider(dashboardsServiceFactory), capabilities: new PluginServiceProvider(capabilitiesServiceFactory), overlays: new PluginServiceProvider(overlaysServiceFactory), + controls: new PluginServiceProvider(controlsServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), }; diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index 05779ffb206c4..3903d1bc2786e 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { PresentationControlsService } from './services/controls'; import { PresentationLabsService } from './services/labs'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -14,6 +15,7 @@ export interface PresentationUtilPluginSetup {} export interface PresentationUtilPluginStart { ContextProvider: React.FC; labsService: PresentationLabsService; + controlsService: PresentationControlsService; } // eslint-disable-next-line @typescript-eslint/no-empty-interface