[Dashboard] [Controls] Use uiActions for control hover actions (#153065)

Closes https://github.com/elastic/kibana/issues/143585
Closes https://github.com/elastic/kibana/issues/151767
Closes https://github.com/elastic/kibana/issues/152609

## Summary
This PR accomplishes three things, the first of which is moving the
edit/delete control hover actions to use the `uiActions` service - this
is the first step in moving existing panel actions (such as replacing
the panel, opening the panel settings flyout, etc.) to this hover
framework, which is outlined in
[this](https://github.com/elastic/kibana/issues/151233) issue.

While this was the primary goal of this PR, this also made the following
fixes possible:
1. Since I was refactoring the control editor flyout code as part of
this PR, I made it so that changes to the control's width/grow
properties are **only applied** when the changes are **saved** rather
than being automatically applied.

    | Before            | After |
    | ------------- | ------------- |
| ![Mar-14-2023
13-05-36](https://user-images.githubusercontent.com/8698078/225110954-d443f76b-4ac7-476c-b5b7-9af082d187fd.gif)
| ![Mar-14-2023
13-06-41](https://user-images.githubusercontent.com/8698078/225111172-ab9cce7e-7a70-45e4-ab06-5a87c053fb95.gif)
|
  


2. Since the edit control button is no longer a custom component, the
tooltip now responds to focus as expected.

    | Before            | After |
    | ------------- | ------------- |
| ![Mar-14-2023
13-05-36](https://user-images.githubusercontent.com/8698078/225113458-fe8f05fb-d56c-437a-b625-2a336bb4ba29.gif)
| ![Mar-14-2023
13-06-41](https://user-images.githubusercontent.com/8698078/225113313-d8cb7fcc-f611-48d0-83b4-f6fd147ce0ae.gif)
|


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2023-03-21 08:47:40 -06:00 committed by GitHub
parent f731ffbf01
commit b6fb66017b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 808 additions and 276 deletions

View file

@ -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<ControlGroupContainer>();
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<ControlGroupInput> = {};
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 = () => {
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="primary"
@ -85,11 +117,32 @@ export const EditExample = () => {
Add control
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiButtonGroup
legend="Text style"
buttonSize="m"
options={[
{
id: ACTION_EDIT_CONTROL,
label: 'Disable edit action',
value: ACTION_EDIT_CONTROL,
},
{
id: ACTION_DELETE_CONTROL,
label: 'Disable delete action',
value: ACTION_DELETE_CONTROL,
},
]}
idToSelectedMap={toggleIconIdToSelectedMapIcon}
type="multi"
onChange={(id: string) => onChangeIconsMultiIcons(id)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="primary"
isDisabled={controlGroup === undefined || isSaving}
fill
onClick={onSave}
isLoading={isSaving}
>

View file

@ -15,10 +15,9 @@
"embeddable",
"dataViews",
"data",
"unifiedSearch"
"unifiedSearch",
"uiActions"
],
"extraPublicDirs": [
"common"
]
"extraPublicDirs": ["common"]
}
}

View file

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

View file

@ -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<DataControlInput>;
}
export class DeleteControlAction implements Action<DeleteControlActionContext> {
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 (
<EuiToolTip content={this.getDisplayName(context)}>
<EuiButtonIcon
data-test-subj={`control-action-${context.embeddable.id}-delete`}
aria-label={this.getDisplayName(context)}
iconType={this.getIconType(context)}
onClick={() => this.execute(context)}
color="danger"
/>
</EuiToolTip>
);
};
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);
}
});
}
}

View file

@ -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();
});

View file

@ -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<DataControlInput>;
}
export class EditControlAction implements Action<EditControlActionContext> {
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 (
<EuiToolTip content={this.getDisplayName(context)}>
<EuiButtonIcon
data-test-subj={`control-action-${embeddable.id}-edit`}
aria-label={this.getDisplayName(context)}
iconType={this.getIconType(context)}
onClick={() => this.execute(context)}
color="text"
/>
</EuiToolTip>
);
};
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(
<ReduxWrapper>
<EditControlFlyout
embeddable={embeddable}
removeControl={() => this.deleteControlAction.execute({ embeddable })}
closeFlyout={() => {
setFlyoutRef(undefined);
flyoutInstance.close();
}}
/>
</ReduxWrapper>,
{ 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);
}
}

View file

@ -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<DataControlInput>;
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<Partial<DataControlInput>>({});
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 (
<ControlEditor
isCreate={false}
width={panel.width}
grow={panel.grow}
embeddable={embeddable}
title={embeddable.getTitle()}
onCancel={() => 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();
}}
/>
);
};

View file

@ -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';

View file

@ -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 && (
<EuiToolTip content={ControlGroupStrings.floatingActions.getEditButtonTitle()}>
<EditControlButton embeddableId={embeddableId} />
</EuiToolTip>
)}
<EuiToolTip content={ControlGroupStrings.floatingActions.getRemoveButtonTitle()}>
<EuiButtonIcon
data-test-subj={`control-action-${embeddableId}-delete`}
aria-label={ControlGroupStrings.floatingActions.getRemoveButtonTitle()}
onClick={() =>
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"
/>
</EuiToolTip>
</>
);
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}
>
<EuiFormRow

View file

@ -106,7 +106,7 @@ const SortableControlInner = forwardRef<
style={style}
>
<ControlFrame
enableActions={isEditable && draggingIndex === -1}
enableActions={draggingIndex === -1}
embeddableId={embeddableId}
embeddableType={embeddableType}
customPrepend={isEditable ? dragHandle : undefined}

View file

@ -1,195 +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 { isEqual } from 'lodash';
import { EuiButtonIcon } from '@elastic/eui';
import React, { useEffect, useRef } from 'react';
import { OverlayRef } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public';
import {
ControlInput,
DataControlInput,
ControlEmbeddable,
IEditableControlFactory,
} from '../../types';
import { pluginServices } from '../../services';
import { ControlEditor } from './control_editor';
import { ControlGroupStrings } from '../control_group_strings';
import { setFlyoutRef } from '../embeddable/control_group_container';
import { useControlGroupContainerContext } from '../control_group_renderer';
interface EditControlResult {
type: string;
controlInput: Omit<ControlInput, 'id'>;
}
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<DataControlInput>;
const initialInputPromise = new Promise<EditControlResult>((resolve, reject) => {
const panel = panels[embeddableId];
let factory = getControlFactory(panel.type);
if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type);
let inputToReturn: Partial<DataControlInput> = {};
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(
<ControlsServicesProvider>
<ReduxWrapper>
<ControlEditor
isCreate={false}
width={panel.width}
grow={panel.grow}
embeddable={embeddable}
title={embeddable.getTitle()}
onCancel={() => 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();
}
});
}}
/>
</ReduxWrapper>
</ControlsServicesProvider>,
{ 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 (
<EuiButtonIcon
data-test-subj={`control-action-${embeddableId}-edit`}
aria-label={ControlGroupStrings.floatingActions.getEditButtonTitle()}
iconType="pencil"
onClick={() => editControl()}
color="text"
/>
);
};

View file

@ -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;
};

View file

@ -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,

View file

@ -58,6 +58,8 @@ export {
LazyControlGroupRenderer,
useControlGroupContainerContext,
type ControlGroupRendererProps,
ACTION_DELETE_CONTROL,
ACTION_EDIT_CONTROL,
} from './control_group';
export function plugin() {

View file

@ -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<OptionsListEmbeddableInput>
@ -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', {

View file

@ -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 { 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,

View file

@ -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();

View file

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

View file

@ -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', {

View file

@ -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

View file

@ -34,6 +34,7 @@
"@kbn/storybook",
"@kbn/ui-theme",
"@kbn/safer-lodash-set",
"@kbn/ui-actions-plugin",
],
"exclude": [
"target/**/*",

View file

@ -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);

View file

@ -89,6 +89,8 @@ export {
isFilterableEmbeddable,
shouldFetch$,
shouldRefreshFilterCompareOptions,
PANEL_HOVER_TRIGGER,
panelHoverTrigger,
} from './lib';
export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service';

View file

@ -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,

View file

@ -12,10 +12,9 @@
"kibanaReact",
"embeddable",
"expressions",
"dataViews"
"dataViews",
"uiActions"
],
"extraPublicDirs": [
"common"
]
"extraPublicDirs": ["common"]
}
}

View file

@ -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;
}
}
}

View file

@ -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<FloatingActionsProps> = ({
className = '',
actions,
isEnabled,
children,
viewMode,
isEnabled,
embeddable,
className = '',
disabledActions,
}) => {
const {
uiActions: { getTriggerCompatibleActions },
} = pluginServices.getServices();
const [floatingActions, setFloatingActions] = useState<JSX.Element | undefined>(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 (
<div className="presentationUtil__floatingActionsWrapper">
{children}
{isEnabled && (
<div className={classNames('presentationUtil__floatingActions', className)}>{actions}</div>
{isEnabled && floatingActions && (
<div className={classNames('presentationUtil__floatingActions', className)}>
{floatingActions}
</div>
)}
</div>
);

View file

@ -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(),

View file

@ -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<PresentationUtilServices> = {
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<PresentationUtilServices>();

View file

@ -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<PresentationUtilServices> = {
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<PresentationUtilServices>();

View file

@ -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<PresentationUtilServices>();

View file

@ -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;
}

View file

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

View file

@ -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<PresentationUiActionsService>;
export const uiActionsServiceFactory: CapabilitiesServiceFactory = () => {
const { getTriggerCompatibleActions } = uiActionsPluginMock.createStartContract();
return { getTriggerCompatibleActions };
};

View file

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

View file

@ -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;
}

View file

@ -28,6 +28,7 @@
"@kbn/react-field",
"@kbn/config-schema",
"@kbn/storybook",
"@kbn/ui-actions-plugin",
],
"exclude": [
"target/**/*",