mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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 | | ------------- | ------------- | |  |  | 2. Since the edit control button is no longer a custom component, the tooltip now responds to focus as expected. | Before | After | | ------------- | ------------- | |  |  | ### 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:
parent
f731ffbf01
commit
b6fb66017b
37 changed files with 808 additions and 276 deletions
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -15,10 +15,9 @@
|
|||
"embeddable",
|
||||
"dataViews",
|
||||
"data",
|
||||
"unifiedSearch"
|
||||
"unifiedSearch",
|
||||
"uiActions"
|
||||
],
|
||||
"extraPublicDirs": [
|
||||
"common"
|
||||
]
|
||||
"extraPublicDirs": ["common"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
10
src/plugins/controls/public/control_group/actions/index.ts
Normal file
10
src/plugins/controls/public/control_group/actions/index.ts
Normal 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';
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -58,6 +58,8 @@ export {
|
|||
LazyControlGroupRenderer,
|
||||
useControlGroupContainerContext,
|
||||
type ControlGroupRendererProps,
|
||||
ACTION_DELETE_CONTROL,
|
||||
ACTION_EDIT_CONTROL,
|
||||
} from './control_group';
|
||||
|
||||
export function plugin() {
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
"@kbn/storybook",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/safer-lodash-set",
|
||||
"@kbn/ui-actions-plugin",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -89,6 +89,8 @@ export {
|
|||
isFilterableEmbeddable,
|
||||
shouldFetch$,
|
||||
shouldRefreshFilterCompareOptions,
|
||||
PANEL_HOVER_TRIGGER,
|
||||
panelHoverTrigger,
|
||||
} from './lib';
|
||||
|
||||
export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -12,10 +12,9 @@
|
|||
"kibanaReact",
|
||||
"embeddable",
|
||||
"expressions",
|
||||
"dataViews"
|
||||
"dataViews",
|
||||
"uiActions"
|
||||
],
|
||||
"extraPublicDirs": [
|
||||
"common"
|
||||
]
|
||||
"extraPublicDirs": ["common"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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'];
|
||||
}
|
|
@ -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 };
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
"@kbn/react-field",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/storybook",
|
||||
"@kbn/ui-actions-plugin",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue