Pluggable panel actions (#18877) (#19415)

* Allow pluggable panel actions

* Need to register it as being used in kibana

* Some cleanup

* update snapshots to match new EUI versions, set time range

* Use newer panelActions service

* add missing await

* More clean up and fixes

* bring back window reload

* Show actions in view mode too

* delete now unused files

* Use toggle action to determin if context menu is open

* Fix tests that assume the toggle is hidden in view mode.

* Add some debug logs

* Fix up assumptions

* Previous failing test was legit - we don't want to show remove option when panel is expanded

* Embeddable can be empty before the panel is loaded

* Should look for either visualize or discover page

* Address code comments

* address code review comments

* whoops, get rid of childPanelToOpenOnClick entirely
This commit is contained in:
Stacey Gammon 2018-05-24 16:41:05 -04:00 committed by GitHub
parent 73173f11c3
commit 225f541abb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 700 additions and 377 deletions

View file

@ -1,6 +1,7 @@
import { createAction } from 'redux-actions';
export const updateViewMode = createAction('UPDATE_VIEW_MODE');
export const setVisibleContextMenuPanelId = createAction('SET_VISIBLE_CONTEXT_MENU_PANEL_ID');
export const maximizePanel = createAction('MAXIMIZE_PANEl');
export const minimizePanel = createAction('MINIMIZE_PANEL');
export const updateIsFullScreenMode = createAction('UPDATE_IS_FULL_SCREEN_MODE');

View file

@ -7,6 +7,8 @@ import { toastNotifications } from 'ui/notify';
import 'ui/query_bar';
import { panelActionsStore } from './store/panel_actions_store';
import { getDashboardTitle } from './dashboard_strings';
import { DashboardViewMode } from './dashboard_view_mode';
import { TopNavIds } from './top_nav/top_nav_ids';
@ -24,6 +26,7 @@ import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
import * as filterActions from 'ui/doc_table/actions/filter';
import { FilterManagerProvider } from 'ui/filter_manager';
import { EmbeddableFactoriesRegistryProvider } from 'ui/embeddable/embeddable_factories_registry';
import { DashboardPanelActionsRegistryProvider } from 'ui/dashboard_panel_actions/dashboard_panel_actions_registry';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
import { VisTypesRegistryProvider } from 'ui/registry/vis_types';
@ -62,6 +65,10 @@ app.directive('dashboardApp', function ($injector) {
const docTitle = Private(DocTitleProvider);
const notify = new Notifier({ location: 'Dashboard' });
const embeddableFactories = Private(EmbeddableFactoriesRegistryProvider);
const panelActionsRegistry = Private(DashboardPanelActionsRegistryProvider);
panelActionsStore.initializeFromRegistry(panelActionsRegistry);
const savedObjectsClient = Private(SavedObjectsClientProvider);
const visTypes = Private(VisTypesRegistryProvider);
$scope.getEmbeddableFactory = panelType => embeddableFactories.byName[panelType];

View file

@ -25,7 +25,7 @@ exports[`DashboardPanel matches snapshot 1`] = `
>
<div
class="euiPopover euiPopover--anchorDownRight dashboardPanelPopOver euiPopover--withTitle"
id="panelContextMenu"
id="dashboardPanelContextMenu"
>
<button
aria-label="Panel options"

View file

@ -121,6 +121,7 @@ export class DashboardPanel extends React.Component {
>
<PanelHeader
panelId={panel.panelIndex}
embeddable={this.embeddable}
/>
{this.renderContent()}

View file

@ -0,0 +1,109 @@
import _ from 'lodash';
/**
* Loops through allActions and extracts those that belong on the given contextMenuPanelId
* @param {string} contextMenuPanelId
* @param {Array.<DashboardPanelAction>} allActions
*/
function getActionsForPanel(contextMenuPanelId, allActions) {
return allActions.filter(action => action.parentPanelId === contextMenuPanelId);
}
/**
* @param {String} contextMenuPanelId
* @param {Array.<DashboardPanelAction>} actions
* @param {Embeddable} embeddable
* @param {ContainerState} containerState
* @return {{
* Array.<EuiContextMenuPanelItemShape> items - panel actions converted into the items expected to be on an
* EuiContextMenuPanel,
* Array.<EuiContextMenuPanelShape> childPanels - extracted child panels, if any actions also open a panel. They
* need to be moved to the top level for EUI.
* }}
*/
function buildEuiContextMenuPanelItemsAndChildPanels({ contextMenuPanelId, actions, embeddable, containerState }) {
const items = [];
const childPanels = [];
const actionsForPanel = getActionsForPanel(contextMenuPanelId, actions);
actionsForPanel.forEach(action => {
const isVisible = action.isVisible({ embeddable, containerState });
if (!isVisible) {
return;
}
if (action.childContextMenuPanel) {
childPanels.push(
...buildEuiContextMenuPanels({
contextMenuPanel: action.childContextMenuPanel,
actions,
embeddable,
containerState
}));
}
items.push(convertPanelActionToContextMenuItem(
{
action,
containerState,
embeddable
}));
});
return { items, childPanels };
}
/**
* Transforms a DashboardContextMenuPanel to the shape EuiContextMenuPanel expects, inserting any registered pluggable
* panel actions.
* @param {DashboardContextMenuPanel} contextMenuPanel
* @param {Array.<DashboardPanelAction>} actions to build the context menu with
* @param {Embeddable} embeddable
* @param {ContainerState} containerState
* @return {Object} An object that conforms to EuiContextMenuPanelShape in elastic/eui
*/
export function buildEuiContextMenuPanels(
{
contextMenuPanel,
actions,
embeddable,
containerState
}) {
const euiContextMenuPanel = {
id: contextMenuPanel.id,
title: contextMenuPanel.title,
items: [],
content: contextMenuPanel.getContent({ embeddable, containerState }),
};
const contextMenuPanels = [euiContextMenuPanel];
const { items, childPanels } =
buildEuiContextMenuPanelItemsAndChildPanels({
contextMenuPanelId: contextMenuPanel.id,
actions,
embeddable,
containerState
});
euiContextMenuPanel.items = items;
return contextMenuPanels.concat(childPanels);
}
/**
*
* @param {DashboardPanelAction} action
* @param {ContainerState} containerState
* @param {Embeddable} embeddable
* @return {Object} See EuiContextMenuPanelItemShape in @elastic/eui
*/
function convertPanelActionToContextMenuItem({ action, containerState, embeddable }) {
return {
id: action.id || action.displayName.replace(/\s/g, ''),
name: action.displayName,
icon: action.icon,
panel: _.get(action, 'childContextMenuPanel.id'),
onClick: () => action.onClick({ containerState, embeddable }),
disabled: action.isDisabled({ containerState, embeddable }),
'data-test-subj': `dashboardPanelAction-${action.id}`,
};
}

View file

@ -0,0 +1,35 @@
import React from 'react';
import {
EuiIcon,
} from '@elastic/eui';
import { PanelOptionsMenuForm } from '../panel_options_menu_form';
import { DashboardPanelAction, DashboardContextMenuPanel } from 'ui/dashboard_panel_actions';
import { DashboardViewMode } from '../../../dashboard_view_mode';
/**
*
* @param {function} onResetPanelTitle
* @param {function} onUpdatePanelTitle
* @param {string} title
* @param {function} closeContextMenu
* @return {DashboardPanelAction}
*/
export function getCustomizePanelAction({ onResetPanelTitle, onUpdatePanelTitle, title, closeContextMenu }) {
return new DashboardPanelAction({
displayName: 'Customize panel',
id: 'customizePanel',
parentPanelId: 'mainMenu',
icon: <EuiIcon type="pencil" />,
isVisible: ({ containerState }) => (containerState.viewMode === DashboardViewMode.EDIT),
childContextMenuPanel: new DashboardContextMenuPanel({
id: 'panelSubOptionsMenu',
title: 'Customize panel',
getContent: () => (<PanelOptionsMenuForm
onReset={onResetPanelTitle}
onUpdatePanelTitle={onUpdatePanelTitle}
title={title}
onClose={closeContextMenu}
/>),
}),
});
}

View file

@ -0,0 +1,24 @@
import React from 'react';
import {
EuiIcon,
} from '@elastic/eui';
import { DashboardPanelAction } from 'ui/dashboard_panel_actions';
import { DashboardViewMode } from '../../../dashboard_view_mode';
/**
*
* @return {DashboardPanelAction}
*/
export function getEditPanelAction() {
return new DashboardPanelAction({
displayName: 'Edit visualization',
id: 'editPanel',
icon: <EuiIcon type="pencil" />,
parentPanelId: 'mainMenu',
onClick: ({ embeddable }) => { window.location = embeddable.metadata.editUrl; },
isVisible: ({ containerState }) => (containerState.viewMode === DashboardViewMode.EDIT),
isDisabled: ({ embeddable }) => (!embeddable || !embeddable.metadata || !embeddable.metadata.editUrl),
});
}

View file

@ -0,0 +1,25 @@
import React from 'react';
import {
EuiIcon,
} from '@elastic/eui';
import { DashboardPanelAction } from 'ui/dashboard_panel_actions';
import { DashboardViewMode } from '../../../dashboard_view_mode';
/**
*
* @param {function} onDeletePanel
* @return {DashboardPanelAction}
*/
export function getRemovePanelAction(onDeletePanel) {
return new DashboardPanelAction({
displayName: 'Delete from dashboard',
id: 'deletePanel',
parentPanelId: 'mainMenu',
icon: <EuiIcon type="trash" />,
isVisible: ({ containerState }) => (
containerState.viewMode === DashboardViewMode.EDIT && !containerState.isPanelExpanded
),
onClick: onDeletePanel,
});
}

View file

@ -0,0 +1,23 @@
import React from 'react';
import {
EuiIcon,
} from '@elastic/eui';
import { DashboardPanelAction } from 'ui/dashboard_panel_actions';
/**
* Returns an action that toggles the panel into maximized or minimized state.
* @param {boolean} isExpanded
* @param {function} toggleExpandedPanel
* @return {DashboardPanelAction}
*/
export function getToggleExpandPanelAction({ isExpanded, toggleExpandedPanel }) {
return new DashboardPanelAction({
displayName: isExpanded ? 'Minimize' : 'Full screen',
id: 'togglePanel',
parentPanelId: 'mainMenu',
// TODO: Update to minimize icon when https://github.com/elastic/eui/issues/837 is complete.
icon: <EuiIcon type={isExpanded ? 'expand' : 'expand'} />,
onClick: toggleExpandedPanel,
});
}

View file

@ -0,0 +1,5 @@
export { getEditPanelAction } from './get_edit_panel_action';
export { getRemovePanelAction } from './get_remove_panel_action';
export { buildEuiContextMenuPanels } from './build_context_menu';
export { getCustomizePanelAction } from './get_customize_panel_action';
export { getToggleExpandPanelAction } from './get_toggle_expand_panel_action';

View file

@ -1,12 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import { embeddableShape } from 'ui/embeddable';
import { PanelOptionsMenuContainer } from './panel_options_menu_container';
export function PanelHeader({ title, actions, isViewOnlyMode, hidePanelTitles }) {
export function PanelHeader({ title, panelId, embeddable, isViewOnlyMode, hidePanelTitles }) {
if (isViewOnlyMode && (!title || hidePanelTitles)) {
return (
<div className="panel-heading-floater">
<div className="kuiMicroButtonGroup">
{actions}
<PanelOptionsMenuContainer panelId={panelId} embeddable={embeddable} />
</div>
</div>
);
@ -24,7 +26,7 @@ export function PanelHeader({ title, actions, isViewOnlyMode, hidePanelTitles })
</span>
<div className="kuiMicroButtonGroup">
{actions}
<PanelOptionsMenuContainer panelId={panelId} embeddable={embeddable} />
</div>
</div>
);
@ -33,6 +35,7 @@ export function PanelHeader({ title, actions, isViewOnlyMode, hidePanelTitles })
PanelHeader.propTypes = {
isViewOnlyMode: PropTypes.bool,
title: PropTypes.string,
actions: PropTypes.node,
hidePanelTitles: PropTypes.bool.isRequired,
embeddable: embeddableShape,
panelId: PropTypes.string.isRequired,
};

View file

@ -1,18 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { embeddableShape } from 'ui/embeddable';
import { PanelHeader } from './panel_header';
import { PanelOptionsMenuContainer } from './panel_options_menu_container';
import { PanelMaximizeIcon } from './panel_maximize_icon';
import { PanelMinimizeIcon } from './panel_minimize_icon';
import { DashboardViewMode } from '../../dashboard_view_mode';
import {
maximizePanel,
minimizePanel,
} from '../../actions';
import {
getPanel,
getMaximizedPanelId,
@ -33,39 +25,11 @@ const mapStateToProps = ({ dashboard }, { panelId }) => {
};
};
const mapDispatchToProps = (dispatch, { panelId }) => ({
onMaximizePanel: () => dispatch(maximizePanel(panelId)),
onMinimizePanel: () => dispatch(minimizePanel()),
});
const mergeProps = (stateProps, dispatchProps, ownProps) => {
const { isExpanded, isViewOnlyMode, title, hidePanelTitles } = stateProps;
const { onMaximizePanel, onMinimizePanel } = dispatchProps;
const { panelId } = ownProps;
let actions;
if (isViewOnlyMode) {
actions = isExpanded ?
<PanelMinimizeIcon onMinimize={onMinimizePanel} /> :
<PanelMaximizeIcon onMaximize={onMaximizePanel} />;
} else {
actions = <PanelOptionsMenuContainer panelId={panelId} />;
}
return {
title,
actions,
isViewOnlyMode,
hidePanelTitles,
};
};
export const PanelHeaderContainer = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
)(PanelHeader);
PanelHeaderContainer.propTypes = {
panelId: PropTypes.string.isRequired,
embeddable: embeddableShape,
};

View file

@ -12,6 +12,7 @@ import {
setPanelTitle,
resetPanelTitle,
embeddableIsInitialized,
updateTimeRange,
} from '../../actions';
import { findTestSubject } from '@elastic/eui/lib/test';
@ -25,6 +26,7 @@ function getProps(props = {}) {
let component;
beforeAll(() => {
store.dispatch(updateTimeRange({ to: 'now', from: 'now-15m' }));
store.dispatch(updateViewMode(DashboardViewMode.EDIT));
store.dispatch(setPanels({ 'foo1': { panelIndex: 'foo1' } }));
const metadata = { title: 'my embeddable title', editUrl: 'editme' };

View file

@ -1,22 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export function PanelMaximizeIcon({ onMaximize }) {
return (
<button
className="kuiMicroButton viewModeExpandPanelToggle"
aria-label="Maximize panel"
data-test-subj="dashboardPanelExpandIcon"
onClick={onMaximize}
>
<span
aria-hidden="true"
className="kuiIcon fa-expand"
/>
</button>
);
}
PanelMaximizeIcon.propTypes = {
onMaximize: PropTypes.func.isRequired
};

View file

@ -1,22 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export function PanelMinimizeIcon({ onMinimize }) {
return (
<button
className="kuiMicroButton viewModeExpandPanelToggle"
aria-label="Minimize panel"
data-test-subj="dashboardPanelExpandIcon"
onClick={onMinimize}
>
<span
aria-hidden="true"
className="kuiIcon fa-compress"
/>
</button>
);
}
PanelMinimizeIcon.propTypes = {
onMinimize: PropTypes.func.isRequired
};

View file

@ -1,143 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PanelOptionsMenuForm } from './panel_options_menu_form';
import {
EuiContextMenu,
EuiPopover,
EuiIcon,
EuiButtonIcon,
} from '@elastic/eui';
export class PanelOptionsMenu extends React.Component {
state = {
isPopoverOpen: false
};
export function PanelOptionsMenu({ toggleContextMenu, isPopoverOpen, closeContextMenu, panels }) {
const button = (
<EuiButtonIcon
iconType="gear"
aria-label="Panel options"
data-test-subj="dashboardPanelToggleMenuIcon"
onClick={toggleContextMenu}
/>
);
toggleMenu = () => {
this.setState({ isPopoverOpen: !this.state.isPopoverOpen });
};
closePopover = () => this.setState({ isPopoverOpen: false });
onEditPanel = () => {
window.location = this.props.editUrl;
};
onDeletePanel = () => {
if (this.props.onDeletePanel) {
this.props.onDeletePanel();
}
};
onToggleExpandPanel = () => {
this.closePopover();
this.props.toggleExpandedPanel();
};
buildMainMenuPanel() {
const { isExpanded } = this.props;
const mainPanelMenuItems = [
{
name: 'Edit visualization',
'data-test-subj': 'dashboardPanelEditLink',
icon: <EuiIcon
type="pencil"
/>,
onClick: this.onEditPanel,
disabled: !this.props.editUrl,
},
{
name: 'Customize panel',
'data-test-subj': 'dashboardPanelOptionsSubMenuLink',
icon: <EuiIcon
type="pencil"
/>,
panel: 'panelSubOptionsMenu',
},
{
name: isExpanded ? 'Minimize' : 'Full screen',
'data-test-subj': 'dashboardPanelExpandIcon',
icon: <EuiIcon
type={isExpanded ? 'expand' : 'expand'}
/>,
onClick: this.onToggleExpandPanel,
}
];
if (!this.props.isExpanded) {
mainPanelMenuItems.push({
name: 'Delete from dashboard',
'data-test-subj': 'dashboardPanelRemoveIcon',
icon: <EuiIcon
type="trash"
/>,
onClick: this.onDeletePanel,
});
}
return {
title: 'Options',
id: 'mainMenu',
items: mainPanelMenuItems,
};
}
buildPanelOptionsSubMenu() {
return {
title: 'Customize panel',
id: 'panelSubOptionsMenu',
content: <PanelOptionsMenuForm
onReset={this.props.onResetPanelTitle}
onUpdatePanelTitle={this.props.onUpdatePanelTitle}
title={this.props.panelTitle}
onClose={this.closePopover}
/>,
};
}
renderPanels() {
return [
this.buildMainMenuPanel(),
this.buildPanelOptionsSubMenu(),
];
}
render() {
const button = (
<EuiButtonIcon
iconType="gear"
aria-label="Panel options"
data-test-subj="dashboardPanelToggleMenuIcon"
onClick={this.toggleMenu}
return (
<EuiPopover
id="dashboardPanelContextMenu"
className="dashboardPanelPopOver"
button={button}
isOpen={isPopoverOpen}
closePopover={closeContextMenu}
panelPaddingSize="none"
anchorPosition="downRight"
withTitle
>
<EuiContextMenu
initialPanelId="mainMenu"
panels={panels}
/>
);
return (
<EuiPopover
id="panelContextMenu"
className="dashboardPanelPopOver"
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downRight"
withTitle
>
<EuiContextMenu
initialPanelId="mainMenu"
panels={this.renderPanels()}
/>
</EuiPopover>
);
}
</EuiPopover>
);
}
PanelOptionsMenu.propTypes = {
panelTitle: PropTypes.string,
onUpdatePanelTitle: PropTypes.func.isRequired,
onResetPanelTitle: PropTypes.func.isRequired,
editUrl: PropTypes.string, // May be empty if the embeddable is still loading
toggleExpandedPanel: PropTypes.func.isRequired,
isExpanded: PropTypes.bool.isRequired,
onDeletePanel: PropTypes.func, // Not available when the panel is expanded.
panels: PropTypes.array,
toggleContextMenu: PropTypes.func,
closeContextMenu: PropTypes.func,
isPopoverOpen: PropTypes.bool,
};

View file

@ -1,7 +1,15 @@
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { panelActionsStore } from '../../store/panel_actions_store';
import { embeddableShape } from 'ui/embeddable';
import { PanelOptionsMenu } from './panel_options_menu';
import {
buildEuiContextMenuPanels,
getEditPanelAction,
getRemovePanelAction,
getCustomizePanelAction,
getToggleExpandPanelAction,
} from './panel_actions';
import {
deletePanel,
@ -9,6 +17,7 @@ import {
minimizePanel,
resetPanelTitle,
setPanelTitle,
setVisibleContextMenuPanelId,
} from '../../actions';
import {
@ -16,17 +25,24 @@ import {
getEmbeddableEditUrl,
getMaximizedPanelId,
getPanel,
getEmbeddableTitle
getEmbeddableTitle,
getContainerState,
getVisibleContextMenuPanelId,
} from '../../selectors';
import { DashboardContextMenuPanel } from 'ui/dashboard_panel_actions';
const mapStateToProps = ({ dashboard }, { panelId }) => {
const embeddable = getEmbeddable(dashboard, panelId);
const panel = getPanel(dashboard, panelId);
const embeddableTitle = getEmbeddableTitle(dashboard, panelId);
const containerState = getContainerState(dashboard, panelId);
const visibleContextMenuPanelId = getVisibleContextMenuPanelId(dashboard);
return {
panelTitle: panel.title === undefined ? embeddableTitle : panel.title,
editUrl: embeddable ? getEmbeddableEditUrl(dashboard, panelId) : null,
isExpanded: getMaximizedPanelId(dashboard) === panelId,
containerState,
visibleContextMenuPanelId,
};
};
@ -39,23 +55,71 @@ const mapDispatchToProps = (dispatch, { panelId }) => ({
onDeletePanel: () => {
dispatch(deletePanel(panelId));
},
closeContextMenu: () => dispatch(setVisibleContextMenuPanelId()),
openContextMenu: () => dispatch(setVisibleContextMenuPanelId(panelId)),
onMaximizePanel: () => dispatch(maximizePanel(panelId)),
onMinimizePanel: () => dispatch(minimizePanel()),
onResetPanelTitle: () => dispatch(resetPanelTitle(panelId)),
onUpdatePanelTitle: (newTitle) => dispatch(setPanelTitle(newTitle, panelId)),
});
const mergeProps = (stateProps, dispatchProps) => {
const { isExpanded, editUrl, panelTitle } = stateProps;
const { onMaximizePanel, onMinimizePanel, ...dispatchers } = dispatchProps;
const toggleExpandedPanel = () => isExpanded ? onMinimizePanel() : onMaximizePanel();
const mergeProps = (stateProps, dispatchProps, ownProps) => {
const { isExpanded, panelTitle, containerState, visibleContextMenuPanelId } = stateProps;
const isPopoverOpen = visibleContextMenuPanelId === ownProps.panelId;
const {
onMaximizePanel,
onMinimizePanel,
onDeletePanel,
onResetPanelTitle,
onUpdatePanelTitle,
closeContextMenu,
openContextMenu,
} = dispatchProps;
const toggleContextMenu = () => isPopoverOpen ? closeContextMenu() : openContextMenu();
// Outside click handlers will trigger for every closed context menu, we only want to react to clicks external to
// the currently opened menu.
const closeMyContextMenuPanel = () => {
if (isPopoverOpen) {
closeContextMenu();
}
};
const toggleExpandedPanel = () => {
isExpanded ? onMinimizePanel() : onMaximizePanel();
closeMyContextMenuPanel();
};
let panels = [];
// Don't build the panels if the pop over is not open, or this gets expensive - this function is called once for
// every panel, every time any state changes.
if (isPopoverOpen) {
const contextMenuPanel = new DashboardContextMenuPanel({
title: 'Options',
id: 'mainMenu'
});
const actions = [
getEditPanelAction(),
getCustomizePanelAction({
onResetPanelTitle,
onUpdatePanelTitle,
title: panelTitle,
closeContextMenu: closeMyContextMenuPanel
}),
getToggleExpandPanelAction({ isExpanded, toggleExpandedPanel }),
getRemovePanelAction(onDeletePanel),
].concat(panelActionsStore.actions);
panels = buildEuiContextMenuPanels({ contextMenuPanel, actions, embeddable: ownProps.embeddable, containerState });
}
return {
panelTitle,
toggleExpandedPanel,
isExpanded,
editUrl,
...dispatchers,
panels,
toggleContextMenu,
closeContextMenu: closeMyContextMenuPanel,
isPopoverOpen,
};
};
@ -67,4 +131,5 @@ export const PanelOptionsMenuContainer = connect(
PanelOptionsMenuContainer.propTypes = {
panelId: PropTypes.string.isRequired,
embeddable: embeddableShape,
};

View file

@ -7,11 +7,17 @@ import {
updateHidePanelTitles,
updateIsFullScreenMode,
updateTimeRange,
setVisibleContextMenuPanelId,
} from '../actions';
import { DashboardViewMode } from '../dashboard_view_mode';
export const view = handleActions({
[setVisibleContextMenuPanelId]: (state, { payload }) => ({
...state,
visibleContextMenuPanelId: payload
}),
[updateViewMode]: (state, { payload }) => ({
...state,
viewMode: payload

View file

@ -5,6 +5,7 @@ import _ from 'lodash';
* @property {DashboardViewMode} viewMode
* @property {boolean} isFullScreenMode
* @property {string|undefined} maximizedPanelId
* @property {string|undefined} getVisibleContextMenuPanelId
*/
/**
@ -103,6 +104,9 @@ export const getEmbeddableEditUrl = (dashboard, panelId) => {
return embeddable && embeddable.initialized ? embeddable.metadata.editUrl : '';
};
export const getVisibleContextMenuPanelId = dashboard => dashboard.view.visibleContextMenuPanelId;
/**
* @param dashboard {DashboardState}
* @return {boolean}
@ -161,10 +165,12 @@ export const getDescription = dashboard => dashboard.metadata.description;
* This state object is specifically for communicating to embeddables and it's structure is not tied to
* the redux tree structure.
* @typedef {Object} ContainerState
* @property {DashboardViewMode} viewMode - edit or view mode.
* @property {String} timeRange.to - either an absolute time range in utc format or a relative one (e.g. now-15m)
* @property {String} timeRange.from - either an absolute time range in utc format or a relative one (e.g. now-15m)
* @property {Object} embeddableCustomization
* @property {boolean} hidePanelTitles
* @property {boolean} isPanelExpanded
*/
/**
@ -183,6 +189,8 @@ export const getContainerState = (dashboard, panelId) => {
embeddableCustomization: _.cloneDeep(getEmbeddableCustomization(dashboard, panelId) || {}),
hidePanelTitles: getHidePanelTitles(dashboard),
customTitle: getPanel(dashboard, panelId).title,
viewMode: getViewMode(dashboard),
isPanelExpanded: getMaximizedPanelId(dashboard) === panelId,
};
};

View file

@ -0,0 +1,21 @@
class PanelActionsStore {
constructor() {
/**
*
* @type {Array.<DashboardPanelAction>}
*/
this.actions = [];
}
/**
*
* @type {IndexedArray} panelActionsRegistry
*/
initializeFromRegistry(panelActionsRegistry) {
panelActionsRegistry.forEach(panelAction => {
this.actions.push(panelAction);
});
}
}
export const panelActionsStore = new PanelActionsStore();

View file

@ -16,6 +16,7 @@ import 'uiExports/spyModes';
import 'uiExports/fieldFormats';
import 'uiExports/fieldFormatEditors';
import 'uiExports/navbarExtensions';
import 'uiExports/dashboardPanelActions';
import 'uiExports/managementSections';
import 'uiExports/devTools';
import 'uiExports/docViews';

View file

@ -0,0 +1,24 @@
export class DashboardContextMenuPanel {
/**
* @param {string} id
* @param {string} title
* @param {function} getContent
*/
constructor({ id, title, getContent }) {
this.id = id;
this.title = title;
if (getContent) {
this.getContent = getContent;
}
}
/**
* Optional, could be composed of actions instead of content.
* @param {Embeddable} embeddable
* @param {ContainerState} containerState
*/
getContent(/*{ embeddable, containerState }*/) {
return null;
}
}

View file

@ -0,0 +1,67 @@
export class DashboardPanelAction {
/**
*
* @param {string} id
* @param {string} displayName
* @param {function} onClick
* @param {DashboardContextMenuPanel} childContextMenuPanel - optional child panel to open when clicked.
* @param {function} isDisabled - optionally set a custom disabled function
* @param {function} isVisible - optionally set a custom isVisible function
* @param {string} parentPanelId - set if this action belongs on a nested child panel
* @param {Element} icon
*/
constructor(
{
id,
displayName,
onClick,
childContextMenuPanel,
isDisabled,
isVisible,
parentPanelId,
icon,
} = {}) {
this.id = id;
this.icon = icon;
this.displayName = displayName;
this.childContextMenuPanel = childContextMenuPanel;
this.parentPanelId = parentPanelId;
if (onClick) {
this.onClick = onClick;
}
if (isDisabled) {
this.isDisabled = isDisabled;
}
if (isVisible) {
this.isVisible = isVisible;
}
}
/**
* @param {Embeddable} embeddable
* @param ContainerState} containerState
*/
onClick(/*{ embeddable, containerState }*/) {}
/**
* Defaults to always visible.
* @param {Embeddable} embeddable
* @param ContainerState} containerState
* @return {boolean}
*/
isVisible(/*{ embeddable, containerState }*/) {
return true;
}
/**
* Defaults to always enabled.
* @param {Embeddable} embeddable
* @param {ContainerState} containerState
*/
isDisabled(/*{ embeddable, containerState }*/) {
return false;
}
}

View file

@ -0,0 +1,6 @@
import { uiRegistry } from 'ui/registry/_registry';
export const DashboardPanelActionsRegistryProvider = uiRegistry({
name: 'dashboardPanelActions',
index: ['name'],
});

View file

@ -0,0 +1,3 @@
export { DashboardPanelAction } from './dashboard_panel_action';
export { DashboardPanelActionsRegistryProvider } from './dashboard_panel_actions_registry';
export { DashboardContextMenuPanel } from './dashboard_context_menu_panel';

View file

@ -1,3 +1,5 @@
import { PropTypes } from 'prop-types';
/**
* @typedef {Object} EmbeddableMetadata - data that does not change over the course of the embeddables life span.
* @property {string} title
@ -5,6 +7,12 @@
* @property {IndexPattern} indexPattern
*/
export const embeddableShape = PropTypes.shape({
metadata: PropTypes.object.isRequired,
onContainerStateChanged: PropTypes.func.isRequired,
render: PropTypes.func.isRequired,
destroy: PropTypes.func.isRequired,
});
export class Embeddable {
/**

View file

@ -1,3 +1,3 @@
export { EmbeddableFactory } from './embeddable_factory';
export { Embeddable } from './embeddable';
export * from './embeddable';
export { EmbeddableFactoriesRegistryProvider } from './embeddable_factories_registry';

View file

@ -24,6 +24,7 @@ export {
spyModes,
chromeNavControls,
navbarExtensions,
dashboardPanelActions,
managementSections,
devTools,
docViews,

View file

@ -20,6 +20,7 @@ export const visRequestHandlers = appExtension;
export const visEditorTypes = appExtension;
export const savedObjectTypes = appExtension;
export const embeddableFactories = appExtension;
export const dashboardPanelActions = appExtension;
export const fieldFormats = appExtension;
export const fieldFormatEditors = appExtension;
export const spyModes = appExtension;

View file

@ -10,6 +10,7 @@ export default function ({ getService, getPageObjects }) {
const dashboardAddPanel = getService('dashboardAddPanel');
const testSubjects = getService('testSubjects');
const filterBar = getService('filterBar');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize']);
describe('dashboard filtering', async () => {
@ -153,9 +154,10 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.dashboard.waitForRenderComplete();
await dashboardExpect.pieSliceCount(5);
await PageObjects.dashboard.clickEditVisualization();
await dashboardPanelActions.clickEdit();
await queryBar.setQuery('weightLbs:>50');
await queryBar.submitQuery();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
await dashboardExpect.pieSliceCount(3);
@ -168,7 +170,7 @@ export default function ({ getService, getPageObjects }) {
});
it('Nested visualization filter pills filters data as expected', async () => {
await PageObjects.dashboard.clickEditVisualization();
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
await PageObjects.dashboard.filterOnPieSlice('grr');

View file

@ -2,6 +2,7 @@ import expect from 'expect.js';
export default function ({ getService, getPageObjects }) {
const remote = getService('remote');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects(['dashboard']);
describe('dashboard grid', () => {
@ -15,7 +16,7 @@ export default function ({ getService, getPageObjects }) {
// Specific test after https://github.com/elastic/kibana/issues/14764 fix
it('Can move panel from bottom to top row', async () => {
const lastVisTitle = 'Rendering Test: datatable';
const panelTitleBeforeMove = await PageObjects.dashboard.getPanelHeading(lastVisTitle);
const panelTitleBeforeMove = await dashboardPanelActions.getPanelHeading(lastVisTitle);
const position1 = await panelTitleBeforeMove.getPosition();
remote
@ -24,7 +25,7 @@ export default function ({ getService, getPageObjects }) {
.moveMouseTo(null, -20, -450)
.releaseMouseButton();
const panelTitleAfterMove = await PageObjects.dashboard.getPanelHeading(lastVisTitle);
const panelTitleAfterMove = await dashboardPanelActions.getPanelHeading(lastVisTitle);
const position2 = await panelTitleAfterMove.getPosition();
expect(position1.y).to.be.greaterThan(position2.y);

View file

@ -8,6 +8,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) {
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common']);
const screenshot = getService('screenshots');
const remote = getService('remote');
const dashboardPanelActions = getService('dashboardPanelActions');
const testSubjects = getService('testSubjects');
describe('dashboard snapshots', function describeIndexTests() {
@ -38,7 +39,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) {
await testSubjects.click('saveDashboardSuccess toastCloseButton');
await PageObjects.dashboard.clickFullScreenMode();
await PageObjects.dashboard.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
await PageObjects.dashboard.waitForRenderComplete();
const percentSimilar = await screenshot.compareAgainstBaseline('tsvb_dashboard', updateBaselines);
@ -59,7 +60,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) {
await testSubjects.click('saveDashboardSuccess toastCloseButton');
await PageObjects.dashboard.clickFullScreenMode();
await PageObjects.dashboard.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
await PageObjects.dashboard.waitForRenderComplete();
// The need for this should have been removed with https://github.com/elastic/kibana/pull/15574 but the

View file

@ -11,6 +11,7 @@ export default function ({ getService, getPageObjects }) {
const remote = getService('remote');
const queryBar = getService('queryBar');
const retry = getService('retry');
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
describe('dashboard state', function describeIndexTests() {
@ -112,7 +113,7 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.visualize.closeSpyPanel();
await PageObjects.dashboard.clickEdit();
await PageObjects.dashboard.clickEditVisualization();
await dashboardPanelActions.clickEdit();
await PageObjects.visualize.clickMapZoomIn();
await PageObjects.visualize.clickMapZoomIn();

View file

@ -2,6 +2,7 @@ import expect from 'expect.js';
export default function ({ getService, getPageObjects }) {
const retry = getService('retry');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects(['dashboard']);
describe('dashboard data-shared attributes', function describeIndexTests() {
@ -37,7 +38,7 @@ export default function ({ getService, getPageObjects }) {
it('data-shared-item title should update a viz when using a custom panel title', async () => {
await PageObjects.dashboard.clickEdit();
const CUSTOM_VIS_TITLE = 'ima custom title for a vis!';
await PageObjects.dashboard.setCustomPanelTitle(CUSTOM_VIS_TITLE);
await dashboardPanelActions.setCustomPanelTitle(CUSTOM_VIS_TITLE);
await retry.try(async () => {
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
const foundSharedItemTitle = !!sharedData.find(item => {
@ -48,7 +49,7 @@ export default function ({ getService, getPageObjects }) {
});
it('data-shared-item title is cleared with an empty panel title string', async () => {
await PageObjects.dashboard.setCustomPanelTitle('h\b');
await dashboardPanelActions.setCustomPanelTitle('h\b');
await retry.try(async () => {
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
const foundSharedItemTitle = !!sharedData.find(item => {
@ -59,7 +60,7 @@ export default function ({ getService, getPageObjects }) {
});
it('data-shared-item title can be reset', async () => {
await PageObjects.dashboard.resetCustomPanelTitle();
await dashboardPanelActions.resetCustomPanelTitle();
await retry.try(async () => {
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
const foundOriginalSharedItemTitle = !!sharedData.find(item => {
@ -71,7 +72,7 @@ export default function ({ getService, getPageObjects }) {
it('data-shared-item title should update a saved search when using a custom panel title', async () => {
const CUSTOM_SEARCH_TITLE = 'ima custom title for a search!';
await PageObjects.dashboard.setCustomPanelTitle(CUSTOM_SEARCH_TITLE, 'Rendering Test: saved search');
await dashboardPanelActions.setCustomPanelTitle(CUSTOM_SEARCH_TITLE, 'Rendering Test: saved search');
await retry.try(async () => {
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
const foundSharedItemTitle = !!sharedData.find(item => {

View file

@ -3,6 +3,7 @@ import expect from 'expect.js';
export default function ({ getService, getPageObjects }) {
const retry = getService('retry');
const remote = getService('remote');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects(['dashboard', 'common']);
describe('full screen mode', async () => {
@ -40,7 +41,7 @@ export default function ({ getService, getPageObjects }) {
});
it('displays exit full screen logo button when panel is expanded', async () => {
await PageObjects.dashboard.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
const exists = await PageObjects.dashboard.exitFullScreenTextButtonExists();
expect(exists).to.be(true);

View file

@ -6,9 +6,9 @@ import {
} from '../../../../src/core_plugins/kibana/public/visualize/visualize_constants';
export default function ({ getService, getPageObjects }) {
const testSubjects = getService('testSubjects');
const kibanaServer = getService('kibanaServer');
const remote = getService('remote');
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'discover']);
const dashboardName = 'Dashboard Panel Controls Test';
@ -40,18 +40,24 @@ export default function ({ getService, getPageObjects }) {
it('are hidden in view mode', async function () {
await PageObjects.dashboard.saveDashboard(dashboardName);
const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon');
expect(panelToggleMenu).to.equal(false);
await dashboardPanelActions.openContextMenu();
const editLinkExists = await dashboardPanelActions.editPanelActionExists();
const removeExists = await dashboardPanelActions.removePanelActionExists();
expect(editLinkExists).to.equal(false);
expect(removeExists).to.equal(false);
});
it('are shown in edit mode', async function () {
await PageObjects.dashboard.clickEdit();
const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon');
expect(panelToggleMenu).to.equal(true);
await testSubjects.click('dashboardPanelToggleMenuIcon');
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
const isContextMenuIconVisible = await dashboardPanelActions.isContextMenuIconVisible();
expect(isContextMenuIconVisible).to.equal(true);
await dashboardPanelActions.openContextMenu();
const editLinkExists = await dashboardPanelActions.editPanelActionExists();
const removeExists = await dashboardPanelActions.removePanelActionExists();
expect(editLinkExists).to.equal(true);
expect(removeExists).to.equal(true);
@ -65,11 +71,11 @@ export default function ({ getService, getPageObjects }) {
await remote.get(currentUrl.toString(), true);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.showPanelEditControlsDropdownMenu();
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
await dashboardPanelActions.openContextMenu();
const editLinkExists = await dashboardPanelActions.editPanelActionExists();
expect(editLinkExists).to.equal(true);
const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
const removeExists = await dashboardPanelActions.removePanelActionExists();
expect(removeExists).to.equal(true);
// Get rid of the timestamp in the url.
@ -79,32 +85,33 @@ export default function ({ getService, getPageObjects }) {
describe('on an expanded panel', function () {
it('are hidden in view mode', async function () {
await PageObjects.dashboard.saveDashboard(dashboardName);
await PageObjects.dashboard.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon');
expect(panelToggleMenu).to.equal(false);
await dashboardPanelActions.openContextMenu();
const editLinkExists = await dashboardPanelActions.editPanelActionExists();
const removeExists = await dashboardPanelActions.removePanelActionExists();
expect(editLinkExists).to.equal(false);
expect(removeExists).to.equal(false);
});
it('in edit mode hides remove icons ', async function () {
await PageObjects.dashboard.clickEdit();
const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon');
expect(panelToggleMenu).to.equal(true);
await testSubjects.click('dashboardPanelToggleMenuIcon');
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
await dashboardPanelActions.openContextMenu();
const editLinkExists = await dashboardPanelActions.editPanelActionExists();
const removeExists = await dashboardPanelActions.removePanelActionExists();
expect(editLinkExists).to.equal(true);
expect(removeExists).to.equal(false);
await PageObjects.dashboard.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
});
});
describe('visualization object edit menu', () => {
it('opens a visualization when edit link is clicked', async () => {
await testSubjects.click('dashboardPanelToggleMenuIcon');
await PageObjects.dashboard.clickDashboardPanelEditLink();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
const currentUrl = await remote.getCurrentUrl();
expect(currentUrl).to.contain(VisualizeConstants.EDIT_PATH);
@ -113,7 +120,7 @@ export default function ({ getService, getPageObjects }) {
it('deletes the visualization when delete link is clicked', async () => {
await PageObjects.header.clickDashboard();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.clickDashboardPanelRemoveIcon();
await dashboardPanelActions.removePanel();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(0);
@ -134,7 +141,7 @@ export default function ({ getService, getPageObjects }) {
});
it('opens a saved search when edit link is clicked', async () => {
await PageObjects.dashboard.clickDashboardPanelEditLink();
await dashboardPanelActions.clickEdit();
await PageObjects.header.waitUntilLoadingHasFinished();
const queryName = await PageObjects.discover.getCurrentQueryName();
expect(queryName).to.be('my search');
@ -143,7 +150,7 @@ export default function ({ getService, getPageObjects }) {
it('deletes the saved search when delete link is clicked', async () => {
await PageObjects.header.clickDashboard();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.clickDashboardPanelRemoveIcon();
await dashboardPanelActions.removePanel();
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.be(0);
@ -155,8 +162,8 @@ export default function ({ getService, getPageObjects }) {
describe('panel expand control', function () {
it('shown in edit mode', async function () {
await PageObjects.dashboard.gotoDashboardEditMode(dashboardName);
await testSubjects.click('dashboardPanelToggleMenuIcon');
const expandExists = await testSubjects.exists('dashboardPanelExpandIcon');
await dashboardPanelActions.openContextMenu();
const expandExists = await dashboardPanelActions.toggleExpandActionExists();
expect(expandExists).to.equal(true);
});
});

View file

@ -3,6 +3,7 @@ import expect from 'expect.js';
export default function ({ getService, getPageObjects }) {
const retry = getService('retry');
const remote = getService('remote');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects(['dashboard', 'visualize', 'header']);
describe('expanding a panel', () => {
@ -11,7 +12,7 @@ export default function ({ getService, getPageObjects }) {
});
it('hides other panels', async () => {
await PageObjects.dashboard.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
await retry.try(async () => {
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.eql(1);
@ -52,8 +53,9 @@ export default function ({ getService, getPageObjects }) {
it('shows other panels after being minimized', async () => {
const panelCount = await PageObjects.dashboard.getPanelCount();
// Panels are all minimized on a fresh open of a dashboard, so we need to re-expand in order to then minimize.
await PageObjects.dashboard.toggleExpandPanel();
await PageObjects.dashboard.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
await dashboardPanelActions.toggleExpandPanel();
// Add a retry to fix https://github.com/elastic/kibana/issues/14574. Perhaps the recent changes to this
// being a CSS update is causing the UI to change slower than grabbing the panels?

View file

@ -28,6 +28,7 @@ import {
FailureDebuggingProvider,
VisualizeListingTableProvider,
DashboardAddPanelProvider,
DashboardPanelActionsProvider,
} from './services';
export default async function ({ readConfigFile }) {
@ -80,6 +81,7 @@ export default async function ({ readConfigFile }) {
failureDebugging: FailureDebuggingProvider,
visualizeListingTable: VisualizeListingTableProvider,
dashboardAddPanel: DashboardAddPanelProvider,
dashboardPanelActions: DashboardPanelActionsProvider,
},
servers: commonConfig.get('servers'),

View file

@ -53,20 +53,6 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
await PageObjects.settings.clickDefaultIndexButton();
}
async clickEditVisualization() {
log.debug('clickEditVisualization');
// Edit link may sometimes be disabled if the embeddable isn't rendered yet.
await retry.try(async () => {
await this.showPanelEditControlsDropdownMenu();
await testSubjects.click('dashboardPanelEditLink');
const current = await remote.getCurrentUrl();
if (current.indexOf('visualize') < 0) {
throw new Error('not on visualize page');
}
});
}
async clickFullScreenMode() {
log.debug(`clickFullScreenMode`);
await testSubjects.click('dashboardFullScreenMode');
@ -396,10 +382,6 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
return Promise.all(getTitlePromises);
}
async getPanelHeading(title) {
return await testSubjects.find(`dashboardPanelHeading-${title.replace(/\s/g, '')}`);
}
async getPanelDimensions() {
const panels = await find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes
async function getPanelDimensions(panel) {
@ -440,34 +422,10 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
return this.getTestVisualizations().map(visualization => visualization.description);
}
async showPanelEditControlsDropdownMenu() {
log.debug('showPanelEditControlsDropdownMenu');
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
if (editLinkExists) return;
await retry.try(async () => {
await testSubjects.click('dashboardPanelToggleMenuIcon');
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
if (!editLinkExists) {
throw new Error('No edit link exists, toggle menu not open. Try again.');
}
});
}
async getDashboardPanels() {
return await testSubjects.findAll('dashboardPanel');
}
async clickDashboardPanelEditLink() {
await this.showPanelEditControlsDropdownMenu();
await testSubjects.click('dashboardPanelEditLink');
}
async clickDashboardPanelRemoveIcon() {
await this.showPanelEditControlsDropdownMenu();
await testSubjects.click('dashboardPanelRemoveIcon');
}
async addVisualizations(visualizations) {
await dashboardAddPanel.addVisualizations(visualizations);
}
@ -536,63 +494,6 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
}
}
async arePanelMainMenuOptionsOpen(parent) {
log.debug('arePanelMainMenuOptionsOpen');
// Sub menu used arbitrarily - any option on the main menu panel would do.
return parent ?
await testSubjects.descendantExists('dashboardPanelOptionsSubMenuLink', parent) :
await testSubjects.exists('dashboardPanelOptionsSubMenuLink');
}
async openPanelOptions(parent) {
log.debug('openPanelOptions');
const panelOpen = await this.arePanelMainMenuOptionsOpen(parent);
if (!panelOpen) {
await retry.try(async () => {
await (parent ? remote.moveMouseTo(parent) : testSubjects.moveMouseTo('dashboardPanelTitle'));
const toggleMenuItem = parent ?
await testSubjects.findDescendant('dashboardPanelToggleMenuIcon', parent) :
await testSubjects.find('dashboardPanelToggleMenuIcon');
await toggleMenuItem.click();
const panelOpen = await this.arePanelMainMenuOptionsOpen(parent);
if (!panelOpen) { throw new Error('Panel menu still not open'); }
});
}
}
async toggleExpandPanel(parent) {
await (parent ? remote.moveMouseTo(parent) : testSubjects.moveMouseTo('dashboardPanelTitle'));
const expandShown = await testSubjects.exists('dashboardPanelExpandIcon');
if (!expandShown) {
await this.openPanelOptions(parent);
}
await testSubjects.click('dashboardPanelExpandIcon');
}
/**
*
* @param customTitle
* @param originalTitle - optional to specify which panel to change the title on.
* @return {Promise<void>}
*/
async setCustomPanelTitle(customTitle, originalTitle) {
log.debug(`setCustomPanelTitle(${customTitle}, ${originalTitle})`);
let panelOptions = null;
if (originalTitle) {
panelOptions = await this.getPanelHeading(originalTitle);
}
await this.openPanelOptions(panelOptions);
await testSubjects.click('dashboardPanelOptionsSubMenuLink');
await testSubjects.setValue('customDashboardPanelTitleInput', customTitle);
}
async resetCustomPanelTitle(panel) {
log.debug('resetCustomPanelTitle');
await this.openPanelOptions(panel);
await testSubjects.click('dashboardPanelOptionsSubMenuLink');
await testSubjects.click('resetCustomDashboardPanelTitle');
}
async getSharedItemsCount() {
log.debug('in getSharedItemsCount');
const attributeName = 'data-shared-items-count';

View file

@ -1,3 +1,4 @@
export { DashboardVisualizationProvider } from './visualizations';
export { DashboardExpectProvider } from './expectations';
export { DashboardAddPanelProvider } from './add_panel';
export { DashboardPanelActionsProvider } from './panel_actions';

View file

@ -0,0 +1,140 @@
const REMOVE_PANEL_DATA_TEST_SUBJ = 'dashboardPanelAction-deletePanel';
const EDIT_PANEL_DATA_TEST_SUBJ = 'dashboardPanelAction-editPanel';
const TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ = 'dashboardPanelAction-togglePanel';
const CUSTOMIZE_PANEL_DATA_TEST_SUBJ = 'dashboardPanelAction-customizePanel';
const OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ = 'dashboardPanelToggleMenuIcon';
export function DashboardPanelActionsProvider({ getService, getPageObjects }) {
const log = getService('log');
const retry = getService('retry');
const remote = getService('remote');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['header', 'common']);
return new class DashboardPanelActions {
async isContextMenuOpen(parent) {
log.debug('isContextMenuOpen');
// Full screen toggle was chosen because it's available in both view and edit mode.
return this.toggleExpandActionExists(parent);
}
async findContextMenu(parent) {
return parent ?
await testSubjects.findDescendant(OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ, parent) :
await testSubjects.find(OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ);
}
async isContextMenuIconVisible() {
log.debug('isContextMenuIconVisible');
return await testSubjects.exists(OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ);
}
async openContextMenu(parent) {
log.debug('openContextMenu');
const panelOpen = await this.isContextMenuOpen(parent);
if (!panelOpen) {
await retry.try(async () => {
await (parent ? remote.moveMouseTo(parent) : testSubjects.moveMouseTo('dashboardPanelTitle'));
const toggleMenuItem = await this.findContextMenu(parent);
await toggleMenuItem.click();
const panelOpen = await this.isContextMenuOpen(parent);
if (!panelOpen) { throw new Error('Context menu still not open'); }
});
}
}
async toggleExpandPanel(parent) {
log.debug('toggleExpandPanel');
await (parent ? remote.moveMouseTo(parent) : testSubjects.moveMouseTo('dashboardPanelTitle'));
const expandShown = await this.toggleExpandActionExists();
if (!expandShown) {
await this.openContextMenu(parent);
}
await this.toggleExpandPanel();
}
async clickEdit() {
log.debug('clickEdit');
await this.openContextMenu();
// Edit link may sometimes be disabled if the embeddable isn't rendered yet.
await retry.try(async () => {
const editExists = await this.editPanelActionExists();
if (editExists) {
await testSubjects.click(EDIT_PANEL_DATA_TEST_SUBJ);
}
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.common.waitForTopNavToBeVisible();
const current = await remote.getCurrentUrl();
if (current.indexOf('dashboard') >= 0) {
throw new Error('Still on dashboard');
}
});
}
async toggleExpandPanel() {
log.debug('toggleExpandPanel');
await this.openContextMenu();
await testSubjects.click(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
}
async removePanel() {
log.debug('removePanel');
await this.openContextMenu();
await testSubjects.click(REMOVE_PANEL_DATA_TEST_SUBJ);
}
async customizePanel(parent) {
await this.openContextMenu(parent);
await testSubjects.click(CUSTOMIZE_PANEL_DATA_TEST_SUBJ);
}
async removePanelActionExists() {
log.debug('removePanelActionExists');
return await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ);
}
async editPanelActionExists() {
log.debug('editPanelActionExists');
return await testSubjects.exists(EDIT_PANEL_DATA_TEST_SUBJ);
}
async toggleExpandActionExists() {
log.debug('toggleExpandActionExists');
return await testSubjects.exists(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
}
async customizePanelActionExists(parent) {
return parent ?
await testSubjects.descendantExists(CUSTOMIZE_PANEL_DATA_TEST_SUBJ, parent) :
await testSubjects.exists(CUSTOMIZE_PANEL_DATA_TEST_SUBJ);
}
async getPanelHeading(title) {
return await testSubjects.find(`dashboardPanelHeading-${title.replace(/\s/g, '')}`);
}
/**
*
* @param customTitle
* @param originalTitle - optional to specify which panel to change the title on.
* @return {Promise<void>}
*/
async setCustomPanelTitle(customTitle, originalTitle) {
log.debug(`setCustomPanelTitle(${customTitle}, ${originalTitle})`);
let panelOptions = null;
if (originalTitle) {
panelOptions = await this.getPanelHeading(originalTitle);
}
await this.customizePanel(panelOptions);
await testSubjects.setValue('customDashboardPanelTitleInput', customTitle);
}
async resetCustomPanelTitle(panel) {
log.debug('resetCustomPanelTitle');
await this.customizePanel(panel);
await testSubjects.click('resetCustomDashboardPanelTitle');
}
};
}

View file

@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }) {
const log = getService('log');
const find = getService('find');
const testSubjects = getService('testSubjects');
const dashboardPanelActions = getService('dashboardPanelActions');
const PageObjects = getPageObjects([
'security',
'common',
@ -163,17 +164,12 @@ export default function ({ getService, getPageObjects }) {
});
it('does not show the visualization edit icon', async () => {
const editIconExists = await testSubjects.exists('dashboardPanelEditLink');
expect(editIconExists).to.be(false);
});
it('does not show the visualization move icon', async () => {
const moveIconExists = await testSubjects.exists('dashboardPanelMoveIcon');
expect(moveIconExists).to.be(false);
const editLinkExists = await dashboardPanelActions.editPanelActionExists();
expect(editLinkExists).to.be(false);
});
it('does not show the visualization delete icon', async () => {
const deleteIconExists = await testSubjects.exists('dashboardPanelRemoveIcon');
const deleteIconExists = await dashboardPanelActions.removePanelActionExists();
expect(deleteIconExists).to.be(false);
});