"Explore underlying data" in-chart action (#69494)

* refactor: 💡 rename folder to "explore_data"

* style: 💄 check for "share" plugin in more semantic way

"explore data" actions use Discover URL generator, which is registered
in "share" plugin, which is optional plugin, so we check for its
existance, because otherwise URL generator is not available.

* refactor: 💡 move KibanaURL to a separate file

* feat: 🎸 add "Explore underlying data" in-chart action

* fix: 🐛 fix imports after refactor

* feat: 🎸 add start.filtersFromContext to embeddable plugin

* feat: 🎸 add type checkers to data plugin

* feat: 🎸 better handle empty filters in Discover URL generator

* feat: 🎸 implement .getUrl() method of explore data in-chart act

* feat: 🎸 add embeddable.filtersAndTimeRangeFromContext()

* feat: 🎸 improve getUrl() method of explore data action

* test: 💍 update test mock

* fix possible stale hashHistory.location in discover

* style: 💄 ensureHashHistoryLocation -> syncHistoryLocations

* docs: ✏️ update autogenerated docs

* test: 💍 add in-chart "Explore underlying data" unit tests

* test: 💍 add in-chart "Explore underlying data" functional tests

* test: 💍 clean-up custom time range after panel action tests

* chore: 🤖 fix embeddable plugin mocks

* chore: 🤖 fix another mock

* test: 💍 add support for new action to pie chart service

Co-authored-by: Anton Dosov <anton.dosov@elastic.co>
This commit is contained in:
Vadim Dalecky 2020-06-26 14:26:35 +02:00 committed by GitHub
parent b3b5dab00d
commit 684aa68f17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 997 additions and 197 deletions

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [isFilter](./kibana-plugin-plugins-data-public.isfilter.md)
## isFilter variable
<b>Signature:</b>
```typescript
isFilter: (x: unknown) => x is Filter
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [isFilters](./kibana-plugin-plugins-data-public.isfilters.md)
## isFilters variable
<b>Signature:</b>
```typescript
isFilters: (x: unknown) => x is Filter[]
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [isQuery](./kibana-plugin-plugins-data-public.isquery.md)
## isQuery variable
<b>Signature:</b>
```typescript
isQuery: (x: unknown) => x is Query
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [isTimeRange](./kibana-plugin-plugins-data-public.istimerange.md)
## isTimeRange variable
<b>Signature:</b>
```typescript
isTimeRange: (x: unknown) => x is TimeRange
```

View file

@ -110,6 +110,10 @@
| [getKbnTypeNames](./kibana-plugin-plugins-data-public.getkbntypenames.md) | Get the esTypes known by all kbnFieldTypes {<!-- -->Array<string>} |
| [indexPatterns](./kibana-plugin-plugins-data-public.indexpatterns.md) | |
| [injectSearchSourceReferences](./kibana-plugin-plugins-data-public.injectsearchsourcereferences.md) | |
| [isFilter](./kibana-plugin-plugins-data-public.isfilter.md) | |
| [isFilters](./kibana-plugin-plugins-data-public.isfilters.md) | |
| [isQuery](./kibana-plugin-plugins-data-public.isquery.md) | |
| [isTimeRange](./kibana-plugin-plugins-data-public.istimerange.md) | |
| [parseSearchSourceJSON](./kibana-plugin-plugins-data-public.parsesearchsourcejson.md) | |
| [QueryStringInput](./kibana-plugin-plugins-data-public.querystringinput.md) | |
| [search](./kibana-plugin-plugins-data-public.search.md) | |

View file

@ -107,3 +107,13 @@ export const pinFilter = (filter: Filter) =>
export const unpinFilter = (filter: Filter) =>
!isFilterPinned(filter) ? filter : toggleFilterPinned(filter);
export const isFilter = (x: unknown): x is Filter =>
!!x &&
typeof x === 'object' &&
!!(x as Filter).meta &&
typeof (x as Filter).meta === 'object' &&
typeof (x as Filter).meta.disabled === 'boolean';
export const isFilters = (x: unknown): x is Filter[] =>
Array.isArray(x) && !x.find((y) => !isFilter(y));

View file

@ -20,11 +20,12 @@
export * from './constants';
export * from './es_query';
export * from './field_formats';
export * from './field_mapping';
export * from './index_patterns';
export * from './kbn_field_types';
export * from './query';
export * from './search';
export * from './search/aggs';
export * from './timefilter';
export * from './types';
export * from './utils';
export * from './field_mapping';

View file

@ -19,3 +19,4 @@
export * from './filter_manager';
export * from './types';
export * from './is_query';

View file

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

View file

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

View file

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

View file

@ -440,6 +440,8 @@ export {
getKbnTypeNames,
} from '../common';
export { isTimeRange, isQuery, isFilter, isFilters } from '../common';
export * from '../common/field_mapping';
/*

View file

@ -1291,6 +1291,26 @@ export interface ISearchStrategy<T extends TStrategyTypes> {
search: ISearch<T>;
}
// Warning: (ae-missing-release-tag) "isFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const isFilter: (x: unknown) => x is Filter;
// Warning: (ae-missing-release-tag) "isFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const isFilters: (x: unknown) => x is Filter[];
// Warning: (ae-missing-release-tag) "isQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const isQuery: (x: unknown) => x is Query;
// Warning: (ae-missing-release-tag) "isTimeRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const isTimeRange: (x: unknown) => x is TimeRange;
// Warning: (ae-missing-release-tag) "ISyncSearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)

View file

@ -27,4 +27,4 @@ export function plugin(initializerContext: PluginInitializerContext) {
export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches';
export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable';
export { DISCOVER_APP_URL_GENERATOR } from './url_generator';
export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator';

View file

@ -60,10 +60,23 @@ export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter<Doc
'DocViewsRegistry'
);
/**
* Makes sure discover and context are using one instance of history
* Makes sure discover and context are using one instance of history.
*/
export const getHistory = _.once(() => createHashHistory());
/**
* Discover currently uses two `history` instances: one from Kibana Platform and
* another from `history` package. Below function is used every time Discover
* app is loaded to synchronize both instances.
*
* This helper is temporary until https://github.com/elastic/kibana/issues/65161 is resolved.
*/
export const syncHistoryLocations = () => {
const h = getHistory();
Object.assign(h.location, createHashHistory().location);
return h;
};
export const [getScopedHistory, setScopedHistory] = createGetterSetter<ScopedHistory>(
'scopedHistory'
);

View file

@ -55,6 +55,7 @@ import {
setServices,
setScopedHistory,
getScopedHistory,
syncHistoryLocations,
getServices,
} from './kibana_services';
import { createSavedSearchesLoader } from './saved_searches';
@ -245,6 +246,7 @@ export class DiscoverPlugin
throw Error('Discover plugin method initializeInnerAngular is undefined');
}
setScopedHistory(params.history);
syncHistoryLocations();
appMounted();
const {
plugins: { data: dataStart },

View file

@ -98,11 +98,13 @@ export class DiscoverUrlGenerator
const queryState: QueryState = {};
if (query) appState.query = query;
if (filters) appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f));
if (filters && filters.length)
appState.filters = filters?.filter((f) => !esFilters.isFilterPinned(f));
if (indexPatternId) appState.index = indexPatternId;
if (timeRange) queryState.time = timeRange;
if (filters) queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f));
if (filters && filters.length)
queryState.filters = filters?.filter((f) => esFilters.isFilterPinned(f));
if (refreshInterval) queryState.refreshInterval = refreshInterval;
let url = `${this.params.appBasePath}#/${savedSearchPath}`;

View file

@ -4,6 +4,7 @@
"server": false,
"ui": true,
"requiredPlugins": [
"data",
"inspector",
"uiActions"
],

View file

@ -28,6 +28,7 @@ export {
ACTION_EDIT_PANEL,
Adapters,
AddPanelAction,
ChartActionContext,
Container,
ContainerInput,
ContainerOutput,

View file

@ -39,10 +39,6 @@ export interface ValueClickTriggerContext<T extends IEmbeddable = IEmbeddable> {
};
}
export const isValueClickTriggerContext = (
context: ValueClickTriggerContext | RangeSelectTriggerContext
): context is ValueClickTriggerContext => context.data && 'data' in context.data;
export interface RangeSelectTriggerContext<T extends IEmbeddable = IEmbeddable> {
embeddable?: T;
data: {
@ -53,8 +49,16 @@ export interface RangeSelectTriggerContext<T extends IEmbeddable = IEmbeddable>
};
}
export type ChartActionContext<T extends IEmbeddable = IEmbeddable> =
| ValueClickTriggerContext<T>
| RangeSelectTriggerContext<T>;
export const isValueClickTriggerContext = (
context: ChartActionContext
): context is ValueClickTriggerContext => context.data && 'data' in context.data;
export const isRangeSelectTriggerContext = (
context: ValueClickTriggerContext | RangeSelectTriggerContext
context: ChartActionContext
): context is RangeSelectTriggerContext => context.data && 'range' in context.data;
export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER';

View file

@ -31,6 +31,7 @@ import { coreMock } from '../../../core/public/mocks';
import { UiActionsService } from './lib/ui_actions';
import { CoreStart } from '../../../core/public';
import { Start as InspectorStart } from '../../inspector/public';
import { dataPluginMock } from '../../data/public/mocks';
// eslint-disable-next-line
import { inspectorPluginMock } from '../../inspector/public/mocks';
@ -100,6 +101,8 @@ const createStartContract = (): Start => {
EmbeddablePanel: jest.fn(),
getEmbeddablePanel: jest.fn(),
getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer),
filtersAndTimeRangeFromContext: jest.fn(),
filtersFromContext: jest.fn(),
};
return startContract;
};
@ -108,11 +111,13 @@ const createInstance = (setupPlugins: Partial<EmbeddableSetupDependencies> = {})
const plugin = new EmbeddablePublicPlugin({} as any);
const setup = plugin.setup(coreMock.createSetup(), {
uiActions: setupPlugins.uiActions || uiActionsPluginMock.createSetupContract(),
data: dataPluginMock.createSetupContract(),
});
const doStart = (startPlugins: Partial<EmbeddableStartDependencies> = {}) =>
plugin.start(coreMock.createStart(), {
uiActions: startPlugins.uiActions || uiActionsPluginMock.createStartContract(),
inspector: inspectorPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
});
return {
plugin,

View file

@ -17,6 +17,13 @@
* under the License.
*/
import React from 'react';
import {
DataPublicPluginSetup,
DataPublicPluginStart,
Filter,
TimeRange,
esFilters,
} from '../../data/public';
import { getSavedObjectFinder } from '../../saved_objects/public';
import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public';
import { Start as InspectorStart } from '../../inspector/public';
@ -36,15 +43,20 @@ import {
defaultEmbeddableFactoryProvider,
IEmbeddable,
EmbeddablePanel,
ChartActionContext,
isRangeSelectTriggerContext,
isValueClickTriggerContext,
} from './lib';
import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition';
import { EmbeddableStateTransfer } from './lib/state_transfer';
export interface EmbeddableSetupDependencies {
data: DataPublicPluginSetup;
uiActions: UiActionsSetup;
}
export interface EmbeddableStartDependencies {
data: DataPublicPluginStart;
uiActions: UiActionsStart;
inspector: InspectorStart;
}
@ -70,6 +82,19 @@ export interface EmbeddableStart {
embeddableFactoryId: string
) => EmbeddableFactory<I, O, E> | undefined;
getEmbeddableFactories: () => IterableIterator<EmbeddableFactory>;
/**
* Given {@link ChartActionContext} returns a list of `data` plugin {@link Filter} entries.
*/
filtersFromContext: (context: ChartActionContext) => Promise<Filter[]>;
/**
* Returns possible time range and filters that can be constructed from {@link ChartActionContext} object.
*/
filtersAndTimeRangeFromContext: (
context: ChartActionContext
) => Promise<{ filters: Filter[]; timeRange?: TimeRange }>;
EmbeddablePanel: EmbeddablePanelHOC;
getEmbeddablePanel: (stateTransfer?: EmbeddableStateTransfer) => EmbeddablePanelHOC;
getStateTransfer: (history?: ScopedHistory) => EmbeddableStateTransfer;
@ -107,7 +132,7 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
public start(
core: CoreStart,
{ uiActions, inspector }: EmbeddableStartDependencies
{ data, uiActions, inspector }: EmbeddableStartDependencies
): EmbeddableStart {
this.embeddableFactoryDefinitions.forEach((def) => {
this.embeddableFactories.set(
@ -121,6 +146,41 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
this.outgoingOnlyStateTransfer = new EmbeddableStateTransfer(core.application.navigateToApp);
this.isRegistryReady = true;
const filtersFromContext: EmbeddableStart['filtersFromContext'] = async (context) => {
try {
if (isRangeSelectTriggerContext(context))
return await data.actions.createFiltersFromRangeSelectAction(context.data);
if (isValueClickTriggerContext(context))
return await data.actions.createFiltersFromValueClickAction(context.data);
// eslint-disable-next-line no-console
console.warn("Can't extract filters from action.", context);
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Error extracting filters from action. Returning empty filter list.', error);
}
return [];
};
const filtersAndTimeRangeFromContext: EmbeddableStart['filtersAndTimeRangeFromContext'] = async (
context
) => {
const filters = await filtersFromContext(context);
if (!context.data.timeFieldName) return { filters };
const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter(
context.data.timeFieldName,
filters
);
return {
filters: restOfFilters,
timeRange: timeRangeFilter
? esFilters.convertRangeFilterToTimeRangeString(timeRangeFilter)
: undefined,
};
};
const getEmbeddablePanelHoc = (stateTransfer?: EmbeddableStateTransfer) => ({
embeddable,
hideHeader,
@ -146,6 +206,8 @@ export class EmbeddablePublicPlugin implements Plugin<EmbeddableSetup, Embeddabl
return {
getEmbeddableFactory: this.getEmbeddableFactory,
getEmbeddableFactories: this.getEmbeddableFactories,
filtersFromContext,
filtersAndTimeRangeFromContext,
getStateTransfer: (history?: ScopedHistory) => {
return history
? new EmbeddableStateTransfer(core.application.navigateToApp, history)

View file

@ -23,6 +23,7 @@ import { UiActionsStart } from '../../../ui_actions/public';
import { uiActionsPluginMock } from '../../../ui_actions/public/mocks';
// eslint-disable-next-line
import { inspectorPluginMock } from '../../../inspector/public/mocks';
import { dataPluginMock } from '../../../data/public/mocks';
import { coreMock } from '../../../../core/public/mocks';
import { EmbeddablePublicPlugin, EmbeddableSetup, EmbeddableStart } from '../plugin';
@ -42,7 +43,10 @@ export const testPlugin = (
const uiActions = uiActionsPluginMock.createPlugin(coreSetup, coreStart);
const initializerContext = {} as any;
const plugin = new EmbeddablePublicPlugin(initializerContext);
const setup = plugin.setup(coreSetup, { uiActions: uiActions.setup });
const setup = plugin.setup(coreSetup, {
data: dataPluginMock.createSetupContract(),
uiActions: uiActions.setup,
});
return {
plugin,
@ -51,8 +55,9 @@ export const testPlugin = (
setup,
doStart: (anotherCoreStart: CoreStart = coreStart) => {
const start = plugin.start(anotherCoreStart, {
uiActions: uiActionsPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
inspector: inspectorPluginMock.createStartContract(),
uiActions: uiActionsPluginMock.createStartContract(),
});
return start;
},

View file

@ -213,5 +213,19 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft
await testSubjects.click('saveNewTitleButton');
await this.toggleContextMenu(panel);
}
async getActionWebElementByText(text: string): Promise<WebElementWrapper> {
log.debug(`getActionWebElement: "${text}"`);
const menu = await testSubjects.find('multipleActionsContextMenu');
const items = await menu.findAllByCssSelector('[data-test-subj*="embeddablePanelAction-"]');
for (const item of items) {
const currentText = await item.getVisibleText();
if (currentText === text) {
return item;
}
}
throw new Error(`No action matching text "${text}"`);
}
})();
}

View file

@ -28,10 +28,13 @@ export function PieChartProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const find = getService('find');
const defaultFindTimeout = config.get('timeouts.find');
const panelActions = getService('dashboardPanelActions');
return new (class PieChart {
async filterOnPieSlice(name?: string) {
log.debug(`PieChart.filterOnPieSlice(${name})`);
private readonly filterActionText = 'Apply filter to current view';
async clickOnPieSlice(name?: string) {
log.debug(`PieChart.clickOnPieSlice(${name})`);
if (name) {
await testSubjects.click(`pieSlice-${name.split(' ').join('-')}`);
} else {
@ -44,6 +47,16 @@ export function PieChartProvider({ getService }: FtrProviderContext) {
}
}
async filterOnPieSlice(name?: string) {
log.debug(`PieChart.filterOnPieSlice(${name})`);
await this.clickOnPieSlice(name);
const hasUiActionsPopup = await testSubjects.exists('multipleActionsContextMenu');
if (hasUiActionsPopup) {
const actionElement = await panelActions.getActionWebElementByText(this.filterActionText);
await actionElement.click();
}
}
async filterByLegendItem(label: string) {
log.debug(`PieChart.filterByLegendItem(${label})`);
await testSubjects.click(`legend-${label}`);

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { DiscoverStart } from '../../../../../../src/plugins/discover/public';
import { EmbeddableStart } from '../../../../../../src/plugins/embeddable/public';
import { ViewMode, IEmbeddable } from '../../../../../../src/plugins/embeddable/public';
import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public';
import { CoreStart } from '../../../../../../src/core/public';
import { KibanaURL } from './kibana_url';
import * as shared from './shared';
export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA';
export interface PluginDeps {
discover: Pick<DiscoverStart, 'urlGenerator'>;
embeddable: Pick<EmbeddableStart, 'filtersAndTimeRangeFromContext'>;
}
export interface CoreDeps {
application: Pick<CoreStart['application'], 'navigateToApp'>;
}
export interface Params {
start: StartServicesGetter<PluginDeps, unknown, CoreDeps>;
}
export abstract class AbstractExploreDataAction<Context extends { embeddable?: IEmbeddable }> {
public readonly getIconType = (context: Context): string => 'discoverApp';
public readonly getDisplayName = (context: Context): string =>
i18n.translate('xpack.discover.FlyoutCreateDrilldownAction.displayName', {
defaultMessage: 'Explore underlying data',
});
constructor(protected readonly params: Params) {}
protected abstract async getUrl(context: Context): Promise<KibanaURL>;
public async isCompatible({ embeddable }: Context): Promise<boolean> {
if (!embeddable) return false;
if (!this.params.start().plugins.discover.urlGenerator) return false;
if (!shared.isVisualizeEmbeddable(embeddable)) return false;
if (!shared.getIndexPattern(embeddable)) return false;
if (embeddable.getInput().viewMode !== ViewMode.VIEW) return false;
return true;
}
public async execute(context: Context): Promise<void> {
if (!shared.isVisualizeEmbeddable(context.embeddable)) return;
const { core } = this.params.start();
const { appName, appPath } = await this.getUrl(context);
await core.application.navigateToApp(appName, {
path: appPath,
});
}
public async getHref(context: Context): Promise<string> {
const { embeddable } = context;
if (!shared.isVisualizeEmbeddable(embeddable)) {
throw new Error(`Embeddable not supported for "${this.getDisplayName(context)}" action.`);
}
const { path } = await this.getUrl(context);
return path;
}
}

View file

@ -0,0 +1,274 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ExploreDataChartAction } from './explore_data_chart_action';
import { Params, PluginDeps } from './abstract_explore_data_action';
import { coreMock } from '../../../../../../src/core/public/mocks';
import { UrlGeneratorContract } from '../../../../../../src/plugins/share/public';
import {
EmbeddableStart,
RangeSelectTriggerContext,
ValueClickTriggerContext,
ChartActionContext,
} from '../../../../../../src/plugins/embeddable/public';
import { i18n } from '@kbn/i18n';
import {
VisualizeEmbeddableContract,
VISUALIZE_EMBEDDABLE_TYPE,
} from '../../../../../../src/plugins/visualizations/public';
import { ViewMode } from '../../../../../../src/plugins/embeddable/public';
import { Filter, TimeRange } from '../../../../../../src/plugins/data/public';
const i18nTranslateSpy = (i18n.translate as unknown) as jest.SpyInstance;
jest.mock('@kbn/i18n', () => ({
i18n: {
translate: jest.fn((key, options) => options.defaultMessage),
},
}));
afterEach(() => {
i18nTranslateSpy.mockClear();
});
const setup = ({ useRangeEvent = false }: { useRangeEvent?: boolean } = {}) => {
type UrlGenerator = UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>;
const core = coreMock.createStart();
const urlGenerator: UrlGenerator = ({
createUrl: jest.fn(() => Promise.resolve('/xyz/app/discover/foo#bar')),
} as unknown) as UrlGenerator;
const filtersAndTimeRangeFromContext = jest.fn((async () => ({
filters: [],
})) as EmbeddableStart['filtersAndTimeRangeFromContext']);
const plugins: PluginDeps = {
discover: {
urlGenerator,
},
embeddable: {
filtersAndTimeRangeFromContext,
},
};
const params: Params = {
start: () => ({
plugins,
self: {},
core,
}),
};
const action = new ExploreDataChartAction(params);
const input = {
viewMode: ViewMode.VIEW,
};
const output = {
indexPatterns: [
{
id: 'index-ptr-foo',
},
],
};
const embeddable: VisualizeEmbeddableContract = ({
type: VISUALIZE_EMBEDDABLE_TYPE,
getInput: () => input,
getOutput: () => output,
} as unknown) as VisualizeEmbeddableContract;
const data: ChartActionContext<typeof embeddable>['data'] = {
...(useRangeEvent
? ({ range: {} } as RangeSelectTriggerContext['data'])
: ({ data: [] } as ValueClickTriggerContext['data'])),
timeFieldName: 'order_date',
};
const context = {
embeddable,
data,
} as ChartActionContext<typeof embeddable>;
return { core, plugins, urlGenerator, params, action, input, output, embeddable, data, context };
};
describe('"Explore underlying data" panel action', () => {
test('action has Discover icon', () => {
const { action, context } = setup();
expect(action.getIconType(context)).toBe('discoverApp');
});
test('title is "Explore underlying data"', () => {
const { action, context } = setup();
expect(action.getDisplayName(context)).toBe('Explore underlying data');
});
test('translates title', () => {
expect(i18nTranslateSpy).toHaveBeenCalledTimes(0);
const { action, context } = setup();
action.getDisplayName(context);
expect(i18nTranslateSpy).toHaveBeenCalledTimes(1);
expect(i18nTranslateSpy.mock.calls[0][0]).toBe(
'xpack.discover.FlyoutCreateDrilldownAction.displayName'
);
});
describe('isCompatible()', () => {
test('returns true when all conditions are met', async () => {
const { action, context } = setup();
const isCompatible = await action.isCompatible(context);
expect(isCompatible).toBe(true);
});
test('returns false when URL generator is not present', async () => {
const { action, plugins, context } = setup();
(plugins.discover as any).urlGenerator = undefined;
const isCompatible = await action.isCompatible(context);
expect(isCompatible).toBe(false);
});
test('returns false if embeddable is not Visualize embeddable', async () => {
const { action, embeddable, context } = setup();
(embeddable as any).type = 'NOT_VISUALIZE_EMBEDDABLE';
const isCompatible = await action.isCompatible(context);
expect(isCompatible).toBe(false);
});
test('returns false if embeddable does not have index patterns', async () => {
const { action, output, context } = setup();
delete output.indexPatterns;
const isCompatible = await action.isCompatible(context);
expect(isCompatible).toBe(false);
});
test('returns false if embeddable index patterns are empty', async () => {
const { action, output, context } = setup();
output.indexPatterns = [];
const isCompatible = await action.isCompatible(context);
expect(isCompatible).toBe(false);
});
test('returns false if dashboard is in edit mode', async () => {
const { action, input, context } = setup();
input.viewMode = ViewMode.EDIT;
const isCompatible = await action.isCompatible(context);
expect(isCompatible).toBe(false);
});
});
describe('getHref()', () => {
test('returns URL path generated by URL generator', async () => {
const { action, context } = setup();
const href = await action.getHref(context);
expect(href).toBe('/xyz/app/discover/foo#bar');
});
test('calls URL generator with right arguments', async () => {
const { action, urlGenerator, context } = setup();
expect(urlGenerator.createUrl).toHaveBeenCalledTimes(0);
await action.getHref(context);
expect(urlGenerator.createUrl).toHaveBeenCalledTimes(1);
expect(urlGenerator.createUrl).toHaveBeenCalledWith({
filters: [],
indexPatternId: 'index-ptr-foo',
timeRange: undefined,
});
});
test('applies chart event filters', async () => {
const { action, context, urlGenerator, plugins } = setup();
((plugins.embeddable
.filtersAndTimeRangeFromContext as unknown) as jest.SpyInstance).mockImplementation(() => {
const filters: Filter[] = [
{
meta: {
alias: 'alias',
disabled: false,
negate: false,
},
},
];
const timeRange: TimeRange = {
from: 'from',
to: 'to',
};
return { filters, timeRange };
});
expect(plugins.embeddable.filtersAndTimeRangeFromContext).toHaveBeenCalledTimes(0);
await action.getHref(context);
expect(plugins.embeddable.filtersAndTimeRangeFromContext).toHaveBeenCalledTimes(1);
expect(plugins.embeddable.filtersAndTimeRangeFromContext).toHaveBeenCalledWith(context);
expect(urlGenerator.createUrl).toHaveBeenCalledWith({
filters: [
{
meta: {
alias: 'alias',
disabled: false,
negate: false,
},
},
],
indexPatternId: 'index-ptr-foo',
timeRange: {
from: 'from',
to: 'to',
},
});
});
});
describe('execute()', () => {
test('calls platform SPA navigation method', async () => {
const { action, context, core } = setup();
expect(core.application.navigateToApp).toHaveBeenCalledTimes(0);
await action.execute(context);
expect(core.application.navigateToApp).toHaveBeenCalledTimes(1);
});
test('calls platform SPA navigation method with right arguments', async () => {
const { action, context, core } = setup();
await action.execute(context);
expect(core.application.navigateToApp).toHaveBeenCalledTimes(1);
expect(core.application.navigateToApp.mock.calls[0]).toEqual([
'discover',
{
path: '/foo#bar',
},
]);
});
});
});

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Action } from '../../../../../../src/plugins/ui_actions/public';
import {
ValueClickTriggerContext,
RangeSelectTriggerContext,
} from '../../../../../../src/plugins/embeddable/public';
import { DiscoverUrlGeneratorState } from '../../../../../../src/plugins/discover/public';
import { isTimeRange, isQuery, isFilters } from '../../../../../../src/plugins/data/public';
import { KibanaURL } from './kibana_url';
import * as shared from './shared';
import { AbstractExploreDataAction } from './abstract_explore_data_action';
export type ExploreDataChartActionContext = ValueClickTriggerContext | RangeSelectTriggerContext;
export const ACTION_EXPLORE_DATA_CHART = 'ACTION_EXPLORE_DATA_CHART';
/**
* This is "Explore underlying data" action which appears in popup context
* menu when user clicks a value in visualization or brushes a time range.
*/
export class ExploreDataChartAction extends AbstractExploreDataAction<ExploreDataChartActionContext>
implements Action<ExploreDataChartActionContext> {
public readonly id = ACTION_EXPLORE_DATA_CHART;
public readonly type = ACTION_EXPLORE_DATA_CHART;
public readonly order = 200;
protected readonly getUrl = async (
context: ExploreDataChartActionContext
): Promise<KibanaURL> => {
const { plugins } = this.params.start();
const { urlGenerator } = plugins.discover;
if (!urlGenerator) {
throw new Error('Discover URL generator not available.');
}
const { embeddable } = context;
const { filters, timeRange } = await plugins.embeddable.filtersAndTimeRangeFromContext(context);
const state: DiscoverUrlGeneratorState = {
filters,
timeRange,
};
if (embeddable) {
state.indexPatternId = shared.getIndexPattern(embeddable) || undefined;
const input = embeddable.getInput();
if (isTimeRange(input.timeRange) && !state.timeRange) state.timeRange = input.timeRange;
if (isQuery(input.query)) state.query = input.query;
if (isFilters(input.filters)) state.filters = [...input.filters, ...(state.filters || [])];
}
const path = await urlGenerator.createUrl(state);
return new KibanaURL(path);
};
}

View file

@ -4,14 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
ExploreDataContextMenuAction,
ACTION_EXPLORE_DATA,
Params,
PluginDeps,
} from './explore_data_context_menu_action';
import { ExploreDataContextMenuAction } from './explore_data_context_menu_action';
import { Params, PluginDeps } from './abstract_explore_data_action';
import { coreMock } from '../../../../../../src/core/public/mocks';
import { UrlGeneratorContract } from '../../../../../../src/plugins/share/public';
import { EmbeddableStart } from '../../../../../../src/plugins/embeddable/public';
import { i18n } from '@kbn/i18n';
import {
VisualizeEmbeddableContract,
@ -37,14 +34,20 @@ const setup = () => {
const core = coreMock.createStart();
const urlGenerator: UrlGenerator = ({
id: ACTION_EXPLORE_DATA,
createUrl: jest.fn(() => Promise.resolve('/xyz/app/discover/foo#bar')),
} as unknown) as UrlGenerator;
const filtersAndTimeRangeFromContext = jest.fn((async () => ({
filters: [],
})) as EmbeddableStart['filtersAndTimeRangeFromContext']);
const plugins: PluginDeps = {
discover: {
urlGenerator,
},
embeddable: {
filtersAndTimeRangeFromContext,
},
};
const params: Params = {
@ -83,19 +86,20 @@ const setup = () => {
describe('"Explore underlying data" panel action', () => {
test('action has Discover icon', () => {
const { action } = setup();
expect(action.getIconType()).toBe('discoverApp');
const { action, context } = setup();
expect(action.getIconType(context)).toBe('discoverApp');
});
test('title is "Explore underlying data"', () => {
const { action } = setup();
expect(action.getDisplayName()).toBe('Explore underlying data');
const { action, context } = setup();
expect(action.getDisplayName(context)).toBe('Explore underlying data');
});
test('translates title', () => {
expect(i18nTranslateSpy).toHaveBeenCalledTimes(0);
setup().action.getDisplayName();
const { action, context } = setup();
action.getDisplayName(context);
expect(i18nTranslateSpy).toHaveBeenCalledTimes(1);
expect(i18nTranslateSpy.mock.calls[0][0]).toBe(

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Action } from '../../../../../../src/plugins/ui_actions/public';
import { EmbeddableContext } from '../../../../../../src/plugins/embeddable/public';
import { DiscoverUrlGeneratorState } from '../../../../../../src/plugins/discover/public';
import { isTimeRange, isQuery, isFilters } from '../../../../../../src/plugins/data/public';
import { KibanaURL } from './kibana_url';
import * as shared from './shared';
import { AbstractExploreDataAction } from './abstract_explore_data_action';
export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA';
/**
* This is "Explore underlying data" action which appears in the context
* menu of a dashboard panel.
*/
export class ExploreDataContextMenuAction extends AbstractExploreDataAction<EmbeddableContext>
implements Action<EmbeddableContext> {
public readonly id = ACTION_EXPLORE_DATA;
public readonly type = ACTION_EXPLORE_DATA;
public readonly order = 200;
protected readonly getUrl = async (context: EmbeddableContext): Promise<KibanaURL> => {
const { plugins } = this.params.start();
const { urlGenerator } = plugins.discover;
if (!urlGenerator) {
throw new Error('Discover URL generator not available.');
}
const { embeddable } = context;
const state: DiscoverUrlGeneratorState = {};
if (embeddable) {
state.indexPatternId = shared.getIndexPattern(embeddable) || undefined;
const input = embeddable.getInput();
if (isTimeRange(input.timeRange) && !state.timeRange) state.timeRange = input.timeRange;
if (isQuery(input.query)) state.query = input.query;
if (isFilters(input.filters)) state.filters = [...input.filters, ...(state.filters || [])];
}
const path = await urlGenerator.createUrl(state);
return new KibanaURL(path);
};
}

View file

@ -5,3 +5,4 @@
*/
export * from './explore_data_context_menu_action';
export * from './explore_data_chart_action';

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// TODO: Replace this logic with KibanaURL once it is available.
// https://github.com/elastic/kibana/issues/64497
export class KibanaURL {
public readonly path: string;
public readonly appName: string;
public readonly appPath: string;
constructor(path: string) {
const match = path.match(/^.*\/app\/([^\/#]+)(.+)$/);
if (!match) {
throw new Error('Unexpected Discover URL path.');
}
const [, appName, appPath] = match;
if (!appName || !appPath) {
throw new Error('Could not parse Discover URL path.');
}
this.path = path;
this.appName = appName;
this.appPath = appPath;
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public';
import {
VISUALIZE_EMBEDDABLE_TYPE,
VisualizeEmbeddableContract,
} from '../../../../../../src/plugins/visualizations/public';
export const isOutputWithIndexPatterns = (
output: unknown
): output is { indexPatterns: Array<{ id: string }> } => {
if (!output || typeof output !== 'object') return false;
return Array.isArray((output as any).indexPatterns);
};
export const isVisualizeEmbeddable = (
embeddable?: IEmbeddable
): embeddable is VisualizeEmbeddableContract =>
embeddable && embeddable?.type === VISUALIZE_EMBEDDABLE_TYPE ? true : false;
/**
* @returns Returns empty string if no index pattern ID found.
*/
export const getIndexPattern = (embeddable?: IEmbeddable): string => {
if (!embeddable) return '';
const output = embeddable.getOutput();
if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) {
return output.indexPatterns[0].id;
}
return '';
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './view_in_discover';
export * from './explore_data';

View file

@ -1,156 +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;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable max-classes-per-file */
import { i18n } from '@kbn/i18n';
import { Action } from '../../../../../../src/plugins/ui_actions/public';
import { DiscoverStart } from '../../../../../../src/plugins/discover/public';
import {
EmbeddableContext,
IEmbeddable,
ViewMode,
} from '../../../../../../src/plugins/embeddable/public';
import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public';
import { CoreStart } from '../../../../../../src/core/public';
import {
VisualizeEmbeddableContract,
VISUALIZE_EMBEDDABLE_TYPE,
} from '../../../../../../src/plugins/visualizations/public';
// TODO: Replace this logic with KibanaURL once it is available.
// https://github.com/elastic/kibana/issues/64497
class KibanaURL {
public readonly path: string;
public readonly appName: string;
public readonly appPath: string;
constructor(path: string) {
const match = path.match(/^.*\/app\/([^\/#]+)(.+)$/);
if (!match) {
throw new Error('Unexpected Discover URL path.');
}
const [, appName, appPath] = match;
if (!appName || !appPath) {
throw new Error('Could not parse Discover URL path.');
}
this.path = path;
this.appName = appName;
this.appPath = appPath;
}
}
export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA';
const isOutputWithIndexPatterns = (
output: unknown
): output is { indexPatterns: Array<{ id: string }> } => {
if (!output || typeof output !== 'object') return false;
return Array.isArray((output as any).indexPatterns);
};
const isVisualizeEmbeddable = (
embeddable: IEmbeddable
): embeddable is VisualizeEmbeddableContract => embeddable?.type === VISUALIZE_EMBEDDABLE_TYPE;
export interface PluginDeps {
discover: Pick<DiscoverStart, 'urlGenerator'>;
}
export interface CoreDeps {
application: Pick<CoreStart['application'], 'navigateToApp'>;
}
export interface Params {
start: StartServicesGetter<PluginDeps, unknown, CoreDeps>;
}
export class ExploreDataContextMenuAction implements Action<EmbeddableContext> {
public readonly id = ACTION_EXPLORE_DATA;
public readonly type = ACTION_EXPLORE_DATA;
public readonly order = 200;
constructor(private readonly params: Params) {}
public getDisplayName() {
return i18n.translate('xpack.discover.FlyoutCreateDrilldownAction.displayName', {
defaultMessage: 'Explore underlying data',
});
}
public getIconType() {
return 'discoverApp';
}
public async isCompatible({ embeddable }: EmbeddableContext) {
if (!this.params.start().plugins.discover.urlGenerator) return false;
if (!isVisualizeEmbeddable(embeddable)) return false;
if (!this.getIndexPattern(embeddable)) return false;
if (embeddable.getInput().viewMode !== ViewMode.VIEW) return false;
return true;
}
public async execute({ embeddable }: EmbeddableContext) {
if (!isVisualizeEmbeddable(embeddable)) return;
const { core } = this.params.start();
const { appName, appPath } = await this.getUrl(embeddable);
await core.application.navigateToApp(appName, {
path: appPath,
});
}
public async getHref({ embeddable }: EmbeddableContext): Promise<string> {
if (!isVisualizeEmbeddable(embeddable)) {
throw new Error(`Embeddable not supported for "${this.getDisplayName()}" action.`);
}
const { path } = await this.getUrl(embeddable);
return path;
}
private async getUrl(embeddable: VisualizeEmbeddableContract): Promise<KibanaURL> {
const { plugins } = this.params.start();
const { urlGenerator } = plugins.discover;
if (!urlGenerator) {
throw new Error('Discover URL generator not available.');
}
const { timeRange, query, filters } = embeddable.getInput();
const indexPatternId = this.getIndexPattern(embeddable);
const path = await urlGenerator.createUrl({
indexPatternId,
filters,
query,
timeRange,
});
return new KibanaURL(path);
}
/**
* @returns Returns empty string if no index pattern ID found.
*/
private getIndexPattern(embeddable: VisualizeEmbeddableContract): string {
const output = embeddable!.getOutput();
if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) {
return output.indexPatterns[0].id;
}
return '';
}
}

View file

@ -6,7 +6,12 @@
import { CoreSetup, CoreStart, Plugin } from 'kibana/public';
import { PluginInitializerContext } from 'kibana/public';
import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public';
import {
UiActionsSetup,
UiActionsStart,
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
} from '../../../../src/plugins/ui_actions/public';
import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public';
import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public';
import { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public';
@ -16,11 +21,18 @@ import {
EmbeddableContext,
CONTEXT_MENU_TRIGGER,
} from '../../../../src/plugins/embeddable/public';
import { ExploreDataContextMenuAction, ACTION_EXPLORE_DATA } from './actions';
import {
ExploreDataContextMenuAction,
ExploreDataChartAction,
ACTION_EXPLORE_DATA,
ACTION_EXPLORE_DATA_CHART,
ExploreDataChartActionContext,
} from './actions';
declare module '../../../../src/plugins/ui_actions/public' {
export interface ActionContextMapping {
[ACTION_EXPLORE_DATA]: EmbeddableContext;
[ACTION_EXPLORE_DATA_CHART]: ExploreDataChartActionContext;
}
}
@ -48,10 +60,17 @@ export class DiscoverEnhancedPlugin
{ uiActions, share }: DiscoverEnhancedSetupDependencies
) {
const start = createStartServicesGetter(core.getStartServices);
const isSharePluginInstalled = !!share;
if (!!share) {
const exploreDataAction = new ExploreDataContextMenuAction({ start });
if (isSharePluginInstalled) {
const params = { start };
const exploreDataAction = new ExploreDataContextMenuAction(params);
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, exploreDataAction);
const exploreDataChartAction = new ExploreDataChartAction(params);
uiActions.addTriggerAction(SELECT_RANGE_TRIGGER, exploreDataChartAction);
uiActions.addTriggerAction(VALUE_CLICK_TRIGGER, exploreDataChartAction);
}
}

View file

@ -59,7 +59,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
// trigger drilldown action by clicking on a pie and picking drilldown action by it's name
await pieChart.filterOnPieSlice('40,000');
await pieChart.clickOnPieSlice('40,000');
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
const href = await dashboardDrilldownPanelActions.getActionHrefByText(

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
const ACTION_ID = 'ACTION_EXPLORE_DATA_CHART';
const ACTION_TEST_SUBJ = `embeddablePanelAction-${ACTION_ID}`;
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const drilldowns = getService('dashboardDrilldownsManage');
const { dashboard, discover, common, timePicker } = getPageObjects([
'dashboard',
'discover',
'common',
'timePicker',
]);
const testSubjects = getService('testSubjects');
const pieChart = getService('pieChart');
const dashboardDrilldownPanelActions = getService('dashboardDrilldownPanelActions');
const filterBar = getService('filterBar');
const browser = getService('browser');
describe('Explore underlying data - chart action', () => {
describe('value click action', () => {
it('action exists in chart click popup menu', async () => {
await common.navigateToApp('dashboard');
await dashboard.preserveCrossAppState();
await dashboard.loadSavedDashboard(drilldowns.DASHBOARD_WITH_PIE_CHART_NAME);
await pieChart.clickOnPieSlice('160,000');
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
await testSubjects.existOrFail(ACTION_TEST_SUBJ);
});
it('action is a link <a> element', async () => {
const actionElement = await testSubjects.find(ACTION_TEST_SUBJ);
const tag = await actionElement.getTagName();
const href = await actionElement.getAttribute('href');
expect(tag.toLowerCase()).to.be('a');
expect(typeof href).to.be('string');
expect(href.length > 5).to.be(true);
});
it('navigates to Discover app on action click carrying over pie slice filter', async () => {
await testSubjects.clickWhenNotDisabled(ACTION_TEST_SUBJ);
await discover.waitForDiscoverAppOnScreen();
await filterBar.hasFilter('memory', '160,000 to 200,000');
const filterCount = await filterBar.getFilterCount();
expect(filterCount).to.be(1);
});
});
describe('brush action', () => {
let originalTimeRangeDurationHours: number | undefined;
it('action exists in chart brush popup menu', async () => {
await common.navigateToApp('dashboard');
await dashboard.preserveCrossAppState();
await dashboard.loadSavedDashboard(drilldowns.DASHBOARD_WITH_AREA_CHART_NAME);
originalTimeRangeDurationHours = await timePicker.getTimeDurationInHours();
const areaChart = await testSubjects.find('visualizationLoader');
await browser.dragAndDrop(
{
location: areaChart,
offset: {
x: -100,
y: 0,
},
},
{
location: areaChart,
offset: {
x: 100,
y: 0,
},
}
);
await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened();
await testSubjects.existOrFail(ACTION_TEST_SUBJ);
});
it('navigates to Discover on click carrying over brushed time range', async () => {
await testSubjects.clickWhenNotDisabled(ACTION_TEST_SUBJ);
await discover.waitForDiscoverAppOnScreen();
const newTimeRangeDurationHours = await timePicker.getTimeDurationInHours();
expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours as number);
});
});
});
}

View file

@ -8,7 +8,7 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
const ACTION_ID = 'ACTION_EXPLORE_DATA';
const EXPLORE_RAW_DATA_ACTION_TEST_SUBJ = `embeddablePanelAction-${ACTION_ID}`;
const ACTION_TEST_SUBJ = `embeddablePanelAction-${ACTION_ID}`;
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const drilldowns = getService('dashboardDrilldownsManage');
@ -24,31 +24,46 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
describe('Explore underlying data - panel action', function () {
before(async () => {
await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash*' });
before(
'change default index pattern to verify action navigates to correct index pattern',
async () => {
await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash*' });
}
);
before('start on Dashboard landing page', async () => {
await common.navigateToApp('dashboard');
await dashboard.preserveCrossAppState();
});
after(async () => {
after('set back default index pattern', async () => {
await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' });
});
after('clean-up custom time range on panel', async () => {
await common.navigateToApp('dashboard');
await dashboard.gotoDashboardEditMode(drilldowns.DASHBOARD_WITH_PIE_CHART_NAME);
await panelActions.openContextMenu();
await panelActionsTimeRange.clickTimeRangeActionInContextMenu();
await panelActionsTimeRange.clickRemovePerPanelTimeRangeButton();
await dashboard.saveDashboard('Dashboard with Pie Chart');
});
it('action exists in panel context menu', async () => {
await dashboard.loadSavedDashboard(drilldowns.DASHBOARD_WITH_PIE_CHART_NAME);
await panelActions.openContextMenu();
await testSubjects.existOrFail(EXPLORE_RAW_DATA_ACTION_TEST_SUBJ);
await testSubjects.existOrFail(ACTION_TEST_SUBJ);
});
it('is a link <a> element', async () => {
const actionElement = await testSubjects.find(EXPLORE_RAW_DATA_ACTION_TEST_SUBJ);
const actionElement = await testSubjects.find(ACTION_TEST_SUBJ);
const tag = await actionElement.getTagName();
expect(tag.toLowerCase()).to.be('a');
});
it('navigates to Discover app to index pattern of the panel on action click', async () => {
await testSubjects.clickWhenNotDisabled(EXPLORE_RAW_DATA_ACTION_TEST_SUBJ);
await testSubjects.clickWhenNotDisabled(ACTION_TEST_SUBJ);
await discover.waitForDiscoverAppOnScreen();
const el = await testSubjects.find('indexPattern-switch-link');
@ -71,7 +86,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboard.saveDashboard('Dashboard with Pie Chart');
await panelActions.openContextMenu();
await testSubjects.clickWhenNotDisabled(EXPLORE_RAW_DATA_ACTION_TEST_SUBJ);
await testSubjects.clickWhenNotDisabled(ACTION_TEST_SUBJ);
await discover.waitForDiscoverAppOnScreen();
const text = await timePicker.getShowDatesButtonText();

View file

@ -24,5 +24,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
loadTestFile(require.resolve('./dashboard_drilldowns'));
loadTestFile(require.resolve('./explore_data_panel_action'));
loadTestFile(require.resolve('./explore_data_chart_action'));
});
}

View file

@ -52,5 +52,11 @@ export function DashboardPanelTimeRangeProvider({ getService }: FtrProviderConte
const button = await this.findModalTestSubject('addPerPanelTimeRangeButton');
await button.click();
}
public async clickRemovePerPanelTimeRangeButton() {
log.debug('clickRemovePerPanelTimeRangeButton');
const button = await this.findModalTestSubject('removePerPanelTimeRangeButton');
await button.click();
}
})();
}