diff --git a/examples/controls_example/public/edit_example.tsx b/examples/controls_example/public/edit_example.tsx index d8bfd515ca7da..cf5430cad48f1 100644 --- a/examples/controls_example/public/edit_example.tsx +++ b/examples/controls_example/public/edit_example.tsx @@ -6,10 +6,12 @@ * Side Public License, v 1. */ +import { pickBy } from 'lodash'; import React, { useState } from 'react'; import { EuiButton, EuiButtonEmpty, + EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiLoadingContent, @@ -19,8 +21,13 @@ import { EuiTitle, } from '@elastic/eui'; import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { LazyControlGroupRenderer, ControlGroupContainer } from '@kbn/controls-plugin/public'; +import { + LazyControlGroupRenderer, + ControlGroupContainer, + ControlGroupInput, +} from '@kbn/controls-plugin/public'; import { withSuspense } from '@kbn/presentation-util-plugin/public'; +import { ACTION_EDIT_CONTROL, ACTION_DELETE_CONTROL } from '@kbn/controls-plugin/public'; const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer); @@ -30,6 +37,27 @@ export const EditExample = () => { const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); const [controlGroup, setControlGroup] = useState(); + const [toggleIconIdToSelectedMapIcon, setToggleIconIdToSelectedMapIcon] = useState<{ + [id: string]: boolean; + }>({}); + + function onChangeIconsMultiIcons(optionId: string) { + const newToggleIconIdToSelectedMapIcon = { + ...toggleIconIdToSelectedMapIcon, + ...{ + [optionId]: !toggleIconIdToSelectedMapIcon[optionId], + }, + }; + + if (controlGroup) { + const disabledActions: string[] = Object.keys( + pickBy(newToggleIconIdToSelectedMapIcon, (value) => value) + ); + controlGroup.updateInput({ disabledActions }); + } + + setToggleIconIdToSelectedMapIcon(newToggleIconIdToSelectedMapIcon); + } async function onSave() { setIsSaving(true); @@ -48,16 +76,20 @@ export const EditExample = () => { // simulated async load await await new Promise((resolve) => setTimeout(resolve, 1000)); - let input = {}; + let input: Partial = {}; const inputAsString = localStorage.getItem(INPUT_KEY); if (inputAsString) { try { input = JSON.parse(inputAsString); + const disabledActions = input.disabledActions ?? []; + setToggleIconIdToSelectedMapIcon({ + [ACTION_EDIT_CONTROL]: disabledActions.includes(ACTION_EDIT_CONTROL), + [ACTION_DELETE_CONTROL]: disabledActions.includes(ACTION_DELETE_CONTROL), + }); } catch (e) { // ignore parse errors } } - setIsLoading(false); return input; } @@ -72,7 +104,7 @@ export const EditExample = () => { - + { Add control + + onChangeIconsMultiIcons(id)} + /> + diff --git a/src/plugins/controls/kibana.jsonc b/src/plugins/controls/kibana.jsonc index defb62693b55c..0defa22bd351f 100644 --- a/src/plugins/controls/kibana.jsonc +++ b/src/plugins/controls/kibana.jsonc @@ -15,10 +15,9 @@ "embeddable", "dataViews", "data", - "unifiedSearch" + "unifiedSearch", + "uiActions" ], - "extraPublicDirs": [ - "common" - ] + "extraPublicDirs": ["common"] } } diff --git a/src/plugins/controls/public/control_group/actions/delete_control_action.test.tsx b/src/plugins/controls/public/control_group/actions/delete_control_action.test.tsx new file mode 100644 index 0000000000000..19d036ae5a6b3 --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/delete_control_action.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { + lazyLoadReduxEmbeddablePackage, + ReduxEmbeddablePackage, +} from '@kbn/presentation-util-plugin/public'; +import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { ControlOutput } from '../../types'; +import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../services'; +import { DeleteControlAction } from './delete_control_action'; +import { OptionsListEmbeddableInput } from '../../options_list'; +import { controlGroupInputBuilder } from '../control_group_input_builder'; +import { ControlGroupContainer } from '../embeddable/control_group_container'; +import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable'; + +let container: ControlGroupContainer; +let embeddable: OptionsListEmbeddable; +let reduxEmbeddablePackage: ReduxEmbeddablePackage; + +beforeAll(async () => { + reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); + + const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput; + controlGroupInputBuilder.addOptionsListControl(controlGroupInput, { + dataViewId: 'test-data-view', + title: 'test', + fieldName: 'test-field', + width: 'medium', + grow: false, + }); + container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await container.untilInitialized(); + + embeddable = container.getChild(container.getChildIds()[0]); +}); + +test('Action is incompatible with Error Embeddables', async () => { + const deleteControlAction = new DeleteControlAction(); + const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' }); + expect(await deleteControlAction.isCompatible({ embeddable: errorEmbeddable as any })).toBe( + false + ); +}); + +test('Execute throws an error when called with an embeddable not in a parent', async () => { + const deleteControlAction = new DeleteControlAction(); + const optionsListEmbeddable = new OptionsListEmbeddable( + reduxEmbeddablePackage, + {} as OptionsListEmbeddableInput, + {} as ControlOutput + ); + await expect(async () => { + await deleteControlAction.execute({ embeddable: optionsListEmbeddable }); + }).rejects.toThrow(Error); +}); + +describe('Execute should open a confirm modal', () => { + test('Canceling modal will keep control', async () => { + const spyOn = jest.fn().mockResolvedValue(false); + pluginServices.getServices().overlays.openConfirm = spyOn; + + const deleteControlAction = new DeleteControlAction(); + await deleteControlAction.execute({ embeddable }); + expect(spyOn).toHaveBeenCalled(); + + expect(container.getPanelCount()).toBe(1); + }); + + test('Confirming modal will delete control', async () => { + const spyOn = jest.fn().mockResolvedValue(true); + pluginServices.getServices().overlays.openConfirm = spyOn; + + const deleteControlAction = new DeleteControlAction(); + await deleteControlAction.execute({ embeddable }); + expect(spyOn).toHaveBeenCalled(); + + expect(container.getPanelCount()).toBe(0); + }); +}); diff --git a/src/plugins/controls/public/control_group/actions/delete_control_action.tsx b/src/plugins/controls/public/control_group/actions/delete_control_action.tsx new file mode 100644 index 0000000000000..7a44c537d1d9d --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/delete_control_action.tsx @@ -0,0 +1,91 @@ +/* + * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { ViewMode, isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; + +import { ACTION_DELETE_CONTROL } from '.'; +import { pluginServices } from '../../services'; +import { ControlGroupStrings } from '../control_group_strings'; +import { ControlEmbeddable, DataControlInput } from '../../types'; +import { isControlGroup } from '../embeddable/control_group_helpers'; + +export interface DeleteControlActionContext { + embeddable: ControlEmbeddable; +} + +export class DeleteControlAction implements Action { + public readonly type = ACTION_DELETE_CONTROL; + public readonly id = ACTION_DELETE_CONTROL; + public order = 2; + + private openConfirm; + + constructor() { + ({ + overlays: { openConfirm: this.openConfirm }, + } = pluginServices.getServices()); + } + + public readonly MenuItem = ({ context }: { context: DeleteControlActionContext }) => { + return ( + + this.execute(context)} + color="danger" + /> + + ); + }; + + public getDisplayName({ embeddable }: DeleteControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return ControlGroupStrings.floatingActions.getRemoveButtonTitle(); + } + + public getIconType({ embeddable }: DeleteControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return 'cross'; + } + + public async isCompatible({ embeddable }: DeleteControlActionContext) { + if (isErrorEmbeddable(embeddable)) return false; + const controlGroup = embeddable.parent; + return Boolean( + controlGroup && + isControlGroup(controlGroup) && + controlGroup.getInput().viewMode === ViewMode.EDIT + ); + } + + public async execute({ embeddable }: DeleteControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + this.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) { + embeddable.parent?.removeEmbeddable(embeddable.id); + } + }); + } +} diff --git a/src/plugins/controls/public/control_group/actions/edit_control_action.test.tsx b/src/plugins/controls/public/control_group/actions/edit_control_action.test.tsx new file mode 100644 index 0000000000000..7c4f15f091b9c --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/edit_control_action.test.tsx @@ -0,0 +1,114 @@ +/* + * 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 { + lazyLoadReduxEmbeddablePackage, + ReduxEmbeddablePackage, +} from '@kbn/presentation-util-plugin/public'; +import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { ControlOutput } from '../../types'; +import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../services'; +import { EditControlAction } from './edit_control_action'; +import { DeleteControlAction } from './delete_control_action'; +import { TimeSliderEmbeddableFactory } from '../../time_slider'; +import { OptionsListEmbeddableFactory, OptionsListEmbeddableInput } from '../../options_list'; +import { ControlGroupContainer } from '../embeddable/control_group_container'; +import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable'; + +let reduxEmbeddablePackage: ReduxEmbeddablePackage; + +const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput; +const deleteControlAction = new DeleteControlAction(); + +beforeAll(async () => { + reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); +}); + +test('Action is incompatible with Error Embeddables', async () => { + const editControlAction = new EditControlAction(deleteControlAction); + const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' }); + expect(await editControlAction.isCompatible({ embeddable: errorEmbeddable as any })).toBe(false); +}); + +test('Action is incompatible with embeddables that are not editable', async () => { + const mockEmbeddableFactory = new TimeSliderEmbeddableFactory(); + const mockGetFactory = jest.fn().mockReturnValue(mockEmbeddableFactory); + pluginServices.getServices().controls.getControlFactory = mockGetFactory; + pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory; + + const editControlAction = new EditControlAction(deleteControlAction); + const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await emptyContainer.untilInitialized(); + await emptyContainer.addTimeSliderControl(); + + expect( + await editControlAction.isCompatible({ + embeddable: emptyContainer.getChild(emptyContainer.getChildIds()[0]) as any, + }) + ).toBe(false); +}); + +test('Action is compatible with embeddables that are editable', async () => { + const mockEmbeddableFactory = new OptionsListEmbeddableFactory(); + const mockGetFactory = jest.fn().mockReturnValue(mockEmbeddableFactory); + pluginServices.getServices().controls.getControlFactory = mockGetFactory; + pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory; + + const editControlAction = new EditControlAction(deleteControlAction); + const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await emptyContainer.untilInitialized(); + await emptyContainer.addOptionsListControl({ + dataViewId: 'test-data-view', + title: 'test', + fieldName: 'test-field', + width: 'medium', + grow: false, + }); + + expect( + await editControlAction.isCompatible({ + embeddable: emptyContainer.getChild(emptyContainer.getChildIds()[0]) as any, + }) + ).toBe(true); +}); + +test('Execute throws an error when called with an embeddable not in a parent', async () => { + const editControlAction = new EditControlAction(deleteControlAction); + const optionsListEmbeddable = new OptionsListEmbeddable( + reduxEmbeddablePackage, + {} as OptionsListEmbeddableInput, + {} as ControlOutput + ); + await expect(async () => { + await editControlAction.execute({ embeddable: optionsListEmbeddable }); + }).rejects.toThrow(Error); +}); + +test('Execute should open a flyout', async () => { + const spyOn = jest.fn().mockResolvedValue(undefined); + pluginServices.getServices().overlays.openFlyout = spyOn; + + const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await emptyContainer.untilInitialized(); + await emptyContainer.addOptionsListControl({ + dataViewId: 'test-data-view', + title: 'test', + fieldName: 'test-field', + width: 'medium', + grow: false, + }); + const embeddable: OptionsListEmbeddable = emptyContainer.getChild( + emptyContainer.getChildIds()[0] + ); + + const editControlAction = new EditControlAction(deleteControlAction); + await editControlAction.execute({ embeddable }); + expect(spyOn).toHaveBeenCalled(); +}); diff --git a/src/plugins/controls/public/control_group/actions/edit_control_action.tsx b/src/plugins/controls/public/control_group/actions/edit_control_action.tsx new file mode 100644 index 0000000000000..9500640332446 --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/edit_control_action.tsx @@ -0,0 +1,125 @@ +/* + * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; +import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; + +import { pluginServices } from '../../services'; +import { EditControlFlyout } from './edit_control_flyout'; +import { DeleteControlAction } from './delete_control_action'; +import { ControlGroupStrings } from '../control_group_strings'; +import { ACTION_EDIT_CONTROL, ControlGroupContainer } from '..'; +import { ControlEmbeddable, DataControlInput } from '../../types'; +import { setFlyoutRef } from '../embeddable/control_group_container'; +import { isControlGroup } from '../embeddable/control_group_helpers'; + +export interface EditControlActionContext { + embeddable: ControlEmbeddable; +} + +export class EditControlAction implements Action { + public readonly type = ACTION_EDIT_CONTROL; + public readonly id = ACTION_EDIT_CONTROL; + public order = 1; + + private getEmbeddableFactory; + private openFlyout; + private theme$; + + constructor(private deleteControlAction: DeleteControlAction) { + ({ + embeddable: { getEmbeddableFactory: this.getEmbeddableFactory }, + overlays: { openFlyout: this.openFlyout }, + theme: { theme$: this.theme$ }, + } = pluginServices.getServices()); + } + + public readonly MenuItem = ({ context }: { context: EditControlActionContext }) => { + const { embeddable } = context; + return ( + + this.execute(context)} + color="text" + /> + + ); + }; + + public getDisplayName({ embeddable }: EditControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return ControlGroupStrings.floatingActions.getEditButtonTitle(); + } + + public getIconType({ embeddable }: EditControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return 'pencil'; + } + + public async isCompatible({ embeddable }: EditControlActionContext) { + if (isErrorEmbeddable(embeddable)) return false; + const controlGroup = embeddable.parent; + const factory = this.getEmbeddableFactory(embeddable.type); + + return Boolean( + controlGroup && + isControlGroup(controlGroup) && + controlGroup.getInput().viewMode === ViewMode.EDIT && + factory && + (await factory.isEditable()) + ); + } + + public async execute({ embeddable }: EditControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + const controlGroup = embeddable.parent as ControlGroupContainer; + const ReduxWrapper = controlGroup.getReduxEmbeddableTools().Wrapper; + + const flyoutInstance = this.openFlyout( + toMountPoint( + + this.deleteControlAction.execute({ embeddable })} + closeFlyout={() => { + setFlyoutRef(undefined); + flyoutInstance.close(); + }} + /> + , + + { theme$: this.theme$ } + ), + { + 'aria-label': ControlGroupStrings.manageControl.getFlyoutEditTitle(), + outsideClickCloses: false, + onClose: (flyout) => { + setFlyoutRef(undefined); + flyout.close(); + }, + ownFocus: true, + // @ts-ignore - TODO: Remove this once https://github.com/elastic/eui/pull/6645 lands in Kibana + focusTrapProps: { scrollLock: true }, + } + ); + setFlyoutRef(flyoutInstance); + } +} diff --git a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx new file mode 100644 index 0000000000000..c5f3dda8f5f8c --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx @@ -0,0 +1,119 @@ +/* + * 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 React, { useState } from 'react'; + +import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; + +import { DataControlInput, ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { pluginServices } from '../../services'; +import { ControlGroupStrings } from '../control_group_strings'; +import { useControlGroupContainerContext } from '../control_group_renderer'; +import { ControlEditor } from '../editor/control_editor'; + +export const EditControlFlyout = ({ + embeddable, + closeFlyout, + removeControl, +}: { + embeddable: ControlEmbeddable; + closeFlyout: () => void; + removeControl: () => void; +}) => { + // Controls Services Context + const { + overlays: { openConfirm }, + controls: { getControlFactory }, + } = pluginServices.getServices(); + // Redux embeddable container Context + const reduxContext = useControlGroupContainerContext(); + const { + embeddableInstance: controlGroup, + actions: { setControlWidth, setControlGrow }, + useEmbeddableSelector, + useEmbeddableDispatch, + } = reduxContext; + const dispatch = useEmbeddableDispatch(); + + // current state + const panels = useEmbeddableSelector((state) => state.explicitInput.panels); + const panel = panels[embeddable.id]; + + const [currentGrow, setCurrentGrow] = useState(panel.grow); + const [currentWidth, setCurrentWidth] = useState(panel.width); + const [inputToReturn, setInputToReturn] = useState>({}); + + const onCancel = () => { + if ( + isEqual(panel.explicitInput, { + ...panel.explicitInput, + ...inputToReturn, + }) && + currentGrow === panel.grow && + currentWidth === panel.width + ) { + closeFlyout(); + 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) { + closeFlyout(); + } + }); + }; + + const onSave = async (type?: string) => { + if (!type) { + closeFlyout(); + return; + } + + const factory = getControlFactory(type) as IEditableControlFactory; + if (!factory) throw new EmbeddableFactoryNotFoundError(type); + if (factory.presaveTransformFunction) { + setInputToReturn(factory.presaveTransformFunction(inputToReturn, embeddable)); + } + + if (currentWidth !== panel.width) + dispatch(setControlWidth({ width: currentWidth, embeddableId: embeddable.id })); + if (currentGrow !== panel.grow) + dispatch(setControlGrow({ grow: currentGrow, embeddableId: embeddable.id })); + + closeFlyout(); + await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + }; + + return ( + onCancel()} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => setCurrentWidth(newWidth)} + updateGrow={(newGrow) => setCurrentGrow(newGrow)} + onTypeEditorChange={(partialInput) => { + setInputToReturn({ ...inputToReturn, ...partialInput }); + }} + onSave={(type) => onSave(type)} + removeControl={() => { + closeFlyout(); + removeControl(); + }} + /> + ); +}; diff --git a/src/plugins/controls/public/control_group/actions/index.ts b/src/plugins/controls/public/control_group/actions/index.ts new file mode 100644 index 0000000000000..2bc869bb8f478 --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export const ACTION_EDIT_CONTROL = 'editControl'; +export const ACTION_DELETE_CONTROL = 'deleteControl'; diff --git a/src/plugins/controls/public/control_group/component/control_frame_component.tsx b/src/plugins/controls/public/control_group/component/control_frame_component.tsx index ac3df892846bd..2697e941ab234 100644 --- a/src/plugins/controls/public/control_group/component/control_frame_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_frame_component.tsx @@ -10,7 +10,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import { EuiButtonEmpty, - EuiButtonIcon, EuiFormControlLayout, EuiFormLabel, EuiFormRow, @@ -23,11 +22,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { Markdown } from '@kbn/kibana-react-plugin/public'; import { useReduxEmbeddableContext, FloatingActions } from '@kbn/presentation-util-plugin/public'; import { ControlGroupReduxState } from '../types'; -import { pluginServices } from '../../services'; -import { EditControlButton } from '../editor/edit_control'; import { ControlGroupStrings } from '../control_group_strings'; import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; -import { TIME_SLIDER_CONTROL } from '../../../common'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer } from '..'; @@ -93,12 +89,9 @@ export const ControlFrame = ({ ControlGroupContainer >(); + const viewMode = select((state) => state.explicitInput.viewMode); const controlStyle = select((state) => state.explicitInput.controlStyle); - - // Controls Services Context - const { - overlays: { openConfirm }, - } = pluginServices.getServices(); + const disabledActions = select((state) => state.explicitInput.disabledActions); const embeddable = useChildEmbeddable({ untilEmbeddableLoaded: controlGroup.untilEmbeddableLoaded.bind(controlGroup), @@ -126,36 +119,6 @@ export const ControlFrame = ({ }; }, [embeddable, embeddableRoot]); - const floatingActions = ( - <> - {!fatalError && embeddableType !== TIME_SLIDER_CONTROL && ( - - - - )} - - - 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) { - controlGroup.removeEmbeddable(embeddableId); - } - }) - } - iconType="cross" - color="danger" - /> - - - ); - const embeddableParentClassNames = classNames('controlFrame__control', { 'controlFrame--twoLine': controlStyle === 'twoLine', 'controlFrame--oneLine': controlStyle === 'oneLine', @@ -219,7 +182,9 @@ export const ControlFrame = ({ 'controlFrameFloatingActions--twoLine': usingTwoLineLayout, 'controlFrameFloatingActions--oneLine': !usingTwoLineLayout, })} - actions={floatingActions} + viewMode={viewMode} + embeddable={embeddable} + disabledActions={disabledActions} isEnabled={embeddable && enableActions} > ; -} - -export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { - // Controls Services Context - const { - overlays: { openFlyout, openConfirm }, - controls: { getControlFactory }, - theme: { theme$ }, - } = pluginServices.getServices(); - // Redux embeddable container Context - const reduxContext = useControlGroupContainerContext(); - const { - embeddableInstance: controlGroup, - actions: { setControlWidth, setControlGrow }, - useEmbeddableSelector, - useEmbeddableDispatch, - } = reduxContext; - const dispatch = useEmbeddableDispatch(); - - // current state - const panels = useEmbeddableSelector((state) => state.explicitInput.panels); - - // 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 ControlsServicesProvider = pluginServices.getContextProvider(); - const embeddable = (await controlGroup.untilEmbeddableLoaded( - embeddableId - )) as ControlEmbeddable; - - const initialInputPromise = new Promise((resolve, reject) => { - const panel = panels[embeddableId]; - let factory = getControlFactory(panel.type); - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - - let inputToReturn: Partial = {}; - - let removed = false; - const onCancel = (ref: OverlayRef) => { - if ( - removed || - (isEqual(latestPanelState.current.explicitInput, { - ...panel.explicitInput, - ...inputToReturn, - }) && - isEqual(latestPanelState.current.width, panel.width) && - isEqual(latestPanelState.current.grow, panel.grow)) - ) { - reject(); - 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) { - dispatch(setControlWidth({ width: panel.width, embeddableId })); - dispatch(setControlGrow({ grow: panel.grow, embeddableId })); - reject(); - ref.close(); - } - }); - }; - - const onSave = (ref: OverlayRef, type?: string) => { - if (!type) { - reject(); - ref.close(); - return; - } - - // if the control now has a new type, need to replace the old factory with - // one of the correct new type - if (latestPanelState.current.type !== type) { - factory = getControlFactory(type); - if (!factory) throw new EmbeddableFactoryNotFoundError(type); - } - const editableFactory = factory as IEditableControlFactory; - if (editableFactory.presaveTransformFunction) { - inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); - } - resolve({ type, controlInput: inputToReturn }); - ref.close(); - }; - - const ReduxWrapper = controlGroup.getReduxEmbeddableTools().Wrapper; - - const flyoutInstance = openFlyout( - toMountPoint( - - - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => - dispatch(setControlWidth({ width: newWidth, embeddableId })) - } - updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} - onTypeEditorChange={(partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }} - onSave={(type) => onSave(flyoutInstance, type)} - removeControl={() => { - 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) { - controlGroup.removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - /> - - , - { theme$ } - ), - { - 'aria-label': ControlGroupStrings.manageControl.getFlyoutEditTitle(), - outsideClickCloses: false, - onClose: (flyout) => { - onCancel(flyout); - setFlyoutRef(undefined); - }, - } - ); - setFlyoutRef(flyoutInstance); - }); - - initialInputPromise.then( - async (promise) => { - await controlGroup.replaceEmbeddable(embeddable.id, promise.controlInput, promise.type); - }, - () => {} // swallow promise rejection because it can be part of normal flow - ); - }; - - return ( - editControl()} - color="text" - /> - ); -}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts b/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts index 1afcdc539bf87..7318a489feac9 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts @@ -6,9 +6,13 @@ * Side Public License, v 1. */ -import { ControlsPanels } from '../types'; -import { pluginServices } from '../../services'; +import { type IEmbeddable } from '@kbn/embeddable-plugin/public'; + import { getDataControlFieldRegistry } from '../editor/data_control_editor_tools'; +import { type ControlGroupContainer } from './control_group_container'; +import { pluginServices } from '../../services'; +import { CONTROL_GROUP_TYPE } from '../types'; +import { ControlsPanels } from '../types'; export const getNextPanelOrder = (panels?: ControlsPanels) => { let nextOrder = 0; @@ -34,3 +38,7 @@ export const getCompatibleControlType = async ({ const field = fieldRegistry[fieldName]; return field.compatibleControlTypes[0]; }; + +export const isControlGroup = (embeddable: IEmbeddable): embeddable is ControlGroupContainer => { + return embeddable.isContainer && embeddable.type === CONTROL_GROUP_TYPE; +}; diff --git a/src/plugins/controls/public/control_group/index.ts b/src/plugins/controls/public/control_group/index.ts index 1967a8074beab..745e41ec474b1 100644 --- a/src/plugins/controls/public/control_group/index.ts +++ b/src/plugins/controls/public/control_group/index.ts @@ -14,6 +14,8 @@ export type { ControlGroupInput, ControlGroupOutput } from './types'; export { CONTROL_GROUP_TYPE } from './types'; export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; +export { ACTION_EDIT_CONTROL, ACTION_DELETE_CONTROL } from './actions'; + export { type AddDataControlProps, type AddOptionsListControlProps, diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts index ff0f139b27a75..1e9df544bd1c9 100644 --- a/src/plugins/controls/public/index.ts +++ b/src/plugins/controls/public/index.ts @@ -58,6 +58,8 @@ export { LazyControlGroupRenderer, useControlGroupContainerContext, type ControlGroupRendererProps, + ACTION_DELETE_CONTROL, + ACTION_EDIT_CONTROL, } from './control_group'; export function plugin() { diff --git a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx index 2292555316b82..8465016ce4402 100644 --- a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx @@ -20,8 +20,8 @@ import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL, } from '../../../common/options_list/types'; -import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { OptionsListEditorOptions } from '../components/options_list_editor_options'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; export class OptionsListEmbeddableFactory implements EmbeddableFactoryDefinition, IEditableControlFactory @@ -48,8 +48,11 @@ export class OptionsListEmbeddableFactory ((newInput.fieldName && !deepEqual(newInput.fieldName, embeddable.getInput().fieldName)) || (newInput.dataViewId && !deepEqual(newInput.dataViewId, embeddable.getInput().dataViewId))) ) { - // if the field name or data view id has changed in this editing session, selected options are invalid, so reset them. - newInput.selectedOptions = []; + // if the field name or data view id has changed in this editing session, reset all selections + newInput.selectedOptions = undefined; + newInput.existsSelected = undefined; + newInput.exclude = undefined; + newInput.sort = undefined; } return newInput; }; @@ -67,7 +70,7 @@ export class OptionsListEmbeddableFactory public controlEditorOptionsComponent = OptionsListEditorOptions; - public isEditable = () => Promise.resolve(false); + public isEditable = () => Promise.resolve(true); public getDisplayName = () => i18n.translate('controls.optionsList.displayName', { diff --git a/src/plugins/controls/public/plugin.ts b/src/plugins/controls/public/plugin.ts index 00673b79d1d2a..85a892c9eea83 100644 --- a/src/plugins/controls/public/plugin.ts +++ b/src/plugins/controls/public/plugin.ts @@ -7,7 +7,7 @@ */ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory, PANEL_HOVER_TRIGGER } from '@kbn/embeddable-plugin/public'; import { ControlGroupContainerFactory, @@ -28,7 +28,6 @@ import { IEditableControlFactory, ControlInput, } from './types'; - export class ControlsPlugin implements Plugin< @@ -113,10 +112,21 @@ export class ControlsPlugin } public start(coreStart: CoreStart, startPlugins: ControlsPluginStartDeps): ControlsPluginStart { - this.startControlsKibanaServices(coreStart, startPlugins); + this.startControlsKibanaServices(coreStart, startPlugins).then(async () => { + const { uiActions } = startPlugins; - const { getControlFactory, getControlTypes } = controlsService; + const { DeleteControlAction } = await import('./control_group/actions/delete_control_action'); + const deleteControlAction = new DeleteControlAction(); + uiActions.registerAction(deleteControlAction); + uiActions.attachAction(PANEL_HOVER_TRIGGER, deleteControlAction.id); + const { EditControlAction } = await import('./control_group/actions/edit_control_action'); + const editControlAction = new EditControlAction(deleteControlAction); + uiActions.registerAction(editControlAction); + uiActions.attachAction(PANEL_HOVER_TRIGGER, editControlAction.id); + }); + + const { getControlFactory, getControlTypes } = controlsService; return { getControlFactory, getControlTypes, diff --git a/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx index 730b0d91bbe61..2a86d5c186fc2 100644 --- a/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx @@ -41,7 +41,7 @@ export class RangeSliderEmbeddableFactory public canCreateNew = () => false; - public isEditable = () => Promise.resolve(false); + public isEditable = () => Promise.resolve(true); public async create(initialInput: RangeSliderEmbeddableInput, parent?: IContainer) { const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); diff --git a/src/plugins/controls/public/services/plugin_services.ts b/src/plugins/controls/public/services/plugin_services.ts index 20950d42df516..805382254130a 100644 --- a/src/plugins/controls/public/services/plugin_services.ts +++ b/src/plugins/controls/public/services/plugin_services.ts @@ -34,12 +34,12 @@ export const providers: PluginServiceProviders< controls: new PluginServiceProvider(controlsServiceFactory), data: new PluginServiceProvider(dataServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), + embeddable: new PluginServiceProvider(embeddableServiceFactory), http: new PluginServiceProvider(httpServiceFactory), optionsList: new PluginServiceProvider(optionsListServiceFactory, ['data', 'http']), overlays: new PluginServiceProvider(overlaysServiceFactory), settings: new PluginServiceProvider(settingsServiceFactory), theme: new PluginServiceProvider(themeServiceFactory), - embeddable: new PluginServiceProvider(embeddableServiceFactory), unifiedSearch: new PluginServiceProvider(unifiedSearchServiceFactory), }; diff --git a/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx b/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx index b062bcc4370fe..d3f3418104328 100644 --- a/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx @@ -35,6 +35,7 @@ export class TimeSliderEmbeddableFactory public isFieldCompatible = () => false; public isEditable = () => Promise.resolve(false); + public canCreateNew = () => false; public getDisplayName = () => i18n.translate('controls.timeSlider.displayName', { diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 17608ee7bef8d..6e26440c1410d 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -7,6 +7,7 @@ */ import { ReactNode } from 'react'; + import { Filter } from '@kbn/es-query'; import { EmbeddableFactory, @@ -15,9 +16,11 @@ import { EmbeddableStart, IEmbeddable, } from '@kbn/embeddable-plugin/public'; +import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; + import { ControlInput } from '../common/types'; import { ControlsServiceType } from './services/controls/types'; @@ -86,10 +89,11 @@ export interface ControlsPluginSetupDeps { embeddable: EmbeddableSetup; } export interface ControlsPluginStartDeps { - data: DataPublicPluginStart; - unifiedSearch: UnifiedSearchPublicPluginStart; + uiActions: UiActionsStart; embeddable: EmbeddableStart; + data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; } // re-export from common diff --git a/src/plugins/controls/tsconfig.json b/src/plugins/controls/tsconfig.json index 5029897be467b..5da6923aaaf5d 100644 --- a/src/plugins/controls/tsconfig.json +++ b/src/plugins/controls/tsconfig.json @@ -34,6 +34,7 @@ "@kbn/storybook", "@kbn/ui-theme", "@kbn/safer-lodash-set", + "@kbn/ui-actions-plugin", ], "exclude": [ "target/**/*", diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 242fa37b75dff..54bcb4c5f647e 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -15,6 +15,7 @@ import { selectRangeTrigger, valueClickTrigger, cellValueTrigger, + panelHoverTrigger, } from './lib'; /** @@ -23,6 +24,7 @@ import { */ export const bootstrap = (uiActions: UiActionsSetup) => { uiActions.registerTrigger(contextMenuTrigger); + uiActions.registerTrigger(panelHoverTrigger); uiActions.registerTrigger(panelBadgeTrigger); uiActions.registerTrigger(panelNotificationTrigger); uiActions.registerTrigger(selectRangeTrigger); diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 18e5484b8fa4d..9d89aab704766 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -89,6 +89,8 @@ export { isFilterableEmbeddable, shouldFetch$, shouldRefreshFilterCompareOptions, + PANEL_HOVER_TRIGGER, + panelHoverTrigger, } from './lib'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index 529b6bfede65b..94c09c2d79376 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -78,6 +78,17 @@ export const contextMenuTrigger: Trigger = { }), }; +export const PANEL_HOVER_TRIGGER = 'PANEL_HOVER_TRIGGER'; +export const panelHoverTrigger: Trigger = { + id: PANEL_HOVER_TRIGGER, + title: i18n.translate('embeddableApi.panelHoverTrigger.title', { + defaultMessage: 'Panel hover', + }), + description: i18n.translate('embeddableApi.panelHoverTrigger.description', { + defaultMessage: "A new action will be added to the panel's hover menu", + }), +}; + export const PANEL_BADGE_TRIGGER = 'PANEL_BADGE_TRIGGER'; export const panelBadgeTrigger: Trigger = { id: PANEL_BADGE_TRIGGER, diff --git a/src/plugins/presentation_util/kibana.jsonc b/src/plugins/presentation_util/kibana.jsonc index 870c9ff27c62b..1dfa765354cf9 100644 --- a/src/plugins/presentation_util/kibana.jsonc +++ b/src/plugins/presentation_util/kibana.jsonc @@ -12,10 +12,9 @@ "kibanaReact", "embeddable", "expressions", - "dataViews" + "dataViews", + "uiActions" ], - "extraPublicDirs": [ - "common" - ] + "extraPublicDirs": ["common"] } } diff --git a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss index 8d3df0fa3a0b7..efb3db68db613 100644 --- a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss +++ b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss @@ -5,7 +5,7 @@ opacity: 0; visibility: hidden; // slower transition on hover leave in case the user accidentally stops hover - transition: visibility .3s, opacity .3s; + transition: visibility $euiAnimSpeedSlow, opacity $euiAnimSpeedSlow; position: absolute; right: $euiSizeXS; @@ -17,7 +17,7 @@ .presentationUtil__floatingActions { opacity: 1; visibility: visible; - transition: visibility .1s, opacity .1s; + transition: visibility $euiAnimSpeedFast, opacity $euiAnimSpeedFast; } } } diff --git a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx index 9f8639cbf04e6..35c92e83035a0 100644 --- a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx +++ b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx @@ -5,29 +5,79 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { FC, ReactElement } from 'react'; - +import React, { FC, ReactElement, useEffect, useState } from 'react'; import classNames from 'classnames'; + +import { IEmbeddable, panelHoverTrigger, PANEL_HOVER_TRIGGER } from '@kbn/embeddable-plugin/public'; +import { Action } from '@kbn/ui-actions-plugin/public'; + +import { pluginServices } from '../../services'; import './floating_actions.scss'; +import { ReduxEmbeddableState } from '../../redux_embeddables'; export interface FloatingActionsProps { - className?: string; - actions?: JSX.Element; children: ReactElement; + + className?: string; isEnabled?: boolean; + embeddable?: IEmbeddable; + viewMode?: ReduxEmbeddableState['explicitInput']['viewMode']; + disabledActions?: ReduxEmbeddableState['explicitInput']['disabledActions']; } export const FloatingActions: FC = ({ - className = '', - actions, - isEnabled, children, + viewMode, + isEnabled, + embeddable, + className = '', + disabledActions, }) => { + const { + uiActions: { getTriggerCompatibleActions }, + } = pluginServices.getServices(); + + const [floatingActions, setFloatingActions] = useState(undefined); + + useEffect(() => { + if (!embeddable) return; + + const getActions = async () => { + const context = { + embeddable, + trigger: panelHoverTrigger, + }; + const actions = (await getTriggerCompatibleActions(PANEL_HOVER_TRIGGER, context)) + .filter((action): action is Action & { MenuItem: React.FC } => { + return action.MenuItem !== undefined && (disabledActions ?? []).indexOf(action.id) === -1; + }) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + if (actions.length > 0) { + setFloatingActions( + <> + {actions.map((action) => + React.createElement(action.MenuItem, { + key: action.id, + context, + }) + )} + + ); + } else { + setFloatingActions(undefined); + } + }; + + getActions(); + }, [embeddable, getTriggerCompatibleActions, viewMode, disabledActions]); + return (
{children} - {isEnabled && ( -
{actions}
+ {isEnabled && floatingActions && ( +
+ {floatingActions} +
)}
); diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts index 8804c21bf6d9c..2a3bdba1a0a7e 100644 --- a/src/plugins/presentation_util/public/mocks.ts +++ b/src/plugins/presentation_util/public/mocks.ts @@ -13,7 +13,9 @@ import { registry } from './services/plugin_services'; import { registerExpressionsLanguage } from '.'; const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart => { - pluginServices.setRegistry(registry.start({ coreStart, startPlugins: { dataViews: {} } as any })); + pluginServices.setRegistry( + registry.start({ coreStart, startPlugins: { dataViews: {}, uiActions: {} } as any }) + ); const startContract: PresentationUtilPluginStart = { ContextProvider: pluginServices.getContextProvider(), diff --git a/src/plugins/presentation_util/public/services/plugin_services.story.ts b/src/plugins/presentation_util/public/services/plugin_services.story.ts index 8eb38a3dee019..b95b99e1dbca8 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.story.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.story.ts @@ -18,12 +18,14 @@ import { capabilitiesServiceFactory } from './capabilities/capabilities.story'; import { dataViewsServiceFactory } from './data_views/data_views.story'; import { dashboardsServiceFactory } from './dashboards/dashboards.stub'; import { labsServiceFactory } from './labs/labs.story'; +import { uiActionsServiceFactory } from './ui_actions/ui_actions.stub'; export const providers: PluginServiceProviders = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/plugin_services.stub.ts b/src/plugins/presentation_util/public/services/plugin_services.stub.ts index 8f69efbcbe0c4..427fbf9a3b6eb 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.stub.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.stub.ts @@ -15,12 +15,14 @@ import { capabilitiesServiceFactory } from './capabilities/capabilities.story'; import { dataViewsServiceFactory } from './data_views/data_views.story'; import { dashboardsServiceFactory } from './dashboards/dashboards.stub'; import { labsServiceFactory } from './labs/labs.story'; +import { uiActionsServiceFactory } from './ui_actions/ui_actions.stub'; export const providers: PluginServiceProviders = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/plugin_services.ts b/src/plugins/presentation_util/public/services/plugin_services.ts index 04f36836bd1ff..266912446b63c 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.ts @@ -18,6 +18,7 @@ import { PresentationUtilPluginStartDeps } from '../types'; import { capabilitiesServiceFactory } from './capabilities/capabilities_service'; import { dataViewsServiceFactory } from './data_views/data_views_service'; import { dashboardsServiceFactory } from './dashboards/dashboards_service'; +import { uiActionsServiceFactory } from './ui_actions/ui_actions_service'; import { labsServiceFactory } from './labs/labs_service'; import { PresentationUtilServices } from './types'; @@ -29,6 +30,7 @@ export const providers: PluginServiceProviders< labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/types.ts b/src/plugins/presentation_util/public/services/types.ts index b2d32788b1762..861d4f55068ac 100644 --- a/src/plugins/presentation_util/public/services/types.ts +++ b/src/plugins/presentation_util/public/services/types.ts @@ -10,11 +10,13 @@ import { PresentationLabsService } from './labs/types'; import { PresentationDashboardsService } from './dashboards/types'; import { PresentationCapabilitiesService } from './capabilities/types'; import { PresentationDataViewsService } from './data_views/types'; +import { PresentationUiActionsService } from './ui_actions/types'; export interface PresentationUtilServices { + capabilities: PresentationCapabilitiesService; dashboards: PresentationDashboardsService; dataViews: PresentationDataViewsService; - capabilities: PresentationCapabilitiesService; + uiActions: PresentationUiActionsService; labs: PresentationLabsService; } diff --git a/src/plugins/presentation_util/public/services/ui_actions/types.ts b/src/plugins/presentation_util/public/services/ui_actions/types.ts new file mode 100644 index 0000000000000..141be2dfa20d0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/ui_actions/types.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 { UiActionsStart } from '@kbn/ui-actions-plugin/public'; + +export interface PresentationUiActionsService { + getTriggerCompatibleActions: UiActionsStart['getTriggerCompatibleActions']; +} diff --git a/src/plugins/presentation_util/public/services/ui_actions/ui_actions.stub.ts b/src/plugins/presentation_util/public/services/ui_actions/ui_actions.stub.ts new file mode 100644 index 0000000000000..449cd7e8184ce --- /dev/null +++ b/src/plugins/presentation_util/public/services/ui_actions/ui_actions.stub.ts @@ -0,0 +1,18 @@ +/* + * 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 { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; + +import { PluginServiceFactory } from '../create'; +import { PresentationUiActionsService } from './types'; + +type CapabilitiesServiceFactory = PluginServiceFactory; + +export const uiActionsServiceFactory: CapabilitiesServiceFactory = () => { + const { getTriggerCompatibleActions } = uiActionsPluginMock.createStartContract(); + return { getTriggerCompatibleActions }; +}; diff --git a/src/plugins/presentation_util/public/services/ui_actions/ui_actions_service.ts b/src/plugins/presentation_util/public/services/ui_actions/ui_actions_service.ts new file mode 100644 index 0000000000000..dc6b16240faba --- /dev/null +++ b/src/plugins/presentation_util/public/services/ui_actions/ui_actions_service.ts @@ -0,0 +1,25 @@ +/* + * 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 { PresentationUtilPluginStartDeps } from '../../types'; +import { PresentationUiActionsService } from './types'; +import { KibanaPluginServiceFactory } from '../create'; + +export type UiActionsServiceFactory = KibanaPluginServiceFactory< + PresentationUiActionsService, + PresentationUtilPluginStartDeps +>; + +export const uiActionsServiceFactory: UiActionsServiceFactory = ({ startPlugins }) => { + const { + uiActions: { getTriggerCompatibleActions }, + } = startPlugins; + return { + getTriggerCompatibleActions, + }; +}; diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index 277c0960b269e..3b2785da82c0e 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -7,6 +7,7 @@ */ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { UiActionsStart } from '@kbn/ui-actions-plugin/public/plugin'; import { registerExpressionsLanguage } from '.'; import { PresentationLabsService } from './services/labs/types'; @@ -23,4 +24,5 @@ export interface PresentationUtilPluginSetupDeps {} export interface PresentationUtilPluginStartDeps { dataViews: DataViewsPublicPluginStart; + uiActions: UiActionsStart; } diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index adc524125392e..394337e477ef9 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -28,6 +28,7 @@ "@kbn/react-field", "@kbn/config-schema", "@kbn/storybook", + "@kbn/ui-actions-plugin", ], "exclude": [ "target/**/*",