[8.x] [Dashboard] Hover actions for panels (#182535) (#197770)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Dashboard] Hover actions for panels
(#182535)](https://github.com/elastic/kibana/pull/182535)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Catherine
Liu","email":"catherine.liu@elastic.co"},"sourceCommit":{"committedDate":"2024-10-25T07:59:41Z","message":"[Dashboard]
Hover actions for panels
(#182535)","sha":"2fdfb8d769442a7591e982a0dcff40fb8eb1699a","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","Feature:Dashboard","Feature:Embedding","Team:Presentation","loe:large","impact:medium","Feature:Drilldowns","v9.0.0","Project:Dashboard
Usability","ci:project-deploy-observability","Team:obs-ux-management","backport:version","v8.17.0"],"title":"[Dashboard]
Hover actions for
panels","number":182535,"url":"https://github.com/elastic/kibana/pull/182535","mergeCommit":{"message":"[Dashboard]
Hover actions for panels
(#182535)","sha":"2fdfb8d769442a7591e982a0dcff40fb8eb1699a"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/182535","number":182535,"mergeCommit":{"message":"[Dashboard]
Hover actions for panels
(#182535)","sha":"2fdfb8d769442a7591e982a0dcff40fb8eb1699a"}},{"branch":"8.x","label":"v8.17.0","branchLabelMappingKey":"^v8.17.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Catherine Liu <catherine.liu@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-25 20:42:51 +11:00 committed by GitHub
parent b2d554e253
commit d05d6598d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 1354 additions and 742 deletions

View file

@ -427,16 +427,16 @@ export class WebElementWrapper {
/**
* Moves the remote environments mouse cursor to the current element with optional offset
* https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/lib/input_exports_Actions.html#move
* @param { xOffset: 0, yOffset: 0 } options
* @param { xOffset: 0, yOffset: 0, topOffset: number } options Optional
* @return {Promise<void>}
*/
public async moveMouseTo(options = { xOffset: 0, yOffset: 0 }) {
public async moveMouseTo({ xOffset = 0, yOffset = 0, topOffset = 0 } = {}) {
await this.retryCall(async function moveMouseTo(wrapper) {
await wrapper.scrollIntoViewIfNecessary();
await wrapper.scrollIntoViewIfNecessary(topOffset);
await wrapper.getActions().move({ x: 0, y: 0 }).perform();
await wrapper
.getActions()
.move({ x: options.xOffset, y: options.yOffset, origin: wrapper._webElement })
.move({ x: xOffset, y: yOffset, origin: wrapper._webElement })
.perform();
});
}

View file

@ -23,7 +23,7 @@ pageLoadAssetSize:
core: 564663
crossClusterReplication: 65408
customIntegrations: 22034
dashboard: 52967
dashboard: 68015
dashboardEnhanced: 65646
data: 454087
dataQuality: 19384

View file

@ -30,6 +30,10 @@ export {
useInheritedViewMode,
type CanAccessViewMode,
} from './interfaces/can_access_view_mode';
export {
apiCanLockHoverActions,
type CanLockHoverActions,
} from './interfaces/can_lock_hover_actions';
export { fetch$, useFetchContext, type FetchContext } from './interfaces/fetch/fetch';
export {
initializeTimeRange,

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { PublishingSubject } from '../publishing_subject';
/**
* This API can lock hover actions
*/
export interface CanLockHoverActions {
hasLockedHoverActions$: PublishingSubject<boolean>;
lockHoverActions: (lock: boolean) => void;
}
export const apiCanLockHoverActions = (api: unknown): api is CanLockHoverActions => {
return Boolean(
api &&
(api as CanLockHoverActions).hasLockedHoverActions$ &&
(api as CanLockHoverActions).lockHoverActions &&
typeof (api as CanLockHoverActions).lockHoverActions === 'function'
);
};

View file

@ -39,6 +39,7 @@ import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { coreServices } from '../services/kibana_services';
import { dashboardAddToLibraryActionStrings } from './_dashboard_actions_strings';
import { DASHBOARD_ACTION_GROUP } from '.';
export const ACTION_ADD_TO_LIBRARY = 'saveToLibrary';
@ -63,6 +64,7 @@ export class AddToLibraryAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_ADD_TO_LIBRARY;
public readonly id = ACTION_ADD_TO_LIBRARY;
public order = 8;
public grouping = [DASHBOARD_ACTION_GROUP];
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();

View file

@ -20,6 +20,7 @@ import {
HasUniqueId,
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { DASHBOARD_ACTION_GROUP } from '.';
import { dashboardClonePanelActionStrings } from './_dashboard_actions_strings';
export const ACTION_CLONE_PANEL = 'clonePanel';
@ -41,6 +42,7 @@ export class ClonePanelAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_CLONE_PANEL;
public readonly id = ACTION_CLONE_PANEL;
public order = 45;
public grouping = [DASHBOARD_ACTION_GROUP];
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();

View file

@ -28,6 +28,7 @@ import { DASHBOARD_CONTAINER_TYPE } from '../dashboard_container';
import { coreServices } from '../services/kibana_services';
import { getDashboardCapabilities } from '../utils/get_dashboard_capabilities';
import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_strings';
import { DASHBOARD_ACTION_GROUP } from '.';
import { CopyToDashboardModal } from './copy_to_dashboard_modal';
export const ACTION_COPY_TO_DASHBOARD = 'copyToDashboard';
@ -59,6 +60,7 @@ export class CopyToDashboardAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_COPY_TO_DASHBOARD;
public readonly id = ACTION_COPY_TO_DASHBOARD;
public order = 1;
public grouping = [DASHBOARD_ACTION_GROUP];
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!apiIsCompatible(embeddable)) throw new IncompatibleActionError();

View file

@ -13,15 +13,17 @@ import { ExpandPanelActionApi, ExpandPanelAction } from './expand_panel_action';
describe('Expand panel action', () => {
let action: ExpandPanelAction;
let context: { embeddable: ExpandPanelActionApi };
let expandPanelIdSubject: BehaviorSubject<string | undefined>;
beforeEach(() => {
expandPanelIdSubject = new BehaviorSubject<string | undefined>(undefined);
action = new ExpandPanelAction();
context = {
embeddable: {
uuid: 'superId',
parentApi: {
expandPanel: jest.fn(),
expandedPanelId: new BehaviorSubject<string | undefined>(undefined),
expandedPanelId: expandPanelIdSubject,
},
},
};
@ -38,19 +40,22 @@ describe('Expand panel action', () => {
expect(await action.isCompatible(emptyContext)).toBe(false);
});
it('calls onChange when expandedPanelId changes', async () => {
const onChange = jest.fn();
action.subscribeToCompatibilityChanges(context, onChange);
expandPanelIdSubject.next('superPanelId');
expect(onChange).toHaveBeenCalledWith(true, action);
});
it('returns the correct icon based on expanded panel id', async () => {
expect(await action.getIconType(context)).toBe('expand');
context.embeddable.parentApi.expandedPanelId = new BehaviorSubject<string | undefined>(
'superPanelId'
);
expandPanelIdSubject.next('superPanelId');
expect(await action.getIconType(context)).toBe('minimize');
});
it('returns the correct display name based on expanded panel id', async () => {
expect(await action.getDisplayName(context)).toBe('Maximize');
context.embeddable.parentApi.expandedPanelId = new BehaviorSubject<string | undefined>(
'superPanelId'
);
expandPanelIdSubject.next('superPanelId');
expect(await action.getDisplayName(context)).toBe('Minimize');
});

View file

@ -16,6 +16,8 @@ import {
HasUniqueId,
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { skip } from 'rxjs';
import { DASHBOARD_ACTION_GROUP } from '.';
import { dashboardExpandPanelActionStrings } from './_dashboard_actions_strings';
@ -29,7 +31,8 @@ const isApiCompatible = (api: unknown | null): api is ExpandPanelActionApi =>
export class ExpandPanelAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_EXPAND_PANEL;
public readonly id = ACTION_EXPAND_PANEL;
public order = 7;
public order = 9;
public grouping = [DASHBOARD_ACTION_GROUP];
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
@ -47,6 +50,20 @@ export class ExpandPanelAction implements Action<EmbeddableApiContext> {
return isApiCompatible(embeddable);
}
public couldBecomeCompatible({ embeddable }: EmbeddableApiContext) {
return apiHasParentApi(embeddable) && apiCanExpandPanels(embeddable.parentApi);
}
public subscribeToCompatibilityChanges(
{ embeddable }: EmbeddableApiContext,
onChange: (isCompatible: boolean, action: ExpandPanelAction) => void
) {
if (!isApiCompatible(embeddable)) return;
return embeddable.parentApi.expandedPanelId.pipe(skip(1)).subscribe(() => {
onChange(isApiCompatible(embeddable), this);
});
}
public async execute({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
embeddable.parentApi.expandPanel(embeddable.uuid);

View file

@ -41,7 +41,7 @@ const isApiCompatible = (api: unknown | null): api is ExportCsvActionApi =>
export class ExportCSVAction implements Action<ExportContext> {
public readonly id = ACTION_EXPORT_CSV;
public readonly type = ACTION_EXPORT_CSV;
public readonly order = 18; // right after Export in discover which is 19
public readonly order = 18;
public getIconType() {
return 'exportAction';

View file

@ -9,7 +9,6 @@
import { Filter, FilterStateStore, type AggregateQuery, type Query } from '@kbn/es-query';
import { ViewMode } from '@kbn/presentation-publishing';
import { BehaviorSubject } from 'rxjs';
import {
FiltersNotificationAction,
@ -42,7 +41,6 @@ describe('filters notification action', () => {
let updateFilters: (filters: Filter[]) => void;
let updateQuery: (query: Query | AggregateQuery | undefined) => void;
let updateViewMode: (viewMode: ViewMode) => void;
beforeEach(() => {
const filtersSubject = new BehaviorSubject<Filter[] | undefined>(undefined);
@ -50,14 +48,10 @@ describe('filters notification action', () => {
const querySubject = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
updateQuery = (query) => querySubject.next(query);
const viewModeSubject = new BehaviorSubject<ViewMode>('edit');
updateViewMode = (viewMode) => viewModeSubject.next(viewMode);
action = new FiltersNotificationAction();
context = {
embeddable: {
uuid: 'testId',
viewMode: viewModeSubject,
filters$: filtersSubject,
query$: querySubject,
},
@ -83,22 +77,6 @@ describe('filters notification action', () => {
expect(await action.isCompatible(context)).toBe(true);
});
it('is incompatible when api is in view mode', async () => {
updateFilters([getMockPhraseFilter('SuperField', 'SuperValue')]);
updateQuery({ esql: 'FROM test_dataview' } as AggregateQuery);
updateViewMode('view');
expect(await action.isCompatible(context)).toBe(false);
});
it('calls onChange when view mode changes', () => {
const onChange = jest.fn();
updateFilters([getMockPhraseFilter('SuperField', 'SuperValue')]);
updateQuery({ esql: 'FROM test_dataview' } as AggregateQuery);
action.subscribeToCompatibilityChanges(context, onChange);
updateViewMode('view');
expect(onChange).toHaveBeenCalledWith(false, action);
});
it('calls onChange when filters change', async () => {
const onChange = jest.fn();
action.subscribeToCompatibilityChanges(context, onChange);

View file

@ -13,17 +13,15 @@ import { merge } from 'rxjs';
import { isOfAggregateQueryType, isOfQueryType } from '@kbn/es-query';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import {
CanAccessViewMode,
apiPublishesPartialUnifiedSearch,
apiHasUniqueId,
EmbeddableApiContext,
HasParentApi,
HasUniqueId,
PublishesDataViews,
PublishesUnifiedSearch,
apiCanAccessViewMode,
apiHasUniqueId,
apiPublishesPartialUnifiedSearch,
getInheritedViewMode,
getViewModeSubject,
CanLockHoverActions,
CanAccessViewMode,
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
@ -34,17 +32,16 @@ import { FiltersNotificationPopover } from './filters_notification_popover';
export const BADGE_FILTERS_NOTIFICATION = 'ACTION_FILTERS_NOTIFICATION';
export type FiltersNotificationActionApi = HasUniqueId &
CanAccessViewMode &
Partial<PublishesUnifiedSearch> &
Partial<HasParentApi<Partial<PublishesDataViews>>>;
Partial<HasParentApi<Partial<PublishesDataViews>>> &
Partial<CanLockHoverActions> &
Partial<CanAccessViewMode>;
const isApiCompatible = (api: unknown | null): api is FiltersNotificationActionApi =>
Boolean(
apiHasUniqueId(api) && apiCanAccessViewMode(api) && apiPublishesPartialUnifiedSearch(api)
);
Boolean(apiHasUniqueId(api) && apiPublishesPartialUnifiedSearch(api));
const compatibilityCheck = (api: EmbeddableApiContext['embeddable']) => {
if (!isApiCompatible(api) || getInheritedViewMode(api) !== 'edit') return false;
if (!isApiCompatible(api)) return false;
const query = api.query$?.value;
return (
(api.filters$?.value ?? []).length > 0 ||
@ -97,9 +94,7 @@ export class FiltersNotificationAction implements Action<EmbeddableApiContext> {
) {
if (!isApiCompatible(embeddable)) return;
return merge(
...[embeddable.query$, embeddable.filters$, getViewModeSubject(embeddable)].filter((value) =>
Boolean(value)
)
...[embeddable.query$, embeddable.filters$].filter((value) => Boolean(value))
).subscribe(() => onChange(compatibilityCheck(embeddable), this));
}

View file

@ -10,13 +10,13 @@
import { AggregateQuery, Filter, FilterStateStore, Query } from '@kbn/es-query';
import { I18nProvider } from '@kbn/i18n-react';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { ViewMode } from '@kbn/presentation-publishing';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { FiltersNotificationActionApi } from './filters_notification_action';
import { FiltersNotificationPopover } from './filters_notification_popover';
import { ViewMode } from '@kbn/presentation-publishing';
const getMockPhraseFilter = (key: string, value: string): Filter => {
return {
@ -50,18 +50,23 @@ describe('filters notification popover', () => {
let api: FiltersNotificationActionApi;
let updateFilters: (filters: Filter[]) => void;
let updateQuery: (query: Query | AggregateQuery | undefined) => void;
let updateViewMode: (viewMode: ViewMode) => void;
beforeEach(async () => {
const filtersSubject = new BehaviorSubject<Filter[] | undefined>(undefined);
updateFilters = (filters) => filtersSubject.next(filters);
const querySubject = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
updateQuery = (query) => querySubject.next(query);
const viewModeSubject = new BehaviorSubject<ViewMode>('view');
updateViewMode = (viewMode) => viewModeSubject.next(viewMode);
api = {
uuid: 'testId',
viewMode: new BehaviorSubject<ViewMode>('edit'),
filters$: filtersSubject,
query$: querySubject,
parentApi: {
viewMode: viewModeSubject,
},
};
});
@ -87,7 +92,15 @@ describe('filters notification popover', () => {
expect(await screen.findByTestId('filtersNotificationModal__query')).toBeInTheDocument();
});
it('does not render an edit button when not in edit mode', async () => {
await renderAndOpenPopover();
expect(
await screen.queryByTestId('filtersNotificationModal__editButton')
).not.toBeInTheDocument();
});
it('renders an edit button when the edit panel action is compatible', async () => {
updateViewMode('edit');
updateFilters([getMockPhraseFilter('ay', 'oh')]);
await renderAndOpenPopover();
expect(await screen.findByTestId('filtersNotificationModal__editButton')).toBeInTheDocument();
@ -104,6 +117,7 @@ describe('filters notification popover', () => {
});
it('calls edit action execute when edit button is clicked', async () => {
updateViewMode('edit');
updateFilters([getMockPhraseFilter('ay', 'oh')]);
await renderAndOpenPopover();
const editButton = await screen.findByTestId('filtersNotificationModal__editButton');

View file

@ -26,8 +26,11 @@ import { css } from '@emotion/react';
import { AggregateQuery, getAggregateQueryMode, isOfQueryType } from '@kbn/es-query';
import { getEditPanelAction } from '@kbn/presentation-panel-plugin/public';
import { FilterItems } from '@kbn/unified-search-plugin/public';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { BehaviorSubject } from 'rxjs';
import {
apiCanLockHoverActions,
getViewModeSubject,
useBatchedOptionalPublishingSubjects,
} from '@kbn/presentation-publishing';
import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings';
import { FiltersNotificationActionApi } from './filters_notification_action';
@ -59,8 +62,10 @@ export function FiltersNotificationPopover({ api }: { api: FiltersNotificationAc
}
}, [api, setDisableEditButton]);
const dataViews = useStateFromPublishingSubject(
api.parentApi?.dataViews ? api.parentApi.dataViews : new BehaviorSubject(undefined)
const [hasLockedHoverActions, dataViews, parentViewMode] = useBatchedOptionalPublishingSubjects(
api.hasLockedHoverActions$,
api.parentApi?.dataViews,
getViewModeSubject(api ?? undefined)
);
return (
@ -69,13 +74,23 @@ export function FiltersNotificationPopover({ api }: { api: FiltersNotificationAc
<EuiButtonIcon
color="text"
iconType={'filter'}
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
if (apiCanLockHoverActions(api)) {
api?.lockHoverActions(!hasLockedHoverActions);
}
}}
data-test-subj={`embeddablePanelNotification-${api.uuid}`}
aria-label={displayName}
/>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
closePopover={() => {
setIsPopoverOpen(false);
if (apiCanLockHoverActions(api)) {
api.lockHoverActions(false);
}
}}
anchorPosition="upCenter"
>
<EuiPopoverTitle>{displayName}</EuiPopoverTitle>
@ -112,8 +127,8 @@ export function FiltersNotificationPopover({ api }: { api: FiltersNotificationAc
</EuiFormRow>
)}
</EuiForm>
<EuiPopoverFooter>
{!disableEditbutton && (
{!disableEditbutton && parentViewMode === 'edit' && (
<EuiPopoverFooter>
<EuiFlexGroup
gutterSize="s"
alignItems="center"
@ -132,8 +147,8 @@ export function FiltersNotificationPopover({ api }: { api: FiltersNotificationAc
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiPopoverFooter>
</EuiPopoverFooter>
)}
</EuiPopover>
);
}

View file

@ -24,6 +24,8 @@ interface BuildAllDashboardActionsProps {
plugins: DashboardStartDependencies;
}
export const DASHBOARD_ACTION_GROUP = { id: 'dashboard_actions', order: 10 } as const;
export const buildAllDashboardActions = async ({
plugins,
allowByValueEmbeddables,

View file

@ -18,6 +18,7 @@ import {
HasLegacyLibraryTransforms,
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { DASHBOARD_ACTION_GROUP } from '.';
import { dashboardAddToLibraryActionStrings } from './_dashboard_actions_strings';
import { coreServices } from '../services/kibana_services';
@ -35,6 +36,7 @@ export class LegacyAddToLibraryAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_LEGACY_ADD_TO_LIBRARY;
public readonly id = ACTION_LEGACY_ADD_TO_LIBRARY;
public order = 15;
public grouping = [DASHBOARD_ACTION_GROUP];
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();

View file

@ -20,6 +20,7 @@ import {
HasLegacyLibraryTransforms,
} from '@kbn/presentation-publishing';
import { dashboardUnlinkFromLibraryActionStrings } from './_dashboard_actions_strings';
import { DASHBOARD_ACTION_GROUP } from '.';
import { coreServices } from '../services/kibana_services';
export const ACTION_LEGACY_UNLINK_FROM_LIBRARY = 'legacyUnlinkFromLibrary';
@ -37,6 +38,7 @@ export class LegacyUnlinkFromLibraryAction implements Action<EmbeddableApiContex
public readonly type = ACTION_LEGACY_UNLINK_FROM_LIBRARY;
public readonly id = ACTION_LEGACY_UNLINK_FROM_LIBRARY;
public order = 15;
public grouping = [DASHBOARD_ACTION_GROUP];
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!legacyUnlinkActionIsCompatible(embeddable)) throw new IncompatibleActionError();

View file

@ -30,6 +30,7 @@ import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { coreServices } from '../services/kibana_services';
import { dashboardUnlinkFromLibraryActionStrings } from './_dashboard_actions_strings';
import { DASHBOARD_ACTION_GROUP } from '.';
export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
@ -54,6 +55,7 @@ export class UnlinkFromLibraryAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_UNLINK_FROM_LIBRARY;
public readonly id = ACTION_UNLINK_FROM_LIBRARY;
public order = 15;
public grouping = [DASHBOARD_ACTION_GROUP];
public getDisplayName({ embeddable }: EmbeddableApiContext) {
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();

View file

@ -73,7 +73,7 @@ export function initializeTrackPanel(untilEmbeddableLoaded: (id: string) => Prom
};
return;
}
panelRef.scrollIntoView({ block: 'nearest' });
panelRef.scrollIntoView({ block: 'start' });
});
},
scrollToTop: () => {

View file

@ -19,7 +19,7 @@
.dshLayout--editing {
.react-resizable-handle {
@include size($euiSizeL);
z-index: $euiZLevel1; /* 1 */
z-index: $euiZLevel2; /* 1 */
right: 0;
bottom: 0;
padding-right: $euiSizeS;
@ -33,6 +33,10 @@
*/
.dshLayout-isMaximizedPanel {
height: 100% !important; /* 1. */
.embPanel__hoverActionsLeft {
visibility: hidden;
}
}
/**
@ -40,8 +44,7 @@
* Shifting the rendered panels offscreen prevents a quick flash when redrawing the panels on minimize
*/
.dshDashboardGrid__item--hidden {
top: -9999px;
left: -9999px;
transform: translate(-9999px, -9999px);
}
/**
@ -98,13 +101,26 @@
*/
&.resizing,
&.react-draggable-dragging {
z-index: $euiZLevel2 !important;
z-index: $euiZLevel3 !important;
}
&.react-draggable-dragging {
transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance;
@include euiBottomShadowLarge;
border-radius: $euiBorderRadius; // keeps shadow within bounds
.embPanel__hoverActionsWrapper {
z-index: $euiZLevel9;
top: -$euiSizeXL;
.embPanel__hoverActions:has(.embPanel--dragHandle) {
opacity: 1;
}
.embPanel__hoverActions:not(:has(.embPanel--dragHandle)) {
opacity: 0;
}
}
}
/**

View file

@ -4,24 +4,42 @@
* .embPanel--editing doesn't get updating without a hard refresh
*/
.dshDashboardGrid__item {
scroll-margin-top: calc((var(--euiFixedHeadersOffset, 100) * 2) + $euiSizeS);
scroll-margin-bottom: $euiSizeS;
.dshLayout--editing {
// change the style of the hover actions border to a dashed line in edit mode
.embPanel__hoverActionsAnchor {
.embPanel__hoverActionsWrapper {
.embPanel__hoverActions {
border-color: $euiColorMediumShade;
border-style: dashed;
}
}
}
}
// LAYOUT MODES
// Adjust borders/etc... for non-spaced out and expanded panels
.dshLayout-withoutMargins {
.embPanel {
.embPanel,
.embPanel__hoverActionsAnchor {
box-shadow: none;
outline: none;
border-radius: 0;
}
.embPanel__content {
border-radius: 0;
&.dshLayout--editing {
.embPanel__hoverActionsAnchor:hover {
outline: 1px dashed $euiColorMediumShade;
}
}
.dshDashboardGrid__item--highlighted {
.embPanel__hoverActionsAnchor:hover {
outline: $euiBorderThin;
z-index: $euiZLevel2;
}
.embPanel__content,
.dshDashboardGrid__item--highlighted,
.lnsExpressionRenderer {
border-radius: 0;
}
}
@ -35,6 +53,20 @@
background-color: unset;
cursor: default;
}
.embPanel__hoverActions {
.embPanel--dragHandle {
visibility: hidden;
}
}
}
// Hide hover actions when dashboard has an overlay
.dshDashboardGrid__item--blurred,
.dshDashboardGrid__item--focused {
.embPanel__hoverActions {
visibility: hidden;
}
}
@keyframes highlightOutline {
@ -52,10 +84,11 @@
}
.dshDashboardGrid__item--highlighted {
border-radius: $euiSizeXS;
animation-name: highlightOutline;
animation-duration: 4s;
animation-timing-function: ease-out;
// keeps outline from getting cut off by other panels without margins
z-index: 999 !important;
.embPanel {
border-radius: $euiSizeXS;
animation-name: highlightOutline;
animation-duration: 4s;
animation-timing-function: ease-out;
z-index: $euiZLevel2;
}
}

View file

@ -133,6 +133,7 @@ export const DashboardGrid = ({ viewportWidth }: { viewportWidth: number }) => {
rowHeight={DASHBOARD_GRID_HEIGHT}
margin={useMargins ? [DASHBOARD_MARGIN_SIZE, DASHBOARD_MARGIN_SIZE] : [0, 0]}
draggableHandle={'.embPanel--dragHandle'}
useCSSTransforms={false}
>
{panelComponents}
</ResponsiveReactGridLayout>

View file

@ -15,6 +15,7 @@ import { css } from '@emotion/react';
import { EmbeddablePanel, ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { DASHBOARD_MARGIN_SIZE } from '../../../dashboard_constants';
import { DashboardPanelState } from '../../../../common';
import { useDashboardApi } from '../../../dashboard_api/use_dashboard_api';
import { embeddableService, presentationUtilService } from '../../../services/kibana_services';
@ -91,12 +92,21 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
}
}, [id, dashboardApi, scrollToPanelId, highlightPanelId, ref, blurPanel]);
const dashboardContainerTopOffset =
(document.querySelector('.dashboardContainer') as HTMLDivElement)?.offsetTop || 0;
const globalNavTopOffset =
(document.querySelector('#app-fixed-viewport') as HTMLDivElement)?.offsetTop || 0;
const focusStyles = blurPanel
? css`
pointer-events: none;
opacity: 0.25;
`
: undefined;
: css`
scroll-margin-top: ${dashboardContainerTopOffset +
globalNavTopOffset +
DASHBOARD_MARGIN_SIZE}px;
`;
const renderedEmbeddable = useMemo(() => {
const panelProps = {

View file

@ -23,3 +23,9 @@
.dashboardViewport--screenshotMode .controlsWrapper--empty {
display:none
}
.dshDashboardViewportWrapper--isFullscreen {
.dshDashboardGrid__item--expanded {
padding: $euiSizeS;
}
}

View file

@ -57,6 +57,7 @@ export const DashboardViewportComponent = () => {
viewMode,
useMargins,
uuid,
fullScreenMode,
] = useBatchedPublishingSubjects(
dashboardApi.controlGroupApi$,
dashboardApi.panelTitle,
@ -66,7 +67,8 @@ export const DashboardViewportComponent = () => {
dashboardApi.panels$,
dashboardApi.viewMode,
dashboardApi.useMargins$,
dashboardApi.uuid$
dashboardApi.uuid$,
dashboardApi.fullScreenMode$
);
const panelCount = useMemo(() => {
@ -114,6 +116,7 @@ export const DashboardViewportComponent = () => {
<div
className={classNames('dshDashboardViewportWrapper', {
'dshDashboardViewportWrapper--defaultBg': !useMargins,
'dshDashboardViewportWrapper--isFullscreen': fullScreenMode,
})}
>
{viewMode !== ViewMode.PRINT ? (

View file

@ -7,7 +7,7 @@
.dashboardTopNav {
width: 100%;
position: sticky;
z-index: $euiZLevel2;
z-index: $euiZLevel3;
top: var(--euiFixedHeadersOffset, 0);
background: $euiPageBackgroundColor;
}

View file

@ -20,6 +20,7 @@ export const ACTION_VIEW_SAVED_SEARCH = 'ACTION_VIEW_SAVED_SEARCH';
export class ViewSavedSearchAction implements Action<EmbeddableApiContext> {
public id = ACTION_VIEW_SAVED_SEARCH;
public readonly type = ACTION_VIEW_SAVED_SEARCH;
public readonly order = 20; // Same order as ACTION_OPEN_IN_DISCOVER
constructor(
private readonly application: ApplicationStart,
@ -43,7 +44,7 @@ export class ViewSavedSearchAction implements Action<EmbeddableApiContext> {
}
getIconType(): string | undefined {
return 'inspect';
return 'discoverApp';
}
async isCompatible({ embeddable }: EmbeddableApiContext) {

View file

@ -245,6 +245,8 @@ export const legacyEmbeddableToApi = (
return !isInputControl && !isMarkdown && !isImage && !isLinks;
};
const hasLockedHoverActions$ = new BehaviorSubject<boolean>(false);
return {
api: {
parentApi: parentApi as LegacyEmbeddableAPI['parentApi'],
@ -270,6 +272,9 @@ export const legacyEmbeddableToApi = (
disabledActionIds,
setDisabledActionIds: (ids) => disabledActionIds.next(ids),
hasLockedHoverActions$,
lockHoverActions: (lock: boolean) => hasLockedHoverActions$.next(lock),
panelTitle,
setPanelTitle,
defaultPanelTitle,

View file

@ -148,6 +148,8 @@ export abstract class Embeddable<
canUnlinkFromLibrary: this.canUnlinkFromLibrary,
isCompatibleWithUnifiedSearch: this.isCompatibleWithUnifiedSearch,
savedObjectId: this.savedObjectId,
hasLockedHoverActions$: this.hasLockedHoverActions$,
lockHoverActions: this.lockHoverActions,
} = api);
setTimeout(() => {
@ -191,6 +193,8 @@ export abstract class Embeddable<
public canUnlinkFromLibrary: LegacyEmbeddableAPI['canUnlinkFromLibrary'];
public isCompatibleWithUnifiedSearch: LegacyEmbeddableAPI['isCompatibleWithUnifiedSearch'];
public savedObjectId: LegacyEmbeddableAPI['savedObjectId'];
public hasLockedHoverActions$: LegacyEmbeddableAPI['hasLockedHoverActions$'];
public lockHoverActions: LegacyEmbeddableAPI['lockHoverActions'];
public async getEditHref(): Promise<string | undefined> {
return this.getOutput().editUrl ?? undefined;

View file

@ -27,6 +27,7 @@ import {
PublishesSavedObjectId,
HasLegacyLibraryTransforms,
EmbeddableAppContext,
CanLockHoverActions,
} from '@kbn/presentation-publishing';
import { Observable } from 'rxjs';
import { EmbeddableInput } from '../../../common/types';
@ -58,7 +59,8 @@ export type LegacyEmbeddableAPI = HasType &
Partial<HasLegacyLibraryTransforms> &
HasParentApi<DefaultPresentationPanelApi['parentApi']> &
EmbeddableHasTimeRange &
PublishesSavedObjectId;
PublishesSavedObjectId &
CanLockHoverActions;
export interface EmbeddableOutput {
// Whether the embeddable is actively loading.

View file

@ -194,6 +194,8 @@ describe('react embeddable renderer', () => {
resetUnsavedChanges: expect.any(Function),
snapshotRuntimeState: expect.any(Function),
phase$: expect.any(Object),
hasLockedHoverActions$: expect.any(Object),
lockHoverActions: expect.any(Function),
})
);
});

View file

@ -122,11 +122,16 @@ export const ReactEmbeddableRenderer = <
const setApi = (
apiRegistration: SetReactEmbeddableApiRegistration<SerializedState, RuntimeState, Api>
) => {
const hasLockedHoverActions$ = new BehaviorSubject(false);
return {
...apiRegistration,
uuid,
phase$,
parentApi,
hasLockedHoverActions$,
lockHoverActions: (lock: boolean) => {
hasLockedHoverActions$.next(lock);
},
type: factory.type,
} as unknown as Api;
};

View file

@ -14,6 +14,7 @@ import {
} from '@kbn/presentation-containers';
import { DefaultPresentationPanelApi } from '@kbn/presentation-panel-plugin/public/panel_component/types';
import {
CanLockHoverActions,
HasType,
PublishesPhaseEvents,
PublishesUnsavedChanges,
@ -48,7 +49,7 @@ export type SetReactEmbeddableApiRegistration<
SerializedState,
RuntimeState
>
> = Omit<Api, 'uuid' | 'parent' | 'type' | 'phase$'>;
> = Omit<Api, 'uuid' | 'parent' | 'type' | 'phase$' | keyof CanLockHoverActions>;
/**
* Defines the subset of the default embeddable API that the `buildApi` method uses, which allows implementors

View file

@ -248,6 +248,7 @@ export const getLinksEmbeddableFactory = () => {
data-shared-item
data-rendering-count={1}
data-test-subj="links--component"
borderRadius="none"
>
<EuiListGroup
maxWidth={false}

View file

@ -30,7 +30,7 @@ const isApiCompatible = (api: unknown | null): api is InspectPanelActionApi => {
export class InspectPanelAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_INSPECT_PANEL;
public readonly id = ACTION_INSPECT_PANEL;
public order = 20;
public order = 19; // right after Explore in Discover which is 20
constructor() {}

View file

@ -33,14 +33,8 @@ const isApiCompatible = (api: unknown | null): api is RemovePanelActionApi =>
export class RemovePanelAction implements Action<EmbeddableApiContext> {
public readonly type = ACTION_REMOVE_PANEL;
public readonly id = ACTION_REMOVE_PANEL;
public order = 1;
public grouping = [
{
id: 'delete_panel_action',
order: 1,
},
];
public order = 0;
public grouping = [{ id: 'remove_panel_group', order: 1 }];
constructor() {}

View file

@ -6,6 +6,8 @@
height: 100%;
min-height: $euiSizeL + 2px; // + 2px to account for border
position: relative;
border: none;
outline: $euiBorderThin;
&-isLoading {
// completely center the loading indicator
@ -44,6 +46,13 @@
display: flex;
// ensure menu button is on the right even if the title doesn't exist
justify-content: flex-end;
height: $euiSizeL;
}
.embPanel__header + .embPanel__content {
border-radius: 0;
border-bottom-left-radius: $euiBorderRadius;
border-bottom-right-radius: $euiBorderRadius;
}
.embPanel__title {
@ -112,7 +121,6 @@
&:focus {
background-color: transparentize($euiColorLightestShade, .5);
}
}
.embPanel__optionsMenuPopover-loading {
@ -129,43 +137,20 @@
font-size: $euiSizeL;
}
.embPanel .embPanel__optionsMenuButton {
opacity: 0; /* 1 */
&:focus {
opacity: 1; /* 2 */
}
}
.embPanel:hover {
.embPanel__optionsMenuButton {
opacity: 1;
}
}
// EDITING MODE
.embPanel--editing {
transition: all $euiAnimSpeedFast $euiAnimSlightResistance;
outline: 1px dashed $euiColorMediumShade;
.embPanel--dragHandle {
transition: background-color $euiAnimSpeedFast $euiAnimSlightResistance;
&:hover {
.embPanel--dragHandle:hover {
background-color: transparentize($euiColorWarning, lightOrDarkTheme(.9, .7));
cursor: move;
}
}
.embPanel__content {
border-radius: 0;
border-bottom-left-radius: $euiBorderRadius;
border-bottom-right-radius: $euiBorderRadius;
}
.embPanel__optionsMenuButton {
opacity: 1; /* 3 */
}
}
// LOADING and ERRORS
@ -184,3 +169,57 @@
padding-left: $euiSizeS;
z-index: $euiZLevel1;
}
.embPanel__hoverActionsAnchor {
position: relative;
height: 100%;
.embPanel__hoverActionsWrapper {
height: $euiSizeXL;
position: absolute;
top: 0;
display: flex;
justify-content: space-between;
padding: 0 $euiSize;
flex-wrap: nowrap;
min-width: 100%;
z-index: -1;
pointer-events: none; // Prevent hover actions wrapper from blocking interactions with other panels
}
.embPanel__hoverActions {
opacity: 0;
padding: calc($euiSizeXS - 1px);
display: flex;
flex-wrap: nowrap;
border: $euiBorderThin;
background-color: $euiColorEmptyShade;
height: $euiSizeXL;
pointer-events: all; // Re-enable pointer-events for hover actions
}
.embPanel--dragHandle {
cursor: move;
img {
pointer-events: all !important;
}
}
.embPanel__descriptionTooltipAnchor {
padding: $euiSizeXS;
}
&:hover .embPanel__hoverActionsWrapper,
&:focus-within .embPanel__hoverActionsWrapper,
.embPanel__hoverActionsWrapper--lockHoverActions {
z-index: $euiZLevel9;
top: -$euiSizeXL;
.embPanel__hoverActions {
opacity: 1;
}
}
}

View file

@ -1,177 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import {
EuiButtonIcon,
EuiContextMenu,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiContextMenuPanelDescriptor,
EuiPopover,
EuiSkeletonText,
} from '@elastic/eui';
import { Action, buildContextMenuForActions } from '@kbn/ui-actions-plugin/public';
import {
getViewModeSubject,
useBatchedOptionalPublishingSubjects,
} from '@kbn/presentation-publishing';
import { uiActions } from '../../kibana_services';
import { contextMenuTrigger, CONTEXT_MENU_TRIGGER } from '../../panel_actions';
import { getContextMenuAriaLabel } from '../presentation_panel_strings';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from '../types';
export const PresentationPanelContextMenu = ({
api,
index,
getActions,
actionPredicate,
}: {
index?: number;
api: DefaultPresentationPanelApi;
getActions: PresentationPanelInternalProps['getActions'];
actionPredicate?: (actionId: string) => boolean;
}) => {
const [menuPanelsLoading, setMenuPanelsLoading] = useState(false);
const [contextMenuActions, setContextMenuActions] = useState<Array<Action<object>>>([]);
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean | undefined>(undefined);
const [contextMenuPanels, setContextMenuPanels] = useState<EuiContextMenuPanelDescriptor[]>([]);
const [title, parentViewMode] = useBatchedOptionalPublishingSubjects(
api.panelTitle,
/**
* View mode changes often have the biggest influence over which actions will be compatible,
* so we build and update all actions when the view mode changes. This is temporary, as these
* actions should eventually all be Frequent Compatibility Change Actions which can track their
* own dependencies.
*/
getViewModeSubject(api)
);
useEffect(() => {
/**
* isContextMenuOpen starts as undefined which allows this use effect to run on mount. This
* is required so that showNotification is calculated on mount.
*/
if (isContextMenuOpen === false || !api) return;
setMenuPanelsLoading(true);
let canceled = false;
(async () => {
/**
* Build and update all actions
*/
let compatibleActions: Array<Action<object>> = await (async () => {
if (getActions) return await getActions(CONTEXT_MENU_TRIGGER, { embeddable: api });
return (
(await uiActions.getTriggerCompatibleActions(CONTEXT_MENU_TRIGGER, {
embeddable: api,
})) ?? []
);
})();
if (canceled) return;
const disabledActions = api.disabledActionIds?.value;
if (disabledActions) {
compatibleActions = compatibleActions.filter(
(action) => disabledActions.indexOf(action.id) === -1
);
}
if (actionPredicate) {
compatibleActions = compatibleActions.filter(({ id }) => actionPredicate(id));
}
compatibleActions.sort(
({ order: orderA }, { order: orderB }) => (orderB || 0) - (orderA || 0)
);
/**
* Build context menu panel from actions
*/
const panels = await buildContextMenuForActions({
actions: compatibleActions.map((action) => ({
action,
context: { embeddable: api },
trigger: contextMenuTrigger,
})),
closeMenu: () => setIsContextMenuOpen(false),
});
if (canceled) return;
setMenuPanelsLoading(false);
setContextMenuActions(compatibleActions);
setContextMenuPanels(panels);
})();
return () => {
canceled = true;
};
}, [actionPredicate, api, getActions, isContextMenuOpen, parentViewMode]);
const showNotification = useMemo(
() => contextMenuActions.some((action) => action.showNotification),
[contextMenuActions]
);
const contextMenuClasses = classNames({
// eslint-disable-next-line @typescript-eslint/naming-convention
embPanel__optionsMenuPopover: true,
'embPanel__optionsMenuPopover-notification': showNotification,
});
const ContextMenuButton = (
<EuiButtonIcon
color="text"
className="embPanel__optionsMenuButton"
data-test-subj="embeddablePanelToggleMenuIcon"
aria-label={getContextMenuAriaLabel(title, index)}
onClick={() => setIsContextMenuOpen((isOpen) => !isOpen)}
iconType={'boxesHorizontal'}
/>
);
return (
<EuiPopover
repositionOnScroll
panelPaddingSize="none"
anchorPosition="downRight"
button={ContextMenuButton}
isOpen={isContextMenuOpen}
className={contextMenuClasses}
closePopover={() => setIsContextMenuOpen(false)}
data-test-subj={
isContextMenuOpen ? 'embeddablePanelContextMenuOpen' : 'embeddablePanelContextMenuClosed'
}
>
{menuPanelsLoading ? (
<EuiContextMenuPanel
className="embPanel__optionsMenuPopover-loading"
title={i18n.translate('presentationPanel.contextMenu.loadingTitle', {
defaultMessage: 'Options',
})}
>
<EuiContextMenuItem>
<EuiSkeletonText />
</EuiContextMenuItem>
</EuiContextMenuPanel>
) : (
<EuiContextMenu
data-test-subj="presentationPanelContextMenuItems"
initialPanelId="mainMenu"
panels={contextMenuPanels}
/>
)}
</EuiPopover>
);
};

View file

@ -13,7 +13,6 @@ import classNames from 'classnames';
import React from 'react';
import { getAriaLabelForTitle } from '../presentation_panel_strings';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from '../types';
import { PresentationPanelContextMenu } from './presentation_panel_context_menu';
import { PresentationPanelTitle } from './presentation_panel_title';
import { usePresentationPanelHeaderActions } from './use_presentation_panel_header_actions';
@ -24,23 +23,18 @@ export type PresentationPanelHeaderProps<ApiType extends DefaultPresentationPane
hideTitle?: boolean;
panelTitle?: string;
panelDescription?: string;
} & Pick<
PresentationPanelInternalProps,
'index' | 'showBadges' | 'getActions' | 'actionPredicate' | 'showNotifications'
>;
} & Pick<PresentationPanelInternalProps, 'showBadges' | 'getActions' | 'showNotifications'>;
export const PresentationPanelHeader = <
ApiType extends DefaultPresentationPanelApi = DefaultPresentationPanelApi
>({
api,
index,
viewMode,
headerId,
getActions,
hideTitle,
panelTitle,
panelDescription,
actionPredicate,
showBadges = true,
showNotifications = true,
}: PresentationPanelHeaderProps<ApiType>) => {
@ -52,11 +46,9 @@ export const PresentationPanelHeader = <
);
const showPanelBar =
!hideTitle ||
panelDescription ||
viewMode !== 'view' ||
badgeElements.length > 0 ||
notificationElements.length > 0;
(!hideTitle && panelTitle) || badgeElements.length > 0 || notificationElements.length > 0;
if (!showPanelBar) return null;
const ariaLabel = getAriaLabelForTitle(showPanelBar ? panelTitle : undefined);
const ariaLabelElement = (
@ -66,6 +58,7 @@ export const PresentationPanelHeader = <
);
const headerClasses = classNames('embPanel__header', {
'embPanel--dragHandle': viewMode === 'edit',
'embPanel__header--floater': !showPanelBar,
});
@ -73,19 +66,6 @@ export const PresentationPanelHeader = <
'embPanel--dragHandle': viewMode === 'edit',
});
const contextMenuElement = (
<PresentationPanelContextMenu {...{ index, api, getActions, actionPredicate }} />
);
if (!showPanelBar) {
return (
<div data-test-subj={`embeddablePanelHeading`} className={headerClasses}>
{contextMenuElement}
{ariaLabelElement}
</div>
);
}
return (
<figcaption
className={headerClasses}
@ -103,7 +83,6 @@ export const PresentationPanelHeader = <
{showBadges && badgeElements}
</h2>
{showNotifications && notificationElements}
{contextMenuElement}
</figcaption>
);
};

View file

@ -0,0 +1,563 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import React, {
MouseEventHandler,
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
EuiButtonIcon,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiIcon,
EuiIconTip,
EuiNotificationBadge,
EuiPopover,
EuiToolTip,
IconType,
} from '@elastic/eui';
import { ActionExecutionContext, buildContextMenuForActions } from '@kbn/ui-actions-plugin/public';
import {
apiCanLockHoverActions,
EmbeddableApiContext,
getViewModeSubject,
useBatchedOptionalPublishingSubjects,
ViewMode,
} from '@kbn/presentation-publishing';
import { Subscription } from 'rxjs';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { ActionWithContext } from '@kbn/ui-actions-plugin/public/context_menu/build_eui_context_menu_panels';
import { uiActions } from '../../kibana_services';
import {
contextMenuTrigger,
CONTEXT_MENU_TRIGGER,
panelNotificationTrigger,
PANEL_NOTIFICATION_TRIGGER,
} from '../../panel_actions';
import { getContextMenuAriaLabel } from '../presentation_panel_strings';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from '../types';
import { AnyApiAction } from '../../panel_actions/types';
const QUICK_ACTION_IDS = {
edit: [
'editPanel',
'ACTION_CONFIGURE_IN_LENS',
'ACTION_CUSTOMIZE_PANEL',
'ACTION_OPEN_IN_DISCOVER',
'ACTION_VIEW_SAVED_SEARCH',
],
view: ['ACTION_OPEN_IN_DISCOVER', 'ACTION_VIEW_SAVED_SEARCH', 'openInspector', 'togglePanel'],
} as const;
const ALLOWED_NOTIFICATIONS = ['ACTION_FILTERS_NOTIFICATION'] as const;
const ALL_ROUNDED_CORNERS = `border-radius: ${euiThemeVars.euiBorderRadius};
`;
const TOP_ROUNDED_CORNERS = `border-top-left-radius: ${euiThemeVars.euiBorderRadius};
border-top-right-radius: ${euiThemeVars.euiBorderRadius};
border-bottom: 0 !important;
`;
const createClickHandler =
(action: AnyApiAction, context: ActionExecutionContext<EmbeddableApiContext>) =>
(event: React.MouseEvent) => {
if (event.currentTarget instanceof HTMLAnchorElement) {
// from react-router's <Link/>
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
(!event.currentTarget.target || event.currentTarget.target === '_self') && // let browser handle "target=_blank" etc.
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys
) {
event.preventDefault();
}
}
(event.currentTarget as HTMLElement).blur();
action.execute(context);
};
export const PresentationPanelHoverActions = ({
api,
index,
getActions,
actionPredicate,
children,
className,
viewMode,
showNotifications = true,
}: {
index?: number;
api: DefaultPresentationPanelApi | null;
getActions: PresentationPanelInternalProps['getActions'];
actionPredicate?: (actionId: string) => boolean;
children: ReactElement;
className?: string;
viewMode?: ViewMode;
showNotifications?: boolean;
}) => {
const [quickActions, setQuickActions] = useState<AnyApiAction[]>([]);
const [contextMenuPanels, setContextMenuPanels] = useState<EuiContextMenuPanelDescriptor[]>([]);
const [showNotification, setShowNotification] = useState<boolean>(false);
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean>(false);
const [notifications, setNotifications] = useState<AnyApiAction[]>([]);
const hoverActionsRef = useRef<HTMLDivElement | null>(null);
const anchorRef = useRef<HTMLDivElement | null>(null);
const leftHoverActionsRef = useRef<HTMLDivElement | null>(null);
const rightHoverActionsRef = useRef<HTMLDivElement | null>(null);
const [combineHoverActions, setCombineHoverActions] = useState<boolean>(false);
const [borderStyles, setBorderStyles] = useState<string>(TOP_ROUNDED_CORNERS);
const updateCombineHoverActions = () => {
if (!hoverActionsRef.current || !anchorRef.current) return;
const anchorBox = anchorRef.current.getBoundingClientRect();
const anchorLeft = anchorBox.left;
const anchorTop = anchorBox.top;
const anchorWidth = anchorRef.current.offsetWidth;
const hoverActionsWidth =
(rightHoverActionsRef.current?.offsetWidth ?? 0) +
(leftHoverActionsRef.current?.offsetWidth ?? 0) +
parseInt(euiThemeVars.euiSize, 10) * 2;
const hoverActionsHeight = rightHoverActionsRef.current?.offsetHeight ?? 0;
// Left align hover actions when they would get cut off by the right edge of the window
if (anchorLeft - (hoverActionsWidth - anchorWidth) <= parseInt(euiThemeVars.euiSize, 10)) {
hoverActionsRef.current.style.removeProperty('right');
hoverActionsRef.current.style.setProperty('left', '0');
} else {
hoverActionsRef.current.style.removeProperty('left');
hoverActionsRef.current.style.setProperty('right', '0');
}
if (anchorRef.current && rightHoverActionsRef.current) {
const shouldCombine = anchorWidth < hoverActionsWidth;
const willGetCutOff = anchorTop < hoverActionsHeight;
if (shouldCombine !== combineHoverActions) {
setCombineHoverActions(shouldCombine);
}
if (willGetCutOff) {
hoverActionsRef.current.style.setProperty('position', 'absolute');
hoverActionsRef.current.style.setProperty('top', `-${euiThemeVars.euiSizeS}`);
} else if (shouldCombine) {
hoverActionsRef.current.style.setProperty('top', `-${euiThemeVars.euiSizeL}`);
} else {
hoverActionsRef.current.style.removeProperty('position');
hoverActionsRef.current.style.removeProperty('top');
}
if (shouldCombine || willGetCutOff) {
setBorderStyles(ALL_ROUNDED_CORNERS);
} else {
setBorderStyles(TOP_ROUNDED_CORNERS);
}
}
};
const [
defaultTitle,
title,
description,
hidePanelTitle,
hasLockedHoverActions,
parentHideTitle,
parentViewMode,
] = useBatchedOptionalPublishingSubjects(
api?.defaultPanelTitle,
api?.panelTitle,
api?.panelDescription,
api?.hidePanelTitle,
api?.hasLockedHoverActions$,
api?.parentApi?.hidePanelTitle,
/**
* View mode changes often have the biggest influence over which actions will be compatible,
* so we build and update all actions when the view mode changes. This is temporary, as these
* actions should eventually all be Frequent Compatibility Change Actions which can track their
* own dependencies.
*/
getViewModeSubject(api ?? undefined)
);
const hideTitle = hidePanelTitle || parentHideTitle;
const showDescription = description && (!title || hideTitle);
const quickActionIds = useMemo(
() => QUICK_ACTION_IDS[parentViewMode === 'edit' ? 'edit' : 'view'],
[parentViewMode]
);
const onClose = useCallback(() => {
setIsContextMenuOpen(false);
if (apiCanLockHoverActions(api)) {
api?.lockHoverActions(false);
}
}, [api]);
useEffect(() => {
if (!api) return;
let canceled = false;
const apiContext = { embeddable: api };
const subscriptions = new Subscription();
const handleActionCompatibilityChange = (
type: 'quickActions' | 'notifications',
isCompatible: boolean,
action: AnyApiAction
) => {
if (canceled) return;
(type === 'quickActions' ? setQuickActions : setNotifications)((currentActions) => {
const newActions = currentActions?.filter((current) => current.id !== action.id);
if (isCompatible) return [...newActions, action];
return newActions;
});
};
(async () => {
// subscribe to any frequently changing context menu actions
const frequentlyChangingActions = uiActions.getFrequentlyChangingActionsForTrigger(
CONTEXT_MENU_TRIGGER,
apiContext
);
for (const frequentlyChangingAction of frequentlyChangingActions) {
if ((quickActionIds as readonly string[]).includes(frequentlyChangingAction.id)) {
subscriptions.add(
frequentlyChangingAction.subscribeToCompatibilityChanges(
apiContext,
(isCompatible, action) =>
handleActionCompatibilityChange(
'quickActions',
isCompatible,
action as AnyApiAction
)
)
);
}
}
// subscribe to any frequently changing notification actions
const frequentlyChangingNotifications = uiActions.getFrequentlyChangingActionsForTrigger(
PANEL_NOTIFICATION_TRIGGER,
apiContext
);
for (const frequentlyChangingNotification of frequentlyChangingNotifications) {
if (
(ALLOWED_NOTIFICATIONS as readonly string[]).includes(frequentlyChangingNotification.id)
) {
subscriptions.add(
frequentlyChangingNotification.subscribeToCompatibilityChanges(
apiContext,
(isCompatible, action) =>
handleActionCompatibilityChange(
'notifications',
isCompatible,
action as AnyApiAction
)
)
);
}
}
})();
return () => {
canceled = true;
subscriptions.unsubscribe();
};
}, [api, quickActionIds]);
useEffect(() => {
if (!api) return;
let canceled = false;
const apiContext = { embeddable: api };
(async () => {
let compatibleActions = (await (async () => {
if (getActions) return await getActions(CONTEXT_MENU_TRIGGER, apiContext);
return (
(await uiActions.getTriggerCompatibleActions(CONTEXT_MENU_TRIGGER, {
embeddable: api,
})) ?? []
);
})()) as AnyApiAction[];
if (canceled) return;
const disabledActions = api.disabledActionIds?.value;
if (disabledActions) {
compatibleActions = compatibleActions.filter(
(action) => disabledActions.indexOf(action.id) === -1
);
}
if (actionPredicate) {
compatibleActions = compatibleActions.filter(({ id }) => actionPredicate(id));
}
compatibleActions.sort(
({ order: orderA }, { order: orderB }) => (orderB || 0) - (orderA || 0)
);
const contextMenuActions = compatibleActions.filter(
({ id }) => !(quickActionIds as readonly string[]).includes(id)
);
const menuPanels = await buildContextMenuForActions({
actions: contextMenuActions.map((action) => ({
action,
context: apiContext,
trigger: contextMenuTrigger,
})) as ActionWithContext[],
closeMenu: onClose,
});
setContextMenuPanels(menuPanels);
setShowNotification(contextMenuActions.some((action) => action.showNotification));
setQuickActions(
compatibleActions.filter(({ id }) => (quickActionIds as readonly string[]).includes(id))
);
})();
return () => {
canceled = true;
};
}, [
actionPredicate,
api,
getActions,
isContextMenuOpen,
onClose,
parentViewMode,
quickActionIds,
]);
const quickActionElements = useMemo(() => {
if (!api || quickActions.length < 1) return [];
const apiContext = { embeddable: api, trigger: contextMenuTrigger };
return quickActions
.sort(({ order: orderA }, { order: orderB }) => {
const orderComparison = (orderB || 0) - (orderA || 0);
return orderComparison;
})
.map((action) => {
const name = action.getDisplayName(apiContext);
const iconType = action.getIconType(apiContext) as IconType;
const id = action.id;
return {
iconType,
'data-test-subj': `embeddablePanelAction-${action.id}`,
onClick: createClickHandler(action, apiContext),
name,
id,
};
});
}, [api, quickActions]);
const notificationElements = useMemo(() => {
if (!showNotifications || !api) return [];
return notifications?.map((notification) => {
let notificationComponent = notification.MenuItem ? (
React.createElement(notification.MenuItem, {
key: notification.id,
context: {
embeddable: api,
trigger: panelNotificationTrigger,
},
})
) : (
<EuiNotificationBadge
data-test-subj={`embeddablePanelNotification-${notification.id}`}
key={notification.id}
style={{ marginTop: euiThemeVars.euiSizeXS, marginRight: euiThemeVars.euiSizeXS }}
onClick={() =>
notification.execute({ embeddable: api, trigger: panelNotificationTrigger })
}
>
{notification.getDisplayName({ embeddable: api, trigger: panelNotificationTrigger })}
</EuiNotificationBadge>
);
if (notification.getDisplayNameTooltip) {
const tooltip = notification.getDisplayNameTooltip({
embeddable: api,
trigger: panelNotificationTrigger,
});
if (tooltip) {
notificationComponent = (
<EuiToolTip position="top" delay="regular" content={tooltip} key={notification.id}>
{notificationComponent}
</EuiToolTip>
);
}
}
return notificationComponent;
});
}, [api, notifications, showNotifications]);
const contextMenuClasses = classNames({
// eslint-disable-next-line @typescript-eslint/naming-convention
embPanel__optionsMenuPopover: true,
'embPanel__optionsMenuPopover-notification': showNotification,
});
const ContextMenuButton = (
<EuiButtonIcon
color="text"
data-test-subj="embeddablePanelToggleMenuIcon"
aria-label={getContextMenuAriaLabel(title, index)}
onClick={() => {
setIsContextMenuOpen(!isContextMenuOpen);
if (apiCanLockHoverActions(api)) {
api?.lockHoverActions(!hasLockedHoverActions);
}
}}
iconType="boxesVertical"
/>
);
const dragHandle = (
<EuiIcon
type="move"
color="text"
className={`${viewMode === 'edit' ? 'embPanel--dragHandle' : ''}`}
aria-label={i18n.translate('presentationPanel.dragHandle', {
defaultMessage: 'Move panel',
})}
data-test-subj="embeddablePanelDragHandle"
css={css`
margin: ${euiThemeVars.euiSizeXS};
`}
/>
);
return (
<div
onMouseOver={updateCombineHoverActions}
onFocus={updateCombineHoverActions}
ref={anchorRef}
className="embPanel__hoverActionsAnchor"
data-test-embeddable-id={api?.uuid}
data-test-subj={`embeddablePanelHoverActions-${(title || defaultTitle || '').replace(
/\s/g,
''
)}`}
>
{children}
{api ? (
<div
ref={hoverActionsRef}
css={css`anchorStyles`}
className={classNames('embPanel__hoverActionsWrapper', {
'embPanel__hoverActionsWrapper--lockHoverActions': hasLockedHoverActions,
})}
>
{viewMode === 'edit' && !combineHoverActions ? (
<div
ref={leftHoverActionsRef}
data-test-subj="embPanel__hoverActions__left"
className={classNames(
'embPanel__hoverActions',
'embPanel__hoverActionsLeft',
className
)}
css={css`
${borderStyles}
`}
>
{dragHandle}
</div>
) : (
<div /> // necessary for the right hover actions to align correctly when left hover actions are not present
)}
<div
ref={rightHoverActionsRef}
data-test-subj="embPanel__hoverActions__right"
className={classNames(
'embPanel__hoverActions',
'embPanel__hoverActionsRight',
className
)}
css={css`
${borderStyles}
`}
>
{viewMode === 'edit' && combineHoverActions && dragHandle}
{showNotifications && notificationElements}
{showDescription && (
<EuiIconTip
title={!hideTitle ? title || undefined : undefined}
content={description}
delay="regular"
position="top"
anchorClassName="embPanel__descriptionTooltipAnchor"
data-test-subj="embeddablePanelDescriptionTooltip"
type="iInCircle"
/>
)}
{quickActionElements.map(
({ iconType, 'data-test-subj': dataTestSubj, onClick, name }, i) => (
<EuiToolTip key={`main_action_${dataTestSubj}_${api?.uuid}`} content={name}>
<EuiButtonIcon
iconType={iconType}
color="text"
onClick={onClick as MouseEventHandler}
data-test-subj={dataTestSubj}
aria-label={name as string}
/>
</EuiToolTip>
)
)}
{contextMenuPanels.length ? (
<EuiPopover
repositionOnScroll
panelPaddingSize="none"
anchorPosition="downRight"
button={ContextMenuButton}
isOpen={isContextMenuOpen}
className={contextMenuClasses}
closePopover={onClose}
data-test-subj={
isContextMenuOpen
? 'embeddablePanelContextMenuOpen'
: 'embeddablePanelContextMenuClosed'
}
focusTrapProps={{
closeOnMouseup: true,
clickOutsideDisables: false,
onClickOutside: onClose,
}}
>
<EuiContextMenu
data-test-subj="presentationPanelContextMenuItems"
initialPanelId={'mainMenu'}
panels={contextMenuPanels}
/>
</EuiPopover>
) : null}
</div>
</div>
) : null}
</div>
);
};

View file

@ -131,8 +131,8 @@ export const PresentationPanelTitle = ({
}, [api, onClick]);
const describedPanelTitleElement = useMemo(() => {
if (hideTitle) return null;
if (!panelDescription) {
if (hideTitle) return null;
return (
<span data-test-subj="embeddablePanelTitleInner" className="embPanel__titleInner">
{panelTitleElement}

View file

@ -22,6 +22,8 @@ import {
import { AnyApiAction } from '../../panel_actions/types';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from '../types';
const disabledNotifications = ['ACTION_FILTERS_NOTIFICATION'];
export const usePresentationPanelHeaderActions = <
ApiType extends DefaultPresentationPanelApi = DefaultPresentationPanelApi
>(
@ -47,10 +49,8 @@ export const usePresentationPanelHeaderActions = <
embeddable: api,
})) as AnyApiAction[]) ?? [];
const disabledActions = api.disabledActionIds?.value;
if (disabledActions) {
nextActions = nextActions.filter((badge) => disabledActions.indexOf(badge.id) === -1);
}
const disabledActions = (api.disabledActionIds?.value ?? []).concat(disabledNotifications);
nextActions = nextActions.filter((badge) => disabledActions.indexOf(badge.id) === -1);
return nextActions;
};
@ -85,8 +85,8 @@ export const usePresentationPanelHeaderActions = <
);
for (const badge of frequentlyChangingBadges) {
subscriptions.add(
badge.subscribeToCompatibilityChanges(apiContext, (isComptaible, action) =>
handleActionCompatibilityChange('badge', isComptaible, action as AnyApiAction)
badge.subscribeToCompatibilityChanges(apiContext, (isCompatible, action) =>
handleActionCompatibilityChange('badge', isCompatible, action as AnyApiAction)
)
);
}
@ -97,11 +97,12 @@ export const usePresentationPanelHeaderActions = <
apiContext
);
for (const notification of frequentlyChangingNotifications) {
subscriptions.add(
notification.subscribeToCompatibilityChanges(apiContext, (isComptaible, action) =>
handleActionCompatibilityChange('notification', isComptaible, action as AnyApiAction)
)
);
if (!disabledNotifications.includes(notification.id))
subscriptions.add(
notification.subscribeToCompatibilityChanges(apiContext, (isCompatible, action) =>
handleActionCompatibilityChange('notification', isCompatible, action as AnyApiAction)
)
);
}
})();

View file

@ -37,7 +37,7 @@ describe('Presentation panel', () => {
<PresentationPanel {...props} Component={getMockPresentationPanelCompatibleComponent(api)} />
);
await waitFor(() => {
expect(screen.getByTestId('embeddablePanelToggleMenuIcon')).toBeInTheDocument();
expect(screen.getByTestId('embeddablePanel')).toBeInTheDocument();
});
};
@ -223,12 +223,10 @@ describe('Presentation panel', () => {
viewMode: new BehaviorSubject<ViewMode>('view'),
};
await renderPresentationPanel({ api });
const header = await screen.findByTestId('embeddablePanelHeading');
const titleComponent = screen.queryByTestId('dashboardPanelTitle');
expect(header).not.toContainElement(titleComponent);
expect(screen.queryByTestId('presentationPanelTitle')).not.toBeInTheDocument();
});
it('renders a placeholder title when in edit mode and the provided title is blank', async () => {
it('does not render a title when in edit mode and the provided title is blank', async () => {
const api: DefaultPresentationPanelApi & PublishesDataViews & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>(''),
@ -236,9 +234,7 @@ describe('Presentation panel', () => {
dataViews: new BehaviorSubject<DataView[] | undefined>([]),
};
await renderPresentationPanel({ api });
await waitFor(() => {
expect(screen.getByTestId('embeddablePanelTitleInner')).toHaveTextContent('[No Title]');
});
expect(screen.queryByTestId('presentationPanelTitle')).not.toBeInTheDocument();
});
it('opens customize panel flyout on title click when in edit mode', async () => {
@ -274,7 +270,7 @@ describe('Presentation panel', () => {
expect(screen.queryByTestId('embeddablePanelTitleLink')).not.toBeInTheDocument();
});
it('hides title when API hide title option is true', async () => {
it('hides title in view mode when API hide title option is true', async () => {
const api: DefaultPresentationPanelApi & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
@ -285,7 +281,18 @@ describe('Presentation panel', () => {
expect(screen.queryByTestId('presentationPanelTitle')).not.toBeInTheDocument();
});
it('hides title when parent hide title option is true', async () => {
it('hides title in edit mode when API hide title option is true', async () => {
const api: DefaultPresentationPanelApi & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
hidePanelTitle: new BehaviorSubject<boolean | undefined>(true),
viewMode: new BehaviorSubject<ViewMode>('edit'),
};
await renderPresentationPanel({ api });
expect(screen.queryByTestId('presentationPanelTitle')).not.toBeInTheDocument();
});
it('hides title in view mode when parent hide title option is true', async () => {
const api: DefaultPresentationPanelApi & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
@ -298,5 +305,19 @@ describe('Presentation panel', () => {
await renderPresentationPanel({ api });
expect(screen.queryByTestId('presentationPanelTitle')).not.toBeInTheDocument();
});
it('hides title in edit mode when parent hide title option is true', async () => {
const api: DefaultPresentationPanelApi & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
viewMode: new BehaviorSubject<ViewMode>('edit'),
parentApi: {
viewMode: new BehaviorSubject<ViewMode>('edit'),
...getMockPresentationContainer(),
},
};
await renderPresentationPanel({ api });
expect(screen.queryByTestId('presentationPanelTitle')).not.toBeInTheDocument();
});
});
});

View file

@ -16,6 +16,7 @@ import {
} from '@kbn/presentation-publishing';
import classNames from 'classnames';
import React, { useMemo, useState } from 'react';
import { PresentationPanelHoverActions } from './panel_header/presentation_panel_hover_actions';
import { PresentationPanelHeader } from './panel_header/presentation_panel_header';
import { PresentationPanelError } from './presentation_panel_error';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from './types';
@ -76,7 +77,7 @@ export const PresentationPanelInternal = <
const hideTitle =
Boolean(hidePanelTitle) ||
Boolean(parentHidePanelTitle) ||
(viewMode === 'view' && !Boolean(panelTitle ?? defaultPanelTitle));
!Boolean(panelTitle ?? defaultPanelTitle);
const contentAttrs = useMemo(() => {
const attrs: { [key: string]: boolean } = {};
@ -90,55 +91,56 @@ export const PresentationPanelInternal = <
}, [dataLoading, blockingError]);
return (
<EuiPanel
role="figure"
paddingSize="none"
className={classNames('embPanel', {
'embPanel--editing': viewMode === 'edit',
})}
hasShadow={showShadow}
hasBorder={showBorder}
aria-labelledby={headerId}
data-test-embeddable-id={api?.uuid}
data-test-subj="embeddablePanel"
{...contentAttrs}
<PresentationPanelHoverActions
{...{ index, api, getActions, actionPredicate, viewMode, showNotifications }}
>
{!hideHeader && api && (
<PresentationPanelHeader
api={api}
index={index}
headerId={headerId}
viewMode={viewMode}
hideTitle={hideTitle}
showBadges={showBadges}
getActions={getActions}
actionPredicate={actionPredicate}
showNotifications={showNotifications}
panelTitle={panelTitle ?? defaultPanelTitle}
panelDescription={panelDescription ?? defaultPanelDescription}
/>
)}
{blockingError && api && (
<EuiFlexGroup
alignItems="center"
className="eui-fullHeight embPanel__error"
data-test-subj="embeddableError"
justifyContent="center"
>
<PresentationPanelError api={api} error={blockingError} />
</EuiFlexGroup>
)}
{!initialLoadComplete && <PanelLoader />}
<div className={blockingError ? 'embPanel__content--hidden' : 'embPanel__content'}>
<EuiErrorBoundary>
<Component
{...(componentProps as React.ComponentProps<typeof Component>)}
ref={(newApi) => {
if (newApi && !api) setApi(newApi);
}}
<EuiPanel
role="figure"
paddingSize="none"
className={classNames('embPanel', {
'embPanel--editing': viewMode === 'edit',
})}
hasShadow={showShadow}
hasBorder={showBorder}
aria-labelledby={headerId}
data-test-subj="embeddablePanel"
{...contentAttrs}
>
{!hideHeader && api && (
<PresentationPanelHeader
api={api}
headerId={headerId}
viewMode={viewMode}
hideTitle={hideTitle}
showBadges={showBadges}
getActions={getActions}
showNotifications={showNotifications}
panelTitle={panelTitle ?? defaultPanelTitle}
panelDescription={panelDescription ?? defaultPanelDescription}
/>
</EuiErrorBoundary>
</div>
</EuiPanel>
)}
{blockingError && api && (
<EuiFlexGroup
alignItems="center"
className="eui-fullHeight embPanel__error"
data-test-subj="embeddableError"
justifyContent="center"
>
<PresentationPanelError api={api} error={blockingError} />
</EuiFlexGroup>
)}
{!initialLoadComplete && <PanelLoader />}
<div className={blockingError ? 'embPanel__content--hidden' : 'embPanel__content'}>
<EuiErrorBoundary>
<Component
{...(componentProps as React.ComponentProps<typeof Component>)}
ref={(newApi) => {
if (newApi && !api) setApi(newApi);
}}
/>
</EuiErrorBoundary>
</div>
</EuiPanel>
</PresentationPanelHoverActions>
);
};

View file

@ -9,6 +9,7 @@
import { PresentationContainer } from '@kbn/presentation-containers';
import {
CanLockHoverActions,
HasParentApi,
HasUniqueId,
PublishesBlockingError,
@ -74,7 +75,8 @@ export interface DefaultPresentationPanelApi
HasParentApi<
PresentationContainer &
Partial<Pick<PublishesPanelTitle, 'hidePanelTitle'> & PublishesViewMode>
>
> &
CanLockHoverActions
> {}
export type PresentationPanelProps<

View file

@ -21,7 +21,7 @@ export const txtMore = i18n.translate('uiActions.actionPanel.more', {
defaultMessage: 'More',
});
interface ActionWithContext<Context extends object = object> {
export interface ActionWithContext<Context extends object = object> {
action: Action<Context> | ActionInternal<Context>;
context: Context;
@ -37,6 +37,7 @@ type ItemDescriptor = EuiContextMenuPanelItemDescriptor & {
};
type PanelDescriptor = EuiContextMenuPanelDescriptor & {
_order?: number;
_level?: number;
_icon?: string;
items: ItemDescriptor[];
@ -101,7 +102,7 @@ const removeItemMetaFields = (items: ItemDescriptor[]): EuiContextMenuPanelItemD
const removePanelMetaFields = (panels: PanelDescriptor[]): EuiContextMenuPanelDescriptor[] => {
const euiPanels: EuiContextMenuPanelDescriptor[] = [];
for (const panel of panels) {
const { _level: omit, _icon: omit2, ...rest } = panel;
const { _level: omit, _icon: omit2, _order: omit3, ...rest } = panel;
euiPanels.push({ ...rest, items: removeItemMetaFields(rest.items) });
}
return euiPanels;
@ -124,15 +125,18 @@ export async function buildContextMenuForActions({
const panels: Record<string, PanelDescriptor> = {
mainMenu: {
id: 'mainMenu',
title,
items: [],
},
};
const promises = actions.map(async (item) => {
const { action } = item;
const context: ActionExecutionContext<object> = { ...item.context, trigger: item.trigger };
const context: ActionExecutionContext<object> = {
...item.context,
trigger: item.trigger,
};
const isCompatible = await item.action.isCompatible(context);
if (!isCompatible) return;
let parentPanel = '';
let currentPanel = '';
if (action.grouping) {
@ -146,6 +150,7 @@ export async function buildContextMenuForActions({
title: name,
items: [],
_level: i,
_order: group.order || 0,
_icon: group.getIconType ? group.getIconType(context) : 'empty',
};
if (parentPanel) {
@ -190,7 +195,11 @@ export async function buildContextMenuForActions({
wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu');
for (const panel of Object.values(panels)) {
const sortedPanels = Object.values(panels).sort((a, b) => {
return (b._order || 0) - (a._order || 0);
});
for (const panel of sortedPanels) {
if (panel._level === 0) {
if (panels.mainMenu.items.length > 0) {
panels.mainMenu.items.push({
@ -198,7 +207,7 @@ export async function buildContextMenuForActions({
key: panel.id + '__separator',
});
}
if (panel.items.length > 3) {
if (panel.items.length > 4) {
panels.mainMenu.items.push({
name: panel.title || panel.id,
icon: panel._icon || 'empty',

View file

@ -21,6 +21,6 @@ export const dynamicActionGrouping: PresentableGrouping<{
defaultMessage: 'Custom actions',
}),
getIconType: () => 'symlink',
order: 26,
order: 0,
},
];

View file

@ -61,7 +61,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const newTitle = 'wowee, my title just got cooler';
await header.waitUntilLoadingHasFinished();
const originalPanelCount = await dashboard.getPanelCount();
await dashboardPanelActions.clickEdit();
await dashboardPanelActions.editPanelByTitle('wowee, looks like I have a new title');
await visualize.saveVisualizationExpectSuccess(newTitle, {
saveAsNew: true,
redirectToOrigin: true,
@ -76,7 +76,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('loses originatingApp connection after save as when redirectToOrigin is false', async () => {
const newTitle = 'wowee, my title just got cooler again';
await header.waitUntilLoadingHasFinished();
await dashboardPanelActions.editPanelByTitle('wowee, my title just got cooler');
await dashboardPanelActions.clickEdit();
await visualize.linkedToOriginatingApp();
await visualize.saveVisualizationExpectSuccess(newTitle, {
saveAsNew: true,

View file

@ -48,9 +48,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('are shown in edit mode', async function () {
await dashboard.switchToEditMode();
const isContextMenuIconVisible = await dashboardPanelActions.isContextMenuIconVisible();
expect(isContextMenuIconVisible).to.equal(true);
await dashboardPanelActions.expectExistsEditPanelAction();
await dashboardPanelActions.expectExistsClonePanelAction();
await dashboardPanelActions.expectExistsRemovePanelAction();

View file

@ -84,7 +84,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
await dashboardPanelActions.clickContextMenuItem(
await dashboardPanelActions.clickPanelAction(
'embeddablePanelAction-ACTION_VIEW_SAVED_SEARCH'
);

View file

@ -296,11 +296,13 @@ export class DashboardPageObject extends FtrService {
// if the dashboard is not already in edit mode
await this.testSubjects.click('dashboardEditMode');
}
// wait until the count of dashboard panels equals the count of toggle menu icons
// wait until the count of dashboard panels equals the count of drag handles
await this.retry.waitFor('in edit mode', async () => {
const panels = await this.testSubjects.findAll('embeddablePanel', 2500);
const menuIcons = await this.testSubjects.findAll('embeddablePanelToggleMenuIcon', 2500);
return panels.length === menuIcons.length;
const panels = await this.find.allByCssSelector('.embPanel__hoverActionsWrapper');
const dragHandles = await this.find.allByCssSelector(
'[data-test-subj="embeddablePanelDragHandle"]'
);
return panels.length === dragHandles.length;
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

@ -26,106 +26,109 @@ const LEGACY_UNLINK_FROM_LIBRARY_TEST_SUBJ = 'embeddablePanelAction-legacyUnlink
const UNLINK_FROM_LIBRARY_TEST_SUBJ = 'embeddablePanelAction-unlinkFromLibrary';
const CONVERT_TO_LENS_TEST_SUBJ = 'embeddablePanelAction-ACTION_EDIT_IN_LENS';
const DASHBOARD_TOP_OFFSET = 96 + 105; // 96 for Kibana navigation bar + 105 for dashboard top nav bar (in edit mode)
const DASHBOARD_MARGIN_SIZE = 8;
export class DashboardPanelActionsService extends FtrService {
private readonly log = this.ctx.getService('log');
private readonly retry = this.ctx.getService('retry');
private readonly browser = this.ctx.getService('browser');
private readonly find = this.ctx.getService('find');
private readonly inspector = this.ctx.getService('inspector');
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly browser = this.ctx.getService('browser');
private readonly header = this.ctx.getPageObject('header');
private readonly common = this.ctx.getPageObject('common');
private readonly dashboard = this.ctx.getPageObject('dashboard');
async findContextMenu(parent?: WebElementWrapper) {
async getContainerTopOffset() {
const containerSelector = (await this.find.existsByCssSelector('.dashboardContainer'))
? '.dashboardContainer'
: '.canvasContainer';
return (
(await (await this.find.byCssSelector(containerSelector)).getPosition()).y +
DASHBOARD_MARGIN_SIZE
);
}
async findContextMenu(wrapper?: WebElementWrapper) {
this.log.debug('findContextMenu');
return parent
? await this.testSubjects.findDescendant(OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ, parent)
return wrapper
? await wrapper.findByTestSubject(OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ)
: await this.testSubjects.find(OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ);
}
async isContextMenuIconVisible() {
this.log.debug('isContextMenuIconVisible');
return await this.testSubjects.exists(OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ);
async scrollPanelIntoView(wrapper?: WebElementWrapper) {
this.log.debug(`scrollPanelIntoView`);
wrapper = wrapper || (await this.getPanelWrapper());
const yOffset = (await wrapper.getPosition()).y;
await this.browser.execute(`
const scrollY = window.scrollY;
window.scrollBy(0, scrollY - ${yOffset});
`);
const containerTop = await this.getContainerTopOffset();
await wrapper.moveMouseTo({
topOffset: containerTop,
});
}
async toggleContextMenu(parent?: WebElementWrapper) {
this.log.debug(`toggleContextMenu(${parent})`);
if (parent) {
await parent.scrollIntoViewIfNecessary(DASHBOARD_TOP_OFFSET);
await this.browser.getActions().move({ x: 0, y: 0, origin: parent._webElement }).perform();
} else {
await this.testSubjects.moveMouseTo('dashboardPanelTitle');
}
const toggleMenuItem = await this.findContextMenu(parent);
await toggleMenuItem.click(DASHBOARD_TOP_OFFSET);
async toggleContextMenu(wrapper?: WebElementWrapper) {
this.log.debug(`toggleContextMenu`);
await this.scrollPanelIntoView(wrapper);
const toggleMenuItem = await this.findContextMenu(wrapper);
await toggleMenuItem.click(await this.getContainerTopOffset());
}
async toggleContextMenuByTitle(title = '') {
this.log.debug(`toggleContextMenu(${title})`);
const header = await this.getPanelHeading(title);
await this.toggleContextMenu(header);
const wrapper = await this.getPanelWrapper(title);
await this.toggleContextMenu(wrapper);
}
async expectContextMenuToBeOpen() {
this.log.debug('expectContextMenuToBeOpen');
await this.testSubjects.existOrFail('embeddablePanelContextMenuOpen');
await this.testSubjects.existOrFail('embeddablePanelContextMenuOpen', { allowHidden: true });
}
async openContextMenu(parent?: WebElementWrapper) {
this.log.debug(`openContextMenu`);
async openContextMenu(wrapper?: WebElementWrapper) {
this.log.debug(`openContextMenu(${wrapper}`);
const open = await this.testSubjects.exists('embeddablePanelContextMenuOpen');
if (!open) await this.toggleContextMenu(parent);
if (!open) await this.toggleContextMenu(wrapper);
await this.expectContextMenuToBeOpen();
}
async openContextMenuByTitle(title = '') {
this.log.debug(`openContextMenuByTitle(${title})`);
const header = await this.getPanelHeading(title);
await this.openContextMenu(header);
const wrapper = await this.getPanelWrapper(title);
await this.openContextMenu(wrapper);
}
async hasContextMenuMoreItem() {
this.log.debug('hasContextMenuMoreItem');
return await this.testSubjects.exists('embeddablePanelMore-mainMenu', { timeout: 500 });
}
async clickContextMenuMoreItem() {
this.log.debug('clickContextMenuMoreItem');
await this.expectContextMenuToBeOpen();
if (await this.hasContextMenuMoreItem()) {
await this.testSubjects.clickWhenNotDisabledWithoutRetry('embeddablePanelMore-mainMenu');
}
}
async openContextMenuMorePanel(parent?: WebElementWrapper) {
this.log.debug('openContextMenuMorePanel');
await this.openContextMenu(parent);
await this.clickContextMenuMoreItem();
}
async clickContextMenuItem(testSubject: string, parent?: WebElementWrapper) {
this.log.debug(`clickContextMenuItem(${testSubject})`);
await this.openContextMenu(parent);
const exists = await this.testSubjects.exists(testSubject, { timeout: 500 });
async clickPanelAction(testSubject: string, wrapper?: WebElementWrapper) {
this.log.debug(`clickPanelAction(${testSubject})`);
wrapper = wrapper || (await this.getPanelWrapper());
await this.scrollPanelIntoView(wrapper);
const exists = await this.testSubjects.descendantExists(testSubject, wrapper);
let action;
if (!exists) {
await this.clickContextMenuMoreItem();
await this.openContextMenu(wrapper);
action = await this.testSubjects.find(testSubject);
} else {
action = await this.testSubjects.findDescendant(testSubject, wrapper);
}
await this.testSubjects.clickWhenNotDisabledWithoutRetry(testSubject, { timeout: 500 });
await action.click(await this.getContainerTopOffset());
}
async clickContextMenuItemByTitle(testSubject: string, title = '') {
this.log.debug(`openContextMenuByTitle(${title})`);
const header = await this.getPanelHeading(title);
await this.clickContextMenuItem(testSubject, header);
async clickPanelActionByTitle(testSubject: string, title = '') {
this.log.debug(`clickPanelActionByTitle(${testSubject},${title})`);
const wrapper = await this.getPanelWrapper(title);
await this.clickPanelAction(testSubject, wrapper);
}
async navigateToEditorFromFlyout() {
async navigateToEditorFromFlyout(wrapper?: WebElementWrapper) {
this.log.debug('navigateToEditorFromFlyout');
await this.clickContextMenuItem(INLINE_EDIT_PANEL_DATA_TEST_SUBJ);
await this.clickPanelAction(INLINE_EDIT_PANEL_DATA_TEST_SUBJ, wrapper);
await this.header.waitUntilLoadingHasFinished();
await this.testSubjects.clickWhenNotDisabledWithoutRetry(EDIT_IN_LENS_EDITOR_DATA_TEST_SUBJ);
const isConfirmModalVisible = await this.testSubjects.exists('confirmModalConfirmButton');
@ -138,7 +141,7 @@ export class DashboardPanelActionsService extends FtrService {
async clickInlineEdit() {
this.log.debug('clickInlineEditAction');
await this.clickContextMenuItem(INLINE_EDIT_PANEL_DATA_TEST_SUBJ);
await this.clickPanelAction(INLINE_EDIT_PANEL_DATA_TEST_SUBJ);
await this.header.waitUntilLoadingHasFinished();
await this.common.waitForTopNavToBeVisible();
}
@ -147,20 +150,16 @@ export class DashboardPanelActionsService extends FtrService {
* The dashboard/canvas panels can be either edited on their editor or inline.
* The inline editing panels allow the navigation to the editor after the flyout opens
*/
async clickEdit(parent?: WebElementWrapper) {
this.log.debug('clickEdit');
await this.openContextMenu(parent);
const isActionVisible = await this.testSubjects.exists(EDIT_PANEL_DATA_TEST_SUBJ);
const isInlineEditingActionVisible = await this.testSubjects.exists(
INLINE_EDIT_PANEL_DATA_TEST_SUBJ
);
if (!isActionVisible && !isInlineEditingActionVisible) await this.clickContextMenuMoreItem();
// navigate to the editor
if (await this.testSubjects.exists(EDIT_PANEL_DATA_TEST_SUBJ)) {
await this.testSubjects.clickWhenNotDisabledWithoutRetry(EDIT_PANEL_DATA_TEST_SUBJ);
// open the flyout and then navigate to the editor
async clickEdit(wrapper?: WebElementWrapper) {
this.log.debug(`clickEdit`);
wrapper = wrapper || (await this.getPanelWrapper());
await this.scrollPanelIntoView(wrapper);
if (await this.testSubjects.descendantExists(EDIT_PANEL_DATA_TEST_SUBJ, wrapper)) {
// navigate to the editor
await this.clickPanelAction(EDIT_PANEL_DATA_TEST_SUBJ, wrapper);
} else {
await this.navigateToEditorFromFlyout();
// open the flyout and then navigate to the editor
await this.navigateToEditorFromFlyout(wrapper);
}
await this.header.waitUntilLoadingHasFinished();
await this.common.waitForTopNavToBeVisible();
@ -172,55 +171,55 @@ export class DashboardPanelActionsService extends FtrService {
*/
async editPanelByTitle(title = '') {
this.log.debug(`editPanelByTitle(${title})`);
const header = await this.getPanelHeading(title);
await this.clickEdit(header);
const wrapper = await this.getPanelWrapper(title);
await this.clickEdit(wrapper);
}
async clickExpandPanelToggle() {
this.log.debug(`clickExpandPanelToggle`);
await this.openContextMenu();
await this.clickContextMenuItem(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
await this.clickPanelAction(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ);
}
async removePanel(parent?: WebElementWrapper) {
async removePanel(wrapper?: WebElementWrapper) {
this.log.debug('removePanel');
await this.openContextMenu(parent);
await this.clickContextMenuItem(REMOVE_PANEL_DATA_TEST_SUBJ, parent);
await this.clickPanelAction(REMOVE_PANEL_DATA_TEST_SUBJ, wrapper);
}
async removePanelByTitle(title = '') {
this.log.debug(`removePanel(${title})`);
const header = await this.getPanelHeading(title);
this.log.debug('found header? ', Boolean(header));
await this.removePanel(header);
const wrapper = await this.getPanelWrapper(title);
await this.removePanel(wrapper);
}
async customizePanel(title = '') {
this.log.debug(`customizePanel(${title})`);
const header = await this.getPanelHeading(title);
await this.clickContextMenuItem(CUSTOMIZE_PANEL_DATA_TEST_SUBJ, header);
await this.clickPanelActionByTitle(CUSTOMIZE_PANEL_DATA_TEST_SUBJ, title);
}
async clonePanel(title = '') {
this.log.debug(`clonePanel(${title})`);
const header = await this.getPanelHeading(title);
await this.clickContextMenuItem(CLONE_PANEL_DATA_TEST_SUBJ, header);
await this.clickPanelActionByTitle(CLONE_PANEL_DATA_TEST_SUBJ, title);
await this.dashboard.waitForRenderComplete();
}
async openCopyToModalByTitle(title = '') {
this.log.debug(`copyPanelTo(${title})`);
const header = await this.getPanelHeading(title);
await this.clickContextMenuItem(COPY_PANEL_TO_DATA_TEST_SUBJ, header);
await this.clickPanelActionByTitle(COPY_PANEL_TO_DATA_TEST_SUBJ, title);
}
async openInspectorByTitle(title: string) {
async openInspector(wrapper?: WebElementWrapper) {
this.log.debug(`openInspector`);
await this.clickPanelAction(OPEN_INSPECTOR_TEST_SUBJ, wrapper);
}
async openInspectorByTitle(title = '') {
this.log.debug(`openInspector(${title})`);
const header = await this.getPanelHeading(title);
await this.openInspector(header);
const wrapper = await this.getPanelWrapper(title);
await this.openInspector(wrapper);
}
async getSearchSessionIdByTitle(title: string) {
async getSearchSessionIdByTitle(title = '') {
this.log.debug(`getSearchSessionId(${title})`);
await this.openInspectorByTitle(title);
await this.inspector.openInspectorRequestsView();
@ -231,7 +230,7 @@ export class DashboardPanelActionsService extends FtrService {
return searchSessionId;
}
async getSearchResponseByTitle(title: string) {
async getSearchResponseByTitle(title = '') {
this.log.debug(`setSearchResponse(${title})`);
await this.openInspectorByTitle(title);
await this.inspector.openInspectorRequestsView();
@ -240,31 +239,23 @@ export class DashboardPanelActionsService extends FtrService {
return response;
}
async openInspector(parent?: WebElementWrapper) {
this.log.debug(`openInspector`);
await this.clickContextMenuItem(OPEN_INSPECTOR_TEST_SUBJ, parent);
}
async legacyUnlinkFromLibrary(title = '') {
this.log.debug(`legacyUnlinkFromLibrary(${title}`);
const header = await this.getPanelHeading(title);
await this.clickContextMenuItem(LEGACY_UNLINK_FROM_LIBRARY_TEST_SUBJ, header);
await this.clickPanelActionByTitle(LEGACY_UNLINK_FROM_LIBRARY_TEST_SUBJ, title);
await this.testSubjects.existOrFail('unlinkPanelSuccess');
await this.expectNotLinkedToLibrary(title, true);
}
async unlinkFromLibrary(title = '') {
this.log.debug(`unlinkFromLibrary(${title})`);
const header = await this.getPanelHeading(title);
await this.clickContextMenuItem(UNLINK_FROM_LIBRARY_TEST_SUBJ, header);
await this.clickPanelActionByTitle(UNLINK_FROM_LIBRARY_TEST_SUBJ, title);
await this.testSubjects.existOrFail('unlinkPanelSuccess');
await this.expectNotLinkedToLibrary(title);
}
async legacySaveToLibrary(newTitle = '', oldTitle = '') {
this.log.debug(`legacySaveToLibrary(${newTitle},${oldTitle})`);
const header = await this.getPanelHeading(oldTitle);
await this.clickContextMenuItem(LEGACY_SAVE_TO_LIBRARY_TEST_SUBJ, header);
await this.clickPanelActionByTitle(LEGACY_SAVE_TO_LIBRARY_TEST_SUBJ, oldTitle);
await this.testSubjects.setValue('savedObjectTitle', newTitle, {
clearWithKeyboard: true,
});
@ -275,8 +266,7 @@ export class DashboardPanelActionsService extends FtrService {
async saveToLibrary(newTitle = '', oldTitle = '') {
this.log.debug(`saveToLibraryByTitle(${newTitle},${oldTitle})`);
const header = await this.getPanelHeading(oldTitle);
await this.clickContextMenuItem(SAVE_TO_LIBRARY_TEST_SUBJ, header);
await this.clickPanelActionByTitle(SAVE_TO_LIBRARY_TEST_SUBJ, oldTitle);
await this.testSubjects.setValue('savedObjectTitle', newTitle, {
clearWithKeyboard: true,
});
@ -285,18 +275,31 @@ export class DashboardPanelActionsService extends FtrService {
await this.expectLinkedToLibrary(newTitle);
}
async panelActionExists(testSubject: string, wrapper?: WebElementWrapper) {
this.log.debug(`panelActionExists(${testSubject})`);
return wrapper
? await this.testSubjects.descendantExists(testSubject, wrapper)
: await this.testSubjects.exists(testSubject, { allowHidden: true });
}
async panelActionExistsByTitle(testSubject: string, title = '') {
this.log.debug(`panelActionExists(${testSubject}) on "${title}"`);
const wrapper = await this.getPanelWrapper(title);
return await this.panelActionExists(testSubject, wrapper);
}
async expectExistsPanelAction(testSubject: string, title = '') {
this.log.debug('expectExistsPanelAction', testSubject, title);
const panelWrapper = await this.getPanelHeading(title);
await this.openContextMenu(panelWrapper);
if (!(await this.testSubjects.exists(testSubject, { timeout: 1000 }))) {
if (await this.hasContextMenuMoreItem()) {
await this.clickContextMenuMoreItem();
}
await this.testSubjects.existOrFail(testSubject, { timeout: 1000 });
const wrapper = await this.getPanelWrapper(title);
const exists = await this.panelActionExists(testSubject, wrapper);
if (!exists) {
await this.openContextMenu(wrapper);
await this.testSubjects.existOrFail(testSubject, { allowHidden: true });
await this.toggleContextMenu(wrapper);
}
await this.toggleContextMenu(panelWrapper);
}
async expectExistsRemovePanelAction(title = '') {
@ -324,15 +327,16 @@ export class DashboardPanelActionsService extends FtrService {
}
async expectMissingPanelAction(testSubject: string, title = '') {
this.log.debug(`expectMissingPanelAction(${title})`, testSubject);
const panelWrapper = await this.getPanelHeading(title);
await this.openContextMenu(panelWrapper);
await this.testSubjects.missingOrFail(testSubject);
if (await this.hasContextMenuMoreItem()) {
await this.clickContextMenuMoreItem();
this.log.debug('expectMissingPanelAction', testSubject, title);
const wrapper = await this.getPanelWrapper(title);
const exists = await this.panelActionExists(testSubject, wrapper);
if (!exists) {
await this.openContextMenu(wrapper);
await this.testSubjects.missingOrFail(testSubject);
await this.toggleContextMenu(wrapper);
}
await this.toggleContextMenu(panelWrapper);
}
async expectMissingEditPanelAction(title = '') {
@ -352,10 +356,21 @@ export class DashboardPanelActionsService extends FtrService {
async getPanelHeading(title = '') {
this.log.debug(`getPanelHeading(${title})`);
if (!title) return await this.find.byClassName('embPanel__header');
if (!title) return await this.find.byClassName('embPanel__wrapper');
return await this.testSubjects.find(`embeddablePanelHeading-${title.replace(/\s/g, '')}`);
}
async getPanelWrapper(title = '') {
this.log.debug(`getPanelWrapper(${title})`);
if (!title) return await this.find.byClassName('embPanel__hoverActionsAnchor');
return await this.testSubjects.find(`embeddablePanelHoverActions-${title.replace(/\s/g, '')}`);
}
async getPanelWrapperById(embeddableId: string) {
this.log.debug(`getPanelWrapperById(${embeddableId})`);
return await this.find.byCssSelector(`[data-test-embeddable-id="${embeddableId}"]`);
}
async getActionWebElementByText(text: string): Promise<WebElementWrapper> {
this.log.debug(`getActionWebElement: "${text}"`);
const menu = await this.testSubjects.find('multipleActionsContextMenu');
@ -370,28 +385,23 @@ export class DashboardPanelActionsService extends FtrService {
throw new Error(`No action matching text "${text}"`);
}
async canConvertToLens(parent?: WebElementWrapper) {
async canConvertToLens(wrapper?: WebElementWrapper) {
this.log.debug('canConvertToLens');
await this.openContextMenu(parent);
const isActionVisible = await this.testSubjects.exists(CONVERT_TO_LENS_TEST_SUBJ);
if (!isActionVisible) await this.clickContextMenuMoreItem();
return await this.testSubjects.exists(CONVERT_TO_LENS_TEST_SUBJ, { timeout: 1000 });
await this.openContextMenu(wrapper);
return await this.testSubjects.exists(CONVERT_TO_LENS_TEST_SUBJ, { timeout: 500 });
}
async canConvertToLensByTitle(title = '') {
this.log.debug(`canConvertToLens(${title})`);
const header = await this.getPanelHeading(title);
await this.openContextMenu(header);
const isActionVisible = await this.testSubjects.exists(CONVERT_TO_LENS_TEST_SUBJ);
if (!isActionVisible) await this.clickContextMenuMoreItem();
return await this.testSubjects.exists(CONVERT_TO_LENS_TEST_SUBJ, { timeout: 1000 });
const wrapper = await this.getPanelWrapper(title);
return await this.canConvertToLens(wrapper);
}
async convertToLens(parent?: WebElementWrapper) {
async convertToLens(wrapper?: WebElementWrapper) {
this.log.debug('convertToLens');
await this.retry.try(async () => {
if (!(await this.canConvertToLens(parent))) {
if (!(await this.canConvertToLens(wrapper))) {
throw new Error('Convert to Lens option not found');
}
@ -401,29 +411,31 @@ export class DashboardPanelActionsService extends FtrService {
async convertToLensByTitle(title = '') {
this.log.debug(`convertToLens(${title})`);
const header = await this.getPanelHeading(title);
return await this.convertToLens(header);
const wrapper = await this.getPanelWrapper(title);
return await this.convertToLens(wrapper);
}
public async expectLinkedToLibrary(title = '', legacy?: boolean) {
async expectLinkedToLibrary(title = '', legacy?: boolean) {
this.log.debug(`expectLinkedToLibrary(${title})`);
const isViewMode = await this.dashboard.getIsInViewMode();
if (isViewMode) await this.dashboard.switchToEditMode();
if (legacy) {
await this.expectExistsPanelAction(LEGACY_UNLINK_FROM_LIBRARY_TEST_SUBJ, title);
} else {
await this.expectExistsPanelAction(UNLINK_FROM_LIBRARY_TEST_SUBJ, title);
}
await this.expectMissingPanelAction(LEGACY_SAVE_TO_LIBRARY_TEST_SUBJ, title);
await this.expectMissingPanelAction(SAVE_TO_LIBRARY_TEST_SUBJ, title);
if (isViewMode) await this.dashboard.clickCancelOutOfEditMode();
}
public async expectNotLinkedToLibrary(title = '', legacy?: boolean) {
async expectNotLinkedToLibrary(title = '', legacy?: boolean) {
this.log.debug(`expectNotLinkedToLibrary(${title})`);
const isViewMode = await this.dashboard.getIsInViewMode();
if (isViewMode) await this.dashboard.switchToEditMode();
if (legacy) {
await this.expectExistsPanelAction(LEGACY_SAVE_TO_LIBRARY_TEST_SUBJ, title);
} else {
await this.expectExistsPanelAction(SAVE_TO_LIBRARY_TEST_SUBJ, title);
}
await this.expectMissingPanelAction(LEGACY_UNLINK_FROM_LIBRARY_TEST_SUBJ, title);
await this.expectMissingPanelAction(UNLINK_FROM_LIBRARY_TEST_SUBJ, title);
if (isViewMode) await this.dashboard.clickCancelOutOfEditMode();
}
}

View file

@ -37,7 +37,7 @@ export function DashboardDrilldownPanelActionsProvider({
async clickCreateDrilldown() {
log.debug('clickCreateDrilldown');
await this.expectExistsCreateDrilldownAction();
await dashboardPanelActions.clickContextMenuItem(CREATE_DRILLDOWN_DATA_TEST_SUBJ);
await dashboardPanelActions.clickPanelAction(CREATE_DRILLDOWN_DATA_TEST_SUBJ);
}
async expectExistsManageDrilldownsAction() {
@ -52,7 +52,7 @@ export function DashboardDrilldownPanelActionsProvider({
async clickManageDrilldowns() {
log.debug('clickManageDrilldowns');
await dashboardPanelActions.clickContextMenuItem(MANAGE_DRILLDOWNS_DATA_TEST_SUBJ);
await dashboardPanelActions.clickPanelAction(MANAGE_DRILLDOWNS_DATA_TEST_SUBJ);
}
async expectMultipleActionsMenuOpened() {
@ -93,14 +93,13 @@ export function DashboardDrilldownPanelActionsProvider({
async getPanelDrilldownCount(panelIndex = 0): Promise<number> {
log.debug('getPanelDrilldownCount');
const panel = (await dashboard.getDashboardPanels())[panelIndex];
await dashboardPanelActions.openContextMenu(panel);
try {
const exists = await testSubjects.exists(MANAGE_DRILLDOWNS_DATA_TEST_SUBJ, {
timeout: 500,
});
if (!exists) {
await dashboardPanelActions.clickContextMenuMoreItem();
await dashboardPanelActions.openContextMenu(panel);
if (!(await testSubjects.exists(MANAGE_DRILLDOWNS_DATA_TEST_SUBJ, { timeout: 500 }))) {
return 0;
}

View file

@ -23,10 +23,6 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
it('allows to register links into the context menu', async () => {
await dashboardPanelActions.openContextMenu();
const actionExists = await testSubjects.exists('embeddablePanelAction-samplePanelLink');
if (!actionExists) {
await dashboardPanelActions.clickContextMenuMoreItem();
}
const actionElement = await testSubjects.find('embeddablePanelAction-samplePanelLink');
const actionElementTag = await actionElement.getTagName();
expect(actionElementTag).to.be('a');

View file

@ -66,7 +66,7 @@ export function containerStyle(): ExpressionFunctionDefinition<
types: ['string'],
help: argHelp.overflow,
options: Object.values(Overflow),
default: 'hidden',
default: 'visible',
},
padding: {
types: ['string'],

View file

@ -140,9 +140,9 @@ describe('containerStyle', () => {
result = fn(null, { overflow: 'hidden' });
expect(result).toHaveProperty('overflow', 'hidden');
});
it(`defaults to 'hidden'`, () => {
it(`defaults to 'visible'`, () => {
const result = fn(null);
expect(result).toHaveProperty('overflow', 'hidden');
expect(result).toHaveProperty('overflow', 'visible');
});
});
});

View file

@ -3,6 +3,7 @@
outline: none !important;
background: none;
border-radius: 0 !important;
box-shadow: none;
.embPanel__title {
margin-bottom: $euiSizeXS;
@ -24,6 +25,15 @@
}
}
.embPanel__hoverActionsLeft, .embPanel__hoverActions > .embPanel--dragHandle {
visibility: hidden;
}
.embPanel--dragHandle:hover {
background-color: transparentize($euiColorWarning, lightOrDarkTheme(.9, .7));
cursor: move;
}
.euiTable {
background: none;
}

View file

@ -1,10 +1,32 @@
.canvasElement {
height: 100%;
width: 100%;
overflow: hidden;
.embPanel {
.embPanel__content {
overflow: visible;
}
.embPanel__hoverActionsLeft, .embPanel__dragHandle {
visibility: hidden;
}
}
}
.canvasElement__content {
height: 100%;
width: 100%;
}
.canvas__element--selected {
.embPanel__hoverActionsAnchor {
.embPanel__hoverActionsWrapper {
z-index: $euiZLevel9;
top: -$euiSizeXL;
.embPanel__hoverActions {
opacity: 1;
}
}
}
}

View file

@ -7,6 +7,7 @@
import React from 'react';
import { omitBy, isNil } from 'lodash';
import classNames from 'classnames';
import { css } from '@emotion/react';
import { ExpressionRenderer } from '@kbn/expressions-plugin/common';
@ -29,6 +30,8 @@ export interface Props {
backgroundColor: string;
selectElement: () => void;
state: string;
selectedElementId: string | null;
id: string;
}
export const ElementContent = (props: Props) => {
@ -59,7 +62,9 @@ export const ElementContent = (props: Props) => {
<div
css={css(renderable.css)}
// TODO: 'canvas__element' was added for BWC, It can be removed after a while
className={'canvas__element canvasElement'}
className={classNames('canvas__element', 'canvasElement', {
'canvas__element--selected': props?.selectedElementId === props.id,
})}
style={{ ...containerStyle, width, height }}
data-test-subj="canvasWorkpadPageElementContent"
>

View file

@ -7,7 +7,7 @@
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { getSelectedPage, getPageById } from '../../state/selectors/workpad';
import { getSelectedPage, getPageById, getSelectedElementId } from '../../state/selectors/workpad';
import { ElementContent as Component, Props as ComponentProps } from './element_content';
import { State } from '../../../types';
import { getCanvasExpressionService } from '../../services/canvas_expressions_service';
@ -16,6 +16,7 @@ export type Props = Omit<ComponentProps, 'renderFunction' | 'backgroundColor'>;
export const ElementContent = (props: Props) => {
const selectedPageId = useSelector(getSelectedPage);
const selectedElementId = useSelector(getSelectedElementId);
const backgroundColor =
useSelector((state: State) => getPageById(state, selectedPageId)?.style.background) || '';
const { renderable } = props;
@ -24,5 +25,5 @@ export const ElementContent = (props: Props) => {
return renderable ? getCanvasExpressionService().getRenderer(renderable.as) : null;
}, [renderable]);
return <Component {...{ ...props, renderFunction, backgroundColor }} />;
return <Component {...{ ...props, renderFunction, backgroundColor, selectedElementId }} />;
};

View file

@ -11,11 +11,12 @@ import { Positionable } from '../positionable';
import { ElementContent } from '../element_content';
export const ElementWrapper = (props) => {
const { renderable, transformMatrix, width, height, state, handlers } = props;
const { renderable, transformMatrix, width, height, state, handlers, id } = props;
return (
<Positionable transformMatrix={transformMatrix} width={width} height={height}>
<ElementContent
id={id}
renderable={renderable}
state={state}
handlers={handlers}
@ -35,4 +36,5 @@ ElementWrapper.propTypes = {
renderable: PropTypes.object,
state: PropTypes.string,
handlers: PropTypes.object.isRequired,
id: PropTypes.string.isRequired,
};

View file

@ -71,7 +71,7 @@ export const ElementWrapper = compose(
// remove element and createHandlers from props passed to component
// eslint-disable-next-line no-unused-vars
const { element, createHandlers, selectedPage, ...restProps } = props;
return restProps;
return { ...restProps, id: element.id };
})
)(Component);

View file

@ -26,6 +26,8 @@ export const createAddToExistingCaseLensAction = (
return createAction<EmbeddableApiContext>({
id: ACTION_ID,
type: 'actionButton',
order: 10,
grouping: [{ id: 'cases', order: 6 }],
getIconType: () => 'casesApp',
getDisplayName: () => ADD_TO_EXISTING_CASE_DISPLAYNAME,
isCompatible: async ({ embeddable }) => {

View file

@ -42,13 +42,13 @@ export const getMockLensApi = (
({
type: 'lens',
getSavedVis: () => {},
canViewUnderlyingData: () => {},
canViewUnderlyingData$: new BehaviorSubject(true),
getViewUnderlyingDataArgs: () => {},
getFullAttributes: () => {
return mockLensAttributes;
},
panelTitle: new BehaviorSubject('myPanel'),
hidePanelTitle: new BehaviorSubject('false'),
hidePanelTitle: new BehaviorSubject(false),
timeslice$: new BehaviorSubject<[number, number] | undefined>(undefined),
timeRange$: new BehaviorSubject<TimeRange | undefined>({
from,

View file

@ -77,3 +77,5 @@ export const createDrilldownTemplatesFromSiblings = (
};
export const DRILLDOWN_MAX_WIDTH = 500;
export const DRILLDOWN_ACTION_GROUP = { id: 'drilldown', order: 3 } as const;

View file

@ -36,6 +36,7 @@ import React from 'react';
import { StartDependencies } from '../../../../plugin';
import {
createDrilldownTemplatesFromSiblings,
DRILLDOWN_ACTION_GROUP,
DRILLDOWN_MAX_WIDTH,
ensureNestedTriggers,
} from '../drilldown_shared';
@ -62,6 +63,7 @@ export class FlyoutCreateDrilldownAction implements Action<EmbeddableApiContext>
public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN;
public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN;
public order = 12;
public grouping = [DRILLDOWN_ACTION_GROUP];
constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {}

View file

@ -34,6 +34,7 @@ import { MenuItem } from './menu_item';
import { StartDependencies } from '../../../../plugin';
import {
createDrilldownTemplatesFromSiblings,
DRILLDOWN_ACTION_GROUP,
DRILLDOWN_MAX_WIDTH,
ensureNestedTriggers,
} from '../drilldown_shared';
@ -57,6 +58,7 @@ export class FlyoutEditDrilldownAction implements Action<EmbeddableApiContext> {
public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN;
public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN;
public order = 10;
public grouping = [DRILLDOWN_ACTION_GROUP];
constructor(protected readonly params: FlyoutEditDrilldownParams) {}

View file

@ -7,7 +7,7 @@
import { partition, uniqBy } from 'lodash';
import React from 'react';
import type { Observable } from 'rxjs';
import { BehaviorSubject, Observable } from 'rxjs';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { render, unmountComponentAtNode } from 'react-dom';
@ -1034,6 +1034,8 @@ export class Embeddable
this.activeData = newActiveData;
this.renderUserMessages();
this.loadViewUnderlyingDataArgs();
};
private onRender: ExpressionWrapperProps['onRender$'] = () => {
@ -1480,7 +1482,7 @@ export class Embeddable
}
}
private async loadViewUnderlyingDataArgs(): Promise<boolean> {
private async loadViewUnderlyingDataArgs(): Promise<void> {
if (
!this.savedVis ||
!this.activeData ||
@ -1489,13 +1491,15 @@ export class Embeddable
!this.activeVisualization ||
!this.activeVisualizationState
) {
return false;
this.canViewUnderlyingData$.next(false);
return;
}
const mergedSearchContext = this.getMergedSearchContext();
if (!mergedSearchContext.timeRange) {
return false;
this.canViewUnderlyingData$.next(false);
return;
}
const viewUnderlyingDataArgs = getViewUnderlyingDataArgs({
@ -1517,7 +1521,8 @@ export class Embeddable
if (loaded) {
this.viewUnderlyingDataArgs = viewUnderlyingDataArgs;
}
return loaded;
this.canViewUnderlyingData$.next(loaded);
}
/**
@ -1529,9 +1534,7 @@ export class Embeddable
return this.viewUnderlyingDataArgs;
}
public canViewUnderlyingData() {
return this.loadViewUnderlyingDataArgs();
}
public canViewUnderlyingData$ = new BehaviorSubject<boolean>(false);
async initializeOutput() {
if (!this.savedVis) {

View file

@ -10,6 +10,7 @@ import type {
HasType,
PublishesUnifiedSearch,
PublishesPanelTitle,
PublishingSubject,
} from '@kbn/presentation-publishing';
import {
apiIsOfType,
@ -20,7 +21,7 @@ import { LensSavedObjectAttributes, ViewUnderlyingDataArgs } from '../embeddable
export type HasLensConfig = HasType<'lens'> & {
getSavedVis: () => Readonly<LensSavedObjectAttributes | undefined>;
canViewUnderlyingData: () => Promise<boolean>;
canViewUnderlyingData$: PublishingSubject<boolean>;
getViewUnderlyingDataArgs: () => ViewUnderlyingDataArgs;
getFullAttributes: () => LensSavedObjectAttributes | undefined;
};
@ -35,7 +36,7 @@ export const isLensApi = (api: unknown): api is LensApi => {
api &&
apiIsOfType(api, 'lens') &&
typeof (api as HasLensConfig).getSavedVis === 'function' &&
typeof (api as HasLensConfig).canViewUnderlyingData === 'function' &&
(api as HasLensConfig).canViewUnderlyingData$ &&
typeof (api as HasLensConfig).getViewUnderlyingDataArgs === 'function' &&
typeof (api as HasLensConfig).getFullAttributes === 'function' &&
apiPublishesPanelTitle(api) &&

View file

@ -22,7 +22,7 @@ describe('open in discover action', () => {
query$: new BehaviorSubject({ query: 'test', language: 'kuery' }),
timeRange$: new BehaviorSubject({ from: 'now-15m', to: 'now' }),
getSavedVis: jest.fn(() => undefined),
canViewUnderlyingData: () => Promise.resolve(true),
canViewUnderlyingData$: new BehaviorSubject(true),
getFullAttributes: jest.fn(() => undefined),
getViewUnderlyingDataArgs: jest.fn(() => ({
dataViewSpec: { id: 'index-pattern-id' },
@ -78,8 +78,7 @@ describe('open in discover action', () => {
// setup
const embeddable = {
...compatibleEmbeddableApi,
canViewUnderlyingData: jest.fn(() => Promise.resolve(false)),
getViewUnderlyingDataArgs: jest.fn(() => undefined),
canViewUnderlyingData$: { getValue: jest.fn(() => false) },
};
// test false
@ -93,10 +92,11 @@ describe('open in discover action', () => {
} as ActionExecutionContext<EmbeddableApiContext>)
).toBeFalsy();
expect(embeddable.canViewUnderlyingData).toHaveBeenCalledTimes(1);
expect(embeddable.canViewUnderlyingData$.getValue).toHaveBeenCalledTimes(1);
// test true
embeddable.canViewUnderlyingData = jest.fn(() => Promise.resolve(true));
embeddable.canViewUnderlyingData$.getValue = jest.fn(() => true);
expect(
await createOpenInDiscoverAction(
{} as DiscoverAppLocator,
@ -107,7 +107,7 @@ describe('open in discover action', () => {
} as ActionExecutionContext<EmbeddableApiContext>)
).toBeTruthy();
expect(embeddable.canViewUnderlyingData).toHaveBeenCalledTimes(1);
expect(embeddable.canViewUnderlyingData$.getValue).toHaveBeenCalledTimes(1);
});
});

View file

@ -6,10 +6,11 @@
*/
import { i18n } from '@kbn/i18n';
import { createAction } from '@kbn/ui-actions-plugin/public';
import { Action, createAction, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import type { DataViewsService } from '@kbn/data-views-plugin/public';
import type { DiscoverAppLocator } from './open_in_discover_helpers';
import { LensApi } from '../embeddable';
const ACTION_OPEN_IN_DISCOVER = 'ACTION_OPEN_IN_DISCOVER';
@ -19,12 +20,12 @@ export const createOpenInDiscoverAction = (
locator: DiscoverAppLocator,
dataViews: Pick<DataViewsService, 'get'>,
hasDiscoverAccess: boolean
) =>
createAction<EmbeddableApiContext>({
) => {
const actionDefinition = {
type: ACTION_OPEN_IN_DISCOVER,
id: ACTION_OPEN_IN_DISCOVER,
order: 19, // right after Inspect which is 20
getIconType: () => 'popout',
order: 20, // right before Inspect which is 19
getIconType: () => 'discoverApp',
getDisplayName: () =>
i18n.translate('xpack.lens.action.exploreInDiscover', {
defaultMessage: 'Explore in Discover',
@ -47,8 +48,26 @@ export const createOpenInDiscoverAction = (
embeddable: context.embeddable,
});
},
couldBecomeCompatible: ({ embeddable }: EmbeddableApiContext) => {
if (!typeof (embeddable as LensApi).canViewUnderlyingData$)
throw new IncompatibleActionError();
return hasDiscoverAccess && Boolean((embeddable as LensApi).canViewUnderlyingData$);
},
subscribeToCompatibilityChanges: (
{ embeddable }: EmbeddableApiContext,
onChange: (isCompatible: boolean, action: Action<EmbeddableApiContext>) => void
) => {
if (!typeof (embeddable as LensApi).canViewUnderlyingData$)
throw new IncompatibleActionError();
return (embeddable as LensApi).canViewUnderlyingData$.subscribe((canViewUnderlyingData) => {
onChange(canViewUnderlyingData, actionDefinition);
});
},
execute: async (context: EmbeddableApiContext) => {
const { execute } = await getDiscoverHelpersAsync();
return execute({ ...context, locator, dataViews, hasDiscoverAccess });
},
});
};
return createAction<EmbeddableApiContext>(actionDefinition);
};

View file

@ -31,10 +31,10 @@ type Context = EmbeddableApiContext & {
timeFieldName?: string;
};
export async function isCompatible({ hasDiscoverAccess, embeddable }: Context) {
export function isCompatible({ hasDiscoverAccess, embeddable }: Context) {
if (!hasDiscoverAccess) return false;
try {
return isLensApi(embeddable) && (await embeddable.canViewUnderlyingData());
return isLensApi(embeddable) && embeddable.canViewUnderlyingData$.getValue();
} catch (e) {
// Fetching underlying data failed, log the error and behave as if the action is not compatible
// eslint-disable-next-line no-console

View file

@ -23,6 +23,8 @@ export function createVisToADJobAction(
return {
id: 'create-ml-ad-job-action',
type: CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION,
order: 8,
grouping: [{ id: 'ml', order: 3 }],
getIconType(context): string {
return 'machineLearningApp';
},

View file

@ -190,5 +190,6 @@ const getAddToCaseAction = ({ callback }: { callback: () => void }): Action => {
return;
},
order: 48,
grouping: [{ id: 'observability', order: 5 }],
};
};

View file

@ -6018,7 +6018,6 @@
"presentationPanel.contextMenu.ariaLabel": "Options de panneau",
"presentationPanel.contextMenu.ariaLabelWithIndex": "Options pour le panneau {index}",
"presentationPanel.contextMenu.ariaLabelWithTitle": "Options de panneau pour {title}",
"presentationPanel.contextMenu.loadingTitle": "Options",
"presentationPanel.contextMenuTrigger.description": "Une nouvelle action sera ajoutée au menu contextuel du panneau",
"presentationPanel.contextMenuTrigger.title": "Menu contextuel",
"presentationPanel.emptyErrorMessage": "Erreur",

View file

@ -5772,7 +5772,6 @@
"presentationPanel.contextMenu.ariaLabel": "パネルオプション",
"presentationPanel.contextMenu.ariaLabelWithIndex": "パネル{index}のオプション",
"presentationPanel.contextMenu.ariaLabelWithTitle": "{title} のパネルオプション",
"presentationPanel.contextMenu.loadingTitle": "オプション",
"presentationPanel.contextMenuTrigger.description": "新しいアクションがパネルのコンテキストメニューに追加されます",
"presentationPanel.contextMenuTrigger.title": "コンテキストメニュー",
"presentationPanel.emptyErrorMessage": "エラー",

View file

@ -5785,7 +5785,6 @@
"presentationPanel.contextMenu.ariaLabel": "面板选项",
"presentationPanel.contextMenu.ariaLabelWithIndex": "面板 {index} 的选项",
"presentationPanel.contextMenu.ariaLabelWithTitle": "{title} 的面板选项",
"presentationPanel.contextMenu.loadingTitle": "选项",
"presentationPanel.contextMenuTrigger.description": "会将一个新操作添加到该面板的上下文菜单",
"presentationPanel.contextMenuTrigger.title": "上下文菜单",
"presentationPanel.emptyErrorMessage": "错误",

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
@ -18,7 +17,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// Failing: See https://github.com/elastic/kibana/issues/147667
describe.skip('Dashboard panel options a11y tests', () => {
let header: WebElementWrapper;
const title = '[Flights] Flight count';
before(async () => {
await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', {
useActualUrl: true,
@ -28,7 +27,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.common.navigateToApp('dashboard');
await testSubjects.click('dashboardListingTitleLink-[Flights]-Global-Flight-Dashboard');
header = await dashboardPanelActions.getPanelHeading('[Flights] Flight count');
});
after(async () => {
@ -40,13 +38,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// dashboard panel options in view mode
it('dashboard panel - open menu', async () => {
await dashboardPanelActions.toggleContextMenu(header);
await dashboardPanelActions.toggleContextMenuByTitle(title);
await a11y.testAppSnapshot();
await dashboardPanelActions.toggleContextMenu(header);
await dashboardPanelActions.toggleContextMenuByTitle(title);
});
it('dashboard panel - customize time range', async () => {
await dashboardPanelActions.toggleContextMenu(header);
await dashboardPanelActions.toggleContextMenuByTitle(title);
await testSubjects.click('embeddablePanelAction-CUSTOM_TIME_RANGE');
await a11y.testAppSnapshot();
await testSubjects.click('cancelPerPanelTimeRangeButton');
@ -79,21 +77,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await inspector.close();
});
it('dashboard panel- more options in view mode', async () => {
await dashboardPanelActions.openContextMenuMorePanel(header);
await a11y.testAppSnapshot();
});
it('dashboard panel - maximize', async () => {
await dashboardPanelActions.openContextMenuMorePanel(header);
await dashboardPanelActions.clickExpandPanelToggle();
await a11y.testAppSnapshot();
await dashboardPanelActions.openContextMenuMorePanel(header);
await dashboardPanelActions.clickExpandPanelToggle();
});
it('dashboard panel - copy to dashboard', async () => {
await dashboardPanelActions.openContextMenuMorePanel(header);
await dashboardPanelActions.openContextMenuByTitle(title);
await testSubjects.click('embeddablePanelAction-copyToDashboard');
await a11y.testAppSnapshot();
await testSubjects.click('cancelCopyToButton');
@ -103,14 +94,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('dashboard panel - clone panel', async () => {
await testSubjects.click('dashboardEditMode');
await dashboardPanelActions.toggleContextMenu(header);
await dashboardPanelActions.openContextMenuByTitle(title);
await testSubjects.click('embeddablePanelAction-clonePanel');
await toasts.dismissAll();
await a11y.testAppSnapshot();
});
it('dashboard panel - edit panel title', async () => {
await dashboardPanelActions.toggleContextMenu(header);
await dashboardPanelActions.customizePanel();
await a11y.testAppSnapshot();
await testSubjects.click('customEmbeddablePanelHideTitleSwitch');
@ -120,8 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('dashboard panel - Create drilldown panel', async () => {
await dashboardPanelActions.toggleContextMenu(header);
await testSubjects.click('embeddablePanelMore-mainMenu');
await dashboardPanelActions.openContextMenuByTitle(title);
await testSubjects.click('embeddablePanelAction-OPEN_FLYOUT_ADD_DRILLDOWN');
await a11y.testAppSnapshot();
await testSubjects.click('actionFactoryItem-DASHBOARD_TO_DASHBOARD_DRILLDOWN');
@ -136,30 +125,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('dashboard panel - manage drilldown', async () => {
await dashboardPanelActions.toggleContextMenu(header);
await testSubjects.click('embeddablePanelMore-mainMenu');
await dashboardPanelActions.openContextMenuByTitle(title);
await testSubjects.click('embeddablePanelAction-OPEN_FLYOUT_EDIT_DRILLDOWN');
await a11y.testAppSnapshot();
await testSubjects.click('euiFlyoutCloseButton');
});
it('dashboard panel - more options in edit view', async () => {
await dashboardPanelActions.openContextMenuMorePanel(header);
await a11y.testAppSnapshot();
});
it('dashboard panel - save to library', async () => {
await dashboardPanelActions.openContextMenuMorePanel(header);
await testSubjects.click('embeddablePanelAction-saveToLibrary');
await dashboardPanelActions.legacySaveToLibrary('', title);
await a11y.testAppSnapshot();
await testSubjects.click('saveCancelButton');
});
it('dashboard panel - replace panel', async () => {
await dashboardPanelActions.openContextMenuMorePanel(header);
await testSubjects.click('embeddablePanelAction-replacePanel');
await a11y.testAppSnapshot();
await testSubjects.click('euiFlyoutCloseButton');
});
});
}

View file

@ -34,8 +34,38 @@ export default function canvasLensTest({ getService, getPageObjects }: FtrProvid
await kibanaServer.savedObjects.cleanStandardList();
});
describe('by-value', () => {
it('creates new lens embeddable', async () => {
await canvas.createNewVis('lens');
await lens.goToTimeRange();
await lens.configureDimension({
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
operation: 'date_histogram',
field: '@timestamp',
});
await lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'average',
field: 'bytes',
});
await lens.saveAndReturn();
await header.waitUntilLoadingHasFinished();
await testSubjects.exists('xyVisChart');
});
it('edits lens by-value embeddable', async () => {
await header.waitUntilLoadingHasFinished();
await dashboardPanelActions.clickEdit();
await lens.saveAndReturn();
await header.waitUntilLoadingHasFinished();
await testSubjects.exists('xyVisChart');
});
});
describe('by-reference', () => {
it('adds existing lens embeddable from the visualize library', async () => {
await canvas.goToListingPageViaBreadcrumbs();
await canvas.createNewWorkpad();
await canvas.clickAddFromLibrary();
await dashboardAddPanel.addEmbeddable('Artistpreviouslyknownaslens', 'lens');
await testSubjects.existOrFail('embeddablePanelHeading-Artistpreviouslyknownaslens');
@ -57,39 +87,9 @@ export default function canvasLensTest({ getService, getPageObjects }: FtrProvid
});
});
describe('by-value', () => {
it('creates new lens embeddable', async () => {
await canvas.addNewPage();
await canvas.createNewVis('lens');
await lens.goToTimeRange();
await lens.configureDimension({
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
operation: 'date_histogram',
field: '@timestamp',
});
await lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'average',
field: 'bytes',
});
await lens.saveAndReturn();
await header.waitUntilLoadingHasFinished();
await testSubjects.exists('xyVisChart');
});
it('edits lens by-value embeddable', async () => {
await header.waitUntilLoadingHasFinished();
const panelHeader = await testSubjects.find('embeddablePanelHeading-');
await dashboardPanelActions.openContextMenu(panelHeader);
await dashboardPanelActions.clickEdit();
await lens.saveAndReturn();
await header.waitUntilLoadingHasFinished();
await testSubjects.exists('xyVisChart');
});
});
describe('switch page smoke test', () => {
it('loads embeddables on page change', async () => {
await canvas.addNewPage();
await canvas.goToPreviousPage();
await header.waitUntilLoadingHasFinished();
await lens.assertLegacyMetric('Maximum of bytes', '16,788');

View file

@ -36,7 +36,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('edits map by-value embeddable', async () => {
const originalEmbeddableCount = await canvas.getEmbeddableCount();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await maps.saveMap('canvas test map');
const embeddableCount = await canvas.getEmbeddableCount();

View file

@ -71,7 +71,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('edits tsvb by-value embeddable', async () => {
const originalEmbeddableCount = await canvas.getEmbeddableCount();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await visualize.saveVisualizationAndReturn();
await retry.try(async () => {
@ -93,7 +92,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('edits vega by-value embeddable', async () => {
const originalEmbeddableCount = await canvas.getEmbeddableCount();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await visualize.saveVisualizationAndReturn();
await retry.try(async () => {

View file

@ -47,7 +47,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('edits to a by value lens panel are properly applied', async () => {
await dashboard.waitForRenderComplete();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await lens.switchToVisualization('pie');
await lens.saveAndReturn();
@ -60,7 +59,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('editing and saving a lens by value panel retains number of panels', async () => {
const originalPanelCount = await dashboard.getPanelCount();
await dashboard.waitForRenderComplete();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await lens.switchToVisualization('treemap');
await lens.saveAndReturn();
@ -73,7 +71,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const newTitle = 'look out library, here I come!';
const originalPanelCount = await dashboard.getPanelCount();
await dashboard.waitForRenderComplete();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await lens.save(newTitle, false, true);
await dashboard.waitForRenderComplete();

View file

@ -42,7 +42,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await dashboard.switchToEditMode();
}
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await maps.clickAddLayer();
await maps.selectLayerGroupCard();

View file

@ -17,7 +17,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const { dashboard, lens } = getPageObjects(['dashboard', 'lens']);
const EMPTY_TITLE = '[No Title]';
const EMPTY_TITLE = undefined;
describe('panel titles', () => {
before(async () => {
@ -112,7 +112,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('resetting description on a by reference panel sets it to the library title', async () => {
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.navigateToEditorFromFlyout();
// legacySaveToLibrary UI cannot set description
await lens.save(

View file

@ -50,11 +50,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('action exists in panel context menu', async () => {
await dashboard.loadSavedDashboard(drilldowns.DASHBOARD_WITH_PIE_CHART_NAME);
await panelActions.openContextMenu();
await testSubjects.existOrFail(ACTION_TEST_SUBJ);
await panelActions.expectExistsPanelAction(ACTION_TEST_SUBJ);
});
it('is a link <a> element', async () => {
await panelActions.openContextMenuByTitle('Visualization PieChart');
const actionElement = await testSubjects.find(ACTION_TEST_SUBJ);
const tag = await actionElement.getTagName();
@ -87,8 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
exitFromEditMode: true,
});
await panelActions.openContextMenu();
await testSubjects.clickWhenNotDisabledWithoutRetry(ACTION_TEST_SUBJ);
await panelActions.clickPanelAction(ACTION_TEST_SUBJ);
await discover.waitForDiscoverAppOnScreen();
const text = await timePicker.getShowDatesButtonText();

View file

@ -6,6 +6,7 @@
*/
import expect from '@kbn/expect';
import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
@ -17,7 +18,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const reportingService = getService('reporting');
const dashboardAddPanel = getService('dashboardAddPanel');
const filterBar = getService('filterBar');
const find = getService('find');
const retry = getService('retry');
const toasts = getService('toasts');
const { reporting, common, dashboard, timePicker } = getPageObjects([
@ -45,14 +45,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
return res.text;
};
const clickActionsMenu = async (headingTestSubj: string) => {
const savedSearchPanel = await testSubjects.find('embeddablePanelHeading-' + headingTestSubj);
await dashboardPanelActions.toggleContextMenu(savedSearchPanel);
const clickDownloadCsv = async (wrapper?: WebElementWrapper) => {
log.debug('click "Generate CSV"');
await dashboardPanelActions.clickPanelAction(
'embeddablePanelAction-generateCsvReport',
wrapper
);
await testSubjects.existOrFail('csvReportStarted'); // validate toast panel
};
const clickDownloadCsv = async () => {
log.debug('click "Generate CSV"');
await dashboardPanelActions.clickContextMenuItem('embeddablePanelAction-generateCsvReport');
const clickDownloadCsvByTitle = async (title?: string) => {
log.debug(`click "Generate CSV" on "${title}"`);
await dashboardPanelActions.clickPanelActionByTitle(
'embeddablePanelAction-generateCsvReport',
title
);
await testSubjects.existOrFail('csvReportStarted'); // validate toast panel
};
@ -82,8 +89,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Generate CSV export of a saved search panel', async function () {
await dashboard.loadSavedDashboard('Ecom Dashboard - 3 Day Period');
await clickActionsMenu('EcommerceData');
await clickDownloadCsv();
await clickDownloadCsvByTitle('EcommerceData');
const csvFile = await getCsvReportData();
expect(csvFile.length).to.be(76137);
@ -95,9 +101,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// add a filter
await filterBar.addFilter({ field: 'category', operation: 'is', value: `Men's Shoes` });
await clickActionsMenu('EcommerceData');
await clickDownloadCsv();
await clickDownloadCsvByTitle('EcommerceData');
const csvFile = await getCsvReportData();
expect(csvFile.length).to.be(17106);
@ -106,9 +110,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Downloads a saved search panel with a custom time range that does not intersect with dashboard time range', async function () {
await dashboard.loadSavedDashboard('Ecom Dashboard - 3 Day Period - custom time range');
await clickActionsMenu('EcommerceData');
await clickDownloadCsv();
await clickDownloadCsvByTitle('EcommerceData');
const csvFile = await getCsvReportData();
expect(csvFile.length).to.be(23277);
@ -117,12 +119,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Gets the correct filename if panel titles are hidden', async () => {
await dashboard.loadSavedDashboard('Ecom Dashboard Hidden Panel Titles');
const savedSearchPanel = await find.byCssSelector(
'[data-test-embeddable-id="94eab06f-60ac-4a85-b771-3a8ed475c9bb"]'
const savedSearchPanel = await dashboardPanelActions.getPanelWrapperById(
'94eab06f-60ac-4a85-b771-3a8ed475c9bb'
); // panel title is hidden
await dashboardPanelActions.toggleContextMenu(savedSearchPanel);
await clickDownloadCsv();
await clickDownloadCsv(savedSearchPanel);
await testSubjects.existOrFail('csvReportStarted');
const csvFile = await getCsvReportData();
@ -158,8 +159,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('Downloads filtered Discover saved search report', async () => {
await clickActionsMenu(TEST_SEARCH_TITLE.replace(/ /g, ''));
await clickDownloadCsv();
await clickDownloadCsvByTitle(TEST_SEARCH_TITLE);
const csvFile = await getCsvReportData();
expect(csvFile.length).to.be(2446);
@ -196,8 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('Generate CSV export of a saved search panel', async () => {
await clickActionsMenu('namessearch');
await clickDownloadCsv();
await clickDownloadCsvByTitle('namessearch');
const csvFile = await getCsvReportData();
expect(csvFile.length).to.be(166);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before After
Before After

View file

@ -54,9 +54,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
}
const checkDiscoverNavigationResult = async () => {
await dashboardPanelActions.clickContextMenuItem(
'embeddablePanelAction-ACTION_OPEN_IN_DISCOVER'
);
await dashboardPanelActions.clickPanelAction('embeddablePanelAction-ACTION_OPEN_IN_DISCOVER');
const [, discoverHandle] = await browser.getAllWindowHandles();
await browser.switchToWindow(discoverHandle);

View file

@ -49,7 +49,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await lens.save('New Lens from Modal', false, false, false, 'new');
await dashboard.waitForRenderComplete();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickInlineEdit();
log.debug('Adds a secondary dimension');
@ -90,7 +89,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardPanelActions.legacySaveToLibrary('My by reference visualization');
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickInlineEdit();
log.debug('Removes breakdown dimension');
@ -110,7 +108,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await lens.save('New Lens from Modal', false, false, false, 'new');
await dashboard.waitForRenderComplete();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickInlineEdit();
log.debug('Adds a secondary dimension');
@ -150,7 +147,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboard.waitForRenderComplete();
await elasticChart.setNewChartUiDebugFlag(true);
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickInlineEdit();
log.debug('Adds annotation');
@ -177,7 +173,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboard.waitForRenderComplete();
await elasticChart.setNewChartUiDebugFlag(true);
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickInlineEdit();
log.debug('Adds reference line');

View file

@ -42,7 +42,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
exitFromEditMode: true,
});
await dashboardPanelActions.clickContextMenuItem(OPEN_IN_DISCOVER_DATA_TEST_SUBJ);
await dashboardPanelActions.clickPanelAction(OPEN_IN_DISCOVER_DATA_TEST_SUBJ);
const [dashboardWindowHandle, discoverWindowHandle] = await browser.getAllWindowHandles();
await browser.switchToWindow(discoverWindowHandle);
@ -59,7 +59,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should show the open button for a compatible saved visualization with annotations and reference line', async () => {
await dashboard.switchToEditMode();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await header.waitUntilLoadingHasFinished();
await lens.createLayer('annotations');
@ -73,7 +72,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
exitFromEditMode: true,
});
await dashboardPanelActions.clickContextMenuItem(OPEN_IN_DISCOVER_DATA_TEST_SUBJ);
await dashboardPanelActions.clickPanelAction(OPEN_IN_DISCOVER_DATA_TEST_SUBJ);
const [dashboardWindowHandle, discoverWindowHandle] = await browser.getAllWindowHandles();
await browser.switchToWindow(discoverWindowHandle);
@ -90,7 +89,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should bring both dashboard context and visualization context to discover', async () => {
await dashboard.switchToEditMode();
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await savedQueryManagementComponent.openSavedQueryManagementComponent();
await queryBar.switchQueryLanguage('lucene');
@ -119,7 +117,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardPanelActions.expectExistsPanelAction(OPEN_IN_DISCOVER_DATA_TEST_SUBJ);
await dashboard.clickCancelOutOfEditMode();
await dashboardPanelActions.clickContextMenuItem(OPEN_IN_DISCOVER_DATA_TEST_SUBJ);
await dashboardPanelActions.clickPanelAction(OPEN_IN_DISCOVER_DATA_TEST_SUBJ);
const [dashboardWindowHandle, discoverWindowHandle] = await browser.getAllWindowHandles();
await browser.switchToWindow(discoverWindowHandle);

View file

@ -97,7 +97,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('retains its saved object tags after save and return', async () => {
await dashboardPanelActions.openContextMenu();
await dashboardPanelActions.clickEdit();
await lens.saveAndReturn();
await header.waitUntilLoadingHasFinished();

View file

@ -53,14 +53,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await dashboardCustomizePanel.clickSaveButton();
await dashboard.waitForRenderComplete();
await dashboardBadgeActions.expectExistsTimeRangeBadgeAction();
await panelActions.openContextMenu();
const editInLensExists = await testSubjects.exists(
'embeddablePanelAction-ACTION_EDIT_IN_LENS'
);
if (!editInLensExists) {
await testSubjects.click('embeddablePanelMore-mainMenu');
}
await testSubjects.click('embeddablePanelAction-ACTION_EDIT_IN_LENS');
await panelActions.convertToLens();
await lens.waitForVisualization('xyVisChart');
await retry.try(async () => {

View file

@ -38,7 +38,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
it('should filter dashboard by map extent when "filter by map extent" is enabled', async () => {
await dashboardPanelActions.clickContextMenuItemByTitle(
await dashboardPanelActions.clickPanelActionByTitle(
FILTER_BY_MAP_EXTENT_DATA_TEST_SUBJ,
'document example'
);
@ -59,7 +59,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
it('should remove map extent filter dashboard when "filter by map extent" is disabled', async () => {
await dashboardPanelActions.clickContextMenuItemByTitle(
await dashboardPanelActions.clickPanelActionByTitle(
FILTER_BY_MAP_EXTENT_DATA_TEST_SUBJ,
'document example'
);

View file

@ -77,7 +77,7 @@ export default function (ctx: FtrProviderContext) {
break;
case 'dashboard':
await dashboard.navigateToApp();
await dashboard.gotoDashboardEditMode('A Dashboard');
await dashboard.loadSavedDashboard('A Dashboard');
break;
case 'maps':
await maps.openNewMap();

View file

@ -18,7 +18,7 @@ export function MachineLearningLensVisualizationsProvider(
return {
async clickCreateMLJobMenuAction(title = '') {
await dashboardPanelActions.clickContextMenuItemByTitle(
await dashboardPanelActions.clickPanelActionByTitle(
'embeddablePanelAction-create-ml-ad-job-action',
title
);

View file

@ -402,7 +402,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await common.navigateToApp('dashboard');
await dashboard.preserveCrossAppState();
await dashboard.loadSavedDashboard(myDashboardName);
await dashboardPanelActions.clickContextMenuItem(ADD_TO_EXISTING_CASE_DATA_TEST_SUBJ);
await dashboardPanelActions.clickPanelAction(ADD_TO_EXISTING_CASE_DATA_TEST_SUBJ);
await testSubjects.click('cases-table-add-case-filter-bar');
await cases.create.createCase({
@ -434,7 +434,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await dashboard.preserveCrossAppState();
await dashboard.loadSavedDashboard(myDashboardName);
await dashboardPanelActions.clickContextMenuItem(ADD_TO_EXISTING_CASE_DATA_TEST_SUBJ);
await dashboardPanelActions.clickPanelAction(ADD_TO_EXISTING_CASE_DATA_TEST_SUBJ);
await testSubjects.click(`cases-table-row-select-${theCase.id}`);

View file

@ -77,7 +77,7 @@ export function createScenarios(
};
const tryDashboardGenerateCsvFail = async (savedSearchTitle: string) => {
await dashboardPanelActions.clickContextMenuItemByTitle(
await dashboardPanelActions.clickPanelActionByTitle(
GENERATE_CSV_DATA_TEST_SUBJ,
savedSearchTitle
);
@ -94,7 +94,7 @@ export function createScenarios(
GENERATE_CSV_DATA_TEST_SUBJ,
savedSearchTitle
);
await dashboardPanelActions.clickContextMenuItemByTitle(
await dashboardPanelActions.clickPanelActionByTitle(
GENERATE_CSV_DATA_TEST_SUBJ,
savedSearchTitle
);

Some files were not shown because too many files have changed in this diff Show more