From 4b49e5a1c81a7b49117b3e1c88705b38a6fabbfc Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 21 Sep 2020 15:42:46 +0200 Subject: [PATCH] Context menu (#76497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- examples/ui_actions_explorer/public/app.tsx | 6 + .../context_menu_examples.tsx | 63 ++++ .../public/context_menu_examples/index.tsx | 20 ++ .../context_menu_examples/panel_edit.tsx | 59 ++++ .../panel_edit_with_drilldowns.tsx | 70 +++++ ...it_with_drilldowns_and_context_actions.tsx | 87 ++++++ .../context_menu_examples/panel_view.tsx | 55 ++++ .../panel_view_with_sharing.tsx | 67 ++++ .../panel_view_with_sharing_long.tsx | 72 +++++ .../public/context_menu_examples/util.ts | 39 +++ .../lib/panel/panel_header/panel_header.tsx | 2 +- .../panel/panel_header/panel_options_menu.tsx | 6 +- .../public/actions/action_internal.ts | 3 +- .../build_eui_context_menu_panels.test.ts | 206 ++++++++++-- .../build_eui_context_menu_panels.tsx | 270 +++++++++------- src/plugins/ui_actions/public/index.ts | 5 +- .../service/ui_actions_execution_service.ts | 4 +- .../ui_actions/public/util/presentable.ts | 16 + test/functional/apps/dashboard/index.js | 4 +- .../apps/dashboard/panel_cloning.ts | 80 +++++ .../apps/dashboard/panel_context_menu.ts | 185 +++++++++++ .../apps/dashboard/panel_controls.js | 293 ------------------ .../apps/dashboard/panel_replacing.ts | 100 ++++++ .../services/dashboard/panel_actions.ts | 86 ++++- .../services/dashboard/replace_panel.ts | 2 +- .../flyout_create_drilldown.tsx | 6 +- .../flyout_edit_drilldown.tsx | 6 +- .../public/actions/drilldown_grouping.ts | 19 ++ .../public/actions/index.ts | 1 + .../embeddable_enhanced/public/index.ts | 1 + .../drilldowns/explore_data_panel_action.ts | 4 +- 31 files changed, 1379 insertions(+), 458 deletions(-) create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/index.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/panel_edit.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns_and_context_actions.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/panel_view.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing_long.tsx create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/util.ts create mode 100644 test/functional/apps/dashboard/panel_cloning.ts create mode 100644 test/functional/apps/dashboard/panel_context_menu.ts delete mode 100644 test/functional/apps/dashboard/panel_controls.js create mode 100644 test/functional/apps/dashboard/panel_replacing.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/actions/drilldown_grouping.ts diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index d59309f006838..bc8bdee75047d 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -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) => { + + + + + diff --git a/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx new file mode 100644 index 0000000000000..ea00b22fa3cdc --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx @@ -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 ( + +

Context menu examples

+

+ Below examples show how context menu panels look with varying number of actions and how the + actions can be grouped into different panels using grouping field. +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/index.tsx b/examples/ui_actions_explorer/public/context_menu_examples/index.tsx new file mode 100644 index 0000000000000..4a8c2fd00cd4d --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/index.tsx @@ -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'; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_edit.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit.tsx new file mode 100644 index 0000000000000..794a8d0348baf --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit.tsx @@ -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 ( + setOpen((x) => !x)}>Edit mode} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns.tsx new file mode 100644 index 0000000000000..185011066e8e4 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns.tsx @@ -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 ( + setOpen((x) => !x)}>Edit mode with drilldowns} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns_and_context_actions.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns_and_context_actions.tsx new file mode 100644 index 0000000000000..e9543814ff015 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns_and_context_actions.tsx @@ -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 ( + setOpen((x) => !x)}> + Edit mode with drilldowns and custom actions + + } + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_view.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_view.tsx new file mode 100644 index 0000000000000..db8763fdf17f8 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_view.tsx @@ -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 ( + setOpen((x) => !x)}>View mode} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing.tsx new file mode 100644 index 0000000000000..2c99d04e7d19a --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing.tsx @@ -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 ( + setOpen((x) => !x)}>View mode with few sharing options + } + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing_long.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing_long.tsx new file mode 100644 index 0000000000000..99b8cbec57677 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing_long.tsx @@ -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 ( + setOpen((x) => !x)}> + View mode with many sharing options + + } + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/util.ts b/examples/ui_actions_explorer/public/context_menu_examples/util.ts new file mode 100644 index 0000000000000..ea14ceec7b0a3 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/util.ts @@ -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, + }; +}; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index f3c4cae720193..5f73ef2ca7688 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -37,7 +37,7 @@ export interface PanelHeaderProps { title?: string; isViewMode: boolean; hidePanelTitles: boolean; - getActionContextMenuPanel: () => Promise; + getActionContextMenuPanel: () => Promise; closeContextMenu: boolean; badges: Array>; notifications: Array>; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx index b4c349600e8f6..629a5f8c880e8 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx @@ -28,14 +28,14 @@ import { } from '@elastic/eui'; export interface PanelOptionsMenuProps { - getActionContextMenuPanel: () => Promise; + getActionContextMenuPanel: () => Promise; isViewMode: boolean; closeContextMenu: boolean; title?: string; } interface State { - actionContextMenuPanel?: EuiContextMenuPanelDescriptor; + actionContextMenuPanel?: EuiContextMenuPanelDescriptor[]; isPopoverOpen: boolean; } @@ -117,7 +117,7 @@ export class PanelOptionsMenu extends React.Component ); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index a22b3fa5b0367..fe7c986bdb7ef 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -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 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> = this.definition.grouping; public execute(context: Context) { return this.definition.execute(context); diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts index a513bb3c95f24..3a598b547e343 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts @@ -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 [ + 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 [ - "a", - "b", - "c", - "d", - "aa", + 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", + }, + ], + }, ] `); }); diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 3be1ec781cef6..1fdddfc272e94 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -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 { action: Action; context: Context; @@ -41,138 +44,177 @@ interface ActionWithContext { trigger: Trigger; } +type ItemDescriptor = EuiContextMenuPanelItemDescriptor & { + _order: number; + _title?: string; +}; + +type PanelDescriptor = EuiContextMenuPanelDescriptor & { + _level?: number; + _icon?: string; + items: ItemDescriptor[]; +}; + +const onClick = (action: Action, context: ActionExecutionContext, close: () => void) => ( + event: React.MouseEvent +) => { + if (event.currentTarget instanceof HTMLAnchorElement) { + // from react-router's + 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(); +}; + /** - * Transforms an array of Actions to the shape EuiContextMenuPanel expects. + * 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. */ -export async function buildContextMenuForActions({ - actions, - title = defaultTitle, - closeMenu = () => {}, -}: { +const wrapMainPanelItemsIntoSubmenu = (panels: Record, 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; -}): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ - actions, - closeMenu, - }); - - return { - id: 'mainMenu', - title, - items: menuItems, - }; } /** - * Transform an array of Actions into the shape needed to build an EUIContextMenu + * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -async function buildEuiContextMenuPanelItems({ +export async function buildContextMenuForActions({ 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; + title = defaultTitle, + closeMenu = () => {}, +}: BuildContextMenuParams): Promise { + const panels: Record = { + mainMenu: { + id: 'mainMenu', + title, + items: [], + }, + }; + const promises = actions.map(async (item) => { + const { action } = item; + const context: ActionExecutionContext = { ...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); -} - -async function convertPanelActionToContextMenuItem({ - action, - actionContext, - trigger, - closeMenu, -}: { - action: Action; - actionContext: Context; - trigger: Trigger; - closeMenu: () => void; -}): Promise { - 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}`, - }; + 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 + ); + } - menuPanelItem.onClick = (event) => { - if (event.currentTarget instanceof HTMLAnchorElement) { - // from react-router's - 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, + wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu'); + + 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); } diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 476ca0ec17066..4b2d6cae1c8e1 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -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, diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts index f65a72f334d07..4f0ab52501a95 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -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', }); } diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts index 57070f7673f61..59440d6c75976 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -68,4 +68,20 @@ export interface Presentable { * the context and should be displayed to user, otherwise resolves to false. */ isCompatible(context: Context): Promise; + + /** + * 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; } + +export interface PresentableGroup + extends Partial< + Pick, 'getDisplayName' | 'getDisplayNameTooltip' | 'getIconType' | 'order'> + > { + id: string; +} + +export type PresentableGrouping = Array>; diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index 5a30456bd59ab..de4b3df9c40ef 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -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')); }); diff --git a/test/functional/apps/dashboard/panel_cloning.ts b/test/functional/apps/dashboard/panel_cloning.ts new file mode 100644 index 0000000000000..0535b66f08a16 --- /dev/null +++ b/test/functional/apps/dashboard/panel_cloning.ts @@ -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)' + ); + }); + }); +} diff --git a/test/functional/apps/dashboard/panel_context_menu.ts b/test/functional/apps/dashboard/panel_context_menu.ts new file mode 100644 index 0000000000000..0b9e873f46151 --- /dev/null +++ b/test/functional/apps/dashboard/panel_context_menu.ts @@ -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(); + }); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard/panel_controls.js b/test/functional/apps/dashboard/panel_controls.js deleted file mode 100644 index 748e9fdc5f19d..0000000000000 --- a/test/functional/apps/dashboard/panel_controls.js +++ /dev/null @@ -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(); - }); - }); - }); -} diff --git a/test/functional/apps/dashboard/panel_replacing.ts b/test/functional/apps/dashboard/panel_replacing.ts new file mode 100644 index 0000000000000..6bf3dbbe47b1d --- /dev/null +++ b/test/functional/apps/dashboard/panel_replacing.ts @@ -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); + }); + }); +} diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index bc21a62b9df79..2cea377d327e1 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -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) { diff --git a/test/functional/services/dashboard/replace_panel.ts b/test/functional/services/dashboard/replace_panel.ts index d1cb4e5e697a1..2abc9cf23b72e 100644 --- a/test/functional/services/dashboard/replace_panel.ts +++ b/test/functional/services/dashboard/replace_panel.ts @@ -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}` ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index 607a11a76a066..cd800baaf026e 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -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 = [ + { + id: 'drilldowns', + getDisplayName: () => 'Drilldowns', + getIconType: () => 'symlink', + order: 25, + }, +]; diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/index.ts b/x-pack/plugins/embeddable_enhanced/public/actions/index.ts index b47abd48fd269..21fcb0041f86e 100644 --- a/x-pack/plugins/embeddable_enhanced/public/actions/index.ts +++ b/x-pack/plugins/embeddable_enhanced/public/actions/index.ts @@ -5,3 +5,4 @@ */ export * from './panel_notifications_action'; +export * from './drilldown_grouping'; diff --git a/x-pack/plugins/embeddable_enhanced/public/index.ts b/x-pack/plugins/embeddable_enhanced/public/index.ts index 059acf9644820..a7916685239df 100644 --- a/x-pack/plugins/embeddable_enhanced/public/index.ts +++ b/x-pack/plugins/embeddable_enhanced/public/index.ts @@ -20,3 +20,4 @@ export function plugin(context: PluginInitializerContext) { export { EnhancedEmbeddable, EnhancedEmbeddableContext } from './types'; export { isEnhancedEmbeddable } from './embeddables'; +export { contextMenuDrilldownGrouping as embeddableEnhancedContextMenuDrilldownGrouping } from './actions'; diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts b/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts index fedc83a2f81c7..288804750277e 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/explore_data_panel_action.ts @@ -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');