[Lens] "Open in Discover" from dashboard (#127355)

This commit is contained in:
Andrew Tate 2022-03-28 10:15:40 -05:00 committed by GitHub
parent 96554163ad
commit 0ea741dcd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 761 additions and 98 deletions

View file

@ -57,7 +57,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('dashboard panel full screen', async () => {
const header = await dashboardPanelActions.getPanelHeading('[Flights] Flight count');
await dashboardPanelActions.toggleContextMenu(header);
await dashboardPanelActions.openContextMenuMorePanel(header);
await testSubjects.click('embeddablePanelAction-togglePanel');
await a11y.testAppSnapshot();

View file

@ -7,7 +7,7 @@
"requiredPlugins": ["uiActions", "embeddable", "discover"],
"optionalPlugins": ["share", "usageCollection"],
"configPath": ["xpack", "discoverEnhanced"],
"requiredBundles": ["kibanaUtils", "data"],
"requiredBundles": ["kibanaUtils", "data", "lens"],
"owner": {
"name": "Data Discovery",
"githubTeam": "kibana-data-discovery"

View file

@ -11,6 +11,7 @@ import { ViewMode, IEmbeddable } from '../../../../../../src/plugins/embeddable/
import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public';
import { CoreStart } from '../../../../../../src/core/public';
import { KibanaLocation } from '../../../../../../src/plugins/share/public';
import { DOC_TYPE as LENS_DOC_TYPE } from '../../../../lens/common/constants';
import * as shared from './shared';
export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA';
@ -41,6 +42,7 @@ export abstract class AbstractExploreDataAction<Context extends { embeddable?: I
public async isCompatible({ embeddable }: Context): Promise<boolean> {
if (!embeddable) return false;
if (embeddable.type === LENS_DOC_TYPE) return false;
const { core, plugins } = this.params.start();
const { capabilities } = core.application;

View file

@ -13,6 +13,7 @@
{ "path": "../../../src/plugins/discover/tsconfig.json" },
{ "path": "../../../src/plugins/share/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../lens/tsconfig.json" },
{ "path": "../../../src/plugins/url_forwarding/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../../src/plugins/embeddable/tsconfig.json" },

View file

@ -9,14 +9,12 @@ import { createMockDatasource } from '../mocks';
import { combineQueryAndFilters, getLayerMetaInfo } from './show_underlying_data';
import { Filter } from '@kbn/es-query';
import { DatasourcePublicAPI } from '../types';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Capabilities } from 'kibana/public';
describe('getLayerMetaInfo', () => {
const capabilities = {
navLinks: { discover: true },
discover: { show: true },
} as unknown as RecursiveReadonly<Capabilities>;
};
it('should return error in case of no data', () => {
expect(
getLayerMetaInfo(createMockDatasource('testDatasource'), {}, undefined, capabilities).error
@ -85,7 +83,7 @@ describe('getLayerMetaInfo', () => {
{
navLinks: { discover: false },
discover: { show: true },
} as unknown as RecursiveReadonly<Capabilities>
}
).isVisible
).toBeFalsy();
expect(
@ -98,7 +96,7 @@ describe('getLayerMetaInfo', () => {
{
navLinks: { discover: true },
discover: { show: false },
} as unknown as RecursiveReadonly<Capabilities>
}
).isVisible
).toBeFalsy();
});
@ -178,7 +176,7 @@ describe('combineQueryAndFilters', () => {
undefined
)
).toEqual({
query: { language: 'kuery', query: '( myfield: * ) AND ( otherField: * )' },
query: { language: 'kuery', query: '( ( myfield: * ) AND ( otherField: * ) )' },
filters: [],
});
});
@ -198,7 +196,7 @@ describe('combineQueryAndFilters', () => {
},
undefined
)
).toEqual({ query: { language: 'kuery', query: '( otherField: * )' }, filters: [] });
).toEqual({ query: { language: 'kuery', query: 'otherField: *' }, filters: [] });
});
it('should build single kuery expression from meta filters and join using OR and AND at the right level', () => {
@ -238,6 +236,7 @@ describe('combineQueryAndFilters', () => {
filters: [],
});
});
it('should assign kuery meta filters to app filters if existing query is using lucene language', () => {
expect(
combineQueryAndFilters(
@ -293,6 +292,7 @@ describe('combineQueryAndFilters', () => {
],
});
});
it('should append lucene meta filters to app filters even if existing filters are using kuery', () => {
expect(
combineQueryAndFilters(
@ -385,7 +385,7 @@ describe('combineQueryAndFilters', () => {
must: [
{
query_string: {
query: '( anotherField )',
query: 'anotherField',
},
},
],
@ -407,6 +407,7 @@ describe('combineQueryAndFilters', () => {
},
});
});
it('should append lucene meta filters to an existing lucene query', () => {
expect(
combineQueryAndFilters(
@ -461,10 +462,158 @@ describe('combineQueryAndFilters', () => {
],
query: {
language: 'lucene',
query: '( myField ) AND ( anotherField )',
query: '( ( myField ) AND ( anotherField ) )',
},
});
});
it('should accept multiple queries (and play nice with meta filters)', () => {
const { query, filters } = combineQueryAndFilters(
[
{ language: 'lucene', query: 'myFirstField' },
{ language: 'lucene', query: 'mySecondField' },
{ language: 'kuery', query: 'myThirdField : *' },
],
[],
{
id: 'testDatasource',
columns: [],
filters: {
enabled: {
kuery: [[{ language: 'kuery', query: 'myFourthField : *' }]],
lucene: [[{ language: 'lucene', query: 'myFifthField' }]],
},
disabled: { kuery: [], lucene: [] },
},
},
undefined
);
expect(query).toEqual({
language: 'lucene',
query: '( ( myFirstField ) AND ( mySecondField ) AND ( myFifthField ) )',
});
expect(filters).toEqual([
{
$state: {
store: 'appState',
},
bool: {
filter: [
{
bool: {
filter: [
{
bool: {
minimum_should_match: 1,
should: [
{
exists: {
field: 'myThirdField',
},
},
],
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
exists: {
field: 'myFourthField',
},
},
],
},
},
],
},
},
],
must: [],
must_not: [],
should: [],
},
meta: {
alias: 'Lens context (kuery)',
disabled: false,
index: 'testDatasource',
negate: false,
type: 'custom',
},
},
]);
});
it('should ignore all empty queries', () => {
const emptyQueryAndFilters = {
filters: [],
query: {
language: 'kuery',
query: '',
},
};
expect(
combineQueryAndFilters(
[{ language: 'lucene', query: '' }],
[],
{
id: 'testDatasource',
columns: [],
filters: {
enabled: {
kuery: [[{ language: 'kuery', query: '' }]],
lucene: [],
},
disabled: { kuery: [], lucene: [] },
},
},
undefined
)
).toEqual(emptyQueryAndFilters);
expect(
combineQueryAndFilters(
{ language: 'lucene', query: '' },
[],
{
id: 'testDatasource',
columns: [],
filters: {
enabled: {
kuery: [[{ language: 'kuery', query: '' }]],
lucene: [],
},
disabled: { kuery: [], lucene: [] },
},
},
undefined
)
).toEqual(emptyQueryAndFilters);
expect(
combineQueryAndFilters(
undefined,
[],
{
id: 'testDatasource',
columns: [],
filters: {
enabled: {
kuery: [[{ language: 'kuery', query: '' }]],
lucene: [],
},
disabled: { kuery: [], lucene: [] },
},
},
undefined
)
).toEqual(emptyQueryAndFilters);
});
it('should work for complex cases of nested meta filters', () => {
// scenario overview:
// A kuery query
@ -596,7 +745,7 @@ describe('combineQueryAndFilters', () => {
query: {
language: 'kuery',
query:
'( myField: * ) AND ( ( bytes > 4000 ) AND ( ( memory > 5000 ) OR ( memory >= 15000 ) ) AND ( myField: * ) AND ( otherField >= 15 ) )',
'( ( myField: * ) AND ( bytes > 4000 ) AND ( ( memory > 5000 ) OR ( memory >= 15000 ) ) AND ( myField: * ) AND ( otherField >= 15 ) )',
},
});
});
@ -796,7 +945,7 @@ describe('combineQueryAndFilters', () => {
],
query: {
language: 'lucene',
query: '( myField ) AND ( anotherField )',
query: '( ( myField ) AND ( anotherField ) )',
},
});
});

View file

@ -16,6 +16,7 @@ import {
import { i18n } from '@kbn/i18n';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Capabilities } from 'kibana/public';
import { partition } from 'lodash';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { Datasource } from '../types';
@ -24,10 +25,17 @@ export const getShowUnderlyingDataLabel = () =>
defaultMessage: 'Open in Discover',
});
function joinQueries(queries: Query[][] | undefined) {
if (!queries) {
return '';
/**
* Joins a series of queries.
*
* Uses "AND" along dimension 1 and "OR" along dimension 2
*/
function joinQueries(queries: Query[][]) {
// leave a single query alone
if (queries.length === 1 && queries[0].length === 1) {
return queries[0][0].query;
}
const expression = queries
.filter((subQueries) => subQueries.length)
.map((subQueries) =>
@ -56,7 +64,10 @@ export function getLayerMetaInfo(
currentDatasource: Datasource | undefined,
datasourceState: unknown,
activeData: TableInspectorAdapter | undefined,
capabilities: RecursiveReadonly<Capabilities>
capabilities: RecursiveReadonly<{
navLinks: Capabilities['navLinks'];
discover?: Capabilities['discover'];
}>
): { meta: LayerMetaInfo | undefined; isVisible: boolean; error: string | undefined } {
const isVisible = Boolean(capabilities.navLinks?.discover && capabilities.discover?.show);
// If Multiple tables, return
@ -123,40 +134,65 @@ export function getLayerMetaInfo(
}
// This enforces on assignment time that the two props are not the same
type LanguageAssignments =
| { queryLanguage: 'lucene'; filtersLanguage: 'kuery' }
| { queryLanguage: 'kuery'; filtersLanguage: 'lucene' };
type QueryLanguage = 'lucene' | 'kuery';
/**
* Translates an arbitrarily-large set of @type {Query}s (including those supplied in @type {LayerMetaInfo})
* and existing Kibana @type {Filter}s into a single query and a new set of @type {Filter}s. This allows them to
* function as an equivalent context in Discover.
*
* If some of the queries are in KQL and some in Lucene, all the queries in one language will be merged into
* a large query to be shown in the query bar, while the queries in the other language will be encoded as an
* extra filter pill.
*/
export function combineQueryAndFilters(
query: Query | undefined,
query: Query | Query[] | undefined,
filters: Filter[],
meta: LayerMetaInfo,
dataViews: DataViewBase[] | undefined
) {
// Unless a lucene query is already defined, kuery is assigned to it
const { queryLanguage, filtersLanguage }: LanguageAssignments =
query?.language === 'lucene'
? { queryLanguage: 'lucene', filtersLanguage: 'kuery' }
: { queryLanguage: 'kuery', filtersLanguage: 'lucene' };
const queries: {
kuery: Query[];
lucene: Query[];
} = {
kuery: [],
lucene: [],
};
let newQuery = query;
const enabledFilters = meta.filters.enabled;
if (enabledFilters[queryLanguage]?.length) {
const filtersQuery = joinQueries(enabledFilters[queryLanguage]);
newQuery = {
language: queryLanguage,
query: query?.query.trim()
? `( ${query.query} ) ${filtersQuery ? `AND ${filtersQuery}` : ''}`
: filtersQuery,
};
}
const allQueries = Array.isArray(query) ? query : query ? [query] : [];
const nonEmptyQueries = allQueries.filter((q) => Boolean(q.query.trim()));
[queries.lucene, queries.kuery] = partition(nonEmptyQueries, (q) => q.language === 'lucene');
const queryLanguage: QueryLanguage =
(nonEmptyQueries[0]?.language as QueryLanguage | undefined) || 'kuery';
const newQuery = {
language: queryLanguage,
query: joinQueries([
...queries[queryLanguage].map((q) => [q]),
...(meta.filters.enabled[queryLanguage] || []),
]),
};
const filtersLanguage = queryLanguage === 'lucene' ? 'kuery' : 'lucene';
// make a copy as the original filters are readonly
const newFilters = [...filters];
const dataView = dataViews?.find(({ id }) => id === meta.id);
if (enabledFilters[filtersLanguage]?.length) {
const queryExpression = joinQueries(enabledFilters[filtersLanguage]);
// Append the new filter based on the queryExpression to the existing ones
const hasQueriesInFiltersLanguage = Boolean(
meta.filters.enabled[filtersLanguage]?.length || queries[filtersLanguage].length
);
if (hasQueriesInFiltersLanguage) {
const queryExpression = joinQueries([
...queries[filtersLanguage].map((q) => [q]),
...(meta.filters.enabled[filtersLanguage] || []),
]);
// Create new filter to encode the rest of the query information
newFilters.push(
buildCustomFilter(
meta.id!,
@ -171,11 +207,11 @@ export function combineQueryAndFilters(
)
);
}
// for each disabled filter create a new custom filter and disable it
// note that both languages go into the filter bar
const disabledFilters = meta.filters.disabled;
for (const language of ['lucene', 'kuery'] as const) {
const [disabledQueries] = disabledFilters[language] || [];
const [disabledQueries] = meta.filters.disabled[language] || [];
for (const disabledQuery of disabledQueries || []) {
let label = disabledQuery.query as string;
if (language === 'lucene') {
@ -193,5 +229,6 @@ export function combineQueryAndFilters(
);
}
}
return { filters: newFilters, query: newQuery };
}

View file

@ -61,7 +61,7 @@ export class EditorFrameService {
private readonly datasources: Array<Datasource | (() => Promise<Datasource>)> = [];
private readonly visualizations: Array<Visualization | (() => Promise<Visualization>)> = [];
private loadDatasources = () => collectAsyncDefinitions(this.datasources);
public loadDatasources = () => collectAsyncDefinitions(this.datasources);
public loadVisualizations = () => collectAsyncDefinitions(this.visualizations);
/**

View file

@ -145,11 +145,14 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
inspector: inspectorPluginMock.createStartContract(),
getTrigger,
theme: themeServiceMock.createStartContract(),
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
documentToExpression: () =>
Promise.resolve({
@ -189,9 +192,15 @@ describe('embeddable', () => {
basePath,
indexPatternService: {} as DataViewsContract,
inspector: inspectorPluginMock.createStartContract(),
capabilities: { canSaveDashboards: true, canSaveVisualizations: true },
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -236,9 +245,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -294,9 +306,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -341,9 +356,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -387,9 +405,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -429,9 +450,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -474,9 +498,15 @@ describe('embeddable', () => {
basePath,
inspector: inspectorPluginMock.createStartContract(),
indexPatternService: {} as DataViewsContract,
capabilities: { canSaveDashboards: true, canSaveVisualizations: true },
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -526,9 +556,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -576,9 +609,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -633,9 +669,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -659,7 +698,7 @@ describe('embeddable', () => {
expect.objectContaining({
timeRange,
query: [query, savedVis.state.query],
filters: mockInjectFilterReferences(filters, []),
filters,
})
);
@ -691,9 +730,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -723,8 +765,10 @@ describe('embeddable', () => {
it('should merge external context with query and filters of the saved object', async () => {
const timeRange: TimeRange = { from: 'now-15d', to: 'now' };
const query: Query = { language: 'kquery', query: 'external filter' };
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }];
const query: Query = { language: 'kquery', query: 'external query' };
const filters: Filter[] = [
{ meta: { alias: 'external filter', negate: false, disabled: false } },
];
const newSavedVis = {
...savedVis,
@ -750,9 +794,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -772,18 +819,16 @@ describe('embeddable', () => {
await embeddable.initializeSavedVis(input);
embeddable.render(mountpoint);
expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({
timeRange,
query: [query, { language: 'kquery', query: 'saved filter' }],
// actual index pattern id gets injected
filters: mockInjectFilterReferences(
[
filters[0],
{ meta: { alias: 'test', negate: false, disabled: false, index: 'injected!' } },
],
[]
),
});
const expectedFilters = [
...input.filters!,
...mockInjectFilterReferences(newSavedVis.state.filters, []),
];
expect(expressionRenderer.mock.calls[0][0].searchContext?.timeRange).toEqual(timeRange);
expect(expressionRenderer.mock.calls[0][0].searchContext?.filters).toEqual(expectedFilters);
expect(expressionRenderer.mock.calls[0][0].searchContext?.query).toEqual([
query,
{ language: 'kquery', query: 'saved filter' },
]);
});
it('should execute trigger on event from expression renderer', async () => {
@ -798,9 +843,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -846,9 +894,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -891,9 +942,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -951,9 +1005,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -1030,9 +1087,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -1084,9 +1144,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -1135,9 +1198,12 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
visualizationMap: {},
datasourceMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
@ -1207,6 +1273,8 @@ describe('embeddable', () => {
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
getTrigger,
theme: themeServiceMock.createStartContract(),
@ -1216,6 +1284,7 @@ describe('embeddable', () => {
onEditAction: onEditActionMock,
} as unknown as Visualization,
},
datasourceMap: {},
documentToExpression: documentToExpressionMock,
},
{ id: '123' } as unknown as LensEmbeddableInput

View file

@ -9,7 +9,7 @@ import { isEqual, uniqBy } from 'lodash';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { render, unmountComponentAtNode } from 'react-dom';
import { Filter } from '@kbn/es-query';
import { DataViewBase, Filter } from '@kbn/es-query';
import {
ExecutionContextSearch,
Query,
@ -55,21 +55,25 @@ import {
LensTableRowContextMenuEvent,
VisualizationMap,
Visualization,
DatasourceMap,
Datasource,
} from '../types';
import type { DataViewsContract, DataView } from '../../../../../src/plugins/data_views/public';
import { getEditPath, DOC_TYPE, PLUGIN_ID } from '../../common';
import type {
Capabilities,
IBasePath,
KibanaExecutionContext,
ThemeServiceStart,
} from '../../../../../src/core/public';
import { LensAttributeService } from '../lens_attribute_service';
import type { ErrorMessage } from '../editor_frame_service/types';
import type { ErrorMessage, TableInspectorAdapter } from '../editor_frame_service/types';
import { getLensInspectorService, LensInspector } from '../lens_inspector_service';
import { SharingSavedObjectProps } from '../types';
import type { SpacesPluginStart } from '../../../spaces/public';
import { inferTimeField } from '../utils';
import { getActiveDatasourceIdFromDoc, getIndexPatternsObjects, inferTimeField } from '../utils';
import { getLayerMetaInfo, combineQueryAndFilters } from '../app_plugin/show_underlying_data';
export type LensSavedObjectAttributes = Omit<Document, 'savedObjectId' | 'type'>;
@ -114,6 +118,7 @@ export interface LensEmbeddableDeps {
) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
indexPatternService: DataViewsContract;
expressionRenderer: ReactExpressionRendererType;
timefilter: TimefilterContract;
@ -121,12 +126,25 @@ export interface LensEmbeddableDeps {
inspector: InspectorStart;
getTrigger?: UiActionsStart['getTrigger'] | undefined;
getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions'];
capabilities: { canSaveVisualizations: boolean; canSaveDashboards: boolean };
capabilities: {
canSaveVisualizations: boolean;
canSaveDashboards: boolean;
navLinks: Capabilities['navLinks'];
discover: Capabilities['discover'];
};
usageCollection?: UsageCollectionSetup;
spaces?: SpacesPluginStart;
theme: ThemeServiceStart;
}
export interface ViewUnderlyingDataArgs {
indexPatternId: string;
timeRange: TimeRange;
filters: Filter[];
query: Query | undefined;
columns: string[];
}
const getExpressionFromDocument = async (
document: Document,
documentToExpression: LensEmbeddableDeps['documentToExpression']
@ -138,6 +156,52 @@ const getExpressionFromDocument = async (
};
};
function getViewUnderlyingDataArgs({
activeDatasource,
activeDatasourceState,
activeData,
dataViews,
capabilities,
query,
filters,
timeRange,
}: {
activeDatasource: Datasource;
activeDatasourceState: unknown;
activeData: TableInspectorAdapter | undefined;
dataViews: DataViewBase[] | undefined;
capabilities: LensEmbeddableDeps['capabilities'];
query: ExecutionContextSearch['query'];
filters: Filter[];
timeRange: TimeRange;
}) {
const { error, meta } = getLayerMetaInfo(
activeDatasource,
activeDatasourceState,
activeData,
capabilities
);
if (error || !meta) {
return;
}
const { filters: newFilters, query: newQuery } = combineQueryAndFilters(
query,
filters,
meta,
dataViews
);
return {
indexPatternId: meta.id,
timeRange,
filters: newFilters,
query: newQuery,
columns: meta.columns,
};
}
export class Embeddable
extends AbstractEmbeddable<LensEmbeddableInput, LensEmbeddableOutput>
implements ReferenceOrValueEmbeddable<LensByValueInput, LensByReferenceInput>
@ -173,6 +237,16 @@ export class Embeddable
searchSessionId?: string;
} = {};
private activeDataInfo: {
activeData?: TableInspectorAdapter;
activeDatasource?: Datasource;
activeDatasourceState?: unknown;
} = {};
private indexPatterns: DataView[] = [];
private viewUnderlyingDataArgs?: ViewUnderlyingDataArgs;
constructor(
private deps: LensEmbeddableDeps,
initialInput: LensEmbeddableInput,
@ -387,7 +461,8 @@ export class Embeddable
return isDirty;
}
private updateActiveData: ExpressionWrapperProps['onData$'] = () => {
private updateActiveData: ExpressionWrapperProps['onData$'] = (_, adapters) => {
this.activeDataInfo.activeData = adapters?.tables?.tables;
if (this.input.onLoad) {
// once onData$ is get's called from expression renderer, loading becomes false
this.input.onLoad(false);
@ -495,22 +570,25 @@ export class Embeddable
if (!this.savedVis) {
throw new Error('savedVis is required for getMergedSearchContext');
}
const output: ExecutionContextSearch = {
const context: ExecutionContextSearch = {
timeRange: this.externalSearchContext.timeRange,
query: [this.savedVis.state.query],
filters: this.deps.injectFilterReferences(
this.savedVis.state.filters,
this.savedVis.references
),
};
if (this.externalSearchContext.query) {
output.query = [this.externalSearchContext.query, this.savedVis.state.query];
} else {
output.query = [this.savedVis.state.query];
}
if (this.externalSearchContext.filters?.length) {
output.filters = [...this.externalSearchContext.filters, ...this.savedVis.state.filters];
} else {
output.filters = [...this.savedVis.state.filters];
context.query = [this.externalSearchContext.query, ...(context.query as Query[])];
}
output.filters = this.deps.injectFilterReferences(output.filters, this.savedVis.references);
return output;
if (this.externalSearchContext.filters?.length) {
context.filters = [...this.externalSearchContext.filters, ...(context.filters as Filter[])];
}
return context;
}
private get onEditAction(): Visualization['onEditAction'] {
@ -597,28 +675,78 @@ export class Embeddable
}
}
private async loadViewUnderlyingDataArgs(): Promise<boolean> {
const mergedSearchContext = this.getMergedSearchContext();
if (!this.activeDataInfo.activeData || !mergedSearchContext.timeRange) {
return false;
}
const activeDatasourceId = getActiveDatasourceIdFromDoc(this.savedVis);
if (!activeDatasourceId) {
return false;
}
this.activeDataInfo.activeDatasource = this.deps.datasourceMap[activeDatasourceId];
const docDatasourceState = this.savedVis?.state.datasourceStates[activeDatasourceId];
if (!this.activeDataInfo.activeDatasourceState) {
this.activeDataInfo.activeDatasourceState =
await this.activeDataInfo.activeDatasource.initialize(
docDatasourceState,
this.savedVis?.references
);
}
const viewUnderlyingDataArgs = getViewUnderlyingDataArgs({
activeDatasource: this.activeDataInfo.activeDatasource,
activeDatasourceState: this.activeDataInfo.activeDatasourceState,
activeData: this.activeDataInfo.activeData,
dataViews: this.indexPatterns,
capabilities: this.deps.capabilities,
query: mergedSearchContext.query,
filters: mergedSearchContext.filters || [],
timeRange: mergedSearchContext.timeRange,
});
const loaded = typeof viewUnderlyingDataArgs !== 'undefined';
if (loaded) {
this.viewUnderlyingDataArgs = viewUnderlyingDataArgs;
}
return loaded;
}
/**
* Returns the necessary arguments to view the underlying data in discover.
*
* Only makes sense to call this after canViewUnderlyingData has been checked
*/
public getViewUnderlyingDataArgs() {
return this.viewUnderlyingDataArgs;
}
public canViewUnderlyingData() {
return this.loadViewUnderlyingDataArgs();
}
async initializeOutput() {
if (!this.savedVis) {
return;
}
const responses = await Promise.allSettled(
uniqBy(
this.savedVis.references.filter(({ type }) => type === 'index-pattern'),
'id'
).map(({ id }) => this.deps.indexPatternService.get(id))
const { indexPatterns } = await getIndexPatternsObjects(
this.savedVis?.references.map(({ id }) => id) || [],
this.deps.indexPatternService
);
const indexPatterns = responses
.filter(
(response): response is PromiseFulfilledResult<DataView> => response.status === 'fulfilled'
)
.map(({ value }) => value);
this.indexPatterns = uniqBy(indexPatterns, 'id');
// passing edit url and index patterns to the output of this embeddable for
// the container to pick them up and use them to configure filter bar and
// config dropdown correctly.
const input = this.getInput();
this.errors = this.maybeAddTimeRangeError(this.errors, input, indexPatterns);
this.errors = this.maybeAddTimeRangeError(this.errors, input, this.indexPatterns);
if (this.errors) {
this.logError('validation');
@ -633,7 +761,7 @@ export class Embeddable
title,
editPath: getEditPath(savedObjectId),
editUrl: this.deps.basePath.prepend(`/app/lens${getEditPath(savedObjectId)}`),
indexPatterns,
indexPatterns: this.indexPatterns,
});
// deferred loading of this embeddable is complete

View file

@ -26,7 +26,7 @@ import { DOC_TYPE } from '../../common/constants';
import { ErrorMessage } from '../editor_frame_service/types';
import { extract, inject } from '../../common/embeddable_factory';
import type { SpacesPluginStart } from '../../../spaces/public';
import { VisualizationMap } from '../types';
import { DatasourceMap, VisualizationMap } from '../types';
export interface LensEmbeddableStartServices {
timefilter: TimefilterContract;
@ -43,6 +43,7 @@ export interface LensEmbeddableStartServices {
) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
datasourceMap: DatasourceMap;
spaces?: SpacesPluginStart;
theme: ThemeServiceStart;
}
@ -92,6 +93,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
documentToExpression,
injectFilterReferences,
visualizationMap,
datasourceMap,
uiActions,
coreHttp,
attributeService,
@ -118,9 +120,12 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
documentToExpression,
injectFilterReferences,
visualizationMap,
datasourceMap,
capabilities: {
canSaveDashboards: Boolean(capabilities.dashboard?.showWriteControls),
canSaveVisualizations: Boolean(capabilities.visualize.save),
navLinks: capabilities.navLinks,
discover: capabilities.discover,
},
usageCollection,
theme,

View file

@ -16,8 +16,12 @@ import type {
DataPublicPluginSetup,
DataPublicPluginStart,
} from '../../../../src/plugins/data/public';
import {
CONTEXT_MENU_TRIGGER,
EmbeddableSetup,
EmbeddableStart,
} from '../../../../src/plugins/embeddable/public';
import type { DataViewsPublicPluginStart } from '../../../../src/plugins/data_views/public';
import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import type { DashboardStart } from '../../../../src/plugins/dashboard/public';
import type { SpacesPluginStart } from '../../spaces/public';
import type {
@ -80,6 +84,7 @@ import type {
LensTopNavMenuEntryGenerator,
} from './types';
import { getLensAliasConfig } from './vis_type_alias';
import { createOpenInDiscoverAction } from './trigger_actions/open_in_discover_action';
import { visualizeFieldAction } from './trigger_actions/visualize_field_actions';
import { visualizeTSVBAction } from './trigger_actions/visualize_tsvb_actions';
@ -259,6 +264,7 @@ export class LensPlugin {
eventAnnotation
);
const visualizationMap = await this.editorFrameService!.loadVisualizations();
const datasourceMap = await this.editorFrameService!.loadDatasources();
return {
attributeService: getLensAttributeService(coreStart, plugins),
@ -269,6 +275,7 @@ export class LensPlugin {
documentToExpression: this.editorFrameService!.documentToExpression,
injectFilterReferences: data.query.filterManager.inject.bind(data.query.filterManager),
visualizationMap,
datasourceMap,
indexPatternService: plugins.dataViews,
uiActions: plugins.uiActions,
usageCollection,
@ -440,6 +447,14 @@ export class LensPlugin {
visualizeTSVBAction(core.application)
);
startDependencies.uiActions.addTriggerAction(
CONTEXT_MENU_TRIGGER,
createOpenInDiscoverAction(
startDependencies.discover!,
core.application.capabilities.discover.show as boolean
)
);
return {
EmbeddableComponent: getEmbeddableComponent(core, startDependencies),
SaveModalComponent: getSaveModalComponent(core, startDependencies),

View file

@ -0,0 +1,107 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DiscoverStart } from '../../../../../src/plugins/discover/public';
import type { IEmbeddable } from '../../../../../src/plugins/embeddable/public';
import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public';
import { DOC_TYPE } from '../../common';
import { Embeddable } from '../embeddable';
import { createOpenInDiscoverAction } from './open_in_discover_action';
describe('open in discover action', () => {
describe('compatibility check', () => {
it('is incompatible with non-lens embeddables', async () => {
const embeddable = { type: 'NOT_LENS' } as IEmbeddable;
const isCompatible = await createOpenInDiscoverAction({} as DiscoverStart, true).isCompatible(
{
embeddable,
} as ActionExecutionContext<{ embeddable: IEmbeddable }>
);
expect(isCompatible).toBeFalsy();
});
it('is incompatible if user cant access Discover app', async () => {
// setup
const embeddable = { type: DOC_TYPE } as Embeddable;
embeddable.canViewUnderlyingData = () => Promise.resolve(true);
let hasDiscoverAccess = true;
// make sure it would work if we had access to Discover
expect(
await createOpenInDiscoverAction({} as DiscoverStart, hasDiscoverAccess).isCompatible({
embeddable,
} as unknown as ActionExecutionContext<{ embeddable: IEmbeddable }>)
).toBeTruthy();
// make sure no Discover access makes the action incompatible
hasDiscoverAccess = false;
expect(
await createOpenInDiscoverAction({} as DiscoverStart, hasDiscoverAccess).isCompatible({
embeddable,
} as unknown as ActionExecutionContext<{ embeddable: IEmbeddable }>)
).toBeFalsy();
});
it('checks for ability to view underlying data if lens embeddable', async () => {
// setup
const embeddable = { type: DOC_TYPE } as Embeddable;
// test false
embeddable.canViewUnderlyingData = jest.fn(() => Promise.resolve(false));
expect(
await createOpenInDiscoverAction({} as DiscoverStart, true).isCompatible({
embeddable,
} as unknown as ActionExecutionContext<{ embeddable: IEmbeddable }>)
).toBeFalsy();
expect(embeddable.canViewUnderlyingData).toHaveBeenCalledTimes(1);
// test true
embeddable.canViewUnderlyingData = jest.fn(() => Promise.resolve(true));
expect(
await createOpenInDiscoverAction({} as DiscoverStart, true).isCompatible({
embeddable,
} as unknown as ActionExecutionContext<{ embeddable: IEmbeddable }>)
).toBeTruthy();
expect(embeddable.canViewUnderlyingData).toHaveBeenCalledTimes(1);
});
});
it('navigates to discover when executed', async () => {
const viewUnderlyingDataArgs = {
indexPatternId: 'index-pattern-id',
timeRange: {},
filters: [],
query: undefined,
columns: [],
};
const embeddable = {
getViewUnderlyingDataArgs: jest.fn(() => viewUnderlyingDataArgs),
};
const discoverUrl = 'https://discover-redirect-url';
const discover = {
locator: {
getRedirectUrl: jest.fn(() => discoverUrl),
},
} as unknown as DiscoverStart;
globalThis.open = jest.fn();
await createOpenInDiscoverAction(discover, true).execute({
embeddable,
} as unknown as ActionExecutionContext<{
embeddable: IEmbeddable;
}>);
expect(embeddable.getViewUnderlyingDataArgs).toHaveBeenCalled();
expect(discover.locator!.getRedirectUrl).toHaveBeenCalledWith(viewUnderlyingDataArgs);
expect(globalThis.open).toHaveBeenCalledWith(discoverUrl, '_blank');
});
});

View file

@ -0,0 +1,41 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { IEmbeddable } from 'src/plugins/embeddable/public';
import { createAction } from '../../../../../src/plugins/ui_actions/public';
import type { Embeddable } from '../embeddable';
import type { DiscoverStart } from '../../../../../src/plugins/discover/public';
import { DOC_TYPE } from '../../common';
const ACTION_OPEN_IN_DISCOVER = 'ACTION_OPEN_IN_DISCOVER';
export const createOpenInDiscoverAction = (discover: DiscoverStart, hasDiscoverAccess: boolean) =>
createAction<{ embeddable: IEmbeddable }>({
type: ACTION_OPEN_IN_DISCOVER,
id: ACTION_OPEN_IN_DISCOVER,
order: 19, // right after Inspect which is 20
getIconType: () => 'popout',
getDisplayName: () =>
i18n.translate('xpack.lens.actions.openInDiscover', {
defaultMessage: 'Open in Discover',
}),
isCompatible: async (context: { embeddable: IEmbeddable }) => {
if (!hasDiscoverAccess) return false;
return (
context.embeddable.type === DOC_TYPE &&
(await (context.embeddable as Embeddable).canViewUnderlyingData())
);
},
execute: async (context: { embeddable: Embeddable }) => {
const args = context.embeddable.getViewUnderlyingDataArgs()!;
const discoverUrl = discover.locator?.getRedirectUrl({
...args,
});
window.open(discoverUrl, '_blank');
},
});

View file

@ -91,8 +91,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
// Requires xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled
// setting set in kibana.yml to work (not enabled by default)
it('should be able to drill down to discover', async () => {
// setting set in kibana.yml to test (not enabled by default)
it('should hide old "explore underlying data" action', async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await dashboardAddPanel.clickOpenAddPanel();
@ -102,13 +102,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.goToTimeRange();
await PageObjects.dashboard.saveDashboard('lnsDrilldown');
await panelActions.openContextMenu();
await testSubjects.clickWhenNotDisabled('embeddablePanelAction-ACTION_EXPLORE_DATA');
await PageObjects.discover.waitForDiscoverAppOnScreen();
const el = await testSubjects.find('indexPattern-switch-link');
const text = await el.getVisibleText();
expect(text).to.be('logstash-*');
expect(await testSubjects.exists('embeddablePanelAction-ACTION_EXPLORE_DATA')).not.to.be.ok();
});
it('should be able to add filters by clicking in pie chart', async () => {

View file

@ -58,6 +58,7 @@ export default function ({ getService, loadTestFile, getPageObjects }: FtrProvid
loadTestFile(require.resolve('./multi_terms'));
loadTestFile(require.resolve('./epoch_millis'));
loadTestFile(require.resolve('./show_underlying_data'));
loadTestFile(require.resolve('./show_underlying_data_dashboard'));
});
describe('', function () {

View file

@ -0,0 +1,113 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import uuid from 'uuid';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects([
'visualize',
'lens',
'dashboard',
'header',
'discover',
'common',
]);
const listingTable = getService('listingTable');
const testSubjects = getService('testSubjects');
const dashboardPanelActions = getService('dashboardPanelActions');
const filterBarService = getService('filterBar');
const queryBar = getService('queryBar');
const browser = getService('browser');
const retry = getService('retry');
describe('lens show underlying data from dashboard', () => {
it('should show the open button for a compatible saved visualization', async () => {
await PageObjects.visualize.gotoVisualizationLandingPage();
await listingTable.searchForItemWithName('lnsXYvis');
await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis');
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.save('Embedded Visualization', true, false, false, 'new');
await PageObjects.dashboard.saveDashboard(`Open in Discover Testing ${uuid()}`, {
exitFromEditMode: true,
});
await dashboardPanelActions.openContextMenu();
await testSubjects.click('embeddablePanelAction-ACTION_OPEN_IN_DISCOVER');
const [dashboardWindowHandle, discoverWindowHandle] = await browser.getAllWindowHandles();
await browser.switchToWindow(discoverWindowHandle);
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('discoverChart');
// check the table columns
const columns = await PageObjects.discover.getColumnHeaders();
expect(columns).to.eql(['ip', '@timestamp', 'bytes']);
await browser.closeCurrentWindow();
await browser.switchToWindow(dashboardWindowHandle);
});
it('should bring both dashboard context and visualization context to discover', async () => {
await PageObjects.dashboard.switchToEditMode();
await dashboardPanelActions.clickEdit();
await queryBar.switchQueryLanguage('lucene');
await queryBar.setQuery('host.keyword www.elastic.co');
await queryBar.submitQuery();
await filterBarService.addFilter('geo.src', 'is', 'AF');
// the filter bar seems to need a moment to settle before saving and returning
await PageObjects.common.sleep(1000);
await PageObjects.lens.saveAndReturn();
await queryBar.switchQueryLanguage('kql');
await queryBar.setQuery('request.keyword : "/apm"');
await queryBar.submitQuery();
await filterBarService.addFilter(
'host.raw',
'is',
'cdn.theacademyofperformingartsandscience.org'
);
await PageObjects.dashboard.clickQuickSave();
// make sure Open in Discover is also available in edit mode
await dashboardPanelActions.openContextMenuMorePanel();
await testSubjects.existOrFail('embeddablePanelAction-ACTION_OPEN_IN_DISCOVER');
await PageObjects.dashboard.clickCancelOutOfEditMode();
await dashboardPanelActions.openContextMenu();
await testSubjects.click('embeddablePanelAction-ACTION_OPEN_IN_DISCOVER');
const [dashboardWindowHandle, discoverWindowHandle] = await browser.getAllWindowHandles();
await browser.switchToWindow(discoverWindowHandle);
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.waitFor('filter count to be correct', async () => {
const filterCount = await filterBarService.getFilterCount();
return filterCount === 3;
});
expect(
await filterBarService.hasFilter('host.raw', 'cdn.theacademyofperformingartsandscience.org')
).to.be.ok();
expect(await filterBarService.hasFilter('geo.src', 'AF')).to.be.ok();
expect(await filterBarService.getFiltersLabel()).to.contain('Lens context (lucene)');
expect(await queryBar.getQueryString()).to.be('request.keyword : "/apm"');
await browser.closeCurrentWindow();
await browser.switchToWindow(dashboardWindowHandle);
});
});
}