Context menu (#76497)

* feat: 🎸 add grouping to presentable interface

* feat: 🎸 add group to "Explore underlying" data action

* refactor: 💡 return panel list and simplify context creation

* refactor: 💡 simplify context menu builder code

* refactor: 💡 further simplify context menu builder code

* feat: 🎸 add grouping to context menu builder

* feat: 🎸 add icon to drilldowns group

* fix: 🐛 sort in the other order

* feat: 🎸 group drilldown actions in edit mode

* fix: 🐛 fix TypeScript error

* feat: 🎸 wrap long context menu list into a submenu

* feat: 🎸 improve context menu long list wrapping

* feat: 🎸 display drilldowns panel at the bottom of main panel

* feat: 🎸 add separator line for context menu

* test: 💍 add basic context menu builder unit tests

* feat: 🎸 remove meta decoratiosn from generated menu

* test: 💍 add test subject attribute to "More" menu item

* chore: 🤖 remove separator line and add comment about EUI

* test: 💍 update Jest snapshots

* chore: 🤖 revert back change of showing both drilldown options

* test: 💍 add context menu samples to example plugin

* feat: 🎸 collapse long groups into a sub-panel

* test: 💍 add context menu panel edit mode examples

* test: 💍 fix OSS functional test

* test: 💍 fix X-Pack functional tests

* fix: 🐛 re-introduce item sorting by title

* test: 💍 allow explicitly opening more menu

* test: 💍 try opening more panel in functional tests

* test: 💍 disable some tests

* chore: 🤖 remove unused code

* test: 💍 use action test helper in unit tests

* refactor: 💡 add helper utility to generate actions in examples

* test: 💍 disable one more functional test

* test: 💍 improve how inspector is opened in functional tests

* test: 💍 enable functional test

* refactor: 💡 convert test suite to typescript

* test: 💍 move panel replace tests into a separate test suite

* test: 💍 move panel cloning tests to a separate test suite

* test: 💍 set up dashboard context menu test suite

* test: 💍 enable few panel context menu tests

* test: 💍 enable saved search panel tests

* test: 💍 enable expanded panel context menu tests

* test: 💍 remove render complete awaits

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Vadim Dalecky 2020-09-21 15:42:46 +02:00 committed by GitHub
parent 337fe73d09
commit 4b49e5a1c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1380 additions and 459 deletions

View file

@ -37,6 +37,7 @@ import { UiActionsStart, createAction } from '../../../src/plugins/ui_actions/pu
import { AppMountParameters, OverlayStart } from '../../../src/core/public';
import { HELLO_WORLD_TRIGGER_ID, ACTION_HELLO_WORLD } from '../../ui_action_examples/public';
import { TriggerContextExample } from './trigger_context_example';
import { ContextMenuExamples } from './context_menu_examples';
interface Props {
uiActionsApi: UiActionsStart;
@ -109,7 +110,12 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => {
</EuiText>
<EuiSpacer />
<TriggerContextExample uiActionsApi={uiActionsApi} />
<EuiSpacer />
<ContextMenuExamples />
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>

View file

@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { EuiCode, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { PanelView } from './panel_view';
import { PanelViewWithSharing } from './panel_view_with_sharing';
import { PanelViewWithSharingLong } from './panel_view_with_sharing_long';
import { PanelEdit } from './panel_edit';
import { PanelEditWithDrilldowns } from './panel_edit_with_drilldowns';
import { PanelEditWithDrilldownsAndContextActions } from './panel_edit_with_drilldowns_and_context_actions';
export const ContextMenuExamples: React.FC = () => {
return (
<EuiText>
<h1>Context menu examples</h1>
<p>
Below examples show how context menu panels look with varying number of actions and how the
actions can be grouped into different panels using <EuiCode>grouping</EuiCode> field.
</p>
<EuiFlexGroup>
<EuiFlexItem>
<PanelView />
</EuiFlexItem>
<EuiFlexItem>
<PanelViewWithSharing />
</EuiFlexItem>
<EuiFlexItem>
<PanelViewWithSharingLong />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<PanelEdit />
</EuiFlexItem>
<EuiFlexItem>
<PanelEditWithDrilldowns />
</EuiFlexItem>
<EuiFlexItem>
<PanelEditWithDrilldownsAndContextActions />
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
);
};

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './context_menu_examples';

View file

@ -0,0 +1,59 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import { buildContextMenuForActions } from '../../../../src/plugins/ui_actions/public';
import { sampleAction } from './util';
export const PanelEdit: React.FC = () => {
const [open, setOpen] = React.useState(false);
const context = {};
const trigger: any = 'TEST_TRIGGER';
const actions = [
sampleAction('test-1', 100, 'Edit visualization', 'pencil'),
sampleAction('test-2', 99, 'Clone panel', 'partial'),
sampleAction('test-3', 98, 'Edit panel title', 'pencil'),
sampleAction('test-4', 97, 'Customize time range', 'calendar'),
sampleAction('test-5', 96, 'Inspect', 'inspect'),
sampleAction('test-6', 95, 'Full screen', 'fullScreen'),
sampleAction('test-7', 94, 'Replace panel', 'submodule'),
sampleAction('test-8', 93, 'Delete from dashboard', 'trash'),
];
const panels = useAsync(() =>
buildContextMenuForActions({
actions: actions.map((action) => ({ action, context, trigger })),
})
);
return (
<EuiPopover
button={<EuiButton onClick={() => setOpen((x) => !x)}>Edit mode</EuiButton>}
isOpen={open}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setOpen(false)}
>
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
</EuiPopover>
);
};

View file

@ -0,0 +1,70 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public';
import { sampleAction } from './util';
export const PanelEditWithDrilldowns: React.FC = () => {
const [open, setOpen] = React.useState(false);
const context = {};
const trigger: any = 'TEST_TRIGGER';
const grouping: Action['grouping'] = [
{
id: 'drilldowns',
getDisplayName: () => 'Drilldowns',
getIconType: () => 'popout',
order: 20,
},
];
const actions = [
sampleAction('test-1', 100, 'Edit visualization', 'pencil'),
sampleAction('test-2', 99, 'Clone panel', 'partial'),
sampleAction('test-3', 98, 'Edit panel title', 'pencil'),
sampleAction('test-4', 97, 'Customize time range', 'calendar'),
sampleAction('test-5', 96, 'Inspect', 'inspect'),
sampleAction('test-6', 95, 'Full screen', 'fullScreen'),
sampleAction('test-7', 94, 'Replace panel', 'submodule'),
sampleAction('test-8', 93, 'Delete from dashboard', 'trash'),
sampleAction('test-9', 10, 'Create drilldown', 'plusInCircle', grouping),
sampleAction('test-10', 9, 'Manage drilldowns', 'list', grouping),
];
const panels = useAsync(() =>
buildContextMenuForActions({
actions: actions.map((action) => ({ action, context, trigger })),
})
);
return (
<EuiPopover
button={<EuiButton onClick={() => setOpen((x) => !x)}>Edit mode with drilldowns</EuiButton>}
isOpen={open}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setOpen(false)}
>
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
</EuiPopover>
);
};

View file

@ -0,0 +1,87 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public';
import { sampleAction } from './util';
export const PanelEditWithDrilldownsAndContextActions: React.FC = () => {
const [open, setOpen] = React.useState(false);
const context = {};
const trigger: any = 'TEST_TRIGGER';
const drilldownGrouping: Action['grouping'] = [
{
id: 'drilldowns',
getDisplayName: () => 'Drilldowns',
getIconType: () => 'popout',
order: 20,
},
];
const customActionGrouping: Action['grouping'] = [
{
id: 'actions',
getDisplayName: () => 'Custom actions',
getIconType: () => 'cloudStormy',
order: 20,
},
];
const actions = [
sampleAction('test-1', 100, 'Edit visualization', 'pencil'),
sampleAction('test-2', 99, 'Clone panel', 'partial'),
sampleAction('test-3', 98, 'Edit panel title', 'pencil'),
sampleAction('test-4', 97, 'Customize time range', 'calendar'),
sampleAction('test-5', 96, 'Inspect', 'inspect'),
sampleAction('test-6', 95, 'Full screen', 'fullScreen'),
sampleAction('test-7', 94, 'Replace panel', 'submodule'),
sampleAction('test-8', 93, 'Delete from dashboard', 'trash'),
sampleAction('test-9', 10, 'Create drilldown', 'plusInCircle', drilldownGrouping),
sampleAction('test-10', 9, 'Manage drilldowns', 'list', drilldownGrouping),
sampleAction('test-11', 10, 'Go to Sales dashboard', 'dashboardApp', customActionGrouping),
sampleAction('test-12', 9, 'Go to Traffic dashboard', 'dashboardApp', customActionGrouping),
sampleAction('test-13', 8, 'Custom actions', 'cloudStormy', customActionGrouping),
sampleAction('test-14', 7, 'View in Salesforce', 'link', customActionGrouping),
];
const panels = useAsync(() =>
buildContextMenuForActions({
actions: actions.map((action) => ({ action, context, trigger })),
})
);
return (
<EuiPopover
button={
<EuiButton onClick={() => setOpen((x) => !x)}>
Edit mode with drilldowns and custom actions
</EuiButton>
}
isOpen={open}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setOpen(false)}
>
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
</EuiPopover>
);
};

View file

@ -0,0 +1,55 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import { buildContextMenuForActions } from '../../../../src/plugins/ui_actions/public';
import { sampleAction } from './util';
export const PanelView: React.FC = () => {
const [open, setOpen] = React.useState(false);
const context = {};
const trigger: any = 'TEST_TRIGGER';
const actions = [
sampleAction('test-1', 100, 'Explore underlying data', 'discoverApp'),
sampleAction('test-2', 99, 'Customize time range', 'calendar'),
sampleAction('test-3', 98, 'Inspect', 'inspect'),
sampleAction('test-4', 97, 'Full screen', 'fullScreen'),
];
const panels = useAsync(() =>
buildContextMenuForActions({
actions: actions.map((action) => ({ action, context, trigger })),
})
);
return (
<EuiPopover
button={<EuiButton onClick={() => setOpen((x) => !x)}>View mode</EuiButton>}
isOpen={open}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setOpen(false)}
>
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
</EuiPopover>
);
};

View file

@ -0,0 +1,67 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public';
import { sampleAction } from './util';
export const PanelViewWithSharing: React.FC = () => {
const [open, setOpen] = React.useState(false);
const context = {};
const trigger: any = 'TEST_TRIGGER';
const grouping: Action['grouping'] = [
{
id: 'sharing',
getDisplayName: () => 'Sharing',
getIconType: () => 'share',
order: 50,
},
];
const actions = [
sampleAction('test-1', 100, 'Explore underlying data', 'discoverApp'),
sampleAction('test-2', 99, 'Customize time range', 'calendar'),
sampleAction('test-3', 98, 'Inspect', 'inspect'),
sampleAction('test-4', 97, 'Full screen', 'fullScreen'),
sampleAction('test-5', 10, 'Copy link', 'link', grouping),
sampleAction('test-6', 9, 'Copy .png', 'image', grouping),
];
const panels = useAsync(() =>
buildContextMenuForActions({
actions: actions.map((action) => ({ action, context, trigger })),
})
);
return (
<EuiPopover
button={
<EuiButton onClick={() => setOpen((x) => !x)}>View mode with few sharing options</EuiButton>
}
isOpen={open}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setOpen(false)}
>
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
</EuiPopover>
);
};

View file

@ -0,0 +1,72 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public';
import { sampleAction } from './util';
export const PanelViewWithSharingLong: React.FC = () => {
const [open, setOpen] = React.useState(false);
const context = {};
const trigger: any = 'TEST_TRIGGER';
const grouping: Action['grouping'] = [
{
id: 'sharing',
getDisplayName: () => 'Sharing',
getIconType: () => 'share',
order: 50,
},
];
const actions = [
sampleAction('test-1', 100, 'Explore underlying data', 'discoverApp'),
sampleAction('test-2', 99, 'Customize time range', 'calendar'),
sampleAction('test-3', 98, 'Inspect', 'inspect'),
sampleAction('test-4', 97, 'Full screen', 'fullScreen'),
sampleAction('test-5', 10, 'Copy link', 'link', grouping),
sampleAction('test-6', 9, 'Copy .png', 'image', grouping),
sampleAction('test-7', 8, 'Copy .pdf', 'link', grouping),
sampleAction('test-8', 7, 'Send to slack', 'link', grouping),
sampleAction('test-9', 6, 'Send by e-mail', 'email', grouping),
];
const panels = useAsync(() =>
buildContextMenuForActions({
actions: actions.map((action) => ({ action, context, trigger })),
})
);
return (
<EuiPopover
button={
<EuiButton onClick={() => setOpen((x) => !x)}>
View mode with many sharing options
</EuiButton>
}
isOpen={open}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setOpen(false)}
>
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
</EuiPopover>
);
};

View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Action } from '../../../../src/plugins/ui_actions/public';
export const sampleAction = (
id: string,
order: number,
name: string,
icon: string,
grouping?: Action['grouping']
): Action => {
return {
id,
type: 'SAMPLE' as any,
order,
getDisplayName: () => name,
getIconType: () => icon,
isCompatible: async () => true,
execute: async () => {},
grouping,
};
};

View file

@ -37,7 +37,7 @@ export interface PanelHeaderProps {
title?: string;
isViewMode: boolean;
hidePanelTitles: boolean;
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor>;
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor[]>;
closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;

View file

@ -28,14 +28,14 @@ import {
} from '@elastic/eui';
export interface PanelOptionsMenuProps {
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor>;
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor[]>;
isViewMode: boolean;
closeContextMenu: boolean;
title?: string;
}
interface State {
actionContextMenuPanel?: EuiContextMenuPanelDescriptor;
actionContextMenuPanel?: EuiContextMenuPanelDescriptor[];
isPopoverOpen: boolean;
}
@ -117,7 +117,7 @@ export class PanelOptionsMenu extends React.Component<PanelOptionsMenuProps, Sta
>
<EuiContextMenu
initialPanelId="mainMenu"
panels={this.state.actionContextMenuPanel ? [this.state.actionContextMenuPanel] : []}
panels={this.state.actionContextMenuPanel || []}
/>
</EuiPopover>
);

View file

@ -20,7 +20,7 @@
// @ts-ignore
import React from 'react';
import { Action, ActionContext as Context, ActionDefinition } from './action';
import { Presentable } from '../util/presentable';
import { Presentable, PresentableGrouping } from '../util/presentable';
import { uiToReactComponent } from '../../../kibana_react/public';
import { ActionType } from '../types';
@ -36,6 +36,7 @@ export class ActionInternal<A extends ActionDefinition = ActionDefinition>
public readonly order: number = this.definition.order || 0;
public readonly MenuItem? = this.definition.MenuItem;
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
public readonly grouping?: PresentableGrouping<Context<A>> = this.definition.grouping;
public execute(context: Context<A>) {
return this.definition.execute(context);

View file

@ -17,6 +17,7 @@
* under the License.
*/
import { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import { buildContextMenuForActions } from './build_eui_context_menu_panels';
import { Action, createAction } from '../actions';
@ -25,9 +26,9 @@ const createTestAction = ({
dispayName,
order,
}: {
type: string;
type?: string;
dispayName: string;
order: number;
order?: number;
}) =>
createAction({
type: type as any, // mapping doesn't matter for this test
@ -36,32 +37,36 @@ const createTestAction = ({
execute: async () => {},
});
test('contextMenu actions sorting: order, type, displayName', async () => {
const resultMapper = (panel: EuiContextMenuPanelDescriptor) => ({
items: panel.items ? panel.items.map((item) => ({ name: item.name })) : [],
});
test('sorts items in DESC order by "order" field first, then by display name', async () => {
const actions: Action[] = [
createTestAction({
order: 100,
type: '1',
dispayName: 'a',
order: 1,
type: 'foo',
dispayName: 'a-1',
}),
createTestAction({
order: 100,
type: '1',
dispayName: 'b',
order: 2,
type: 'foo',
dispayName: 'a-2',
}),
createTestAction({
order: 0,
type: '2',
dispayName: 'c',
order: 3,
type: 'foo',
dispayName: 'a-3',
}),
createTestAction({
order: 0,
type: '2',
dispayName: 'd',
order: 2,
type: 'foo',
dispayName: 'b-2',
}),
createTestAction({
order: 0,
type: '3',
dispayName: 'aa',
order: 2,
type: 'foo',
dispayName: 'c-2',
}),
].sort(() => 0.5 - Math.random());
@ -69,13 +74,166 @@ test('contextMenu actions sorting: order, type, displayName', async () => {
actions: actions.map((action) => ({ action, context: {}, trigger: '' as any })),
});
expect(result.items?.map((item) => item.name as string)).toMatchInlineSnapshot(`
expect(result.map(resultMapper)).toMatchInlineSnapshot(`
Array [
"a",
"b",
"c",
"d",
"aa",
Object {
"items": Array [
Object {
"name": "a-3",
},
Object {
"name": "a-2",
},
Object {
"name": "b-2",
},
Object {
"name": "More",
},
],
},
Object {
"items": Array [
Object {
"name": "c-2",
},
Object {
"name": "a-1",
},
],
},
]
`);
});
test('builds empty menu when no actions provided', async () => {
const menu = await buildContextMenuForActions({
actions: [],
closeMenu: () => {},
});
expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
Array [
Object {
"items": Array [],
},
]
`);
});
test('can build menu with one action', async () => {
const menu = await buildContextMenuForActions({
actions: [
{
action: createTestAction({
dispayName: 'Foo',
}),
context: {},
trigger: 'TETS_TRIGGER' as any,
},
],
closeMenu: () => {},
});
expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
Array [
Object {
"items": Array [
Object {
"name": "Foo",
},
],
},
]
`);
});
test('orders items according to "order" field', async () => {
const actions = [
createTestAction({
order: 1,
dispayName: 'Foo',
}),
createTestAction({
order: 2,
dispayName: 'Bar',
}),
];
const menu = await buildContextMenuForActions({
actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
});
expect(menu[0].items![0].name).toBe('Bar');
expect(menu[0].items![1].name).toBe('Foo');
const actions2 = [
createTestAction({
order: 2,
dispayName: 'Bar',
}),
createTestAction({
order: 1,
dispayName: 'Foo',
}),
];
const menu2 = await buildContextMenuForActions({
actions: actions2.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
});
expect(menu2[0].items![0].name).toBe('Bar');
expect(menu2[0].items![1].name).toBe('Foo');
});
test('hides items behind in "More" submenu if there are more than 4 actions', async () => {
const actions = [
createTestAction({
dispayName: 'Foo 1',
}),
createTestAction({
dispayName: 'Foo 2',
}),
createTestAction({
dispayName: 'Foo 3',
}),
createTestAction({
dispayName: 'Foo 4',
}),
createTestAction({
dispayName: 'Foo 5',
}),
];
const menu = await buildContextMenuForActions({
actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
});
expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
Array [
Object {
"items": Array [
Object {
"name": "Foo 1",
},
Object {
"name": "Foo 2",
},
Object {
"name": "Foo 3",
},
Object {
"name": "More",
},
],
},
Object {
"items": Array [
Object {
"name": "Foo 4",
},
Object {
"name": "Foo 5",
},
],
},
]
`);
});

View file

@ -20,10 +20,9 @@
import * as React from 'react';
import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui';
import _ from 'lodash';
import sortBy from 'lodash/sortBy';
import { i18n } from '@kbn/i18n';
import { uiToReactComponent } from '../../../kibana_react/public';
import { Action } from '../actions';
import { Action, ActionExecutionContext } from '../actions';
import { Trigger } from '../triggers';
import { BaseContext } from '../types';
@ -31,6 +30,10 @@ export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
});
export const txtMore = i18n.translate('uiActions.actionPanel.more', {
defaultMessage: 'More',
});
interface ActionWithContext<Context extends BaseContext = BaseContext> {
action: Action<Context>;
context: Context;
@ -41,6 +44,88 @@ interface ActionWithContext<Context extends BaseContext = BaseContext> {
trigger: Trigger;
}
type ItemDescriptor = EuiContextMenuPanelItemDescriptor & {
_order: number;
_title?: string;
};
type PanelDescriptor = EuiContextMenuPanelDescriptor & {
_level?: number;
_icon?: string;
items: ItemDescriptor[];
};
const onClick = (action: Action, context: ActionExecutionContext<object>, close: () => void) => (
event: React.MouseEvent
) => {
if (event.currentTarget instanceof HTMLAnchorElement) {
// from react-router's <Link/>
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
(!event.currentTarget.target || event.currentTarget.target === '_self') && // let browser handle "target=_blank" etc.
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys
) {
event.preventDefault();
action.execute(context);
}
} else action.execute(context);
close();
};
/**
* This method adds "More" item to panels, which have more than 4 items; and
* moves all items after the thrird one into that "More" sub-menu.
*/
const wrapMainPanelItemsIntoSubmenu = (panels: Record<string, PanelDescriptor>, id: string) => {
const panel = panels[id];
if (!panel) return;
const maxItemsBeforeWrapping = 4;
if (!panel.items) return;
if (panel.items.length <= maxItemsBeforeWrapping) return;
const visibleItems = panel.items.slice(0, 3) as ItemDescriptor[];
const itemsInSubmenu = panel.items.slice(3) as ItemDescriptor[];
const morePanelId = panel.id + '__more';
const more: ItemDescriptor = {
name: txtMore,
panel: morePanelId,
icon: 'boxesHorizontal',
'data-test-subj': `embeddablePanelMore-${id}`,
_order: -1,
};
panel.items = [...visibleItems, more];
const subPanel: PanelDescriptor = {
id: morePanelId,
title: panel.title || defaultTitle,
items: itemsInSubmenu,
};
panels[morePanelId] = subPanel;
};
const removeItemMetaFields = (items: ItemDescriptor[]): EuiContextMenuPanelItemDescriptor[] => {
const euiItems: EuiContextMenuPanelItemDescriptor[] = [];
for (const item of items) {
const { _order: omit, _title: omit2, ...rest } = item;
euiItems.push(rest);
}
return euiItems;
};
const removePanelMetaFields = (panels: PanelDescriptor[]): EuiContextMenuPanelDescriptor[] => {
const euiPanels: EuiContextMenuPanelDescriptor[] = [];
for (const panel of panels) {
const { _level: omit, _icon: omit2, ...rest } = panel;
euiPanels.push({ ...rest, items: removeItemMetaFields(rest.items) });
}
return euiPanels;
};
export interface BuildContextMenuParams {
actions: ActionWithContext[];
title?: string;
closeMenu?: () => void;
}
/**
* Transforms an array of Actions to the shape EuiContextMenuPanel expects.
*/
@ -48,131 +133,88 @@ export async function buildContextMenuForActions({
actions,
title = defaultTitle,
closeMenu = () => {},
}: {
actions: ActionWithContext[];
title?: string;
closeMenu?: () => void;
}): Promise<EuiContextMenuPanelDescriptor> {
const menuItems = await buildEuiContextMenuPanelItems({
actions,
closeMenu,
});
return {
id: 'mainMenu',
title,
items: menuItems,
}: BuildContextMenuParams): Promise<EuiContextMenuPanelDescriptor[]> {
const panels: Record<string, PanelDescriptor> = {
mainMenu: {
id: 'mainMenu',
title,
items: [],
},
};
}
/**
* Transform an array of Actions into the shape needed to build an EUIContextMenu
*/
async function buildEuiContextMenuPanelItems({
actions,
closeMenu,
}: {
actions: ActionWithContext[];
closeMenu: () => void;
}) {
actions = sortBy(
actions,
(a) => -1 * (a.action.order ?? 0),
(a) => a.action.type,
(a) => a.action.getDisplayName({ ...a.context, trigger: a.trigger })
);
const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length);
const promises = actions.map(async ({ action, context, trigger }, index) => {
const isCompatible = await action.isCompatible({
...context,
trigger,
});
if (!isCompatible) {
return;
const promises = actions.map(async (item) => {
const { action } = item;
const context: ActionExecutionContext<object> = { ...item.context, trigger: item.trigger };
const isCompatible = await item.action.isCompatible(context);
if (!isCompatible) return;
let parentPanel = '';
let currentPanel = '';
if (action.grouping) {
for (let i = 0; i < action.grouping.length; i++) {
const group = action.grouping[i];
currentPanel = group.id;
if (!panels[currentPanel]) {
const name = group.getDisplayName ? group.getDisplayName(context) : group.id;
panels[currentPanel] = {
id: currentPanel,
title: name,
items: [],
_level: i,
_icon: group.getIconType ? group.getIconType(context) : 'empty',
};
if (parentPanel) {
panels[parentPanel].items!.push({
name,
panel: currentPanel,
icon: group.getIconType ? group.getIconType(context) : 'empty',
_order: group.order || 0,
_title: group.getDisplayName ? group.getDisplayName(context) : '',
});
}
}
parentPanel = currentPanel;
}
}
items[index] = await convertPanelActionToContextMenuItem({
action,
actionContext: context,
trigger,
closeMenu,
panels[parentPanel || 'mainMenu'].items!.push({
name: action.MenuItem
? React.createElement(uiToReactComponent(action.MenuItem), { context })
: action.getDisplayName(context),
icon: action.getIconType(context),
'data-test-subj': `embeddablePanelAction-${action.id}`,
onClick: onClick(action, context, closeMenu),
href: action.getHref ? await action.getHref(context) : undefined,
_order: action.order || 0,
_title: action.getDisplayName(context),
});
});
await Promise.all(promises);
return items.filter(Boolean);
}
for (const panel of Object.values(panels)) {
const items = panel.items.filter(Boolean) as ItemDescriptor[];
panel.items = _.sortBy(
items,
(a) => -1 * (a._order ?? 0),
(a) => a._title
);
}
async function convertPanelActionToContextMenuItem<Context extends object>({
action,
actionContext,
trigger,
closeMenu,
}: {
action: Action<Context>;
actionContext: Context;
trigger: Trigger;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelItemDescriptor> {
const menuPanelItem: EuiContextMenuPanelItemDescriptor = {
name: action.MenuItem
? React.createElement(uiToReactComponent(action.MenuItem), {
context: {
...actionContext,
trigger,
},
})
: action.getDisplayName({
...actionContext,
trigger,
}),
icon: action.getIconType({
...actionContext,
trigger,
}),
panel: _.get(action, 'childContextMenuPanel.id'),
'data-test-subj': `embeddablePanelAction-${action.id}`,
};
wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu');
menuPanelItem.onClick = (event) => {
if (event.currentTarget instanceof HTMLAnchorElement) {
// from react-router's <Link/>
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
(!event.currentTarget.target || event.currentTarget.target === '_self') && // let browser handle "target=_blank" etc.
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys
) {
event.preventDefault();
action.execute({
...actionContext,
trigger,
for (const panel of Object.values(panels)) {
if (panel._level === 0) {
// TODO: Add separator line here once it is available in EUI.
// See https://github.com/elastic/eui/pull/4018
if (panel.items.length > 3) {
panels.mainMenu.items.push({
name: panel.title || panel.id,
icon: panel._icon || 'empty',
panel: panel.id,
});
} else {
// let browser handle navigation
panels.mainMenu.items.push(...panel.items);
}
} else {
// not a link
action.execute({
...actionContext,
trigger,
});
}
closeMenu();
};
if (action.getHref) {
const href = await action.getHref({
...actionContext,
trigger,
});
if (href) {
menuPanelItem.href = href;
}
}
return menuPanelItem;
const panelList = Object.values(panels);
return removePanelMetaFields(panelList);
}

View file

@ -33,7 +33,10 @@ export {
IncompatibleActionError,
} from './actions';
export { buildContextMenuForActions } from './context_menu';
export { Presentable as UiActionsPresentable } from './util';
export {
Presentable as UiActionsPresentable,
PresentableGrouping as UiActionsPresentableGrouping,
} from './util';
export {
Trigger,
TriggerContext,

View file

@ -109,7 +109,7 @@ export class UiActionsExecutionService {
}
private async executeMultipleActions(tasks: ExecuteActionTask[]) {
const panel = await buildContextMenuForActions({
const panels = await buildContextMenuForActions({
actions: tasks.map(({ action, context, trigger }) => ({
action,
context,
@ -121,7 +121,7 @@ export class UiActionsExecutionService {
session.close();
},
});
const session = openContextMenu([panel], {
const session = openContextMenu(panels, {
'data-test-subj': 'multipleActionsContextMenu',
});
}

View file

@ -68,4 +68,20 @@ export interface Presentable<Context extends object = object> {
* the context and should be displayed to user, otherwise resolves to false.
*/
isCompatible(context: Context): Promise<boolean>;
/**
* Grouping where this item should appear as a submenu. Each entry is a new
* sub-menu level. For example, used to show drilldowns and sharing options
* in panel context menu in a sub-menu.
*/
readonly grouping?: PresentableGrouping<Context>;
}
export interface PresentableGroup<Context extends object = object>
extends Partial<
Pick<Presentable<Context>, 'getDisplayName' | 'getDisplayNameTooltip' | 'getIconType' | 'order'>
> {
id: string;
}
export type PresentableGrouping<Context extends object = object> = Array<PresentableGroup<Context>>;

View file

@ -96,7 +96,9 @@ export default function ({ getService, loadTestFile }) {
loadTestFile(require.resolve('./dashboard_time_picker'));
loadTestFile(require.resolve('./bwc_shared_urls'));
loadTestFile(require.resolve('./panel_controls'));
loadTestFile(require.resolve('./panel_replacing'));
loadTestFile(require.resolve('./panel_cloning'));
loadTestFile(require.resolve('./panel_context_menu'));
loadTestFile(require.resolve('./dashboard_state'));
});

View file

@ -0,0 +1,80 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
const PageObjects = getPageObjects([
'dashboard',
'header',
'visualize',
'discover',
'timePicker',
]);
describe('dashboard panel cloning', function viewEditModeTests() {
before(async function () {
await PageObjects.dashboard.initTests();
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setHistoricalDataRange();
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
});
after(async function () {
await PageObjects.dashboard.gotoDashboardLandingPage();
});
it('clones a panel', async () => {
const initialPanelTitles = await PageObjects.dashboard.getPanelTitles();
await dashboardPanelActions.clonePanelByTitle(PIE_CHART_VIS_NAME);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const postPanelTitles = await PageObjects.dashboard.getPanelTitles();
expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1);
});
it('appends a clone title tag', async () => {
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles[1]).to.equal(PIE_CHART_VIS_NAME + ' (copy)');
});
it('retains original panel dimensions', async () => {
const panelDimensions = await PageObjects.dashboard.getPanelDimensions();
expect(panelDimensions[0]).to.eql(panelDimensions[1]);
});
it('gives a correct title to the clone of a clone', async () => {
const initialPanelTitles = await PageObjects.dashboard.getPanelTitles();
const clonedPanelName = initialPanelTitles[initialPanelTitles.length - 1];
await dashboardPanelActions.clonePanelByTitle(clonedPanelName);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const postPanelTitles = await PageObjects.dashboard.getPanelTitles();
expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1);
expect(postPanelTitles[postPanelTitles.length - 1]).to.equal(
PIE_CHART_VIS_NAME + ' (copy 1)'
);
});
});
}

View file

@ -0,0 +1,185 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page';
import { VisualizeConstants } from '../../../../src/plugins/visualize/public/application/visualize_constants';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const browser = getService('browser');
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardVisualizations = getService('dashboardVisualizations');
const PageObjects = getPageObjects([
'dashboard',
'header',
'visualize',
'discover',
'timePicker',
]);
const dashboardName = 'Dashboard Panel Controls Test';
describe('dashboard panel context menu', function viewEditModeTests() {
before(async function () {
await PageObjects.dashboard.initTests();
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setHistoricalDataRange();
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
});
after(async function () {
await PageObjects.dashboard.gotoDashboardLandingPage();
});
it('are hidden in view mode', async function () {
await PageObjects.dashboard.saveDashboard(dashboardName);
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.expectMissingEditPanelAction();
await dashboardPanelActions.expectMissingRemovePanelAction();
});
it('are shown in edit mode', async function () {
await PageObjects.dashboard.switchToEditMode();
const isContextMenuIconVisible = await dashboardPanelActions.isContextMenuIconVisible();
expect(isContextMenuIconVisible).to.equal(true);
await dashboardPanelActions.expectExistsEditPanelAction();
await dashboardPanelActions.expectExistsClonePanelAction();
await dashboardPanelActions.expectExistsReplacePanelAction();
await dashboardPanelActions.expectExistsRemovePanelAction();
await dashboardPanelActions.expectExistsToggleExpandAction();
});
it('are shown in edit mode after a hard refresh', async () => {
// Based off an actual bug encountered in a PR where a hard refresh in
// edit mode did not show the edit mode controls.
const currentUrl = await browser.getCurrentUrl();
// The second parameter of true will include the timestamp in the url and
// trigger a hard refresh.
await browser.get(currentUrl.toString(), true);
await PageObjects.header.waitUntilLoadingHasFinished();
await dashboardPanelActions.expectExistsEditPanelAction();
await dashboardPanelActions.expectExistsClonePanelAction();
await dashboardPanelActions.expectExistsReplacePanelAction();
await dashboardPanelActions.expectExistsRemovePanelAction();
// Get rid of the timestamp in the url.
await browser.get(currentUrl.toString(), false);
});
describe('visualization object edit menu', () => {
it('opens a visualization when edit link is clicked', async () => {
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
const currentUrl = await browser.getCurrentUrl();
expect(currentUrl).to.contain(VisualizeConstants.EDIT_PATH);
});
it('deletes the visualization when delete link is clicked', async () => {
await PageObjects.header.clickDashboard();
await PageObjects.header.waitUntilLoadingHasFinished();
await dashboardPanelActions.removePanel();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(0);
});
});
describe('saved search object edit menu', () => {
const searchName = 'my search';
before(async () => {
await PageObjects.header.clickDiscover();
await PageObjects.discover.clickNewSearchButton();
await dashboardVisualizations.createSavedSearch({ name: searchName, fields: ['bytes'] });
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.header.clickDashboard();
const inViewMode = await PageObjects.dashboard.getIsInViewMode();
if (inViewMode) await PageObjects.dashboard.switchToEditMode();
await dashboardAddPanel.addSavedSearch(searchName);
});
it('should be one panel on dashboard', async () => {
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(1);
});
it('opens a saved search when edit link is clicked', async () => {
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
const queryName = await PageObjects.discover.getCurrentQueryName();
expect(queryName).to.be(searchName);
});
it('deletes the saved search when delete link is clicked', async () => {
await PageObjects.header.clickDashboard();
await PageObjects.header.waitUntilLoadingHasFinished();
await dashboardPanelActions.removePanel();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(0);
});
});
describe('on an expanded panel', function () {
before('reset dashboard', async () => {
const currentUrl = await browser.getCurrentUrl();
await browser.get(currentUrl.toString(), false);
});
before('and add one panel and save to put dashboard in "view" mode', async () => {
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
await PageObjects.dashboard.saveDashboard(dashboardName);
});
before('expand panel to "full screen"', async () => {
await dashboardPanelActions.clickExpandPanelToggle();
});
it('context menu actions are hidden in view mode', async function () {
await dashboardPanelActions.expectMissingEditPanelAction();
await dashboardPanelActions.expectMissingDuplicatePanelAction();
await dashboardPanelActions.expectMissingReplacePanelAction();
await dashboardPanelActions.expectMissingRemovePanelAction();
});
describe('in edit mode', () => {
it('switch to edit mode', async function () {
await PageObjects.dashboard.switchToEditMode();
});
it('some context menu actions should be present', async function () {
await dashboardPanelActions.expectExistsEditPanelAction();
await dashboardPanelActions.expectExistsClonePanelAction();
await dashboardPanelActions.expectExistsReplacePanelAction();
});
it('"remove panel" action should not be present', async function () {
await dashboardPanelActions.expectMissingRemovePanelAction();
});
});
});
});
}

View file

@ -1,293 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import {
PIE_CHART_VIS_NAME,
AREA_CHART_VIS_NAME,
LINE_CHART_VIS_NAME,
} from '../../page_objects/dashboard_page';
import { VisualizeConstants } from '../../../../src/plugins/visualize/public/application/visualize_constants';
export default function ({ getService, getPageObjects }) {
const browser = getService('browser');
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardReplacePanel = getService('dashboardReplacePanel');
const dashboardVisualizations = getService('dashboardVisualizations');
const renderable = getService('renderable');
const PageObjects = getPageObjects([
'dashboard',
'header',
'visualize',
'discover',
'timePicker',
]);
const dashboardName = 'Dashboard Panel Controls Test';
describe('dashboard panel controls', function viewEditModeTests() {
before(async function () {
await PageObjects.dashboard.initTests();
await PageObjects.dashboard.preserveCrossAppState();
});
after(async function () {
await PageObjects.dashboard.gotoDashboardLandingPage();
});
describe('visualization object replace flyout', () => {
let intialDimensions;
before(async () => {
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setHistoricalDataRange();
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
await dashboardAddPanel.addVisualization(LINE_CHART_VIS_NAME);
intialDimensions = await PageObjects.dashboard.getPanelDimensions();
});
after(async function () {
await PageObjects.dashboard.gotoDashboardLandingPage();
});
it('replaces old panel with selected panel', async () => {
await dashboardPanelActions.replacePanelByTitle(PIE_CHART_VIS_NAME);
await dashboardReplacePanel.replaceEmbeddable(AREA_CHART_VIS_NAME);
await PageObjects.header.waitUntilLoadingHasFinished();
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles.length).to.be(2);
expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME);
});
it('replaces selected visualization with old dimensions', async () => {
const newDimensions = await PageObjects.dashboard.getPanelDimensions();
expect(intialDimensions[0]).to.eql(newDimensions[0]);
});
it('replaced panel persisted correctly when dashboard is hard refreshed', async () => {
const currentUrl = await browser.getCurrentUrl();
await browser.get(currentUrl, true);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles.length).to.be(2);
expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME);
});
it('replaced panel with saved search', async () => {
const replacedSearch = 'replaced saved search';
await dashboardVisualizations.createSavedSearch({
name: replacedSearch,
fields: ['bytes', 'agent'],
});
await PageObjects.header.clickDashboard();
const inViewMode = await PageObjects.dashboard.getIsInViewMode();
if (inViewMode) {
await PageObjects.dashboard.switchToEditMode();
}
await dashboardPanelActions.replacePanelByTitle(AREA_CHART_VIS_NAME);
await dashboardReplacePanel.replaceEmbeddable(replacedSearch, 'search');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles.length).to.be(2);
expect(panelTitles[0]).to.be(replacedSearch);
});
});
describe('panel cloning', function () {
before(async () => {
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setHistoricalDataRange();
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
});
after(async function () {
await PageObjects.dashboard.gotoDashboardLandingPage();
});
it('clones a panel', async () => {
const initialPanelTitles = await PageObjects.dashboard.getPanelTitles();
await dashboardPanelActions.clonePanelByTitle(PIE_CHART_VIS_NAME);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const postPanelTitles = await PageObjects.dashboard.getPanelTitles();
expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1);
});
it('appends a clone title tag', async () => {
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles[1]).to.equal(PIE_CHART_VIS_NAME + ' (copy)');
});
it('retains original panel dimensions', async () => {
const panelDimensions = await PageObjects.dashboard.getPanelDimensions();
expect(panelDimensions[0]).to.eql(panelDimensions[1]);
});
it('gives a correct title to the clone of a clone', async () => {
const initialPanelTitles = await PageObjects.dashboard.getPanelTitles();
const clonedPanelName = initialPanelTitles[initialPanelTitles.length - 1];
await dashboardPanelActions.clonePanelByTitle(clonedPanelName);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const postPanelTitles = await PageObjects.dashboard.getPanelTitles();
expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1);
expect(postPanelTitles[postPanelTitles.length - 1]).to.equal(
PIE_CHART_VIS_NAME + ' (copy 1)'
);
});
});
describe('panel edit controls', function () {
before(async () => {
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setHistoricalDataRange();
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
});
it('are hidden in view mode', async function () {
await PageObjects.dashboard.saveDashboard(dashboardName);
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.expectMissingEditPanelAction();
await dashboardPanelActions.expectMissingRemovePanelAction();
});
it('are shown in edit mode', async function () {
await PageObjects.dashboard.switchToEditMode();
const isContextMenuIconVisible = await dashboardPanelActions.isContextMenuIconVisible();
expect(isContextMenuIconVisible).to.equal(true);
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.expectExistsEditPanelAction();
await dashboardPanelActions.expectExistsReplacePanelAction();
await dashboardPanelActions.expectExistsDuplicatePanelAction();
await dashboardPanelActions.expectExistsRemovePanelAction();
});
// Based off an actual bug encountered in a PR where a hard refresh in edit mode did not show the edit mode
// controls.
it('are shown in edit mode after a hard refresh', async () => {
const currentUrl = await browser.getCurrentUrl();
// the second parameter of true will include the timestamp in the url and trigger a hard refresh.
await browser.get(currentUrl.toString(), true);
await PageObjects.header.waitUntilLoadingHasFinished();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.expectExistsEditPanelAction();
await dashboardPanelActions.expectExistsReplacePanelAction();
await dashboardPanelActions.expectExistsDuplicatePanelAction();
await dashboardPanelActions.expectExistsRemovePanelAction();
// Get rid of the timestamp in the url.
await browser.get(currentUrl.toString(), false);
});
describe('on an expanded panel', function () {
it('are hidden in view mode', async function () {
await renderable.waitForRender();
await PageObjects.dashboard.saveDashboard(dashboardName);
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickExpandPanelToggle();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.expectMissingEditPanelAction();
await dashboardPanelActions.expectMissingReplacePanelAction();
await dashboardPanelActions.expectMissingDuplicatePanelAction();
await dashboardPanelActions.expectMissingRemovePanelAction();
});
it('in edit mode hides remove icons ', async function () {
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.expectExistsEditPanelAction();
await dashboardPanelActions.expectExistsReplacePanelAction();
await dashboardPanelActions.expectExistsDuplicatePanelAction();
await dashboardPanelActions.expectMissingRemovePanelAction();
await dashboardPanelActions.clickExpandPanelToggle();
});
});
describe('visualization object edit menu', () => {
it('opens a visualization when edit link is clicked', async () => {
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
const currentUrl = await browser.getCurrentUrl();
expect(currentUrl).to.contain(VisualizeConstants.EDIT_PATH);
});
it('deletes the visualization when delete link is clicked', async () => {
await PageObjects.header.clickDashboard();
await PageObjects.header.waitUntilLoadingHasFinished();
await dashboardPanelActions.removePanel();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(0);
});
});
describe('saved search object edit menu', () => {
const searchName = 'my search';
before(async () => {
await PageObjects.header.clickDiscover();
await PageObjects.discover.clickNewSearchButton();
await dashboardVisualizations.createSavedSearch({ name: searchName, fields: ['bytes'] });
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.header.clickDashboard();
const inViewMode = await PageObjects.dashboard.getIsInViewMode();
if (inViewMode) {
await PageObjects.dashboard.switchToEditMode();
}
await dashboardAddPanel.addSavedSearch(searchName);
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(1);
});
it('opens a saved search when edit link is clicked', async () => {
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
const queryName = await PageObjects.discover.getCurrentQueryName();
expect(queryName).to.be(searchName);
});
it('deletes the saved search when delete link is clicked', async () => {
await PageObjects.header.clickDashboard();
await PageObjects.header.waitUntilLoadingHasFinished();
await dashboardPanelActions.removePanel();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(0);
});
});
});
// Panel expand should also be shown in view mode, but only on mouse hover.
describe('panel expand control', function () {
it('shown in edit mode', async function () {
await PageObjects.dashboard.gotoDashboardEditMode(dashboardName);
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.expectExistsToggleExpandAction();
});
});
});
}

View file

@ -0,0 +1,100 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import {
PIE_CHART_VIS_NAME,
AREA_CHART_VIS_NAME,
LINE_CHART_VIS_NAME,
} from '../../page_objects/dashboard_page';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const browser = getService('browser');
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardReplacePanel = getService('dashboardReplacePanel');
const dashboardVisualizations = getService('dashboardVisualizations');
const PageObjects = getPageObjects([
'dashboard',
'header',
'visualize',
'discover',
'timePicker',
]);
describe('replace dashboard panels', function viewEditModeTests() {
let intialDimensions: undefined | Array<{ width: number; height: number }>;
before(async function () {
await PageObjects.dashboard.initTests();
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.timePicker.setHistoricalDataRange();
await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME);
await dashboardAddPanel.addVisualization(LINE_CHART_VIS_NAME);
intialDimensions = await PageObjects.dashboard.getPanelDimensions();
});
after(async function () {
await PageObjects.dashboard.gotoDashboardLandingPage();
});
it('replaces old panel with selected panel', async () => {
await dashboardPanelActions.replacePanelByTitle(PIE_CHART_VIS_NAME);
await dashboardReplacePanel.replaceEmbeddable(AREA_CHART_VIS_NAME);
await PageObjects.header.waitUntilLoadingHasFinished();
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles.length).to.be(2);
expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME);
const newDimensions = await PageObjects.dashboard.getPanelDimensions();
expect(intialDimensions![0]).to.eql(newDimensions[0]);
});
it('replaced panel persisted correctly when dashboard is hard refreshed', async () => {
const currentUrl = await browser.getCurrentUrl();
await browser.get(currentUrl, true);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles.length).to.be(2);
expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME);
});
it('replaced panel with saved search', async () => {
const replacedSearch = 'replaced saved search';
await dashboardVisualizations.createSavedSearch({
name: replacedSearch,
fields: ['bytes', 'agent'],
});
await PageObjects.header.clickDashboard();
const inViewMode = await PageObjects.dashboard.getIsInViewMode();
if (inViewMode) {
await PageObjects.dashboard.switchToEditMode();
}
await dashboardPanelActions.replacePanelByTitle(AREA_CHART_VIS_NAME);
await dashboardReplacePanel.replaceEmbeddable(replacedSearch, 'search');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const panelTitles = await PageObjects.dashboard.getPanelTitles();
expect(panelTitles.length).to.be(2);
expect(panelTitles[0]).to.be(replacedSearch);
});
});
}

View file

@ -59,12 +59,32 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
async openContextMenu(parent?: WebElementWrapper) {
log.debug(`openContextMenu(${parent}`);
if (await testSubjects.exists('embeddablePanelContextMenuOpen')) return;
await this.toggleContextMenu(parent);
await this.expectContextMenuToBeOpen();
}
async hasContextMenuMoreItem() {
return await testSubjects.exists('embeddablePanelMore-mainMenu');
}
async clickContextMenuMoreItem() {
const hasMoreSubPanel = await testSubjects.exists('embeddablePanelMore-mainMenu');
if (hasMoreSubPanel) {
await testSubjects.click('embeddablePanelMore-mainMenu');
}
}
async openContextMenuMorePanel(parent?: WebElementWrapper) {
await this.openContextMenu(parent);
await this.clickContextMenuMoreItem();
}
async clickEdit() {
log.debug('clickEdit');
await this.openContextMenu();
const isActionVisible = await testSubjects.exists(EDIT_PANEL_DATA_TEST_SUBJ);
if (!isActionVisible) await this.clickContextMenuMoreItem();
await testSubjects.clickWhenNotDisabled(EDIT_PANEL_DATA_TEST_SUBJ);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.common.waitForTopNavToBeVisible();
@ -82,18 +102,28 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
}
async clickExpandPanelToggle() {
log.debug(`clickExpandPanelToggle`);
this.openContextMenu();
const isActionVisible = await testSubjects.exists(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
if (!isActionVisible) await this.clickContextMenuMoreItem();
await testSubjects.click(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
}
async removePanel() {
log.debug('removePanel');
await this.openContextMenu();
const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
if (!isActionVisible) await this.clickContextMenuMoreItem();
const isPanelActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
if (!isPanelActionVisible) await this.clickContextMenuMoreItem();
await testSubjects.click(REMOVE_PANEL_DATA_TEST_SUBJ);
}
async removePanelByTitle(title: string) {
const header = await this.getPanelHeading(title);
await this.openContextMenu(header);
const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
if (!isActionVisible) await this.clickContextMenuMoreItem();
await testSubjects.click(REMOVE_PANEL_DATA_TEST_SUBJ);
}
@ -110,6 +140,10 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
} else {
await this.openContextMenu();
}
const actionExists = await testSubjects.exists(REPLACE_PANEL_DATA_TEST_SUBJ);
if (!actionExists) {
await this.clickContextMenuMoreItem();
}
await testSubjects.click(REPLACE_PANEL_DATA_TEST_SUBJ);
}
@ -131,52 +165,78 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
async openInspector(parent: WebElementWrapper) {
await this.openContextMenu(parent);
const exists = await testSubjects.exists(OPEN_INSPECTOR_TEST_SUBJ);
if (!exists) {
await this.clickContextMenuMoreItem();
}
await testSubjects.click(OPEN_INSPECTOR_TEST_SUBJ);
}
async expectExistsRemovePanelAction() {
log.debug('expectExistsRemovePanelAction');
await testSubjects.existOrFail(REMOVE_PANEL_DATA_TEST_SUBJ);
await this.expectExistsPanelAction(REMOVE_PANEL_DATA_TEST_SUBJ);
}
async expectMissingRemovePanelAction() {
log.debug('expectMissingRemovePanelAction');
await testSubjects.missingOrFail(REMOVE_PANEL_DATA_TEST_SUBJ);
async expectExistsPanelAction(testSubject: string) {
log.debug('expectExistsPanelAction', testSubject);
await this.openContextMenu();
if (await testSubjects.exists(CLONE_PANEL_DATA_TEST_SUBJ)) return;
if (await this.hasContextMenuMoreItem()) {
await this.clickContextMenuMoreItem();
}
await testSubjects.existOrFail(CLONE_PANEL_DATA_TEST_SUBJ);
await this.toggleContextMenu();
}
async expectMissingPanelAction(testSubject: string) {
log.debug('expectMissingPanelAction', testSubject);
await this.openContextMenu();
await testSubjects.missingOrFail(testSubject);
if (await this.hasContextMenuMoreItem()) {
await this.clickContextMenuMoreItem();
await testSubjects.missingOrFail(testSubject);
}
await this.toggleContextMenu();
}
async expectExistsEditPanelAction() {
log.debug('expectExistsEditPanelAction');
await testSubjects.existOrFail(EDIT_PANEL_DATA_TEST_SUBJ);
await this.expectExistsPanelAction(EDIT_PANEL_DATA_TEST_SUBJ);
}
async expectExistsReplacePanelAction() {
log.debug('expectExistsReplacePanelAction');
await testSubjects.existOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
await this.expectExistsPanelAction(REPLACE_PANEL_DATA_TEST_SUBJ);
}
async expectExistsDuplicatePanelAction() {
log.debug('expectExistsDuplicatePanelAction');
await testSubjects.existOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
async expectExistsClonePanelAction() {
log.debug('expectExistsClonePanelAction');
await this.expectExistsPanelAction(CLONE_PANEL_DATA_TEST_SUBJ);
}
async expectMissingEditPanelAction() {
log.debug('expectMissingEditPanelAction');
await testSubjects.missingOrFail(EDIT_PANEL_DATA_TEST_SUBJ);
await this.expectMissingPanelAction(EDIT_PANEL_DATA_TEST_SUBJ);
}
async expectMissingReplacePanelAction() {
log.debug('expectMissingReplacePanelAction');
await testSubjects.missingOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
await this.expectMissingPanelAction(REPLACE_PANEL_DATA_TEST_SUBJ);
}
async expectMissingDuplicatePanelAction() {
log.debug('expectMissingDuplicatePanelAction');
await testSubjects.missingOrFail(REPLACE_PANEL_DATA_TEST_SUBJ);
await this.expectMissingPanelAction(CLONE_PANEL_DATA_TEST_SUBJ);
}
async expectMissingRemovePanelAction() {
log.debug('expectMissingRemovePanelAction');
await this.expectMissingPanelAction(REMOVE_PANEL_DATA_TEST_SUBJ);
}
async expectExistsToggleExpandAction() {
log.debug('expectExistsToggleExpandAction');
await testSubjects.existOrFail(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
await this.expectExistsPanelAction(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
}
async getPanelHeading(title: string) {

View file

@ -73,7 +73,7 @@ export function DashboardReplacePanelProvider({ getService }: FtrProviderContext
return this.replaceEmbeddable(vizName, 'visualization');
}
async replaceEmbeddable(embeddableName: string, embeddableType: string) {
async replaceEmbeddable(embeddableName: string, embeddableType?: string) {
log.debug(
`DashboardReplacePanel.replaceEmbeddable, name: ${embeddableName}, type: ${embeddableType}`
);

View file

@ -8,7 +8,10 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public';
import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public';
import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public';
import {
isEnhancedEmbeddable,
embeddableEnhancedContextMenuDrilldownGrouping,
} from '../../../../../../embeddable_enhanced/public';
import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public';
import { StartDependencies } from '../../../../plugin';
import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public';
@ -24,6 +27,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLY
public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN;
public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN;
public order = 12;
public grouping = embeddableEnhancedContextMenuDrilldownGrouping;
constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {}

View file

@ -13,7 +13,10 @@ import {
import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public';
import { txtDisplayName } from './i18n';
import { MenuItem } from './menu_item';
import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public';
import {
isEnhancedEmbeddable,
embeddableEnhancedContextMenuDrilldownGrouping,
} from '../../../../../../embeddable_enhanced/public';
import { StartDependencies } from '../../../../plugin';
import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public';
import { ensureNestedTriggers } from '../drilldown_shared';
@ -28,6 +31,7 @@ export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOU
public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN;
public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN;
public order = 10;
public grouping = embeddableEnhancedContextMenuDrilldownGrouping;
constructor(protected readonly params: FlyoutEditDrilldownParams) {}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IEmbeddable } from '../../../../../src/plugins/embeddable/public';
import { UiActionsPresentableGrouping as PresentableGrouping } from '../../../../../src/plugins/ui_actions/public';
export const contextMenuDrilldownGrouping: PresentableGrouping<{
embeddable?: IEmbeddable;
}> = [
{
id: 'drilldowns',
getDisplayName: () => 'Drilldowns',
getIconType: () => 'symlink',
order: 25,
},
];

View file

@ -5,3 +5,4 @@
*/
export * from './panel_notifications_action';
export * from './drilldown_grouping';

View file

@ -20,3 +20,4 @@ export function plugin(context: PluginInitializerContext) {
export { EnhancedEmbeddable, EnhancedEmbeddableContext } from './types';
export { isEnhancedEmbeddable } from './embeddables';
export { contextMenuDrilldownGrouping as embeddableEnhancedContextMenuDrilldownGrouping } from './actions';

View file

@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
after('clean-up custom time range on panel', async () => {
await common.navigateToApp('dashboard');
await dashboard.gotoDashboardEditMode(drilldowns.DASHBOARD_WITH_PIE_CHART_NAME);
await panelActions.openContextMenu();
await panelActions.openContextMenuMorePanel();
await panelActionsTimeRange.clickTimeRangeActionInContextMenu();
await panelActionsTimeRange.clickRemovePerPanelTimeRangeButton();
await dashboard.saveDashboard('Dashboard with Pie Chart');
@ -77,7 +77,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboard.gotoDashboardEditMode(drilldowns.DASHBOARD_WITH_PIE_CHART_NAME);
await panelActions.openContextMenu();
await panelActions.openContextMenuMorePanel();
await panelActionsTimeRange.clickTimeRangeActionInContextMenu();
await panelActionsTimeRange.clickToggleQuickMenuButton();
await panelActionsTimeRange.clickCommonlyUsedTimeRange('Last_90 days');