From e1665e8b27987997896f38c2b734cf3eaf1b2332 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 1 Jul 2020 09:57:23 +0200 Subject: [PATCH 01/10] [Lens] Multiple y axes (#69911) --- .../editor_frame/config_panel/layer_panel.tsx | 2 +- .../__snapshots__/to_expression.test.ts.snap | 1 + .../__snapshots__/xy_expression.test.tsx.snap | 383 ++++++++++++++++-- .../axes_configuration.test.ts | 295 ++++++++++++++ .../xy_visualization/axes_configuration.ts | 106 +++++ .../lens/public/xy_visualization/index.ts | 4 +- .../public/xy_visualization/to_expression.ts | 15 + .../lens/public/xy_visualization/types.ts | 52 ++- .../xy_visualization/xy_config_panel.tsx | 77 +++- .../xy_visualization/xy_expression.test.tsx | 289 ++++++++----- .../public/xy_visualization/xy_expression.tsx | 73 ++-- .../public/xy_visualization/xy_suggestions.ts | 12 +- .../xy_visualization/xy_visualization.tsx | 18 +- 13 files changed, 1141 insertions(+), 186 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index bd501db2b752a..36d5bfd965e26 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -186,7 +186,7 @@ export function LayerPanel( }, ]; - if (activeVisualization.renderDimensionEditor) { + if (activeVisualization.renderDimensionEditor && group.enableDimensionEditor) { tabs.push({ id: 'visualization', name: i18n.translate('xpack.lens.editorFrame.formatStyleLabel', { diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 6b68679bfd4ec..c037aecde558b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -38,6 +38,7 @@ Object { "xScaleType": Array [ "linear", ], + "yConfig": Array [], "yScaleType": Array [ "linear", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index fc5ed7480dd1f..48c70e0a4a05b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -12,6 +12,11 @@ exports[`xy_expression XYChart component it renders area 1`] = ` showLegend={false} showLegendExtra={false} theme={Object {}} + tooltip={ + Object { + "headerFormatter": [Function], + } + } /> + + + + + + + { + const tables: Record = { + first: { + type: 'kibana_datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date_histogram', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }, + formatHint: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'terms', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }, + formatHint: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', + }, + }, + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'count', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'number' }, + }, + { + id: 'yAccessorId2', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'bytes' }, + }, + { + id: 'yAccessorId3', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'currency' }, + }, + { + id: 'yAccessorId4', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'currency' }, + }, + ], + }, + }; + + const sampleLayer: LayerArgs = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['yAccessorId'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }; + + it('should map auto series to left axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration([sampleLayer], tables, formatFactory, false); + expect(groups.length).toEqual(1); + expect(groups[0].position).toEqual('left'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[0].layer).toEqual('first'); + }); + + it('should map auto series to right axis if formatters do not match', () => { + const formatFactory = jest.fn(); + const twoSeriesLayer = { ...sampleLayer, accessors: ['yAccessorId', 'yAccessorId2'] }; + const groups = getAxesConfiguration([twoSeriesLayer], tables, formatFactory, false); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[1].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId2'); + }); + + it('should map auto series to left if left and right are already filled with non-matching series', () => { + const formatFactory = jest.fn(); + const threeSeriesLayer = { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId2', 'yAccessorId3'], + }; + const groups = getAxesConfiguration([threeSeriesLayer], tables, formatFactory, false); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[1].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[1].accessor).toEqual('yAccessorId3'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId2'); + }); + + it('should map right series to right axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration( + [{ ...sampleLayer, yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }] }], + tables, + formatFactory, + false + ); + expect(groups.length).toEqual(1); + expect(groups[0].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[0].layer).toEqual('first'); + }); + + it('should map series with matching formatters to same axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration( + [ + { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId3', 'yAccessorId4'], + yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }], + }, + ], + tables, + formatFactory, + false + ); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId3'); + expect(groups[0].series[1].accessor).toEqual('yAccessorId4'); + expect(groups[1].position).toEqual('right'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId'); + expect(formatFactory).toHaveBeenCalledWith({ id: 'number' }); + expect(formatFactory).toHaveBeenCalledWith({ id: 'currency' }); + }); + + it('should create one formatter per series group', () => { + const formatFactory = jest.fn(); + getAxesConfiguration( + [ + { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId3', 'yAccessorId4'], + yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }], + }, + ], + tables, + formatFactory, + false + ); + expect(formatFactory).toHaveBeenCalledTimes(2); + expect(formatFactory).toHaveBeenCalledWith({ id: 'number' }); + expect(formatFactory).toHaveBeenCalledWith({ id: 'currency' }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts new file mode 100644 index 0000000000000..7d1d3389bb916 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts @@ -0,0 +1,106 @@ +/* + * 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 { LayerConfig } from './types'; +import { + KibanaDatatable, + SerializedFieldFormat, +} from '../../../../../src/plugins/expressions/public'; +import { IFieldFormat } from '../../../../../src/plugins/data/public'; + +interface FormattedMetric { + layer: string; + accessor: string; + fieldFormat: SerializedFieldFormat; +} + +type GroupsConfiguration = Array<{ + groupId: string; + position: 'left' | 'right' | 'bottom' | 'top'; + formatter: IFieldFormat; + series: Array<{ layer: string; accessor: string }>; +}>; + +export function isFormatterCompatible( + formatter1: SerializedFieldFormat, + formatter2: SerializedFieldFormat +) { + return formatter1.id === formatter2.id; +} + +export function getAxesConfiguration( + layers: LayerConfig[], + tables: Record, + formatFactory: (mapping: SerializedFieldFormat) => IFieldFormat, + shouldRotate: boolean +): GroupsConfiguration { + const series: { auto: FormattedMetric[]; left: FormattedMetric[]; right: FormattedMetric[] } = { + auto: [], + left: [], + right: [], + }; + + layers.forEach((layer) => { + const table = tables[layer.layerId]; + layer.accessors.forEach((accessor) => { + const mode = + layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode || + 'auto'; + const formatter: SerializedFieldFormat = table.columns.find( + (column) => column.id === accessor + )?.formatHint || { id: 'number' }; + series[mode].push({ + layer: layer.layerId, + accessor, + fieldFormat: formatter, + }); + }); + }); + + series.auto.forEach((currentSeries) => { + if ( + series.left.length === 0 || + series.left.every((leftSeries) => + isFormatterCompatible(leftSeries.fieldFormat, currentSeries.fieldFormat) + ) + ) { + series.left.push(currentSeries); + } else if ( + series.right.length === 0 || + series.right.every((rightSeries) => + isFormatterCompatible(rightSeries.fieldFormat, currentSeries.fieldFormat) + ) + ) { + series.right.push(currentSeries); + } else if (series.right.length >= series.left.length) { + series.left.push(currentSeries); + } else { + series.right.push(currentSeries); + } + }); + + const axisGroups: GroupsConfiguration = []; + + if (series.left.length > 0) { + axisGroups.push({ + groupId: 'left', + position: shouldRotate ? 'bottom' : 'left', + formatter: formatFactory(series.left[0].fieldFormat), + series: series.left.map(({ fieldFormat, ...currentSeries }) => currentSeries), + }); + } + + if (series.right.length > 0) { + axisGroups.push({ + groupId: 'right', + position: shouldRotate ? 'top' : 'right', + formatter: formatFactory(series.right[0].fieldFormat), + series: series.right.map(({ fieldFormat, ...currentSeries }) => currentSeries), + }); + } + + return axisGroups; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index cd25cb5729511..88a60089f6a24 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -11,7 +11,7 @@ import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public' import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; import { xyVisualization } from './xy_visualization'; import { xyChart, getXyChartRenderer } from './xy_expression'; -import { legendConfig, xConfig, layerConfig } from './types'; +import { legendConfig, layerConfig, yAxisConfig } from './types'; import { EditorFrameSetup, FormatFactory } from '../types'; export interface XyVisualizationPluginSetupPlugins { @@ -37,7 +37,7 @@ export class XyVisualization { { expressions, formatFactory, editorFrame }: XyVisualizationPluginSetupPlugins ) { expressions.registerFunction(() => legendConfig); - expressions.registerFunction(() => xConfig); + expressions.registerFunction(() => yAxisConfig); expressions.registerFunction(() => layerConfig); expressions.registerFunction(() => xyChart); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index e02d135d9a455..6ec22270d8b18 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -179,6 +179,21 @@ export const buildExpression = ( ], isHistogram: [isHistogramDimension], splitAccessor: layer.splitAccessor ? [layer.splitAccessor] : [], + yConfig: layer.yConfig + ? layer.yConfig.map((yConfig) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_yConfig', + arguments: { + forAccessor: [yConfig.forAccessor], + axisMode: [yConfig.axisMode], + }, + }, + ], + })) + : [], seriesType: [layer.seriesType], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 7a5837d382c7b..e62c5f60a58e1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -77,37 +77,33 @@ const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = }, }; -export interface YState extends AxisConfig { - accessors: string[]; -} - -export interface XConfig extends AxisConfig { - accessor: string; -} +type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; -type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; - -export const xConfig: ExpressionFunctionDefinition< - 'lens_xy_xConfig', +export const yAxisConfig: ExpressionFunctionDefinition< + 'lens_xy_yConfig', null, - XConfig, - XConfigResult + YConfig, + YConfigResult > = { - name: 'lens_xy_xConfig', + name: 'lens_xy_yConfig', aliases: [], - type: 'lens_xy_xConfig', - help: `Configure the xy chart's x axis`, + type: 'lens_xy_yConfig', + help: `Configure the behavior of a xy chart's y axis metric`, inputTypes: ['null'], args: { - ...axisConfig, - accessor: { + forAccessor: { types: ['string'], - help: 'The column to display on the x axis.', + help: 'The accessor this configuration is for', + }, + axisMode: { + types: ['string'], + options: ['auto', 'left', 'right'], + help: 'The axis mode of the metric', }, }, - fn: function fn(input: unknown, args: XConfig) { + fn: function fn(input: unknown, args: YConfig) { return { - type: 'lens_xy_xConfig', + type: 'lens_xy_yConfig', ...args, }; }, @@ -166,6 +162,12 @@ export const layerConfig: ExpressionFunctionDefinition< help: 'The columns to display on the y axis.', multi: true, }, + yConfig: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + types: ['lens_xy_yConfig' as any], + help: 'Additional configuration for y axes', + multi: true, + }, columnToLabel: { types: ['string'], help: 'JSON key-value pairs of column ID to label', @@ -188,11 +190,19 @@ export type SeriesType = | 'bar_horizontal_stacked' | 'area_stacked'; +export type YAxisMode = 'auto' | 'left' | 'right'; + +export interface YConfig { + forAccessor: string; + axisMode?: YAxisMode; +} + export interface LayerConfig { hide?: boolean; layerId: string; xAccessor?: string; accessors: string[]; + yConfig?: YConfig[]; seriesType: SeriesType; splitAccessor?: string; } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 0ea44e469f8dd..3e73cd256bdbf 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; -import { State, SeriesType, visualizationTypes } from './types'; -import { VisualizationLayerWidgetProps } from '../types'; +import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; +import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; +import { VisualizationDimensionEditorProps, VisualizationLayerWidgetProps } from '../types'; import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; @@ -68,3 +67,73 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } + +const idPrefix = htmlIdGenerator()(); + +export function DimensionEditor({ + state, + setState, + layerId, + accessor, +}: VisualizationDimensionEditorProps) { + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + const axisMode = + (layer.yConfig && + layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || + 'auto'; + return ( + + { + const newMode = id.replace(idPrefix, '') as YAxisMode; + const newYAxisConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYAxisConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYAxisConfigs[existingIndex].axisMode = newMode; + } else { + newYAxisConfigs.push({ + forAccessor: accessor, + axisMode: newMode, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); + }} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index b2d9f6acfc9f5..34f2a9111253b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -280,6 +280,58 @@ describe('xy_expression', () => { let getFormatSpy: jest.Mock; let convertSpy: jest.Mock; + const dataWithoutFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd' }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + const dataWithFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd', formatHint: { id: 'custom' } }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + + const getRenderedComponent = (data: LensMultiTable, args: XYArgs) => { + return shallow( + + ); + }; + beforeEach(() => { convertSpy = jest.fn((x) => x); getFormatSpy = jest.fn(); @@ -302,7 +354,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(LineSeries)).toHaveLength(1); + expect(component.find(LineSeries)).toHaveLength(2); + expect(component.find(LineSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(LineSeries).at(1).prop('yAccessors')).toEqual(['b']); }); describe('date range', () => { @@ -559,7 +613,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(BarSeries).at(1).prop('yAccessors')).toEqual(['b']); }); test('it renders area', () => { @@ -577,7 +633,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); + expect(component.find(AreaSeries)).toHaveLength(2); + expect(component.find(AreaSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(AreaSeries).at(1).prop('yAccessors')).toEqual(['b']); }); test('it renders horizontal bar', () => { @@ -595,7 +653,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(BarSeries).at(1).prop('yAccessors')).toEqual(['b']); expect(component.find(Settings).prop('rotation')).toEqual(90); }); @@ -705,8 +765,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries).at(1).prop('stackAccessors')).toHaveLength(1); }); test('it renders stacked area', () => { @@ -724,8 +785,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); - expect(component.find(AreaSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(AreaSeries)).toHaveLength(2); + expect(component.find(AreaSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(AreaSeries).at(1).prop('stackAccessors')).toHaveLength(1); }); test('it renders stacked horizontal bar', () => { @@ -746,8 +808,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries).at(1).prop('stackAccessors')).toHaveLength(1); expect(component.find(Settings).prop('rotation')).toEqual(90); }); @@ -765,7 +828,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); + expect(component.find(LineSeries).at(0).prop('timeZone')).toEqual('CEST'); + expect(component.find(LineSeries).at(1).prop('timeZone')).toEqual('CEST'); }); test('it applies histogram mode to the series for single series', () => { @@ -784,7 +848,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(true); }); test('it applies histogram mode to the series for stacked series', () => { @@ -810,7 +875,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(true); }); test('it does not apply histogram mode for splitted series', () => { @@ -830,47 +896,104 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(false); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(false); }); - describe('provides correct series naming', () => { - const dataWithoutFormats: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'kibana_datatable', - columns: [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - { id: 'c', name: 'c' }, - { id: 'd', name: 'd' }, - ], - rows: [ - { a: 1, b: 2, c: 'I', d: 'Row 1' }, - { a: 1, b: 5, c: 'J', d: 'Row 2' }, - ], - }, - }, - }; - const dataWithFormats: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'kibana_datatable', - columns: [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - { id: 'c', name: 'c' }, - { id: 'd', name: 'd', formatHint: { id: 'custom' } }, - ], - rows: [ - { a: 1, b: 2, c: 'I', d: 'Row 1' }, - { a: 1, b: 5, c: 'J', d: 'Row 2' }, - ], - }, - }, - }; + describe('y axes', () => { + test('single axis if possible', () => { + const args = createArgsWithLayers(); + + const component = getRenderedComponent(dataWithoutFormats, args); + const axes = component.find(Axis); + expect(axes).toHaveLength(2); + }); + + test('multiple axes because of config', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a', 'b'], + yConfig: [ + { + forAccessor: 'a', + axisMode: 'left', + }, + { + forAccessor: 'b', + axisMode: 'right', + }, + ], + }, + ], + } as XYArgs; + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(3); + expect(component.find(LineSeries).at(0).prop('groupId')).toEqual( + axes.at(1).prop('groupId') + ); + expect(component.find(LineSeries).at(1).prop('groupId')).toEqual( + axes.at(2).prop('groupId') + ); + }); + + test('multiple axes because of incompatible formatters', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['c', 'd'], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(3); + expect(component.find(LineSeries).at(0).prop('groupId')).toEqual( + axes.at(1).prop('groupId') + ); + expect(component.find(LineSeries).at(1).prop('groupId')).toEqual( + axes.at(2).prop('groupId') + ); + }); + + test('single axis despite different formatters if enforced', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['c', 'd'], + yConfig: [ + { + forAccessor: 'c', + axisMode: 'left', + }, + { + forAccessor: 'd', + axisMode: 'left', + }, + ], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(2); + }); + }); + + describe('provides correct series naming', () => { const nameFnArgs = { seriesKeys: [], key: '', @@ -879,21 +1002,6 @@ describe('xy_expression', () => { splitAccessors: new Map(), }; - const getRenderedComponent = (data: LensMultiTable, args: XYArgs) => { - return shallow( - - ); - }; - test('simplest xy chart without human-readable name', () => { const args = createArgsWithLayers(); const newArgs = { @@ -973,13 +1081,14 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithoutFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(1).prop('name') as SeriesNameFn; // This accessor has a human-readable name - expect(nameFn({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Label A'); + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Label A'); // This accessor does not - expect(nameFn({ ...nameFnArgs, seriesKeys: ['b'] }, false)).toEqual(''); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['b'] }, false)).toEqual(''); + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); }); test('split series without formatting and single y accessor', () => { @@ -1039,9 +1148,13 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithoutFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( + 'split1 - Label A' + ); + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( 'split1 - Label B' ); }); @@ -1061,13 +1174,14 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(1).prop('name') as SeriesNameFn; convertSpy.mockReturnValueOnce('formatted1').mockReturnValueOnce('formatted2'); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( 'formatted1 - Label A' ); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( 'formatted2 - Label B' ); }); @@ -1088,7 +1202,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); + expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal); + expect(component.find(LineSeries).at(1).prop('xScaleType')).toEqual(ScaleType.Ordinal); }); test('it set the scale of the y axis according to the args prop', () => { @@ -1106,7 +1221,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); + expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt); + expect(component.find(LineSeries).at(1).prop('yScaleType')).toEqual(ScaleType.Sqrt); }); test('it gets the formatter for the x axis', () => { @@ -1128,25 +1244,6 @@ describe('xy_expression', () => { expect(getFormatSpy).toHaveBeenCalledWith({ id: 'string' }); }); - test('it gets a default formatter for y if there are multiple y accessors', () => { - const { data, args } = sampleArgs(); - - shallow( - - ); - - expect(getFormatSpy).toHaveBeenCalledWith({ id: 'number' }); - }); - test('it gets the formatter for the y axis if there is only one accessor', () => { const { data, args } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 003036b211f03..17ed04aa0e9c4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -40,6 +40,7 @@ import { isHorizontalChart } from './state_helpers'; import { parseInterval } from '../../../../../src/plugins/data/common'; import { EmptyPlaceholder } from '../shared_components'; import { desanitizeFilterContext } from '../utils'; +import { getAxesConfiguration } from './axes_configuration'; type InferPropType = T extends React.FunctionComponent ? P : T; type SeriesSpec = InferPropType & @@ -213,23 +214,19 @@ export function XYChart({ ); const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.formatHint); - // use default number formatter for y axis and use formatting hint if there is just a single y column - let yAxisFormatter = formatFactory({ id: 'number' }); - if (filteredLayers.length === 1 && filteredLayers[0].accessors.length === 1) { - const firstYAxisColumn = Object.values(data.tables)[0].columns.find( - ({ id }) => id === filteredLayers[0].accessors[0] - ); - if (firstYAxisColumn && firstYAxisColumn.formatHint) { - yAxisFormatter = formatFactory(firstYAxisColumn.formatHint); - } - } - const chartHasMoreThanOneSeries = filteredLayers.length > 1 || filteredLayers.some((layer) => layer.accessors.length > 1) || filteredLayers.some((layer) => layer.splitAccessor); const shouldRotate = isHorizontalChart(filteredLayers); + const yAxesConfiguration = getAxesConfiguration( + filteredLayers, + data.tables, + formatFactory, + shouldRotate + ); + const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; function calculateMinInterval() { @@ -279,6 +276,9 @@ export function XYChart({ legendPosition={legend.position} showLegendExtra={false} theme={chartTheme} + tooltip={{ + headerFormatter: (d) => xAxisFormatter.convert(d.value), + }} rotation={shouldRotate ? 90 : 0} xDomain={xDomain} onBrushEnd={({ x }) => { @@ -368,18 +368,30 @@ export function XYChart({ tickFormat={(d) => xAxisFormatter.convert(d)} /> - yAxisFormatter.convert(d)} - /> + {yAxesConfiguration.map((axis, index) => ( + + data.tables[series.layer].columns.find((column) => column.id === series.accessor) + ?.name + ) + .filter((name) => Boolean(name))[0] || args.yTitle + } + showGridLines={false} + hide={filteredLayers[0].hide} + tickFormat={(d) => axis.formatter.convert(d)} + /> + ))} - {filteredLayers.map( - ( - { + {filteredLayers.flatMap((layer, layerIndex) => + layer.accessors.map((accessor, accessorIndex) => { + const { splitAccessor, seriesType, accessors, @@ -389,9 +401,7 @@ export function XYChart({ yScaleType, xScaleType, isHistogram, - }, - index - ) => { + } = layer; const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; @@ -407,19 +417,22 @@ export function XYChart({ !( splitAccessor && typeof row[splitAccessor] === 'undefined' && - accessors.every((accessor) => typeof row[accessor] === 'undefined') + typeof row[accessor] === 'undefined' ) ); const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor as string] : [], - id: splitAccessor || accessors.join(','), + id: `${splitAccessor}-${accessor}`, xAccessor, - yAccessors: accessors, + yAccessors: [accessor], data: rows, xScaleType, yScaleType, + groupId: yAxesConfiguration.find((axisConfiguration) => + axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor) + )?.groupId, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), timeZone, name(d) { @@ -451,6 +464,8 @@ export function XYChart({ }, }; + const index = `${layerIndex}-${accessorIndex}`; + switch (seriesType) { case 'line': return ; @@ -462,7 +477,7 @@ export function XYChart({ default: return ; } - } + }) )} ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index ffbd3b7e2c1f2..9d0ebbb389c07 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -14,7 +14,7 @@ import { TableSuggestion, TableChangeType, } from '../types'; -import { State, SeriesType, XYState, visualizationTypes } from './types'; +import { State, SeriesType, XYState, visualizationTypes, LayerConfig } from './types'; import { getIconForSeries } from './state_helpers'; const columnSortOrder = { @@ -379,13 +379,19 @@ function buildSuggestion({ changeType: TableChangeType; keptLayerIds: string[]; }) { + const existingLayer: LayerConfig | {} = getExistingLayer(currentState, layerId) || {}; + const accessors = yValues.map((col) => col.columnId); const newLayer = { - ...(getExistingLayer(currentState, layerId) || {}), + ...existingLayer, layerId, seriesType, xAccessor: xValue.columnId, splitAccessor: splitBy?.columnId, - accessors: yValues.map((col) => col.columnId), + accessors, + yConfig: + 'yConfig' in existingLayer && existingLayer.yConfig + ? existingLayer.yConfig.filter(({ forAccessor }) => accessors.indexOf(forAccessor) !== -1) + : undefined, }; const keptLayers = currentState diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx index ffacfbf8555eb..474ea5c5b08cd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx @@ -11,13 +11,13 @@ import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; -import { LayerContextMenu } from './xy_config_panel'; +import { DimensionEditor, LayerContextMenu } from './xy_config_panel'; import { Visualization, OperationMetadata, VisualizationType } from '../types'; import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types'; -import { toExpression, toPreviewExpression } from './to_expression'; import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; import chartMixedSVG from '../assets/chart_mixed_xy.svg'; import { isHorizontalChart } from './state_helpers'; +import { toExpression, toPreviewExpression } from './to_expression'; const defaultIcon = chartBarStackedSVG; const defaultSeriesType = 'bar_stacked'; @@ -187,6 +187,7 @@ export const xyVisualization: Visualization = { supportsMoreColumns: true, required: true, dataTestSubj: 'lnsXY_yDimensionPanel', + enableDimensionEditor: true, }, { groupId: 'breakdown', @@ -239,6 +240,10 @@ export const xyVisualization: Visualization = { newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } + if (newLayer.yConfig) { + newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); + } + return { ...prevState, layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), @@ -259,6 +264,15 @@ export const xyVisualization: Visualization = { ); }, + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, + toExpression, toPreviewExpression, }; From 6e268898f9f5592def9200c6d92c33bb6d75fd4d Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Wed, 1 Jul 2020 10:01:32 +0200 Subject: [PATCH 02/10] chore: add missing mjs extension (#70326) --- src/dev/jest/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 391a52b7f0397..e11668ab57f55 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -84,7 +84,7 @@ export default { moduleFileExtensions: ['js', 'mjs', 'json', 'ts', 'tsx', 'node'], modulePathIgnorePatterns: ['__fixtures__/', 'target/'], testEnvironment: 'jest-environment-jsdom-thirteen', - testMatch: ['**/*.test.{js,ts,tsx}'], + testMatch: ['**/*.test.{js,mjs,ts,tsx}'], testPathIgnorePatterns: [ '/packages/kbn-ui-framework/(dist|doc_site|generator-kui)/', '/packages/kbn-pm/dist/', From a49f5cec64024ceb1807b3e4a71ff6c642b9bf40 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 1 Jul 2020 10:07:59 +0200 Subject: [PATCH 03/10] [Lens] Move chart switcher over (#70182) --- .../editor_frame/config_panel/_index.scss | 1 - .../config_panel/config_panel.tsx | 18 +-------- .../chart_switch.scss} | 5 +-- .../chart_switch.test.tsx | 0 .../chart_switch.tsx | 10 ++--- .../editor_frame/workspace_panel/index.ts | 7 ++++ .../workspace_panel.test.tsx | 20 +++++----- .../{ => workspace_panel}/workspace_panel.tsx | 27 +++++++------ .../workspace_panel_wrapper.test.tsx | 14 +++++-- .../workspace_panel_wrapper.tsx | 38 ++++++++++++++++--- 10 files changed, 83 insertions(+), 57 deletions(-) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{config_panel/_chart_switch.scss => workspace_panel/chart_switch.scss} (86%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{config_panel => workspace_panel}/chart_switch.test.tsx (100%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{config_panel => workspace_panel}/chart_switch.tsx (98%) create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel.test.tsx (97%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel.tsx (91%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel_wrapper.test.tsx (82%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel_wrapper.tsx (69%) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss index 8f09a358dd5e4..5b968abd0c061 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss @@ -1,4 +1,3 @@ -@import 'chart_switch'; @import 'config_panel'; @import 'dimension_popover'; @import 'layer_panel'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index e53e465c18950..7f4a48fa2fda2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -8,7 +8,6 @@ import React, { useMemo, memo } from 'react'; import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Visualization } from '../../../types'; -import { ChartSwitch } from './chart_switch'; import { LayerPanel } from './layer_panel'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; @@ -20,21 +19,8 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config const { visualizationState } = props; return ( - <> - - {activeVisualization && visualizationState && ( - - )} - + activeVisualization && + visualizationState && ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss similarity index 86% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index d7efab2405f3f..ae4a7861b1d90 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -1,6 +1,4 @@ .lnsChartSwitch__header { - padding: $euiSizeS 0; - > * { display: flex; align-items: center; @@ -9,7 +7,8 @@ .lnsChartSwitch__triggerButton { @include euiTitle('xs'); - line-height: $euiSizeXXL; + background-color: $euiColorEmptyShade; + border-color: $euiColorLightShade; } .lnsChartSwitch__summaryIcon { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx similarity index 98% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index e212cb70d1855..4c5a44ecc695e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -11,7 +11,7 @@ import { EuiPopoverTitle, EuiKeyPadMenu, EuiKeyPadMenuItem, - EuiButtonEmpty, + EuiButton, } from '@elastic/eui'; import { flatten } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -72,6 +72,8 @@ function VisualizationSummary(props: Props) { ); } +import './chart_switch.scss'; + export function ChartSwitch(props: Props) { const [flyoutOpen, setFlyoutOpen] = useState(false); @@ -198,20 +200,18 @@ export function ChartSwitch(props: Props) { ownFocus initialFocus=".lnsChartSwitch__popoverPanel" panelClassName="lnsChartSwitch__popoverPanel" - anchorClassName="eui-textTruncate" panelPaddingSize="s" button={ - setFlyoutOpen(!flyoutOpen)} data-test-subj="lnsChartSwitchPopover" - flush="left" iconSide="right" iconType="arrowDown" color="text" > - + } isOpen={flyoutOpen} closePopover={() => setFlyoutOpen(false)} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts new file mode 100644 index 0000000000000..d23afd4129cbe --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { WorkspacePanel } from './workspace_panel'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx similarity index 97% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 49d12e9f41440..a9c638df8cad1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -6,19 +6,19 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { ReactExpressionRendererProps } from '../../../../../../src/plugins/expressions/public'; -import { FramePublicAPI, TableSuggestion, Visualization } from '../../types'; +import { ReactExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public'; +import { FramePublicAPI, TableSuggestion, Visualization } from '../../../types'; import { createMockVisualization, createMockDatasource, createExpressionRendererMock, DatasourceMock, createMockFramePublicAPI, -} from '../mocks'; +} from '../../mocks'; import { InnerWorkspacePanel, WorkspacePanelProps } from './workspace_panel'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; -import { DragDrop, ChildDragDropProvider } from '../../drag_drop'; +import { DragDrop, ChildDragDropProvider } from '../../../drag_drop'; import { Ast } from '@kbn/interpreter/common'; import { coreMock } from 'src/core/public/mocks'; import { @@ -26,12 +26,12 @@ import { esFilters, IFieldType, IIndexPattern, -} from '../../../../../../src/plugins/data/public'; -import { TriggerId, UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks'; -import { TriggerContract } from '../../../../../../src/plugins/ui_actions/public/triggers'; -import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public/embeddable'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +} from '../../../../../../../src/plugins/data/public'; +import { TriggerId, UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; +import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks'; +import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; describe('workspace_panel', () => { let mockVisualization: jest.Mocked; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx similarity index 91% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 670afe28293a4..beb6952556067 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -20,23 +20,23 @@ import { CoreStart, CoreSetup } from 'kibana/public'; import { ExpressionRendererEvent, ReactExpressionRendererType, -} from '../../../../../../src/plugins/expressions/public'; -import { Action } from './state_management'; +} from '../../../../../../../src/plugins/expressions/public'; +import { Action } from '../state_management'; import { Datasource, Visualization, FramePublicAPI, isLensBrushEvent, isLensFilterEvent, -} from '../../types'; -import { DragDrop, DragContext } from '../../drag_drop'; -import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; -import { buildExpression } from './expression_helpers'; -import { debouncedComponent } from '../../debounced_component'; -import { trackUiEvent } from '../../lens_ui_telemetry'; -import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public'; -import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +} from '../../../types'; +import { DragDrop, DragContext } from '../../../drag_drop'; +import { getSuggestions, switchToSuggestion } from '../suggestion_helpers'; +import { buildExpression } from '../expression_helpers'; +import { debouncedComponent } from '../../../debounced_component'; +import { trackUiEvent } from '../../../lens_ui_telemetry'; +import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public'; +import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; export interface WorkspacePanelProps { @@ -300,7 +300,10 @@ export function InnerWorkspacePanel({ dispatch={dispatch} emptyExpression={expression === null} visualizationState={visualizationState} - activeVisualization={activeVisualization} + visualizationId={activeVisualizationId} + datasourceStates={datasourceStates} + datasourceMap={datasourceMap} + visualizationMap={visualizationMap} > { dispatch={jest.fn()} framePublicAPI={mockFrameAPI} visualizationState={{}} - activeVisualization={mockVisualization} + visualizationId="myVis" + visualizationMap={{ myVis: mockVisualization }} + datasourceMap={{}} + datasourceStates={{}} emptyExpression={false} > @@ -51,7 +54,10 @@ describe('workspace_panel_wrapper', () => { framePublicAPI={mockFrameAPI} visualizationState={visState} children={} - activeVisualization={{ ...mockVisualization, renderToolbar: renderToolbarMock }} + visualizationId="myVis" + visualizationMap={{ myVis: { ...mockVisualization, renderToolbar: renderToolbarMock } }} + datasourceMap={{}} + datasourceStates={{}} emptyExpression={false} /> ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx similarity index 69% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index 17461b9fc274f..60c31e5d090e5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -14,29 +14,43 @@ import { EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; -import { FramePublicAPI, Visualization } from '../../types'; -import { NativeRenderer } from '../../native_renderer'; -import { Action } from './state_management'; +import { Datasource, FramePublicAPI, Visualization } from '../../../types'; +import { NativeRenderer } from '../../../native_renderer'; +import { Action } from '../state_management'; +import { ChartSwitch } from './chart_switch'; export interface WorkspacePanelWrapperProps { children: React.ReactNode | React.ReactNode[]; framePublicAPI: FramePublicAPI; visualizationState: unknown; - activeVisualization: Visualization | null; dispatch: (action: Action) => void; emptyExpression: boolean; title?: string; + visualizationMap: Record; + visualizationId: string | null; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; } export function WorkspacePanelWrapper({ children, framePublicAPI, visualizationState, - activeVisualization, dispatch, title, emptyExpression, + visualizationId, + visualizationMap, + datasourceMap, + datasourceStates, }: WorkspacePanelWrapperProps) { + const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null; const setVisualizationState = useCallback( (newState: unknown) => { if (!activeVisualization) { @@ -52,7 +66,19 @@ export function WorkspacePanelWrapper({ [dispatch] ); return ( - + + + + {activeVisualization && activeVisualization.renderToolbar && ( Date: Wed, 1 Jul 2020 10:08:37 +0200 Subject: [PATCH 04/10] [Lens] Add "no data" popover (#69147) --- ...na-plugin-plugins-data-public.searchbar.md | 4 +- src/plugins/data/public/public.api.md | 4 +- .../no_data_popover.test.tsx | 95 ++++++++++++++++++ .../ui/query_string_input/no_data_popover.tsx | 96 +++++++++++++++++++ .../query_string_input/query_bar_top_row.tsx | 12 ++- .../ui/search_bar/create_search_bar.tsx | 1 + .../data/public/ui/search_bar/search_bar.tsx | 2 + test/functional/page_objects/time_picker.ts | 7 ++ .../lens/public/app_plugin/app.test.tsx | 1 + x-pack/plugins/lens/public/app_plugin/app.tsx | 21 ++++ .../editor_frame/data_panel_wrapper.tsx | 2 + .../editor_frame/editor_frame.test.tsx | 1 + .../editor_frame/editor_frame.tsx | 2 + .../editor_frame/state_management.test.ts | 1 + .../editor_frame_service/service.test.tsx | 2 + .../public/editor_frame_service/service.tsx | 6 +- .../__mocks__/loader.ts | 1 + .../datapanel.test.tsx | 7 +- .../indexpattern_datasource/datapanel.tsx | 6 +- .../dimension_panel/dimension_panel.test.tsx | 2 + .../indexpattern.test.ts | 5 + .../indexpattern_suggestions.test.tsx | 8 ++ .../layerpanel.test.tsx | 1 + .../indexpattern_datasource/loader.test.ts | 50 +++++++++- .../public/indexpattern_datasource/loader.ts | 18 ++++ .../definitions/date_histogram.test.tsx | 1 + .../operations/definitions/terms.test.tsx | 1 + .../operations/operations.test.ts | 1 + .../state_helpers.test.ts | 8 ++ .../public/indexpattern_datasource/types.ts | 1 + x-pack/plugins/lens/public/types.ts | 2 + .../apps/lens/persistent_context.ts | 6 +- .../test/functional/page_objects/lens_page.ts | 5 +- 33 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx create mode 100644 src/plugins/data/public/ui/query_string_input/no_data_popover.tsx diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index fc141b8c89c18..498691c06285d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index f2c7a907cda1d..f19611bc1d526 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1748,8 +1748,8 @@ export const search: { // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx b/src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx new file mode 100644 index 0000000000000..27f924d98e6eb --- /dev/null +++ b/src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx @@ -0,0 +1,95 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { NoDataPopover } from './no_data_popover'; +import { EuiTourStep } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; + +describe('NoDataPopover', () => { + const createMockStorage = () => ({ + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }); + + it('should hide popover if showNoDataPopover is set to false', () => { + const Child = () => ; + const instance = mount( + + + + ); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + expect(instance.find(EuiTourStep).find(Child)).toHaveLength(1); + }); + + it('should hide popover if showNoDataPopover is set to true, but local storage flag is set', () => { + const child = ; + const storage = createMockStorage(); + storage.get.mockReturnValue(true); + const instance = mount( + + {child} + + ); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); + + it('should render popover if showNoDataPopover is set to true and local storage flag is not set', () => { + const child = ; + const instance = mount( + + {child} + + ); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(true); + }); + + it('should hide popover if it is closed', async () => { + const props = { + children: , + showNoDataPopover: true, + storage: createMockStorage(), + }; + const instance = mount(); + act(() => { + instance.find(EuiTourStep).prop('closePopover')!(); + }); + instance.setProps({ ...props }); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); + + it('should set local storage flag and hide on closing with button', () => { + const props = { + children: , + showNoDataPopover: true, + storage: createMockStorage(), + }; + const instance = mount(); + act(() => { + instance.find(EuiTourStep).prop('footerAction')!.props.onClick(); + }); + instance.setProps({ ...props }); + expect(props.storage.set).toHaveBeenCalledWith(expect.any(String), true); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); +}); diff --git a/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx b/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx new file mode 100644 index 0000000000000..302477a5fff5e --- /dev/null +++ b/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx @@ -0,0 +1,96 @@ +/* + * 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 { ReactElement, useEffect, useState } from 'react'; +import React from 'react'; +import { EuiButtonEmpty, EuiText, EuiTourStep } from '@elastic/eui'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { i18n } from '@kbn/i18n'; + +const NO_DATA_POPOVER_STORAGE_KEY = 'data.noDataPopover'; + +export function NoDataPopover({ + showNoDataPopover, + storage, + children, +}: { + showNoDataPopover?: boolean; + storage: IStorageWrapper; + children: ReactElement; +}) { + const [noDataPopoverDismissed, setNoDataPopoverDismissed] = useState(() => + Boolean(storage.get(NO_DATA_POPOVER_STORAGE_KEY)) + ); + const [noDataPopoverVisible, setNoDataPopoverVisible] = useState(false); + + useEffect(() => { + if (showNoDataPopover && !noDataPopoverDismissed) { + setNoDataPopoverVisible(true); + } + }, [noDataPopoverDismissed, showNoDataPopover]); + + return ( + {}} + closePopover={() => { + setNoDataPopoverVisible(false); + }} + content={ + +

+ {i18n.translate('data.noDataPopover.content', { + defaultMessage: + "This time range doesn't contain any data. Increase or adjust the time range to see more fields and create charts", + })} +

+
+ } + minWidth={300} + anchorPosition="downCenter" + step={1} + stepsTotal={1} + isStepOpen={noDataPopoverVisible} + subtitle={i18n.translate('data.noDataPopover.title', { defaultMessage: 'Tip' })} + title="" + footerAction={ + { + storage.set(NO_DATA_POPOVER_STORAGE_KEY, true); + setNoDataPopoverDismissed(true); + setNoDataPopoverVisible(false); + }} + > + {i18n.translate('data.noDataPopover.dismissAction', { + defaultMessage: "Don't show again", + })} + + } + > +
{ + setNoDataPopoverVisible(false); + }} + > + {children} +
+
+ ); +} diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index f65bf97e391e2..4b0dc579c39ce 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -40,6 +40,7 @@ import { useKibana, toMountPoint } from '../../../../kibana_react/public'; import { QueryStringInput } from './query_string_input'; import { doesKueryExpressionHaveLuceneSyntaxError, UI_SETTINGS } from '../../../common'; import { PersistedLog, getQueryLog } from '../../query'; +import { NoDataPopover } from './no_data_popover'; interface Props { query?: Query; @@ -63,6 +64,7 @@ interface Props { customSubmitButton?: any; isDirty: boolean; timeHistory?: TimeHistoryContract; + indicateNoData?: boolean; } export function QueryBarTopRow(props: Props) { @@ -230,10 +232,12 @@ export function QueryBarTopRow(props: Props) { } return ( - - {renderDatePicker()} - {button} - + + + {renderDatePicker()} + {button} + + ); } diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index 81e84e3198072..a0df7604f23aa 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -198,6 +198,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) showSaveQuery={props.showSaveQuery} screenTitle={props.screenTitle} indexPatterns={props.indexPatterns} + indicateNoData={props.indicateNoData} timeHistory={data.query.timefilter.history} dateRangeFrom={timeRange.from} dateRangeTo={timeRange.to} diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index a5ac227559115..2f740cc476087 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -75,6 +75,7 @@ export interface SearchBarOwnProps { onClearSavedQuery?: () => void; onRefresh?: (payload: { dateRange: TimeRange }) => void; + indicateNoData?: boolean; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -402,6 +403,7 @@ class SearchBarUI extends Component { this.props.customSubmitButton ? this.props.customSubmitButton : undefined } dataTestSubj={this.props.dataTestSubj} + indicateNoData={this.props.indicateNoData} /> ); } diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 20ae89fc1a8d0..7ef291c8c7005 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -52,6 +52,13 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); } + async ensureHiddenNoDataPopover() { + const isVisible = await testSubjects.exists('noDataPopoverDismissButton'); + if (isVisible) { + await testSubjects.click('noDataPopoverDismissButton'); + } + } + /** * the provides a quicker way to set the timepicker to the default range, saves a few seconds */ diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index cd6fbf96d6750..3bd12a87456a0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -226,6 +226,7 @@ describe('Lens App', () => { "query": "", }, "savedQuery": undefined, + "showNoDataPopover": [Function], }, ], ] diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 0ab547bed6d37..9b8b9a8531cf0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -40,6 +40,7 @@ import { } from '../../../../../src/plugins/data/public'; interface State { + indicateNoData: boolean; isLoading: boolean; isSaveModalVisible: boolean; indexPatternsForTopNav: IndexPatternInstance[]; @@ -97,9 +98,27 @@ export function App({ toDate: currentRange.to, }, filters: [], + indicateNoData: false, }; }); + const showNoDataPopover = useCallback(() => { + setState((prevState) => ({ ...prevState, indicateNoData: true })); + }, [setState]); + + useEffect(() => { + if (state.indicateNoData) { + setState((prevState) => ({ ...prevState, indicateNoData: false })); + } + }, [ + setState, + state.indicateNoData, + state.query, + state.filters, + state.dateRange, + state.indexPatternsForTopNav, + ]); + const { lastKnownDoc } = state; const isSaveable = @@ -458,6 +477,7 @@ export function App({ query={state.query} dateRangeFrom={state.dateRange.fromDate} dateRangeTo={state.dateRange.toDate} + indicateNoData={state.indicateNoData} /> @@ -472,6 +492,7 @@ export function App({ savedQuery: state.savedQuery, doc: state.persistedDoc, onError, + showNoDataPopover, onChange: ({ filterableIndexPatterns, doc }) => { if (!_.isEqual(state.persistedDoc, doc)) { setState((s) => ({ ...s, lastKnownDoc: doc })); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index afb2719f28e89..0f74abe97c418 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -19,6 +19,7 @@ interface DataPanelWrapperProps { activeDatasource: string | null; datasourceIsLoading: boolean; dispatch: (action: Action) => void; + showNoDataPopover: () => void; core: DatasourceDataPanelProps['core']; query: Query; dateRange: FramePublicAPI['dateRange']; @@ -46,6 +47,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { query: props.query, dateRange: props.dateRange, filters: props.filters, + showNoDataPopover: props.showNoDataPopover, }; const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index ff9e24f95d1e2..ad4f6e74c9e92 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -56,6 +56,7 @@ function getDefaultProps() { data: dataPluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), }, + showNoDataPopover: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index af3d0ed068d2f..bcceb1222ce03 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -48,6 +48,7 @@ export interface EditorFrameProps { filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; doc: Document; }) => void; + showNoDataPopover: () => void; } export function EditorFrame(props: EditorFrameProps) { @@ -255,6 +256,7 @@ export function EditorFrame(props: EditorFrameProps) { query={props.query} dateRange={props.dateRange} filters={props.filters} + showNoDataPopover={props.showNoDataPopover} /> } configPanel={ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts index e1151b92aac51..969467b5789ec 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts @@ -35,6 +35,7 @@ describe('editor_frame state management', () => { dateRange: { fromDate: 'now-7d', toDate: 'now' }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }; }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx index fbd65c5044d51..7b1d091c1c8fe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx @@ -51,6 +51,7 @@ describe('editor_frame service', () => { dateRange: { fromDate: '', toDate: '' }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }); instance.unmount(); })() @@ -70,6 +71,7 @@ describe('editor_frame service', () => { dateRange: { fromDate: '', toDate: '' }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }); instance.unmount(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index f57acf3bef62d..47339373b6d1a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -102,7 +102,10 @@ export class EditorFrameService { ]); return { - mount: (element, { doc, onError, dateRange, query, filters, savedQuery, onChange }) => { + mount: ( + element, + { doc, onError, dateRange, query, filters, savedQuery, onChange, showNoDataPopover } + ) => { domElement = element; const firstDatasourceId = Object.keys(resolvedDatasources)[0]; const firstVisualizationId = Object.keys(resolvedVisualizations)[0]; @@ -127,6 +130,7 @@ export class EditorFrameService { filters={filters} savedQuery={savedQuery} onChange={onChange} + showNoDataPopover={showNoDataPopover} /> , domElement diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts index f2fedda1fa353..ca5fe706985f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts @@ -19,6 +19,7 @@ export function loadInitialState() { [restricted.id]: restricted, }, layers: {}, + isFirstExistenceFetch: false, }; return result; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 7653dab2c9b84..f70df855fe0cb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -204,12 +204,15 @@ const initialState: IndexPatternPrivateState = { ], }, }, + isFirstExistenceFetch: false, }; const dslQuery = { bool: { must: [{ match_all: {} }], filter: [], should: [], must_not: [] } }; describe('IndexPattern Data Panel', () => { - let defaultProps: Parameters[0]; + let defaultProps: Parameters[0] & { + showNoDataPopover: () => void; + }; let core: ReturnType; beforeEach(() => { @@ -229,6 +232,7 @@ describe('IndexPattern Data Panel', () => { }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }; }); @@ -301,6 +305,7 @@ describe('IndexPattern Data Panel', () => { state: { indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, currentIndexPatternId: 'a', indexPatterns: { a: { id: 'a', title: 'aaa', timeFieldName: 'atime', fields: [] }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index b72f87e243dcd..87fbf81fceba0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -82,6 +82,7 @@ export function IndexPatternDataPanel({ filters, dateRange, changeIndexPattern, + showNoDataPopover, }: Props) { const { indexPatternRefs, indexPatterns, currentIndexPatternId } = state; const onChangeIndexPattern = useCallback( @@ -116,6 +117,9 @@ export function IndexPatternDataPanel({ syncExistingFields({ dateRange, setState, + isFirstExistenceFetch: state.isFirstExistenceFetch, + currentIndexPatternTitle: indexPatterns[currentIndexPatternId].title, + showNoDataPopover, indexPatterns: indexPatternList, fetchJson: core.http.post, dslQuery, @@ -210,7 +214,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ core, data, existingFields, -}: Pick> & { +}: Omit & { data: DataPublicPluginStart; currentIndexPatternId: string; indexPatternRefs: IndexPatternRef[]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index ee9b6778650ef..a1c084f83e447 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -79,6 +79,7 @@ describe('IndexPatternDimensionEditorPanel', () => { indexPatternRefs: [], indexPatterns: expectedIndexPatterns, currentIndexPatternId: '1', + isFirstExistenceFetch: false, existingFields: { 'my-fake-index-pattern': { timestamp: true, @@ -1257,6 +1258,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, }, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { myLayer: { indexPatternId: 'foo', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index a69d7c055eaa7..6a79ce450cd9a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -127,6 +127,7 @@ function stateFromPersistedState( indexPatterns: expectedIndexPatterns, indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, }; } @@ -401,6 +402,7 @@ describe('IndexPattern Data Source', () => { }, }, currentIndexPatternId: '1', + isFirstExistenceFetch: false, }; expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ ...state, @@ -421,6 +423,7 @@ describe('IndexPattern Data Source', () => { const state = { indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { @@ -455,6 +458,7 @@ describe('IndexPattern Data Source', () => { indexPatternDatasource.getLayers({ indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { @@ -480,6 +484,7 @@ describe('IndexPattern Data Source', () => { indexPatternDatasource.getMetaData({ indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 87d91b56d2a5c..b6246c6e91e7e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -146,6 +146,7 @@ function testInitialState(): IndexPatternPrivateState { }, }, }, + isFirstExistenceFetch: false, }; } @@ -304,6 +305,7 @@ describe('IndexPattern Data Source suggestions', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', @@ -508,6 +510,7 @@ describe('IndexPattern Data Source suggestions', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', @@ -1046,6 +1049,7 @@ describe('IndexPattern Data Source suggestions', () => { it('returns no suggestions if there are no columns', () => { expect( getDatasourceSuggestionsFromCurrentState({ + isFirstExistenceFetch: false, indexPatternRefs: [], existingFields: {}, indexPatterns: expectedIndexPatterns, @@ -1351,6 +1355,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, @@ -1470,6 +1475,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, @@ -1523,6 +1529,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, @@ -1553,6 +1560,7 @@ describe('IndexPattern Data Source suggestions', () => { existingFields: {}, currentIndexPatternId: '1', indexPatterns: expectedIndexPatterns, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 9cbd624b42d3e..f9a74ee477d57 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -22,6 +22,7 @@ const initialState: IndexPatternPrivateState = { ], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index e8c8c5762bb83..5776691fbcc7f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'kibana/public'; +import { HttpHandler, SavedObjectsClientContract } from 'kibana/public'; import _ from 'lodash'; import { loadInitialState, @@ -429,6 +429,7 @@ describe('loader', () => { indexPatterns: {}, existingFields: {}, layers: {}, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'b' }); @@ -463,6 +464,7 @@ describe('loader', () => { existingFields: {}, indexPatterns: {}, layers: {}, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'b' }); @@ -520,6 +522,7 @@ describe('loader', () => { indexPatternId: 'a', }, }, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'a' }); @@ -588,6 +591,7 @@ describe('loader', () => { indexPatternId: 'a', }, }, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'b' }); @@ -625,7 +629,7 @@ describe('loader', () => { it('should call once for each index pattern', async () => { const setState = jest.fn(); - const fetchJson = jest.fn((path: string) => { + const fetchJson = (jest.fn((path: string) => { const indexPatternTitle = _.last(path.split('/')); return { indexPatternTitle, @@ -633,15 +637,17 @@ describe('loader', () => { (fieldName) => `${indexPatternTitle}_${fieldName}` ), }; - }); + }) as unknown) as HttpHandler; await syncExistingFields({ dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fetchJson: fetchJson as any, + fetchJson, indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], setState, dslQuery, + showNoDataPopover: jest.fn(), + currentIndexPatternTitle: 'abc', + isFirstExistenceFetch: false, }); expect(fetchJson).toHaveBeenCalledTimes(3); @@ -655,6 +661,7 @@ describe('loader', () => { expect(newState).toEqual({ foo: 'bar', + isFirstExistenceFetch: false, existingFields: { a: { a_field_1: true, a_field_2: true }, b: { b_field_1: true, b_field_2: true }, @@ -662,5 +669,38 @@ describe('loader', () => { }, }); }); + + it('should call showNoDataPopover callback if current index pattern returns no fields', async () => { + const setState = jest.fn(); + const showNoDataPopover = jest.fn(); + const fetchJson = (jest.fn((path: string) => { + const indexPatternTitle = _.last(path.split('/')); + return { + indexPatternTitle, + existingFieldNames: + indexPatternTitle === 'a' + ? ['field_1', 'field_2'].map((fieldName) => `${indexPatternTitle}_${fieldName}`) + : [], + }; + }) as unknown) as HttpHandler; + + const args = { + dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, + fetchJson, + indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + setState, + dslQuery, + showNoDataPopover: jest.fn(), + currentIndexPatternTitle: 'abc', + isFirstExistenceFetch: false, + }; + + await syncExistingFields(args); + + expect(showNoDataPopover).not.toHaveBeenCalled(); + + await syncExistingFields({ ...args, isFirstExistenceFetch: true }); + expect(showNoDataPopover).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 6c57988dfc7b6..101f536993365 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -119,6 +119,7 @@ export async function loadInitialState({ indexPatternRefs, indexPatterns, existingFields: {}, + isFirstExistenceFetch: true, }; } @@ -128,6 +129,7 @@ export async function loadInitialState({ indexPatterns, layers: {}, existingFields: {}, + isFirstExistenceFetch: true, }; } @@ -238,13 +240,19 @@ export async function syncExistingFields({ dateRange, fetchJson, setState, + isFirstExistenceFetch, + currentIndexPatternTitle, dslQuery, + showNoDataPopover, }: { dateRange: DateRange; indexPatterns: Array<{ id: string; timeFieldName?: string | null }>; fetchJson: HttpSetup['post']; setState: SetState; + isFirstExistenceFetch: boolean; + currentIndexPatternTitle: string; dslQuery: object; + showNoDataPopover: () => void; }) { const emptinessInfo = await Promise.all( indexPatterns.map((pattern) => { @@ -264,8 +272,18 @@ export async function syncExistingFields({ }) ); + if (isFirstExistenceFetch) { + const fieldsCurrentIndexPattern = emptinessInfo.find( + (info) => info.indexPatternTitle === currentIndexPatternTitle + ); + if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { + showNoDataPopover(); + } + } + setState((state) => ({ ...state, + isFirstExistenceFetch: false, existingFields: emptinessInfo.reduce((acc, info) => { acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); return acc; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index d0c7af42114e3..1a094a36f68e3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -51,6 +51,7 @@ describe('date_histogram', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx index 1e1d83a0a5c4c..d7f00e185a5bb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx @@ -34,6 +34,7 @@ describe('terms', () => { indexPatterns: {}, existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index a73f6e13d94c5..1a37e5e4cf6a4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -147,6 +147,7 @@ describe('getOperationTypesForField', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts index 65a2401fd689a..d778749ef3940 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts @@ -42,6 +42,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -95,6 +96,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -145,6 +147,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -185,6 +188,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -218,6 +222,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -279,6 +284,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -331,6 +337,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -410,6 +417,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 35a82d8774130..b7beb67196add 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -51,6 +51,7 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { * indexPatternId -> fieldName -> boolean */ existingFields: Record>; + isFirstExistenceFetch: boolean; }; export interface IndexPatternRef { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index d451e312446bd..c7bda65cd1327 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -47,6 +47,7 @@ export interface EditorFrameProps { filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; doc: Document; }) => void; + showNoDataPopover: () => void; } export interface EditorFrameInstance { mount: (element: Element, props: EditorFrameProps) => void; @@ -186,6 +187,7 @@ export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; setState: StateSetter; + showNoDataPopover: () => void; core: Pick; query: Query; dateRange: DateRange; diff --git a/x-pack/test/functional/apps/lens/persistent_context.ts b/x-pack/test/functional/apps/lens/persistent_context.ts index 00d9208772798..b980116c581da 100644 --- a/x-pack/test/functional/apps/lens/persistent_context.ts +++ b/x-pack/test/functional/apps/lens/persistent_context.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['visualize', 'header', 'timePicker']); + const PageObjects = getPageObjects(['visualize', 'lens', 'header', 'timePicker']); const browser = getService('browser'); const filterBar = getService('filterBar'); const appsMenu = getService('appsMenu'); @@ -19,7 +19,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should carry over time range and pinned filters to discover', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); - await PageObjects.timePicker.setAbsoluteRange( + await PageObjects.lens.goToTimeRange( 'Sep 06, 2015 @ 06:31:44.000', 'Sep 18, 2025 @ 06:31:44.000' ); @@ -33,7 +33,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should remember time range and pinned filters from discover', async () => { - await PageObjects.timePicker.setAbsoluteRange( + await PageObjects.lens.goToTimeRange( 'Sep 07, 2015 @ 06:31:44.000', 'Sep 19, 2025 @ 06:31:44.000' ); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index bae11e1ea8a90..ce621d4471d0f 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -38,10 +38,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * Move the date filter to the specified time range, defaults to * a range that has data in our dataset. */ - goToTimeRange(fromTime?: string, toTime?: string) { + async goToTimeRange(fromTime?: string, toTime?: string) { + await PageObjects.timePicker.ensureHiddenNoDataPopover(); fromTime = fromTime || PageObjects.timePicker.defaultStartTime; toTime = toTime || PageObjects.timePicker.defaultEndTime; - return PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); }, /** From 22a41c51e53f851bc67e5e4f63e393a838f6f8aa Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Wed, 1 Jul 2020 10:54:22 +0200 Subject: [PATCH 05/10] [Discover] Deangularization context error message refactoring (#70090) Co-authored-by: Elastic Machine --- .../application/angular/context_app.html | 42 ++---------- .../context_error_message.test.tsx | 54 ++++++++++++++++ .../context_error_message.tsx | 64 +++++++++++++++++++ .../context_error_message_directive.ts | 26 ++++++++ .../components/context_error_message/index.ts | 21 ++++++ .../discover/public/get_inner_angular.ts | 2 + 6 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx create mode 100644 src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx create mode 100644 src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts create mode 100644 src/plugins/discover/public/application/components/context_error_message/index.ts diff --git a/src/plugins/discover/public/application/angular/context_app.html b/src/plugins/discover/public/application/angular/context_app.html index 9c37fd3bfc5be..6adcaeeae94f5 100644 --- a/src/plugins/discover/public/application/angular/context_app.html +++ b/src/plugins/discover/public/application/angular/context_app.html @@ -12,44 +12,10 @@ -
-
-
- - -
- -
-
-
-
- -
-
-
-
+ +
{ + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0); + }); + + it('ContextErrorMessage does not render on success loading', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0); + }); + + it('ContextErrorMessage renders just the title if the reason is not specifically handled', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1); + expect(findTestSubject(component, 'contextErrorMessageBody').text()).toBe(''); + }); + + it('ContextErrorMessage renders the reason for unknown errors', () => { + component = mountWithIntl( + + ); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1); + expect(findTestSubject(component, 'contextErrorMessageBody').length).toBe(1); + }); +}); diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx b/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx new file mode 100644 index 0000000000000..f73496c2eeada --- /dev/null +++ b/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +// @ts-ignore +import { FAILURE_REASONS, LOADING_STATUS } from '../../angular/context/query'; + +export interface ContextErrorMessageProps { + /** + * the status of the loading action + */ + status: string; + /** + * the reason of the error + */ + reason?: string; +} + +export function ContextErrorMessage({ status, reason }: ContextErrorMessageProps) { + if (status !== LOADING_STATUS.FAILED) { + return null; + } + return ( + + + } + color="danger" + iconType="alert" + data-test-subj="contextErrorMessageTitle" + > + + {reason === FAILURE_REASONS.UNKNOWN && ( + + )} + + + + ); +} diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts b/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts new file mode 100644 index 0000000000000..925d560761a84 --- /dev/null +++ b/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts @@ -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 { ContextErrorMessage } from './context_error_message'; + +export function createContextErrorMessageDirective(reactDirective: any) { + return reactDirective(ContextErrorMessage, [ + ['status', { watchDepth: 'reference' }], + ['reason', { watchDepth: 'reference' }], + ]); +} diff --git a/src/plugins/discover/public/application/components/context_error_message/index.ts b/src/plugins/discover/public/application/components/context_error_message/index.ts new file mode 100644 index 0000000000000..f20f2ccf8afa0 --- /dev/null +++ b/src/plugins/discover/public/application/components/context_error_message/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { ContextErrorMessage } from './context_error_message'; +export { createContextErrorMessageDirective } from './context_error_message_directive'; diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index 05513eef93624..0b3c2fad8d45b 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -61,6 +61,7 @@ import { createDiscoverSidebarDirective } from './application/components/sidebar import { createHitsCounterDirective } from '././application/components/hits_counter'; import { createLoadingSpinnerDirective } from '././application/components/loading_spinner/loading_spinner'; import { createTimechartHeaderDirective } from './application/components/timechart_header'; +import { createContextErrorMessageDirective } from './application/components/context_error_message'; import { DiscoverStartPlugins } from './plugin'; import { getScopedHistory } from './kibana_services'; import { createSkipBottomButtonDirective } from './application/components/skip_bottom_button'; @@ -160,6 +161,7 @@ export function initializeInnerAngularModule( .directive('hitsCounter', createHitsCounterDirective) .directive('loadingSpinner', createLoadingSpinnerDirective) .directive('timechartHeader', createTimechartHeaderDirective) + .directive('contextErrorMessage', createContextErrorMessageDirective) .service('debounce', ['$timeout', DebounceProviderTimeout]); } From 5c98b11d73c926733df0ab01c9fb44f6254734d0 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Wed, 1 Jul 2020 10:56:05 +0200 Subject: [PATCH 06/10] expressions indexPattern function (#70315) --- .../index_patterns/expressions/index.ts | 20 ++++++ .../expressions/load_index_pattern.test.ts | 39 ++++++++++++ .../expressions/load_index_pattern.ts | 62 +++++++++++++++++++ src/plugins/data/public/plugin.ts | 2 + 4 files changed, 123 insertions(+) create mode 100644 src/plugins/data/public/index_patterns/expressions/index.ts create mode 100644 src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts create mode 100644 src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts diff --git a/src/plugins/data/public/index_patterns/expressions/index.ts b/src/plugins/data/public/index_patterns/expressions/index.ts new file mode 100644 index 0000000000000..fa37e3b216ac9 --- /dev/null +++ b/src/plugins/data/public/index_patterns/expressions/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './load_index_pattern'; diff --git a/src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts new file mode 100644 index 0000000000000..378ceb376f5f1 --- /dev/null +++ b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { indexPatternLoad } from './load_index_pattern'; + +jest.mock('../../services', () => ({ + getIndexPatterns: () => ({ + get: (id: string) => ({ + toSpec: () => ({ + title: 'value', + }), + }), + }), +})); + +describe('indexPattern expression function', () => { + test('returns serialized index pattern', async () => { + const indexPatternDefinition = indexPatternLoad(); + const result = await indexPatternDefinition.fn(null, { id: '1' }, {} as any); + expect(result.type).toEqual('index_pattern'); + expect(result.value.title).toEqual('value'); + }); +}); diff --git a/src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts new file mode 100644 index 0000000000000..901d6aac7fbff --- /dev/null +++ b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts @@ -0,0 +1,62 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../plugins/expressions/public'; +import { getIndexPatterns } from '../../services'; +import { IndexPatternSpec } from '../../../common/index_patterns'; + +const name = 'indexPatternLoad'; + +type Input = null; +type Output = Promise<{ type: 'index_pattern'; value: IndexPatternSpec }>; + +interface Arguments { + id: string; +} + +export const indexPatternLoad = (): ExpressionFunctionDefinition< + typeof name, + Input, + Arguments, + Output +> => ({ + name, + type: 'index_pattern', + inputTypes: ['null'], + help: i18n.translate('data.functions.indexPatternLoad.help', { + defaultMessage: 'Loads an index pattern', + }), + args: { + id: { + types: ['string'], + required: true, + help: i18n.translate('data.functions.indexPatternLoad.id.help', { + defaultMessage: 'index pattern id to load', + }), + }, + }, + async fn(input, args) { + const indexPatterns = getIndexPatterns(); + + const indexPattern = await indexPatterns.get(args.id); + + return { type: 'index_pattern', value: indexPattern.toSpec() }; + }, +}); diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index d5929cb9cd564..ec71794fde87d 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -82,6 +82,7 @@ import { ValueClickActionContext, } from './actions/value_click_action'; import { SavedObjectsClientPublicToCommon } from './index_patterns'; +import { indexPatternLoad } from './index_patterns/expressions/load_index_pattern'; declare module '../../ui_actions/public' { export interface ActionContextMapping { @@ -126,6 +127,7 @@ export class DataPublicPlugin implements Plugin Date: Wed, 1 Jul 2020 04:06:56 -0500 Subject: [PATCH 07/10] Initial work on uptime homepage API (#70135) Co-authored-by: Shahzad --- .../common/runtime_types/ping/histogram.ts | 1 + x-pack/plugins/uptime/kibana.json | 2 +- x-pack/plugins/uptime/public/apps/plugin.ts | 13 ++++ .../public/apps/uptime_overview_fetcher.ts | 62 +++++++++++++++++++ .../plugins/uptime/public/state/api/ping.ts | 2 + .../__tests__/get_ping_histogram.test.ts | 3 +- .../server/lib/requests/get_ping_histogram.ts | 29 ++++++--- .../rest_api/pings/get_ping_histogram.ts | 4 +- 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts index 209770a19f4aa..47e4dd52299b1 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts @@ -21,6 +21,7 @@ export interface GetPingHistogramParams { dateEnd: string; filters?: string; monitorId?: string; + bucketSize?: string; } export interface HistogramResult { diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 5fbd6129fd18f..152839836ad99 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "uptime"], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["capabilities", "data", "home"], + "optionalPlugins": ["capabilities", "data", "home", "observability"], "requiredPlugins": [ "alerts", "embeddable", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index 26810a9b1cda3..9af4dea9dbb44 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -27,10 +27,14 @@ import { } from '../../../../../src/plugins/data/public'; import { alertTypeInitializers } from '../lib/alert_types'; import { kibanaService } from '../state/kibana_service'; +import { fetchIndexStatus } from '../state/api'; +import { ObservabilityPluginSetup } from '../../../observability/public'; +import { fetchUptimeOverviewData } from './uptime_overview_fetcher'; export interface ClientPluginsSetup { data: DataPublicPluginSetup; home: HomePublicPluginSetup; + observability: ObservabilityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } @@ -63,6 +67,15 @@ export class UptimePlugin }); } + plugins.observability.dashboard.register({ + appName: 'uptime', + hasData: async () => { + const status = await fetchIndexStatus(); + return status.docCount > 0; + }, + fetchData: fetchUptimeOverviewData, + }); + core.application.register({ appRoute: '/app/uptime#/', id: PLUGIN.ID, diff --git a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts new file mode 100644 index 0000000000000..8467714e9661e --- /dev/null +++ b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts @@ -0,0 +1,62 @@ +/* + * 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 { fetchPingHistogram, fetchSnapshotCount } from '../state/api'; +import { UptimeFetchDataResponse } from '../../../observability/public/typings/fetch_data_response'; + +export async function fetchUptimeOverviewData({ + startTime, + endTime, + bucketSize, +}: { + startTime: string; + endTime: string; + bucketSize: string; +}) { + const snapshot = await fetchSnapshotCount({ + dateRangeStart: startTime, + dateRangeEnd: endTime, + }); + + const pings = await fetchPingHistogram({ dateStart: startTime, dateEnd: endTime, bucketSize }); + + const response: UptimeFetchDataResponse = { + title: 'Uptime', + appLink: '/app/uptime#/', + stats: { + monitors: { + type: 'number', + label: 'Monitors', + value: snapshot.total, + }, + up: { + type: 'number', + label: 'Up', + value: snapshot.up, + }, + down: { + type: 'number', + label: 'Down', + value: snapshot.down, + }, + }, + series: { + up: { + label: 'Up', + coordinates: pings.histogram.map((p) => { + return { x: p.x!, y: p.upCount || 0 }; + }), + }, + down: { + label: 'Down', + coordinates: pings.histogram.map((p) => { + return { x: p.x!, y: p.downCount || 0 }; + }), + }, + }, + }; + return response; +} diff --git a/x-pack/plugins/uptime/public/state/api/ping.ts b/x-pack/plugins/uptime/public/state/api/ping.ts index a2937c9c794dd..2d6a69064f277 100644 --- a/x-pack/plugins/uptime/public/state/api/ping.ts +++ b/x-pack/plugins/uptime/public/state/api/ping.ts @@ -25,12 +25,14 @@ export const fetchPingHistogram: APIFn dateStart, dateEnd, filters, + bucketSize, }) => { const queryParams = { dateStart, dateEnd, monitorId, filters, + bucketSize, }; return await apiService.get(API_URLS.PING_HISTOGRAM, queryParams); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts index 9042186145eb7..11c7511dec370 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts @@ -62,7 +62,6 @@ describe('getPingHistogram', () => { dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, from: 'now-15m', to: 'now', - filters: null, }); expect(mockEsClient).toHaveBeenCalledTimes(1); @@ -81,7 +80,7 @@ describe('getPingHistogram', () => { dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, from: 'now-15m', to: 'now', - filters: null, + filters: '', }); expect(mockEsClient).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index 863eff82c360e..a74b55c24e227 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -15,15 +15,17 @@ export interface GetPingHistogramParams { /** @member dateRangeEnd timestamp bounds */ to: string; /** @member filters user-defined filters */ - filters?: string | null; + filters?: string; /** @member monitorId optional limit to monitorId */ - monitorId?: string | null; + monitorId?: string; + + bucketSize?: string; } export const getPingHistogram: UMElasticsearchQueryFn< GetPingHistogramParams, HistogramResult -> = async ({ callES, dynamicSettings, from, to, filters, monitorId }) => { +> = async ({ callES, dynamicSettings, from, to, filters, monitorId, bucketSize }) => { const boolFilters = filters ? JSON.parse(filters) : null; const additionalFilters = []; if (monitorId) { @@ -34,6 +36,22 @@ export const getPingHistogram: UMElasticsearchQueryFn< } const filter = getFilterClause(from, to, additionalFilters); + const seriesHistogram: any = {}; + + if (bucketSize) { + seriesHistogram.date_histogram = { + field: '@timestamp', + fixed_interval: bucketSize, + missing: 0, + }; + } else { + seriesHistogram.auto_date_histogram = { + field: '@timestamp', + buckets: QUERY.DEFAULT_BUCKET_COUNT, + missing: 0, + }; + } + const params = { index: dynamicSettings.heartbeatIndices, body: { @@ -45,10 +63,7 @@ export const getPingHistogram: UMElasticsearchQueryFn< size: 0, aggs: { timeseries: { - auto_date_histogram: { - field: '@timestamp', - buckets: QUERY.DEFAULT_BUCKET_COUNT, - }, + ...seriesHistogram, aggs: { down: { filter: { diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts index a589997889069..4ac50d0e78c4c 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts @@ -18,10 +18,11 @@ export const createGetPingHistogramRoute: UMRestApiRouteFactory = (libs: UMServe dateEnd: schema.string(), monitorId: schema.maybe(schema.string()), filters: schema.maybe(schema.string()), + bucketSize: schema.maybe(schema.string()), }), }, handler: async ({ callES, dynamicSettings }, _context, request, response): Promise => { - const { dateStart, dateEnd, monitorId, filters } = request.query; + const { dateStart, dateEnd, monitorId, filters, bucketSize } = request.query; const result = await libs.requests.getPingHistogram({ callES, @@ -30,6 +31,7 @@ export const createGetPingHistogramRoute: UMRestApiRouteFactory = (libs: UMServe to: dateEnd, monitorId, filters, + bucketSize, }); return response.ok({ From 8a6a55097da187f07ca8c6f298f8586b4f1fe435 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 1 Jul 2020 03:16:23 -0700 Subject: [PATCH 08/10] Enable "Explore underlying data" actions for Lens visualizations (#70047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat: 🎸 enable "Explore underlying data" action for Lens vis * test: 💍 make tests green again * refactor: 💡 rename trigger contexts * chore: 🤖 fix TypeScript errors Co-authored-by: Anton Dosov Co-authored-by: Elastic Machine --- .../create_filters_from_range_select.ts | 4 +-- .../create_filters_from_value_click.test.ts | 4 +-- .../create_filters_from_value_click.ts | 4 +-- .../public/actions/select_range_action.ts | 4 +-- .../data/public/actions/value_click_action.ts | 4 +-- src/plugins/embeddable/public/index.ts | 4 +-- .../public/lib/triggers/triggers.ts | 12 ++++----- src/plugins/ui_actions/public/types.ts | 6 ++--- .../dashboard_hello_world_drilldown/index.tsx | 7 ++--- .../dashboard_to_discover_drilldown/types.ts | 7 ++--- .../dashboard_to_url_drilldown/index.tsx | 7 ++--- .../drilldown.test.tsx | 8 +++--- .../dashboard_to_dashboard_drilldown/types.ts | 8 +++--- .../abstract_explore_data_action.ts | 7 +++-- .../explore_data_chart_action.test.ts | 21 ++++++++++----- .../explore_data/explore_data_chart_action.ts | 8 +++--- .../explore_data_context_menu_action.test.ts | 13 ++++++--- .../explore_data_context_menu_action.ts | 2 +- .../public/actions/explore_data/shared.ts | 27 +++++-------------- 19 files changed, 74 insertions(+), 83 deletions(-) diff --git a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts index 409614ca9c380..a0eb49d773f3d 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts @@ -22,9 +22,9 @@ import moment from 'moment'; import { esFilters, IFieldType, RangeFilterParams } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; import { deserializeAggConfig } from '../../search/expressions/utils'; -import { RangeSelectTriggerContext } from '../../../../embeddable/public'; +import { RangeSelectContext } from '../../../../embeddable/public'; -export async function createFiltersFromRangeSelectAction(event: RangeSelectTriggerContext['data']) { +export async function createFiltersFromRangeSelectAction(event: RangeSelectContext['data']) { const column: Record = event.table.columns[event.column]; if (!column || !column.meta) { diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts index a0e285c20d776..3e38477a908b8 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts @@ -27,7 +27,7 @@ import { dataPluginMock } from '../../../public/mocks'; import { setIndexPatterns } from '../../../public/services'; import { mockDataServices } from '../../../public/search/aggs/test_helpers'; import { createFiltersFromValueClickAction } from './create_filters_from_value_click'; -import { ValueClickTriggerContext } from '../../../../embeddable/public'; +import { ValueClickContext } from '../../../../embeddable/public'; const mockField = { name: 'bytes', @@ -39,7 +39,7 @@ const mockField = { }; describe('createFiltersFromValueClick', () => { - let dataPoints: ValueClickTriggerContext['data']['data']; + let dataPoints: ValueClickContext['data']['data']; beforeEach(() => { dataPoints = [ diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts index 2fdd746535519..1974b9f776748 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts @@ -21,7 +21,7 @@ import { KibanaDatatable } from '../../../../../plugins/expressions/public'; import { deserializeAggConfig } from '../../search/expressions'; import { esFilters, Filter } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; -import { ValueClickTriggerContext } from '../../../../embeddable/public'; +import { ValueClickContext } from '../../../../embeddable/public'; /** * For terms aggregations on `__other__` buckets, this assembles a list of applicable filter @@ -114,7 +114,7 @@ const createFilter = async ( export const createFiltersFromValueClickAction = async ({ data, negate, -}: ValueClickTriggerContext['data']) => { +}: ValueClickContext['data']) => { const filters: Filter[] = []; await Promise.all( diff --git a/src/plugins/data/public/actions/select_range_action.ts b/src/plugins/data/public/actions/select_range_action.ts index 18853f7e292f6..49766143b5588 100644 --- a/src/plugins/data/public/actions/select_range_action.ts +++ b/src/plugins/data/public/actions/select_range_action.ts @@ -24,12 +24,12 @@ import { ActionByType, } from '../../../../plugins/ui_actions/public'; import { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select'; -import { RangeSelectTriggerContext } from '../../../embeddable/public'; +import { RangeSelectContext } from '../../../embeddable/public'; import { FilterManager, TimefilterContract, esFilters } from '..'; export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE'; -export type SelectRangeActionContext = RangeSelectTriggerContext; +export type SelectRangeActionContext = RangeSelectContext; async function isCompatible(context: SelectRangeActionContext) { try { diff --git a/src/plugins/data/public/actions/value_click_action.ts b/src/plugins/data/public/actions/value_click_action.ts index 5d4f1f5f1d6db..dd74a7ee507f3 100644 --- a/src/plugins/data/public/actions/value_click_action.ts +++ b/src/plugins/data/public/actions/value_click_action.ts @@ -27,12 +27,12 @@ import { import { getOverlays, getIndexPatterns } from '../services'; import { applyFiltersPopover } from '../ui/apply_filters'; import { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click'; -import { ValueClickTriggerContext } from '../../../embeddable/public'; +import { ValueClickContext } from '../../../embeddable/public'; import { Filter, FilterManager, TimefilterContract, esFilters } from '..'; export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK'; -export type ValueClickActionContext = ValueClickTriggerContext; +export type ValueClickActionContext = ValueClickContext; async function isCompatible(context: ValueClickActionContext) { try { diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index f19974942c43d..6960550b59d1c 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -48,8 +48,8 @@ export { EmbeddableOutput, EmbeddablePanel, EmbeddableRoot, - ValueClickTriggerContext, - RangeSelectTriggerContext, + ValueClickContext, + RangeSelectContext, ErrorEmbeddable, IContainer, IEmbeddable, diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index 5bb96a708b7ac..ccba5cf771088 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -25,7 +25,7 @@ export interface EmbeddableContext { embeddable: IEmbeddable; } -export interface ValueClickTriggerContext { +export interface ValueClickContext { embeddable?: T; data: { data: Array<{ @@ -39,7 +39,7 @@ export interface ValueClickTriggerContext { }; } -export interface RangeSelectTriggerContext { +export interface RangeSelectContext { embeddable?: T; data: { table: KibanaDatatable; @@ -50,16 +50,16 @@ export interface RangeSelectTriggerContext } export type ChartActionContext = - | ValueClickTriggerContext - | RangeSelectTriggerContext; + | ValueClickContext + | RangeSelectContext; export const isValueClickTriggerContext = ( context: ChartActionContext -): context is ValueClickTriggerContext => context.data && 'data' in context.data; +): context is ValueClickContext => context.data && 'data' in context.data; export const isRangeSelectTriggerContext = ( context: ChartActionContext -): context is RangeSelectTriggerContext => context.data && 'range' in context.data; +): context is RangeSelectContext => context.data && 'range' in context.data; export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER'; export const contextMenuTrigger: Trigger<'CONTEXT_MENU_TRIGGER'> = { diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 85c87306cc4f9..9fcd8a32881df 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -22,7 +22,7 @@ import { TriggerInternal } from './triggers/trigger_internal'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; import { IEmbeddable } from '../../embeddable/public'; -import { RangeSelectTriggerContext, ValueClickTriggerContext } from '../../embeddable/public'; +import { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; export type TriggerRegistry = Map>; export type ActionRegistry = Map; @@ -37,8 +37,8 @@ export type TriggerContext = BaseContext; export interface TriggerContextMapping { [DEFAULT_TRIGGER]: TriggerContext; - [SELECT_RANGE_TRIGGER]: RangeSelectTriggerContext; - [VALUE_CLICK_TRIGGER]: ValueClickTriggerContext; + [SELECT_RANGE_TRIGGER]: RangeSelectContext; + [VALUE_CLICK_TRIGGER]: ValueClickContext; [APPLY_FILTER_TRIGGER]: { embeddable: IEmbeddable; filters: Filter[]; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx index bfe853241ae1d..2598d66c4976f 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx @@ -8,13 +8,10 @@ import React from 'react'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { - RangeSelectTriggerContext, - ValueClickTriggerContext, -} from '../../../../../src/plugins/embeddable/public'; +import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; -export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; +export type ActionContext = ChartActionContext; export interface Config { name: string; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts index 5dfc250a56d28..d8147827ed473 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts @@ -4,13 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - RangeSelectTriggerContext, - ValueClickTriggerContext, -} from '../../../../../src/plugins/embeddable/public'; +import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; -export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; +export type ActionContext = ChartActionContext; export interface Config { /** diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx index 5e4ba54864461..037e017097e53 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -8,10 +8,7 @@ import React from 'react'; import { EuiFormRow, EuiSwitch, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { - RangeSelectTriggerContext, - ValueClickTriggerContext, -} from '../../../../../src/plugins/embeddable/public'; +import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; function isValidUrl(url: string) { @@ -23,7 +20,7 @@ function isValidUrl(url: string) { } } -export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; +export type ActionContext = ChartActionContext; export interface Config { url: string; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx index 6ce7dccd3a3ec..52b232afa9410 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx @@ -22,8 +22,8 @@ import { createDashboardUrlGenerator } from '../../../../../../../src/plugins/da import { UrlGeneratorsService } from '../../../../../../../src/plugins/share/public/url_generators'; import { VisualizeEmbeddableContract } from '../../../../../../../src/plugins/visualizations/public'; import { - RangeSelectTriggerContext, - ValueClickTriggerContext, + RangeSelectContext, + ValueClickContext, } from '../../../../../../../src/plugins/embeddable/public'; import { StartDependencies } from '../../../plugin'; import { SavedObjectLoader } from '../../../../../../../src/plugins/saved_objects/public'; @@ -136,8 +136,8 @@ describe('.execute() & getHref', () => { const context = ({ data: { ...(useRangeEvent - ? ({ range: {} } as RangeSelectTriggerContext['data']) - : ({ data: [] } as ValueClickTriggerContext['data'])), + ? ({ range: {} } as RangeSelectContext['data']) + : ({ data: [] } as ValueClickContext['data'])), timeFieldName: 'order_date', }, embeddable: { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts index 1fbff0a7269e2..6be2e2a77269f 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -5,14 +5,14 @@ */ import { - ValueClickTriggerContext, - RangeSelectTriggerContext, + ValueClickContext, + RangeSelectContext, IEmbeddable, } from '../../../../../../../src/plugins/embeddable/public'; export type ActionContext = - | ValueClickTriggerContext - | RangeSelectTriggerContext; + | ValueClickContext + | RangeSelectContext; export interface Config { dashboardId?: string; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 620cabe652778..59359fb35f544 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -43,14 +43,13 @@ export abstract class AbstractExploreDataAction { 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 (!shared.hasExactlyOneIndexPattern(embeddable)) return false; if (embeddable.getInput().viewMode !== ViewMode.VIEW) return false; return true; } public async execute(context: Context): Promise { - if (!shared.isVisualizeEmbeddable(context.embeddable)) return; + if (!shared.hasExactlyOneIndexPattern(context.embeddable)) return; const { core } = this.params.start(); const { appName, appPath } = await this.getUrl(context); @@ -63,7 +62,7 @@ export abstract class AbstractExploreDataAction { const { embeddable } = context; - if (!shared.isVisualizeEmbeddable(embeddable)) { + if (!shared.hasExactlyOneIndexPattern(embeddable)) { throw new Error(`Embeddable not supported for "${this.getDisplayName(context)}" action.`); } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts index a273f0d50e45e..0d22f0a36d418 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts @@ -10,8 +10,8 @@ import { coreMock } from '../../../../../../src/core/public/mocks'; import { UrlGeneratorContract } from '../../../../../../src/plugins/share/public'; import { EmbeddableStart, - RangeSelectTriggerContext, - ValueClickTriggerContext, + RangeSelectContext, + ValueClickContext, ChartActionContext, } from '../../../../../../src/plugins/embeddable/public'; import { i18n } from '@kbn/i18n'; @@ -85,8 +85,8 @@ const setup = ({ useRangeEvent = false }: { useRangeEvent?: boolean } = {}) => { const data: ChartActionContext['data'] = { ...(useRangeEvent - ? ({ range: {} } as RangeSelectTriggerContext['data']) - : ({ data: [] } as ValueClickTriggerContext['data'])), + ? ({ range: {} } as RangeSelectContext['data']) + : ({ data: [] } as ValueClickContext['data'])), timeFieldName: 'order_date', }; @@ -139,9 +139,16 @@ describe('"Explore underlying data" panel action', () => { 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'; + test('returns false if embeddable has more than one index pattern', async () => { + const { action, output, context } = setup(); + output.indexPatterns = [ + { + id: 'index-ptr-foo', + }, + { + id: 'index-ptr-bar', + }, + ]; const isCompatible = await action.isCompatible(context); diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts index 359f14959c6a6..658a6bcb3cf4d 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts @@ -6,8 +6,8 @@ import { Action } from '../../../../../../src/plugins/ui_actions/public'; import { - ValueClickTriggerContext, - RangeSelectTriggerContext, + ValueClickContext, + RangeSelectContext, } from '../../../../../../src/plugins/embeddable/public'; import { DiscoverUrlGeneratorState } from '../../../../../../src/plugins/discover/public'; import { isTimeRange, isQuery, isFilters } from '../../../../../../src/plugins/data/public'; @@ -15,7 +15,7 @@ import { KibanaURL } from './kibana_url'; import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; -export type ExploreDataChartActionContext = ValueClickTriggerContext | RangeSelectTriggerContext; +export type ExploreDataChartActionContext = ValueClickContext | RangeSelectContext; export const ACTION_EXPLORE_DATA_CHART = 'ACTION_EXPLORE_DATA_CHART'; @@ -49,7 +49,7 @@ export class ExploreDataChartAction extends AbstractExploreDataAction { 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'; + test('returns false if embeddable has more than one index pattern', async () => { + const { action, output, context } = setup(); + output.indexPatterns = [ + { + id: 'index-ptr-foo', + }, + { + id: 'index-ptr-bar', + }, + ]; const isCompatible = await action.isCompatible(context); diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts index 6691089f875d8..8b79211a914cc 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts @@ -38,7 +38,7 @@ export class ExploreDataContextMenuAction extends AbstractExploreDataAction } => { 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 ''; +export const getIndexPatterns = (embeddable?: IEmbeddable): string[] => { + if (!embeddable) return []; const output = embeddable.getOutput(); - if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) { - return output.indexPatterns[0].id; - } - - return ''; + return isOutputWithIndexPatterns(output) ? output.indexPatterns.map(({ id }) => id) : []; }; + +export const hasExactlyOneIndexPattern = (embeddable?: IEmbeddable): boolean => + getIndexPatterns(embeddable).length === 1; From e70bc819987f064c3f224adc479808b73a0dd66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 1 Jul 2020 14:05:29 +0200 Subject: [PATCH 09/10] [Logs UI] Avoid CCS-incompatible index name resolution (#70179) This fixes #70048 by avoiding a CCS-incompatible ES API call when determining the existence of log indices. --- .../http_api/log_sources/get_log_source_status.ts | 2 +- .../public/containers/logs/log_source/log_source.ts | 6 ------ .../infra/public/pages/logs/stream/page_content.tsx | 4 ++-- .../infra/public/pages/logs/stream/page_providers.tsx | 4 ++-- .../plugins/infra/server/routes/log_sources/status.ts | 11 +++++------ 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts index ae872cee9aa56..b522d86987283 100644 --- a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts +++ b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts @@ -42,7 +42,7 @@ export type LogIndexField = rt.TypeOf; const logSourceStatusRT = rt.strict({ logIndexFields: rt.array(logIndexFieldRT), - logIndexNames: rt.array(rt.string), + logIndicesExist: rt.boolean, }); export type LogSourceStatus = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts index 80aab6237518f..b45ea0a042f49 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts @@ -78,11 +78,6 @@ export const useLogSource = ({ [sourceId, fetch] ); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const logIndicesExist = useMemo(() => (sourceStatus?.logIndexNames?.length ?? 0) > 0, [ - sourceStatus, - ]); - const derivedIndexPattern = useMemo( () => ({ fields: sourceStatus?.logIndexFields ?? [], @@ -160,7 +155,6 @@ export const useLogSource = ({ loadSourceFailureMessage, loadSourceConfiguration, loadSourceStatus, - logIndicesExist, sourceConfiguration, sourceId, sourceStatus, diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx index 40ac5c74a6836..b2a4ce65ab2b6 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx @@ -18,14 +18,14 @@ export const StreamPageContent: React.FunctionComponent = () => { isUninitialized, loadSource, loadSourceFailureMessage, - logIndicesExist, + sourceStatus, } = useLogSourceContext(); if (isLoading || isUninitialized) { return ; } else if (hasFailedLoadingSource) { return ; - } else if (logIndicesExist) { + } else if (sourceStatus?.logIndicesExist) { return ; } else { return ; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx index 428a7d3fdfe4b..82c21f663bc96 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx @@ -104,10 +104,10 @@ const LogHighlightsStateProvider: React.FC = ({ children }) => { }; export const LogsPageProviders: React.FunctionComponent = ({ children }) => { - const { logIndicesExist } = useLogSourceContext(); + const { sourceStatus } = useLogSourceContext(); // The providers assume the source is loaded, so short-circuit them otherwise - if (!logIndicesExist) { + if (!sourceStatus?.logIndicesExist) { return <>{children}; } diff --git a/x-pack/plugins/infra/server/routes/log_sources/status.ts b/x-pack/plugins/infra/server/routes/log_sources/status.ts index cdd053d2bb10a..4cd85ecfe23c1 100644 --- a/x-pack/plugins/infra/server/routes/log_sources/status.ts +++ b/x-pack/plugins/infra/server/routes/log_sources/status.ts @@ -31,17 +31,16 @@ export const initLogSourceStatusRoutes = ({ const { sourceId } = request.params; try { - const logIndexNames = await sourceStatus.getLogIndexNames(requestContext, sourceId); - const logIndexFields = - logIndexNames.length > 0 - ? await fields.getFields(requestContext, sourceId, InfraIndexType.LOGS) - : []; + const logIndicesExist = await sourceStatus.hasLogIndices(requestContext, sourceId); + const logIndexFields = logIndicesExist + ? await fields.getFields(requestContext, sourceId, InfraIndexType.LOGS) + : []; return response.ok({ body: getLogSourceStatusSuccessResponsePayloadRT.encode({ data: { + logIndicesExist, logIndexFields, - logIndexNames, }, }), }); From 518e88cf287ee8fdfefc51ae5040765371d8065b Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 1 Jul 2020 14:20:02 +0200 Subject: [PATCH 10/10] update (#70424) --- x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts index 8467714e9661e..bede391537ec5 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts +++ b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts @@ -5,7 +5,7 @@ */ import { fetchPingHistogram, fetchSnapshotCount } from '../state/api'; -import { UptimeFetchDataResponse } from '../../../observability/public/typings/fetch_data_response'; +import { UptimeFetchDataResponse } from '../../../observability/public'; export async function fetchUptimeOverviewData({ startTime,