From 185ed8c257dd570733c23d9f8909ba911a3254ec Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 5 May 2020 15:59:32 -0400 Subject: [PATCH] [Lens] Pie and treemap charts (#55477) * [Lens] Add pie and treemap visualizations * Fix types * Update to new platform * Support 2-layer treemap and legends, dark mode * Significant restructuring of code * Upgrade to latest charts library * Commit yarn.lock * chore: update elastic-charts * fix types after merge master * Add settings panel and merge visualizations * Fix tests * build: upgrade @elastic/charts to 19.0.0 * refactor: onBrushEnd breaking changes * fix: missing onBrushEnd argument changes * More updates * Fix XY rendering issue when all dates are empty * Fix bugs and tests * Use shared services location * Fix bug in XY chart * fix: update ech to 19.1.1 * fix: lens onBrushEnd breaking changes * Change how pie/treemap settings work * [Design] Fix up settings panel * [Design] Update partition chart config styles * fix eslint * Fix legend issues in pie and XY, add some tests * update to 19.1.2 * Fix text color for treemap * Fix chart switch bug * Fix suggestions Co-authored-by: Marta Bondyra Co-authored-by: Elastic Machine Co-authored-by: Marco Vettorello Co-authored-by: cchaos --- .../lens/public/assets/chart_donut.svg | 4 + .../plugins/lens/public/assets/chart_pie.svg | 4 + .../lens/public/assets/chart_treemap.svg | 5 + .../datatable_visualization/visualization.tsx | 4 +- .../config_panel/chart_switch.test.tsx | 72 ++- .../config_panel/chart_switch.tsx | 5 +- .../config_panel/layer_settings.tsx | 24 +- .../editor_frame/editor_frame.test.tsx | 1 + .../indexpattern.test.ts | 6 +- .../indexpattern_datasource/to_expression.ts | 28 +- .../metric_suggestions.test.ts | 2 +- .../metric_suggestions.ts | 2 +- .../public/pie_visualization/constants.ts | 36 ++ .../lens/public/pie_visualization/index.ts | 53 ++ .../pie_visualization/pie_visualization.tsx | 215 ++++++++ .../pie_visualization/register_expression.tsx | 130 +++++ .../render_function.test.tsx | 113 ++++ .../pie_visualization/render_function.tsx | 259 +++++++++ .../pie_visualization/render_helpers.test.ts | 183 ++++++ .../pie_visualization/render_helpers.ts | 49 ++ .../pie_visualization/settings_widget.scss | 3 + .../pie_visualization/settings_widget.tsx | 212 +++++++ .../pie_visualization/suggestions.test.ts | 522 ++++++++++++++++++ .../public/pie_visualization/suggestions.ts | 137 +++++ .../public/pie_visualization/to_expression.ts | 54 ++ .../lens/public/pie_visualization/types.ts | 42 ++ .../pie_visualization/visualization.scss | 4 + x-pack/plugins/lens/public/plugin.ts | 5 + .../xy_visualization/xy_expression.test.tsx | 130 +++++ .../public/xy_visualization/xy_expression.tsx | 48 +- .../xy_visualization/xy_suggestions.test.ts | 97 +++- .../public/xy_visualization/xy_suggestions.ts | 34 +- 32 files changed, 2393 insertions(+), 90 deletions(-) create mode 100644 x-pack/plugins/lens/public/assets/chart_donut.svg create mode 100644 x-pack/plugins/lens/public/assets/chart_pie.svg create mode 100644 x-pack/plugins/lens/public/assets/chart_treemap.svg create mode 100644 x-pack/plugins/lens/public/pie_visualization/constants.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/index.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx create mode 100644 x-pack/plugins/lens/public/pie_visualization/register_expression.tsx create mode 100644 x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx create mode 100644 x-pack/plugins/lens/public/pie_visualization/render_function.tsx create mode 100644 x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/render_helpers.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/settings_widget.scss create mode 100644 x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx create mode 100644 x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/suggestions.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/to_expression.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/types.ts create mode 100644 x-pack/plugins/lens/public/pie_visualization/visualization.scss diff --git a/x-pack/plugins/lens/public/assets/chart_donut.svg b/x-pack/plugins/lens/public/assets/chart_donut.svg new file mode 100644 index 0000000000000..5e0d8b7ea83bf --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_donut.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/lens/public/assets/chart_pie.svg b/x-pack/plugins/lens/public/assets/chart_pie.svg new file mode 100644 index 0000000000000..22faaf5d97661 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_pie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/x-pack/plugins/lens/public/assets/chart_treemap.svg b/x-pack/plugins/lens/public/assets/chart_treemap.svg new file mode 100644 index 0000000000000..b0ee04d02b2a6 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_treemap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 21bbcce68bf36..ed0512ba220eb 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -115,8 +115,8 @@ export const datatableVisualization: Visualization< return [ { title, - // table with >= 10 columns will have a score of 0.6, fewer columns reduce score - score: (Math.min(table.columns.length, 10) / 10) * 0.6, + // table with >= 10 columns will have a score of 0.4, fewer columns reduce score + score: (Math.min(table.columns.length, 10) / 10) * 0.4, state: { layers: [ { 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/config_panel/chart_switch.test.tsx index c8d8064e60e38..157a871e202f7 100644 --- 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/config_panel/chart_switch.test.tsx @@ -22,10 +22,11 @@ describe('chart_switch', () => { return { ...createMockVisualization(), id, + getVisualizationTypeId: jest.fn(_state => id), visualizationTypes: [ { icon: 'empty', - id: `sub${id}`, + id, label: `Label ${id}`, }, ], @@ -51,6 +52,7 @@ describe('chart_switch', () => { visB: generateVisualization('visB'), visC: { ...generateVisualization('visC'), + initialize: jest.fn((_frame, state) => state ?? { type: 'subvisC1' }), visualizationTypes: [ { icon: 'empty', @@ -68,15 +70,23 @@ describe('chart_switch', () => { label: 'C3', }, ], + getVisualizationTypeId: jest.fn(state => state.type), getSuggestions: jest.fn(options => { if (options.subVisualizationId === 'subvisC2') { return []; } + // Multiple suggestions need to be filtered return [ + { + score: 1, + title: 'Primary suggestion', + state: { type: 'subvisC3' }, + previewIcon: 'empty', + }, { score: 1, title: '', - state: `suggestion`, + state: { type: 'subvisC1', notPrimary: true }, previewIcon: 'empty', }, ]; @@ -162,7 +172,7 @@ describe('chart_switch', () => { const component = mount( { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(dispatch).toHaveBeenCalledWith({ initialState: 'suggestion visB', @@ -201,7 +211,7 @@ describe('chart_switch', () => { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(frame.removeLayers).toHaveBeenCalledWith(['a']); @@ -265,7 +275,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should indicate data loss if not all layers will be used', () => { @@ -285,7 +295,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should indicate data loss if no data will be used', () => { @@ -306,7 +316,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toEqual('alert'); }); it('should not indicate data loss if there is no data', () => { @@ -328,7 +338,7 @@ describe('chart_switch', () => { /> ); - expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toBeUndefined(); + expect(getMenuItem('visB', component).prop('betaBadgeIconType')).toBeUndefined(); }); it('should not show a warning when the subvisualization is the same', () => { @@ -336,14 +346,14 @@ describe('chart_switch', () => { const frame = mockFrame(['a', 'b', 'c']); const visualizations = mockVisualizations(); visualizations.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); - const switchVisualizationType = jest.fn(() => 'therebedragons'); + const switchVisualizationType = jest.fn(() => ({ type: 'subvisC1' })); visualizations.visC.switchVisualizationType = switchVisualizationType; const component = mount( { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(frame.removeLayers).toHaveBeenCalledTimes(1); expect(frame.removeLayers).toHaveBeenCalledWith(['a', 'b', 'c']); @@ -403,7 +413,7 @@ describe('chart_switch', () => { const component = mount( { ); switchTo('subvisC3', component); - expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', 'suggestion'); + expect(switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC3' }); expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'SWITCH_VISUALIZATION', @@ -471,7 +481,7 @@ describe('chart_switch', () => { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(dispatch).toHaveBeenCalledWith({ type: 'SWITCH_VISUALIZATION', @@ -503,10 +513,10 @@ describe('chart_switch', () => { /> ); - switchTo('subvisB', component); + switchTo('visB', component); expect(dispatch).toHaveBeenCalledWith({ - initialState: 'suggestion visB subvisB', + initialState: 'suggestion visB visB', newVisualizationId: 'visB', type: 'SWITCH_VISUALIZATION', datasourceId: 'testDatasource', @@ -514,6 +524,32 @@ describe('chart_switch', () => { }); }); + it('should use the suggestion that matches the subtype', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn(); + + visualizations.visC.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + switchTo('subvisC1', component); + expect(switchVisualizationType).toHaveBeenCalledWith('subvisC1', { + type: 'subvisC1', + notPrimary: true, + }); + }); + it('should show all visualization types', () => { const component = mount( { showFlyout(component); - const allDisplayed = ['subvisA', 'subvisB', 'subvisC1', 'subvisC2'].every( + const allDisplayed = ['visA', 'visB', 'subvisC1', 'subvisC2', 'subvisC3'].every( subType => component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0 ); 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/config_panel/chart_switch.tsx index d73f83e75c0e4..81eb82dfdbab4 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/config_panel/chart_switch.tsx @@ -272,7 +272,10 @@ function getTopSuggestion( }).filter(suggestion => { // don't use extended versions of current data table on switching between visualizations // to avoid confusing the user. - return suggestion.changeType !== 'extended'; + return ( + suggestion.changeType !== 'extended' && + newVisualization.getVisualizationTypeId(suggestion.visualizationState) === subVisualizationId + ); }); // We prefer unchanged or reduced suggestions when switching diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx index 57588e31590b4..49f2224bf4231 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx @@ -5,7 +5,7 @@ */ import React, { useState } from 'react'; -import { EuiPopover, EuiButtonIcon } from '@elastic/eui'; +import { EuiPopover, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NativeRenderer } from '../../../native_renderer'; import { Visualization, VisualizationLayerWidgetProps } from '../../../types'; @@ -28,21 +28,27 @@ export function LayerSettings({ return ( setIsOpen(!isOpen)} - data-test-subj="lns_layer_settings" - /> + > + setIsOpen(!isOpen)} + data-test-subj="lns_layer_settings" + /> + } isOpen={isOpen} closePopover={() => setIsOpen(false)} - anchorPosition="leftUp" + anchorPosition="downLeft" > { it('should use suggestions to switch to new visualization', async () => { const initialState = { suggested: true }; mockVisualization2.initialize.mockReturnValueOnce({ initial: true }); + mockVisualization2.getVisualizationTypeId.mockReturnValueOnce('testVis2'); mockVisualization2.getSuggestions.mockReturnValueOnce([ { title: 'Suggested vis', 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 06635e663361d..d8449143b569f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -272,10 +272,10 @@ describe('IndexPattern Data Source', () => { "1", ], "metricsAtAllLevels": Array [ - false, + true, ], "partialRows": Array [ - false, + true, ], "timeFields": Array [ "timestamp", @@ -287,7 +287,7 @@ describe('IndexPattern Data Source', () => { Object { "arguments": Object { "idMap": Array [ - "{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", + "{\\"col--1-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-2-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}", ], }, "function": "lens_rename_columns", diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 1308fa3b7ca60..1dde03ca8ee9b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -26,17 +26,35 @@ function getExpressionForLayer( } const columnEntries = columnOrder.map(colId => [colId, columns[colId]] as const); + const bucketsCount = columnEntries.filter(([, entry]) => entry.isBucketed).length; + const metricsCount = columnEntries.length - bucketsCount; if (columnEntries.length) { const aggs = columnEntries.map(([colId, col]) => { return getEsAggsConfig(col, colId); }); - const idMap = columnEntries.reduce((currentIdMap, [colId], index) => { + /** + * Because we are turning on metrics at all levels, the sequence generation + * logic here is more complicated. Examples follow: + * + * Example 1: [Count] + * Output: [`col-0-count`] + * + * Example 2: [Terms, Terms, Count] + * Output: [`col-0-terms0`, `col-2-terms1`, `col-3-count`] + * + * Example 3: [Terms, Terms, Count, Max] + * Output: [`col-0-terms0`, `col-3-terms1`, `col-4-count`, `col-5-max`] + */ + const idMap = columnEntries.reduce((currentIdMap, [colId, column], index) => { + const newIndex = column.isBucketed + ? index * (metricsCount + 1) // Buckets are spaced apart by N + 1 + : (index ? index + 1 : 0) - bucketsCount + (bucketsCount - 1) * (metricsCount + 1); return { ...currentIdMap, - [`col-${index}-${colId}`]: { - ...columns[colId], + [`col-${columnEntries.length === 1 ? 0 : newIndex}-${colId}`]: { + ...column, id: colId, }, }; @@ -83,8 +101,8 @@ function getExpressionForLayer( function: 'esaggs', arguments: { index: [indexPattern.id], - metricsAtAllLevels: [false], - partialRows: [false], + metricsAtAllLevels: [true], + partialRows: [true], includeFormatHints: [true], timeFields: allDateHistogramFields, aggConfigs: [JSON.stringify(aggs)], diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts index ef93f0b5bf064..173119714189d 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts @@ -101,7 +101,7 @@ describe('metric_suggestions', () => { expect(suggestion).toMatchInlineSnapshot(` Object { "previewIcon": "test-file-stub", - "score": 0.5, + "score": 0.1, "state": Object { "accessor": "bytes", "layerId": "l1", diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts index 23df3f55f2777..0caac7dd0d092 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts @@ -43,7 +43,7 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion { return { title, - score: 0.5, + score: 0.1, previewIcon: chartMetricSVG, state: { layerId: table.layerId, diff --git a/x-pack/plugins/lens/public/pie_visualization/constants.ts b/x-pack/plugins/lens/public/pie_visualization/constants.ts new file mode 100644 index 0000000000000..10672f91a81c7 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/constants.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import chartDonutSVG from '../assets/chart_donut.svg'; +import chartPieSVG from '../assets/chart_pie.svg'; +import chartTreemapSVG from '../assets/chart_treemap.svg'; + +export const CHART_NAMES = { + donut: { + icon: chartDonutSVG, + label: i18n.translate('xpack.lens.pie.donutLabel', { + defaultMessage: 'Donut', + }), + }, + pie: { + icon: chartPieSVG, + label: i18n.translate('xpack.lens.pie.pielabel', { + defaultMessage: 'Pie', + }), + }, + treemap: { + icon: chartTreemapSVG, + label: i18n.translate('xpack.lens.pie.treemaplabel', { + defaultMessage: 'Treemap', + }), + }, +}; + +export const MAX_PIE_BUCKETS = 3; +export const MAX_TREEMAP_BUCKETS = 2; + +export const DEFAULT_PERCENT_DECIMALS = 3; diff --git a/x-pack/plugins/lens/public/pie_visualization/index.ts b/x-pack/plugins/lens/public/pie_visualization/index.ts new file mode 100644 index 0000000000000..b2aae2e8529a5 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/index.ts @@ -0,0 +1,53 @@ +/* + * 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 { EUI_CHARTS_THEME_DARK, EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; +import { CoreSetup, CoreStart } from 'src/core/public'; +import { ExpressionsSetup } from 'src/plugins/expressions/public'; +import { pieVisualization } from './pie_visualization'; +import { pie, getPieRenderer } from './register_expression'; +import { EditorFrameSetup, FormatFactory } from '../types'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { setExecuteTriggerActions } from '../services'; + +export interface PieVisualizationPluginSetupPlugins { + editorFrame: EditorFrameSetup; + expressions: ExpressionsSetup; + formatFactory: Promise; +} + +export interface PieVisualizationPluginStartPlugins { + uiActions: UiActionsStart; +} + +export class PieVisualization { + constructor() {} + + setup( + core: CoreSetup, + { expressions, formatFactory, editorFrame }: PieVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => pie); + + expressions.registerRenderer( + getPieRenderer({ + formatFactory, + chartTheme: core.uiSettings.get('theme:darkMode') + ? EUI_CHARTS_THEME_DARK.theme + : EUI_CHARTS_THEME_LIGHT.theme, + isDarkMode: core.uiSettings.get('theme:darkMode'), + }) + ); + + editorFrame.registerVisualization(pieVisualization); + } + + start(core: CoreStart, { uiActions }: PieVisualizationPluginStartPlugins) { + setExecuteTriggerActions(uiActions.executeTriggerActions); + } + + stop() {} +} diff --git a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx new file mode 100644 index 0000000000000..78e13bc51588c --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx @@ -0,0 +1,215 @@ +/* + * 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 React from 'react'; +import { render } from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Visualization, OperationMetadata } from '../types'; +import { toExpression, toPreviewExpression } from './to_expression'; +import { LayerState, PieVisualizationState } from './types'; +import { suggestions } from './suggestions'; +import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; +import { SettingsWidget } from './settings_widget'; + +function newLayerState(layerId: string): LayerState { + return { + layerId, + groups: [], + metric: undefined, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }; +} + +const bucketedOperations = (op: OperationMetadata) => op.isBucketed; +const numberMetricOperations = (op: OperationMetadata) => + !op.isBucketed && op.dataType === 'number'; + +export const pieVisualization: Visualization = { + id: 'lnsPie', + + visualizationTypes: [ + { + id: 'donut', + largeIcon: CHART_NAMES.donut.icon, + label: CHART_NAMES.donut.label, + }, + { + id: 'pie', + largeIcon: CHART_NAMES.pie.icon, + label: CHART_NAMES.pie.label, + }, + { + id: 'treemap', + largeIcon: CHART_NAMES.treemap.icon, + label: CHART_NAMES.treemap.label, + }, + ], + + getVisualizationTypeId(state) { + return state.shape; + }, + + getLayerIds(state) { + return state.layers.map(l => l.layerId); + }, + + clearLayer(state) { + return { + shape: state.shape, + layers: state.layers.map(l => newLayerState(l.layerId)), + }; + }, + + getDescription(state) { + if (state.shape === 'treemap') { + return CHART_NAMES.treemap; + } + if (state.shape === 'donut') { + return CHART_NAMES.donut; + } + return CHART_NAMES.pie; + }, + + switchVisualizationType: (visualizationTypeId, state) => ({ + ...state, + shape: visualizationTypeId as PieVisualizationState['shape'], + }), + + initialize(frame, state) { + return ( + state || { + shape: 'donut', + layers: [newLayerState(frame.addNewLayer())], + } + ); + }, + + getPersistableState: state => state, + + getSuggestions: suggestions, + + getConfiguration({ state, frame, layerId }) { + const layer = state.layers.find(l => l.layerId === layerId); + if (!layer) { + return { groups: [] }; + } + + const datasource = frame.datasourceLayers[layer.layerId]; + const originalOrder = datasource + .getTableSpec() + .map(({ columnId }) => columnId) + .filter(columnId => columnId !== layer.metric); + // When we add a column it could be empty, and therefore have no order + const sortedColumns = Array.from(new Set(originalOrder.concat(layer.groups))); + + if (state.shape === 'treemap') { + return { + groups: [ + { + groupId: 'groups', + groupLabel: i18n.translate('xpack.lens.pie.treemapGroupLabel', { + defaultMessage: 'Group by', + }), + layerId, + accessors: sortedColumns, + supportsMoreColumns: sortedColumns.length < MAX_TREEMAP_BUCKETS, + filterOperations: bucketedOperations, + required: true, + }, + { + groupId: 'metric', + groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { + defaultMessage: 'Size by', + }), + layerId, + accessors: layer.metric ? [layer.metric] : [], + supportsMoreColumns: !layer.metric, + filterOperations: numberMetricOperations, + required: true, + }, + ], + }; + } + + return { + groups: [ + { + groupId: 'groups', + groupLabel: i18n.translate('xpack.lens.pie.sliceGroupLabel', { + defaultMessage: 'Slice by', + }), + layerId, + accessors: sortedColumns, + supportsMoreColumns: sortedColumns.length < MAX_PIE_BUCKETS, + filterOperations: bucketedOperations, + required: true, + }, + { + groupId: 'metric', + groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { + defaultMessage: 'Size by', + }), + layerId, + accessors: layer.metric ? [layer.metric] : [], + supportsMoreColumns: !layer.metric, + filterOperations: numberMetricOperations, + required: true, + }, + ], + }; + }, + + setDimension({ prevState, layerId, columnId, groupId }) { + return { + ...prevState, + + shape: + prevState.shape === 'donut' && prevState.layers.every(l => l.groups.length === 1) + ? 'pie' + : prevState.shape, + layers: prevState.layers.map(l => { + if (l.layerId !== layerId) { + return l; + } + if (groupId === 'groups') { + return { ...l, groups: [...l.groups, columnId] }; + } + return { ...l, metric: columnId }; + }), + }; + }, + removeDimension({ prevState, layerId, columnId }) { + return { + ...prevState, + layers: prevState.layers.map(l => { + if (l.layerId !== layerId) { + return l; + } + + if (l.metric === columnId) { + return { ...l, metric: undefined }; + } + return { ...l, groups: l.groups.filter(c => c !== columnId) }; + }), + }; + }, + + toExpression, + toPreviewExpression, + + renderLayerContextMenu(domElement, props) { + render( + + + , + domElement + ); + }, +}; diff --git a/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx b/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx new file mode 100644 index 0000000000000..998d2162f7f5d --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx @@ -0,0 +1,130 @@ +/* + * 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 React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { PartialTheme } from '@elastic/charts'; +import { + IInterpreterRenderHandlers, + ExpressionRenderDefinition, + ExpressionFunctionDefinition, +} from 'src/plugins/expressions/public'; +import { LensMultiTable, FormatFactory } from '../types'; +import { PieExpressionProps, PieExpressionArgs } from './types'; +import { getExecuteTriggerActions } from '../services'; +import { PieComponent } from './render_function'; + +export interface PieRender { + type: 'render'; + as: 'lens_pie_renderer'; + value: PieExpressionProps; +} + +export const pie: ExpressionFunctionDefinition< + 'lens_pie', + LensMultiTable, + PieExpressionArgs, + PieRender +> = { + name: 'lens_pie', + type: 'render', + help: i18n.translate('xpack.lens.pie.expressionHelpLabel', { + defaultMessage: 'Pie renderer', + }), + args: { + groups: { + types: ['string'], + multi: true, + help: '', + }, + metric: { + types: ['string'], + help: '', + }, + shape: { + types: ['string'], + options: ['pie', 'donut', 'treemap'], + help: '', + }, + hideLabels: { + types: ['boolean'], + help: '', + }, + numberDisplay: { + types: ['string'], + options: ['hidden', 'percent', 'value'], + help: '', + }, + categoryDisplay: { + types: ['string'], + options: ['default', 'inside', 'hide'], + help: '', + }, + legendDisplay: { + types: ['string'], + options: ['default', 'show', 'hide'], + help: '', + }, + nestedLegend: { + types: ['boolean'], + help: '', + }, + percentDecimals: { + types: ['number'], + help: '', + }, + }, + inputTypes: ['lens_multitable'], + fn(data: LensMultiTable, args: PieExpressionArgs) { + return { + type: 'render', + as: 'lens_pie_renderer', + value: { + data, + args, + }, + }; + }, +}; + +export const getPieRenderer = (dependencies: { + formatFactory: Promise; + chartTheme: PartialTheme; + isDarkMode: boolean; +}): ExpressionRenderDefinition => ({ + name: 'lens_pie_renderer', + displayName: i18n.translate('xpack.lens.pie.visualizationName', { + defaultMessage: 'Pie', + }), + help: '', + validate: () => undefined, + reuseDomNode: true, + render: async ( + domNode: Element, + config: PieExpressionProps, + handlers: IInterpreterRenderHandlers + ) => { + const executeTriggerActions = getExecuteTriggerActions(); + const formatFactory = await dependencies.formatFactory; + ReactDOM.render( + , + domNode, + () => { + handlers.done(); + } + ); + handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); + }, +}); + +const MemoizedChart = React.memo(PieComponent); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx new file mode 100644 index 0000000000000..bdc8004540bae --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Settings } from '@elastic/charts'; +import { shallow } from 'enzyme'; +import { LensMultiTable } from '../types'; +import { PieComponent } from './render_function'; +import { PieExpressionArgs } from './types'; + +describe('PieVisualization component', () => { + let getFormatSpy: jest.Mock; + let convertSpy: jest.Mock; + + beforeEach(() => { + convertSpy = jest.fn(x => x); + getFormatSpy = jest.fn(); + getFormatSpy.mockReturnValue({ convert: convertSpy }); + }); + + describe('legend options', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [ + { a: 6, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + + const args: PieExpressionArgs = { + shape: 'pie', + groups: ['a', 'b'], + metric: 'c', + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + percentDecimals: 3, + hideLabels: false, + }; + + function getDefaultArgs() { + return { + data, + formatFactory: getFormatSpy, + isDarkMode: false, + chartTheme: {}, + executeTriggerActions: jest.fn(), + }; + } + + test('it shows legend for 2 groups using default legendDisplay', () => { + const component = shallow(); + expect(component.find(Settings).prop('showLegend')).toEqual(true); + }); + + test('it hides legend for 1 group using default legendDisplay', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + test('it hides legend that would show otherwise in preview mode', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + test('it hides legend with 2 groups for treemap', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + test('it shows treemap legend only when forced on', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('showLegend')).toEqual(true); + }); + + test('it defaults to 1-level legend depth', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toEqual(1); + }); + + test('it shows nested legend only when forced on', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx new file mode 100644 index 0000000000000..f451a6b74e299 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -0,0 +1,259 @@ +/* + * 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 React, { useState, useEffect } from 'react'; +import color from 'color'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText } from '@elastic/eui'; +// @ts-ignore no types +import { euiPaletteColorBlindBehindText } from '@elastic/eui/lib/services'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { + Chart, + Datum, + Settings, + Partition, + PartitionConfig, + PartitionLayer, + PartitionLayout, + PartialTheme, + PartitionFillLabel, + RecursivePartial, + LayerValue, +} from '@elastic/charts'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; +import { FormatFactory } from '../types'; +import { VisualizationContainer } from '../visualization_container'; +import { CHART_NAMES, DEFAULT_PERCENT_DECIMALS } from './constants'; +import { ColumnGroups, PieExpressionProps } from './types'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { getSliceValueWithFallback, getFilterContext } from './render_helpers'; +import './visualization.scss'; + +const EMPTY_SLICE = Symbol('empty_slice'); + +const sortedColors = euiPaletteColorBlindBehindText(); + +export function PieComponent( + props: PieExpressionProps & { + formatFactory: FormatFactory; + chartTheme: Exclude; + isDarkMode: boolean; + executeTriggerActions: UiActionsStart['executeTriggerActions']; + } +) { + const [firstTable] = Object.values(props.data.tables); + const formatters: Record> = {}; + + const { chartTheme, isDarkMode, executeTriggerActions } = props; + const { + shape, + groups, + metric, + numberDisplay, + categoryDisplay, + legendDisplay, + nestedLegend, + percentDecimals, + hideLabels, + } = props.args; + + if (!hideLabels) { + firstTable.columns.forEach(column => { + formatters[column.id] = props.formatFactory(column.formatHint); + }); + } + + // The datatable for pie charts should include subtotals, like this: + // [bucket, subtotal, bucket, count] + // But the user only configured [bucket, bucket, count] + const columnGroups: ColumnGroups = []; + firstTable.columns.forEach(col => { + if (groups.includes(col.id)) { + columnGroups.push({ + col, + metrics: [], + }); + } else if (columnGroups.length > 0) { + columnGroups[columnGroups.length - 1].metrics.push(col); + } + }); + + const fillLabel: Partial = { + textInvertible: false, + valueFont: { + fontWeight: 700, + }, + }; + + if (numberDisplay === 'hidden') { + // Hides numbers from appearing inside chart, but they still appear in linkLabel + // and tooltips. + fillLabel.valueFormatter = () => ''; + } + + const layers: PartitionLayer[] = columnGroups.map(({ col }, layerIndex) => { + return { + groupByRollup: (d: Datum) => d[col.id] ?? EMPTY_SLICE, + showAccessor: (d: Datum) => d !== EMPTY_SLICE, + nodeLabel: (d: unknown) => { + if (hideLabels || d === EMPTY_SLICE) { + return ''; + } + if (col.formatHint) { + return formatters[col.id].convert(d) ?? ''; + } + return String(d); + }, + fillLabel: + isDarkMode && shape === 'treemap' && layerIndex < columnGroups.length - 1 + ? { ...fillLabel, textColor: euiDarkVars.euiTextColor } + : fillLabel, + shape: { + fillColor: d => { + // Color is determined by round-robin on the index of the innermost slice + // This has to be done recursively until we get to the slice index + let parentIndex = 0; + let tempParent: typeof d | typeof d['parent'] = d; + while (tempParent.parent && tempParent.depth > 0) { + parentIndex = tempParent.sortIndex; + tempParent = tempParent.parent; + } + + // Look up round-robin color from default palette + const outputColor = sortedColors[parentIndex % sortedColors.length]; + + if (shape === 'treemap') { + // Only highlight the innermost color of the treemap, as it accurately represents area + return layerIndex < columnGroups.length - 1 ? 'rgba(0,0,0,0)' : outputColor; + } + + const lighten = (d.depth - 1) / (columnGroups.length * 2); + return color(outputColor, 'hsl') + .lighten(lighten) + .hex(); + }, + }, + }; + }); + + const config: RecursivePartial = { + partitionLayout: shape === 'treemap' ? PartitionLayout.treemap : PartitionLayout.sunburst, + fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, + outerSizeRatio: 1, + specialFirstInnermostSector: true, + clockwiseSectors: false, + minFontSize: 10, + maxFontSize: 16, + // Labels are added outside the outer ring when the slice is too small + linkLabel: { + maxCount: 5, + fontSize: 11, + // Dashboard background color is affected by dark mode, which we need + // to account for in outer labels + // This does not handle non-dashboard embeddables, which are allowed to + // have different backgrounds. + textColor: chartTheme.axes?.axisTitleStyle?.fill, + }, + sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, + sectorLineWidth: 1.5, + circlePadding: 4, + }; + if (shape === 'treemap') { + if (hideLabels || categoryDisplay === 'hide') { + config.fillLabel = { textColor: 'rgba(0,0,0,0)' }; + } + } else { + config.emptySizeRatio = shape === 'donut' ? 0.3 : 0; + + if (hideLabels || categoryDisplay === 'hide') { + // Force all labels to be linked, then prevent links from showing + config.linkLabel = { maxCount: 0, maximumSection: Number.POSITIVE_INFINITY }; + } else if (categoryDisplay === 'inside') { + // Prevent links from showing + config.linkLabel = { maxCount: 0 }; + } + } + const metricColumn = firstTable.columns.find(c => c.id === metric)!; + const percentFormatter = props.formatFactory({ + id: 'percent', + params: { + pattern: `0,0.${'0'.repeat(percentDecimals ?? DEFAULT_PERCENT_DECIMALS)}%`, + }, + }); + + const [state, setState] = useState({ isReady: false }); + // It takes a cycle for the chart to render. This prevents + // reporting from printing a blank chart placeholder. + useEffect(() => { + setState({ isReady: true }); + }, []); + + const reverseGroups = [...columnGroups].reverse(); + + const hasNegative = firstTable.rows.some(row => { + const value = row[metricColumn.id]; + return typeof value === 'number' && value < 0; + }); + if (firstTable.rows.length === 0 || hasNegative) { + return ( + + {hasNegative ? ( + + ) : ( + + )} + + ); + } + + return ( + + + + + ); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts new file mode 100644 index 0000000000000..824eec63ba118 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { KibanaDatatable } from 'src/plugins/expressions/public'; +import { getSliceValueWithFallback, getFilterContext } from './render_helpers'; + +describe('render helpers', () => { + describe('#getSliceValueWithFallback', () => { + describe('without fallback', () => { + const columnGroups = [ + { col: { id: 'a', name: 'A' }, metrics: [] }, + { col: { id: 'b', name: 'C' }, metrics: [] }, + ]; + + it('returns the metric when positive number', () => { + expect( + getSliceValueWithFallback({ a: 'Cat', b: 'Home', c: 5 }, columnGroups, { + id: 'c', + name: 'C', + }) + ).toEqual(5); + }); + + it('returns the metric when negative number', () => { + expect( + getSliceValueWithFallback({ a: 'Cat', b: 'Home', c: -100 }, columnGroups, { + id: 'c', + name: 'C', + }) + ).toEqual(-100); + }); + + it('returns epsilon when metric is 0 without fallback', () => { + expect( + getSliceValueWithFallback({ a: 'Cat', b: 'Home', c: 0 }, columnGroups, { + id: 'c', + name: 'C', + }) + ).toEqual(Number.EPSILON); + }); + }); + + describe('fallback behavior', () => { + const columnGroups = [ + { col: { id: 'a', name: 'A' }, metrics: [{ id: 'a_subtotal', name: '' }] }, + { col: { id: 'b', name: 'C' }, metrics: [] }, + ]; + + it('falls back to metric from previous column if available', () => { + expect( + getSliceValueWithFallback( + { a: 'Cat', a_subtotal: 5, b: 'Home', c: undefined }, + columnGroups, + { id: 'c', name: 'C' } + ) + ).toEqual(5); + }); + + it('uses epsilon if fallback is 0', () => { + expect( + getSliceValueWithFallback( + { a: 'Cat', a_subtotal: 0, b: 'Home', c: undefined }, + columnGroups, + { id: 'c', name: 'C' } + ) + ).toEqual(Number.EPSILON); + }); + + it('uses epsilon if fallback is missing', () => { + expect( + getSliceValueWithFallback( + { a: 'Cat', a_subtotal: undefined, b: 'Home', c: undefined }, + columnGroups, + { id: 'c', name: 'C' } + ) + ).toEqual(Number.EPSILON); + }); + }); + }); + + describe('#getFilterContext', () => { + it('handles single slice click for single ring', () => { + const table: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + rows: [ + { a: 'Hi', b: 2 }, + { a: 'Test', b: 4 }, + { a: 'Foo', b: 6 }, + ], + }; + expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a'], table)).toEqual({ + data: { + data: [ + { + row: 1, + column: 0, + value: 'Test', + table, + }, + ], + }, + }); + }); + + it('handles single slice click with 2 rings', () => { + const table: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + ], + rows: [ + { a: 'Hi', b: 'Two', c: 2 }, + { a: 'Test', b: 'Two', c: 5 }, + { a: 'Foo', b: 'Three', c: 6 }, + ], + }; + expect(getFilterContext([{ groupByRollup: 'Test', value: 100 }], ['a', 'b'], table)).toEqual({ + data: { + data: [ + { + row: 1, + column: 0, + value: 'Test', + table, + }, + ], + }, + }); + }); + + it('finds right row for multi slice click', () => { + const table: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'c', name: 'C' }, + ], + rows: [ + { a: 'Hi', b: 'Two', c: 2 }, + { a: 'Test', b: 'Two', c: 5 }, + { a: 'Foo', b: 'Three', c: 6 }, + ], + }; + expect( + getFilterContext( + [ + { groupByRollup: 'Test', value: 100 }, + { groupByRollup: 'Two', value: 5 }, + ], + ['a', 'b'], + table + ) + ).toEqual({ + data: { + data: [ + { + row: 1, + column: 0, + value: 'Test', + table, + }, + { + row: 1, + column: 1, + value: 'Two', + table, + }, + ], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts new file mode 100644 index 0000000000000..bc3c29ba0fff1 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts @@ -0,0 +1,49 @@ +/* + * 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 { Datum, LayerValue } from '@elastic/charts'; +import { KibanaDatatable, KibanaDatatableColumn } from 'src/plugins/expressions/public'; +import { ValueClickTriggerContext } from '../../../../../src/plugins/embeddable/public'; +import { ColumnGroups } from './types'; + +export function getSliceValueWithFallback( + d: Datum, + reverseGroups: ColumnGroups, + metricColumn: KibanaDatatableColumn +) { + if (typeof d[metricColumn.id] === 'number' && d[metricColumn.id] !== 0) { + return d[metricColumn.id]; + } + // Sometimes there is missing data for outer groups + // When there is missing data, we fall back to the next groups + // This creates a sunburst effect + const hasMetric = reverseGroups.find(group => group.metrics.length && d[group.metrics[0].id]); + return hasMetric ? d[hasMetric.metrics[0].id] || Number.EPSILON : Number.EPSILON; +} + +export function getFilterContext( + clickedLayers: LayerValue[], + layerColumnIds: string[], + table: KibanaDatatable +): ValueClickTriggerContext { + const matchingIndex = table.rows.findIndex(row => + clickedLayers.every((layer, index) => { + const columnId = layerColumnIds[index]; + return row[columnId] === layer.groupByRollup; + }) + ); + + return { + data: { + data: clickedLayers.map((clickedLayer, index) => ({ + column: table.columns.findIndex(col => col.id === layerColumnIds[index]), + row: matchingIndex, + value: clickedLayer.groupByRollup, + table, + })), + }, + }; +} diff --git a/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss b/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss new file mode 100644 index 0000000000000..4fa328d8a904d --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss @@ -0,0 +1,3 @@ +.lnsPieSettingsWidget { + min-width: $euiSizeXL * 10; +} diff --git a/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx b/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx new file mode 100644 index 0000000000000..5a02b91efc749 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx @@ -0,0 +1,212 @@ +/* + * 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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiForm, + EuiFormRow, + EuiSuperSelect, + EuiRange, + EuiSwitch, + EuiHorizontalRule, + EuiSpacer, + EuiButtonGroup, +} from '@elastic/eui'; +import { DEFAULT_PERCENT_DECIMALS } from './constants'; +import { PieVisualizationState, SharedLayerState } from './types'; +import { VisualizationLayerWidgetProps } from '../types'; +import './settings_widget.scss'; + +const numberOptions: Array<{ value: SharedLayerState['numberDisplay']; inputDisplay: string }> = [ + { + value: 'hidden', + inputDisplay: i18n.translate('xpack.lens.pieChart.hiddenNumbersLabel', { + defaultMessage: 'Hide from chart', + }), + }, + { + value: 'percent', + inputDisplay: i18n.translate('xpack.lens.pieChart.showPercentValuesLabel', { + defaultMessage: 'Show percent', + }), + }, + { + value: 'value', + inputDisplay: i18n.translate('xpack.lens.pieChart.showFormatterValuesLabel', { + defaultMessage: 'Show value', + }), + }, +]; + +const categoryOptions: Array<{ + value: SharedLayerState['categoryDisplay']; + inputDisplay: string; +}> = [ + { + value: 'default', + inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', { + defaultMessage: 'Inside or outside', + }), + }, + { + value: 'inside', + inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', { + defaultMessage: 'Inside only', + }), + }, + { + value: 'hide', + inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { + defaultMessage: 'Hide labels', + }), + }, +]; + +const legendOptions: Array<{ + value: SharedLayerState['legendDisplay']; + label: string; + id: string; +}> = [ + { + id: 'pieLegendDisplay-default', + value: 'default', + label: i18n.translate('xpack.lens.pieChart.defaultLegendLabel', { + defaultMessage: 'auto', + }), + }, + { + id: 'pieLegendDisplay-show', + value: 'show', + label: i18n.translate('xpack.lens.pieChart.alwaysShowLegendLabel', { + defaultMessage: 'show', + }), + }, + { + id: 'pieLegendDisplay-hide', + value: 'hide', + label: i18n.translate('xpack.lens.pieChart.hideLegendLabel', { + defaultMessage: 'hide', + }), + }, +]; + +export function SettingsWidget(props: VisualizationLayerWidgetProps) { + const { state, setState } = props; + const layer = state.layers[0]; + if (!layer) { + return null; + } + + return ( + + + { + setState({ + ...state, + layers: [{ ...layer, categoryDisplay: option }], + }); + }} + /> + + + { + setState({ + ...state, + layers: [{ ...layer, numberDisplay: option }], + }); + }} + /> + + + + { + setState({ + ...state, + layers: [{ ...layer, percentDecimals: Number(e.currentTarget.value) }], + }); + }} + /> + + + +
+ value === layer.legendDisplay)!.id} + onChange={optionId => { + setState({ + ...state, + layers: [ + { + ...layer, + legendDisplay: legendOptions.find(({ id }) => id === optionId)!.value, + }, + ], + }); + }} + buttonSize="compressed" + isFullWidth + /> + + + { + setState({ ...state, layers: [{ ...layer, nestedLegend: !layer.nestedLegend }] }); + }} + /> +
+
+
+ ); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts new file mode 100644 index 0000000000000..7935d53f56845 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -0,0 +1,522 @@ +/* + * 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 { DataType } from '../types'; +import { suggestions } from './suggestions'; + +describe('suggestions', () => { + describe('pie', () => { + it('should reject multiple layer suggestions', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first', 'second'], + }) + ).toHaveLength(0); + }); + + it('should reject when layer is different', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['second'], + }) + ).toHaveLength(0); + }); + + it('should reject when currently active and unchanged data', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'pie', + layers: [ + { + layerId: 'first', + groups: [], + metric: 'a', + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when table is reordered', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'reorder', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject any date operations', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'b', + operation: { label: 'Days', dataType: 'date' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are no buckets', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are no metrics', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many buckets', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many metrics', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should suggest a donut chart as initial state when only one bucket', () => { + const results = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + + expect(results).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ shape: 'donut' }), + }) + ); + }); + + it('should suggest a pie chart as initial state when more than one bucket', () => { + const results = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + + expect(results).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ shape: 'pie' }), + }) + ); + }); + + it('should keep the layer settings when switching from treemap', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toContainEqual( + expect.objectContaining({ + state: { + shape: 'donut', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + }) + ); + }); + }); + + describe('treemap', () => { + it('should reject when currently active and unchanged data', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: [], + metric: 'a', + + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many buckets being added', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'extended', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a', 'b'], + metric: 'e', + numberDisplay: 'value', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject when there are too many metrics', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a', 'b'], + metric: 'e', + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should keep the layer settings when switching from pie', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'pie', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toContainEqual( + expect.objectContaining({ + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + groups: ['a'], + metric: 'b', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + nestedLegend: true, + }, + ], + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts new file mode 100644 index 0000000000000..e363cf922b356 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -0,0 +1,137 @@ +/* + * 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 { partition } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { SuggestionRequest, VisualizationSuggestion } from '../types'; +import { PieVisualizationState } from './types'; +import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; + +function shouldReject({ table, keptLayerIds }: SuggestionRequest) { + return ( + keptLayerIds.length > 1 || + (keptLayerIds.length && table.layerId !== keptLayerIds[0]) || + table.changeType === 'reorder' || + table.columns.some(col => col.operation.dataType === 'date') + ); +} + +export function suggestions({ + table, + state, + keptLayerIds, +}: SuggestionRequest): Array< + VisualizationSuggestion +> { + if (shouldReject({ table, state, keptLayerIds })) { + return []; + } + + const [groups, metrics] = partition(table.columns, col => col.operation.isBucketed); + + if ( + groups.length === 0 || + metrics.length !== 1 || + groups.length > Math.max(MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS) + ) { + return []; + } + + const results: Array> = []; + + if (groups.length <= MAX_PIE_BUCKETS) { + let newShape: PieVisualizationState['shape'] = 'donut'; + if (groups.length !== 1) { + newShape = 'pie'; + } + + const baseSuggestion: VisualizationSuggestion = { + title: i18n.translate('xpack.lens.pie.suggestionLabel', { + defaultMessage: 'As {chartName}', + values: { chartName: CHART_NAMES[newShape].label }, + description: 'chartName is already translated', + }), + score: state && state.shape !== 'treemap' ? 0.6 : 0.4, + state: { + shape: newShape, + layers: [ + state?.layers[0] + ? { + ...state.layers[0], + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + } + : { + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }, + ], + }, + previewIcon: 'bullseye', + // dont show suggestions for same type + hide: table.changeType === 'reduced' || (state && state.shape !== 'treemap'), + }; + + results.push(baseSuggestion); + results.push({ + ...baseSuggestion, + title: i18n.translate('xpack.lens.pie.suggestionLabel', { + defaultMessage: 'As {chartName}', + values: { chartName: CHART_NAMES[newShape === 'pie' ? 'donut' : 'pie'].label }, + description: 'chartName is already translated', + }), + score: 0.1, + state: { + ...baseSuggestion.state, + shape: newShape === 'pie' ? 'donut' : 'pie', + }, + hide: true, + }); + } + + if (groups.length <= MAX_TREEMAP_BUCKETS) { + results.push({ + title: i18n.translate('xpack.lens.pie.treemapSuggestionLabel', { + defaultMessage: 'As Treemap', + }), + // Use a higher score when currently active, to prevent chart type switching + // on the user unintentionally + score: state?.shape === 'treemap' ? 0.7 : 0.5, + state: { + shape: 'treemap', + layers: [ + state?.layers[0] + ? { + ...state.layers[0], + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + } + : { + layerId: table.layerId, + groups: groups.map(col => col.columnId), + metric: metrics[0].columnId, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }, + ], + }, + previewIcon: 'bullseye', + // hide treemap suggestions from bottom bar, but keep them for chart switcher + hide: table.changeType === 'reduced' || !state || (state && state.shape === 'treemap'), + }); + } + + return [...results].sort((a, b) => a.score - b.score); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts new file mode 100644 index 0000000000000..4a7272b26a63f --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Ast } from '@kbn/interpreter/common'; +import { FramePublicAPI, Operation } from '../types'; +import { DEFAULT_PERCENT_DECIMALS } from './constants'; +import { PieVisualizationState } from './types'; + +export function toExpression(state: PieVisualizationState, frame: FramePublicAPI) { + return expressionHelper(state, frame, false); +} + +function expressionHelper( + state: PieVisualizationState, + frame: FramePublicAPI, + isPreview: boolean +): Ast | null { + const layer = state.layers[0]; + const datasource = frame.datasourceLayers[layer.layerId]; + const operations = layer.groups + .map(columnId => ({ columnId, operation: datasource.getOperationForColumnId(columnId) })) + .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); + if (!layer.metric || !operations.length) { + return null; + } + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_pie', + arguments: { + shape: [state.shape], + hideLabels: [isPreview], + groups: operations.map(o => o.columnId), + metric: [layer.metric], + numberDisplay: [layer.numberDisplay], + categoryDisplay: [layer.categoryDisplay], + legendDisplay: [layer.legendDisplay], + percentDecimals: [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS], + nestedLegend: [!!layer.nestedLegend], + }, + }, + ], + }; +} + +export function toPreviewExpression(state: PieVisualizationState, frame: FramePublicAPI) { + return expressionHelper(state, frame, true); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/types.ts b/x-pack/plugins/lens/public/pie_visualization/types.ts new file mode 100644 index 0000000000000..60b6564248640 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/types.ts @@ -0,0 +1,42 @@ +/* + * 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 { KibanaDatatableColumn } from 'src/plugins/expressions/public'; +import { LensMultiTable } from '../types'; + +export interface SharedLayerState { + groups: string[]; + metric?: string; + numberDisplay: 'hidden' | 'percent' | 'value'; + categoryDisplay: 'default' | 'inside' | 'hide'; + legendDisplay: 'default' | 'show' | 'hide'; + nestedLegend?: boolean; + percentDecimals?: number; +} + +export type LayerState = SharedLayerState & { + layerId: string; +}; + +export interface PieVisualizationState { + shape: 'donut' | 'pie' | 'treemap'; + layers: LayerState[]; +} + +export type PieExpressionArgs = SharedLayerState & { + shape: 'pie' | 'donut' | 'treemap'; + hideLabels: boolean; +}; + +export interface PieExpressionProps { + data: LensMultiTable; + args: PieExpressionArgs; +} + +export type ColumnGroups = Array<{ + col: KibanaDatatableColumn; + metrics: KibanaDatatableColumn[]; +}>; diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.scss b/x-pack/plugins/lens/public/pie_visualization/visualization.scss new file mode 100644 index 0000000000000..d9ff75d849708 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.scss @@ -0,0 +1,4 @@ +.lnsPieExpression__container { + height: 100%; + width: 100%; +} diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index a6acc61922177..a8ec525604977 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -16,6 +16,7 @@ import { IndexPatternDatasource } from './indexpattern_datasource'; import { XyVisualization } from './xy_visualization'; import { MetricVisualization } from './metric_visualization'; import { DatatableVisualization } from './datatable_visualization'; +import { PieVisualization } from './pie_visualization'; import { stopReportManager } from './lens_ui_telemetry'; import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; @@ -48,6 +49,7 @@ export class LensPlugin { private indexpatternDatasource: IndexPatternDatasource; private xyVisualization: XyVisualization; private metricVisualization: MetricVisualization; + private pieVisualization: PieVisualization; constructor() { this.datatableVisualization = new DatatableVisualization(); @@ -55,6 +57,7 @@ export class LensPlugin { this.indexpatternDatasource = new IndexPatternDatasource(); this.xyVisualization = new XyVisualization(); this.metricVisualization = new MetricVisualization(); + this.pieVisualization = new PieVisualization(); } setup( @@ -78,6 +81,7 @@ export class LensPlugin { this.xyVisualization.setup(core, dependencies); this.datatableVisualization.setup(core, dependencies); this.metricVisualization.setup(core, dependencies); + this.pieVisualization.setup(core, dependencies); visualizations.registerAlias(getLensAliasConfig()); @@ -95,6 +99,7 @@ export class LensPlugin { this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; this.xyVisualization.start(core, startDependencies); this.datatableVisualization.start(core, startDependencies); + this.pieVisualization.start(core, startDependencies); } stop() { 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 92a09f361230c..0f9aa1c10e127 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 @@ -1178,5 +1178,135 @@ describe('xy_expression', () => { expect(convertSpy).toHaveBeenCalledWith('I'); }); + + test('it should remove invalid rows', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [ + { a: undefined, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + second: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [ + { a: undefined, b: undefined, c: undefined }, + { a: undefined, b: undefined, c: undefined }, + ], + }, + }, + }; + + const args: XYArgs = { + xTitle: '', + yTitle: '', + legend: { type: 'lens_xy_legendConfig', isVisible: false, position: Position.Top }, + layers: [ + { + layerId: 'first', + seriesType: 'line', + xAccessor: 'a', + accessors: ['c'], + splitAccessor: 'b', + columnToLabel: '', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }, + { + layerId: 'second', + seriesType: 'line', + xAccessor: 'a', + accessors: ['c'], + splitAccessor: 'b', + columnToLabel: '', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }, + ], + }; + + const component = shallow( + + ); + + const series = component.find(LineSeries); + + // Only one series should be rendered, even though 2 are configured + // This one series should only have one row, even though 2 are sent + expect(series.prop('data')).toEqual([{ a: 1, b: 5, c: 'J', d: 'Row 2' }]); + }); + + test('it should show legend for split series, even with one row', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + ], + rows: [{ a: 1, b: 5, c: 'J' }], + }, + }, + }; + + const args: XYArgs = { + xTitle: '', + yTitle: '', + legend: { type: 'lens_xy_legendConfig', isVisible: true, position: Position.Top }, + layers: [ + { + layerId: 'first', + seriesType: 'line', + xAccessor: 'a', + accessors: ['c'], + splitAccessor: 'b', + columnToLabel: '', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }, + ], + }; + + const component = shallow( + + ); + + expect(component.find(Settings).prop('showLegend')).toEqual(true); + }); }); }); 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 81ae57a5ee638..ab0af94cbc2b4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -181,7 +181,17 @@ export function XYChart({ }: XYChartRenderProps) { const { legend, layers } = args; - if (Object.values(data.tables).every(table => table.rows.length === 0)) { + const filteredLayers = layers.filter(({ layerId, xAccessor, accessors }) => { + return !( + !xAccessor || + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + data.tables[layerId].rows.every(row => typeof row[xAccessor] === 'undefined') + ); + }); + + if (filteredLayers.length === 0) { const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; return ( @@ -198,16 +208,16 @@ export function XYChart({ } // use formatting hint of first x axis column to format ticks - const xAxisColumn = Object.values(data.tables)[0].columns.find( - ({ id }) => id === layers[0].xAccessor + const xAxisColumn = data.tables[filteredLayers[0].layerId].columns.find( + ({ id }) => id === filteredLayers[0].xAccessor ); 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 (layers.length === 1 && layers[0].accessors.length === 1) { + if (filteredLayers.length === 1 && filteredLayers[0].accessors.length === 1) { const firstYAxisColumn = Object.values(data.tables)[0].columns.find( - ({ id }) => id === layers[0].accessors[0] + ({ id }) => id === filteredLayers[0].accessors[0] ); if (firstYAxisColumn && firstYAxisColumn.formatHint) { yAxisFormatter = formatFactory(firstYAxisColumn.formatHint); @@ -215,8 +225,10 @@ export function XYChart({ } const chartHasMoreThanOneSeries = - layers.length > 1 || data.tables[layers[0].layerId].columns.length > 2; - const shouldRotate = isHorizontalChart(layers); + filteredLayers.length > 1 || + filteredLayers.some(layer => layer.accessors.length > 1) || + filteredLayers.some(layer => layer.splitAccessor); + const shouldRotate = isHorizontalChart(filteredLayers); const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; @@ -311,7 +323,7 @@ export function XYChart({ const xySeries = series as XYChartSeriesIdentifier; const xyGeometry = geometry as GeometryValue; - const layer = layers.find(l => + const layer = filteredLayers.find(l => xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) ); if (!layer) { @@ -366,7 +378,7 @@ export function XYChart({ position={shouldRotate ? Position.Left : Position.Bottom} title={xTitle} showGridLines={false} - hide={layers[0].hide} + hide={filteredLayers[0].hide} tickFormat={d => xAxisFormatter.convert(d)} /> @@ -375,11 +387,11 @@ export function XYChart({ position={shouldRotate ? Position.Bottom : Position.Left} title={args.yTitle} showGridLines={false} - hide={layers[0].hide} + hide={filteredLayers[0].hide} tickFormat={d => yAxisFormatter.convert(d)} /> - {layers.map( + {filteredLayers.map( ( { splitAccessor, @@ -394,16 +406,6 @@ export function XYChart({ }, index ) => { - if ( - !xAccessor || - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - data.tables[layerId].rows.every(row => typeof row[xAccessor] === 'undefined') - ) { - return; - } - const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; @@ -414,12 +416,14 @@ export function XYChart({ // To not display them in the legend, they need to be filtered out. const rows = table.rows.filter( row => + xAccessor && + row[xAccessor] && !(splitAccessor && !row[splitAccessor] && accessors.every(accessor => !row[accessor])) ); const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], - stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], + stackAccessors: seriesType.includes('stacked') ? [xAccessor as string] : [], id: splitAccessor || accessors.join(','), xAccessor, yAccessors: accessors, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 73ff88e97f479..722a07f581db5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -11,7 +11,7 @@ import { DataType, TableSuggestion, } from '../types'; -import { State, XYState } from './types'; +import { State, XYState, visualizationTypes } from './types'; import { generateId } from '../id_generator'; jest.mock('../id_generator'); @@ -106,7 +106,68 @@ describe('xy_suggestions', () => { ); }); - test('suggests a basic x y chart with date on x', () => { + test('suggests all basic x y charts when switching from another vis', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + }); + + expect(suggestions).toHaveLength(visualizationTypes.length); + expect(suggestions.map(({ state }) => state.preferredSeriesType)).toEqual([ + 'bar_stacked', + 'area_stacked', + 'area', + 'line', + 'bar_horizontal_stacked', + 'bar_horizontal', + 'bar', + ]); + }); + + test('suggests all basic x y charts when switching from another x y chart', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: ['first'], + state: { + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + seriesType: 'bar', + xAccessor: 'date', + accessors: ['bytes'], + splitAccessor: undefined, + }, + ], + }, + }); + + expect(suggestions).toHaveLength(visualizationTypes.length); + expect(suggestions.map(({ state }) => state.preferredSeriesType)).toEqual([ + 'line', + 'bar', + 'bar_horizontal', + 'bar_stacked', + 'bar_horizontal_stacked', + 'area', + 'area_stacked', + ]); + }); + + test('suggests all basic x y chart with date on x', () => { (generateId as jest.Mock).mockReturnValueOnce('aaa'); const [suggestion, ...rest] = getSuggestions({ table: { @@ -118,7 +179,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 1); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Array [ Object { @@ -164,7 +225,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 1); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` Array [ Object { @@ -208,8 +269,8 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); - expect(suggestion.title).toEqual('Bar chart'); + expect(rest).toHaveLength(visualizationTypes.length - 1); + expect(suggestion.title).toEqual('Stacked bar'); expect(suggestion.state).toEqual( expect.objectContaining({ layers: [ @@ -267,7 +328,7 @@ describe('xy_suggestions', () => { expect(suggestion.hide).toBeTruthy(); }); - test('only makes a seriesType suggestion for unchanged table without split', () => { + test('makes a visible seriesType suggestion for unchanged table without split', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', @@ -292,8 +353,9 @@ describe('xy_suggestions', () => { keptLayerIds: ['first'], }); - expect(suggestions).toHaveLength(1); + expect(suggestions).toHaveLength(visualizationTypes.length); + expect(suggestions[0].hide).toEqual(false); expect(suggestions[0].state).toEqual({ ...currentState, preferredSeriesType: 'line', @@ -327,7 +389,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 2); expect(seriesSuggestion.state).toEqual({ ...currentState, preferredSeriesType: 'line', @@ -368,7 +430,7 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(visualizationTypes.length - 1); expect(suggestion.state.preferredSeriesType).toEqual('bar_horizontal'); expect(suggestion.state.layers.every(l => l.seriesType === 'bar_horizontal')).toBeTruthy(); expect(suggestion.title).toEqual('Flip'); @@ -399,14 +461,13 @@ describe('xy_suggestions', () => { keptLayerIds: [], }); - const suggestion = suggestions[suggestions.length - 1]; - - expect(suggestion.state).toEqual({ - ...currentState, - preferredSeriesType: 'bar_stacked', - layers: [{ ...currentState.layers[0], seriesType: 'bar_stacked' }], - }); - expect(suggestion.title).toEqual('Stacked'); + const visibleSuggestions = suggestions.filter(suggestion => !suggestion.hide); + expect(visibleSuggestions).toContainEqual( + expect.objectContaining({ + title: 'Stacked', + state: expect.objectContaining({ preferredSeriesType: 'bar_stacked' }), + }) + ); }); test('keeps column to dimension mappings on extended tables', () => { 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 abd7640344064..71cb8e0cbdc99 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 } from './types'; +import { State, SeriesType, XYState, visualizationTypes } from './types'; import { getIconForSeries } from './state_helpers'; const columnSortOrder = { @@ -180,14 +180,14 @@ function getSuggestionsForLayer({ // handles the simplest cases, acting as a chart switcher if (!currentState && changeType === 'unchanged') { - return [ - { - ...buildSuggestion(options), - title: i18n.translate('xpack.lens.xySuggestions.barChartTitle', { - defaultMessage: 'Bar chart', - }), - }, - ]; + // Chart switcher needs to include every chart type + return visualizationTypes + .map(visType => ({ + ...buildSuggestion({ ...options, seriesType: visType.id as SeriesType }), + title: visType.label, + hide: visType.id !== 'bar_stacked', + })) + .sort((a, b) => (a.state.preferredSeriesType === 'bar_stacked' ? -1 : 1)); } const isSameState = currentState && changeType === 'unchanged'; @@ -248,7 +248,21 @@ function getSuggestionsForLayer({ ); } - return sameStateSuggestions; + // Combine all pre-built suggestions with hidden suggestions for remaining chart types + return sameStateSuggestions.concat( + visualizationTypes + .filter(visType => { + return !sameStateSuggestions.find( + suggestion => suggestion.state.preferredSeriesType === visType.id + ); + }) + .map(visType => { + return { + ...buildSuggestion({ ...options, seriesType: visType.id as SeriesType }), + hide: true, + }; + }) + ); } function toggleStackSeriesType(oldSeriesType: SeriesType) {