diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts b/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts index ed0391a16af25..053b46e480c7b 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts @@ -49,7 +49,7 @@ export const pie: ExpressionFunctionDefinition< }, shape: { types: ['string'], - options: ['pie', 'donut', 'treemap'], + options: ['pie', 'donut', 'treemap', 'mosaic'], help: '', }, hideLabels: { diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts index 8712675740f1c..00fc7abaa043b 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts @@ -8,6 +8,8 @@ import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; import type { LensMultiTable, LayerType } from '../../types'; +export type PieChartTypes = 'donut' | 'pie' | 'treemap' | 'mosaic'; + export interface SharedPieLayerState { groups: string[]; metric?: string; @@ -27,7 +29,7 @@ export type PieLayerState = SharedPieLayerState & { }; export interface PieVisualizationState { - shape: 'donut' | 'pie' | 'treemap'; + shape: PieChartTypes; layers: PieLayerState[]; palette?: PaletteOutput; } @@ -35,7 +37,7 @@ export interface PieVisualizationState { export type PieExpressionArgs = SharedPieLayerState & { title?: string; description?: string; - shape: 'pie' | 'donut' | 'treemap'; + shape: PieChartTypes; hideLabels: boolean; palette: PaletteOutput; }; diff --git a/x-pack/plugins/lens/public/assets/chart_mosaic.tsx b/x-pack/plugins/lens/public/assets/chart_mosaic.tsx new file mode 100644 index 0000000000000..c385f0df1a008 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_mosaic.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartMosaic = ({ title, titleId, ...props }: Omit) => ( + + {title ? : null} + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 5b432f85efde2..7f203e4aa4977 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -227,6 +227,39 @@ describe('LayerPanel', () => { expect(group).toHaveLength(1); }); + it('should render the required warning when only one group is configured (with minDimensions)', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + { + groupLabel: 'B', + groupId: 'b', + accessors: [{ columnId: 'y' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + required: true, + minDimensions: 2, + }, + ], + }); + + const { instance } = await mountWithProvider(); + + const group = instance + .find(EuiFormRow) + .findWhere((e) => e.prop('error') === 'Required dimension'); + + expect(group).toHaveLength(1); + }); + it('should render the datasource and visualization panels inside the dimension container', async () => { mockVisualization.getConfiguration.mockReturnValueOnce({ groups: [ 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 bdd5d93c2c2c8..248bc68fb5d33 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 @@ -388,7 +388,10 @@ export function LayerPanel( {groups.map((group, groupIndex) => { - const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + const isMissing = + !isEmptyLayer && group.required && group.minDimensions + ? group.accessors.length < group.minDimensions + : group.accessors.length === 0; return ( ['treemap', 'mosaic'].includes(shape); + export function PieComponent( props: PieExpressionProps & { formatFactory: FormatFactory; @@ -140,7 +142,7 @@ export function PieComponent( tempParent = tempParent.parent; } - if (shape === 'treemap') { + if (isTreemapOrMosaic(shape)) { // Only highlight the innermost color of the treemap, as it accurately represents area if (layerIndex < bucketColumns.length - 1) { // Mind the difference here: the contrast computation for the text ignores the alpha/opacity @@ -171,7 +173,7 @@ export function PieComponent( }); const config: RecursivePartial = { - partitionLayout: shape === 'treemap' ? PartitionLayout.treemap : PartitionLayout.sunburst, + partitionLayout: CHART_NAMES[shape].partitionType, fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, outerSizeRatio: 1, specialFirstInnermostSector: true, @@ -191,7 +193,7 @@ export function PieComponent( sectorLineWidth: 1.5, circlePadding: 4, }; - if (shape === 'treemap') { + if (isTreemapOrMosaic(shape)) { if (hideLabels || categoryDisplay === 'hide') { config.fillLabel = { textColor: 'rgba(0,0,0,0)' }; } @@ -285,7 +287,9 @@ export function PieComponent( showLegend={ !hideLabels && (legendDisplay === 'show' || - (legendDisplay === 'default' && bucketColumns.length > 1 && shape !== 'treemap')) + (legendDisplay === 'default' && + bucketColumns.length > 1 && + !isTreemapOrMosaic(shape))) } legendPosition={legendPosition || Position.Right} legendMaxDepth={nestedLegend ? undefined : 1 /* Color is based only on first layer */} diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 5a57371eb6459..d4e6790b5e4ff 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -721,4 +721,170 @@ describe('suggestions', () => { ); }); }); + + describe('mosaic', () => { + it('should reject when currently active and unchanged data', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [], + changeType: 'unchanged', + }, + state: { + shape: 'mosaic', + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + groups: [], + metric: 'a', + + numberDisplay: 'hidden', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + }, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('mosaic type should be added only in case of 2 groups', () => { + 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 6', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'treemap', + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + groups: ['a', 'b'], + metric: 'c', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }).filter(({ hide, state }) => !hide && state.shape === 'mosaic') + ).toMatchInlineSnapshot(` + Array [ + Object { + "hide": false, + "previewIcon": "bullseye", + "score": 0.7999999999999999, + "state": Object { + "layers": Array [ + Object { + "categoryDisplay": "default", + "groups": Array [ + "a", + "b", + ], + "layerId": "first", + "layerType": "data", + "legendDisplay": "show", + "legendMaxLines": 1, + "metric": "c", + "nestedLegend": true, + "numberDisplay": "hidden", + "percentDecimals": 0, + "truncateLegend": true, + }, + ], + "palette": undefined, + "shape": "mosaic", + }, + "title": "As Mosaic", + }, + ] + `); + }); + + it('mosaic type should be added only in case of 2 groups (negative test)', () => { + const meta: Parameters[0] = { + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'unchanged', + }, + state: { + shape: 'pie', + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + groups: ['a', 'b'], + metric: 'c', + + numberDisplay: 'hidden', + categoryDisplay: 'inside', + legendDisplay: 'show', + percentDecimals: 0, + legendMaxLines: 1, + truncateLegend: true, + nestedLegend: true, + }, + ], + }, + keptLayerIds: ['first'], + }; + + expect( + suggestions(meta).filter(({ hide, state }) => !hide && state.shape === 'mosaic') + ).toMatchInlineSnapshot(`Array []`); + + meta.table.columns.push({ + columnId: 'b', + operation: { label: 'Top 6', dataType: 'string' as DataType, isBucketed: true }, + }); + + meta.table.columns.push({ + columnId: 'c', + operation: { label: 'Top 7', dataType: 'string' as DataType, isBucketed: true }, + }); + + expect( + suggestions(meta).filter(({ hide, state }) => !hide && state.shape === 'mosaic') + ).toMatchInlineSnapshot(`Array []`); + }); + }); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 9078e18588a2f..591c9fb4c4d74 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -157,6 +157,45 @@ export function suggestions({ }); } + if (groups.length === 2 && state && state.shape !== 'mosaic') { + results.push({ + title: i18n.translate('xpack.lens.pie.mosaicSuggestionLabel', { + defaultMessage: 'As Mosaic', + }), + score: state.shape === 'treemap' ? 0.7 : 0.5, + state: { + shape: 'mosaic', + palette: mainPalette || state?.palette, + layers: [ + state?.layers[0] + ? { + ...state.layers[0], + layerId: table.layerId, + groups: groups.map((col) => col.columnId), + metric: metricColumnId, + categoryDisplay: + state.layers[0].categoryDisplay === 'inside' + ? 'default' + : state.layers[0].categoryDisplay, + layerType: layerTypes.DATA, + } + : { + layerId: table.layerId, + groups: groups.map((col) => col.columnId), + metric: metricColumnId, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + layerType: layerTypes.DATA, + }, + ], + }, + previewIcon: 'bullseye', + hide: table.changeType === 'reduced', + }); + } + return [...results] .map((suggestion) => ({ ...suggestion, diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index ea89ef0bfb854..fc4cd051d398d 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -10,7 +10,12 @@ import { render } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import type { PaletteRegistry } from 'src/plugins/charts/public'; -import type { Visualization, OperationMetadata, AccessorConfig } from '../types'; +import type { + Visualization, + OperationMetadata, + AccessorConfig, + VisualizationDimensionGroupConfig, +} from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; import type { PieLayerState, PieVisualizationState } from '../../common/expressions'; import { layerTypes } from '../../common'; @@ -61,6 +66,12 @@ export const getPieVisualization = ({ label: CHART_NAMES.treemap.label, groupLabel: CHART_NAMES.treemap.groupLabel, }, + { + id: 'mosaic', + icon: CHART_NAMES.mosaic.icon, + label: CHART_NAMES.mosaic.label, + groupLabel: CHART_NAMES.mosaic.groupLabel, + }, ], getVisualizationTypeId(state) { @@ -79,13 +90,7 @@ export const getPieVisualization = ({ }, getDescription(state) { - if (state.shape === 'treemap') { - return CHART_NAMES.treemap; - } - if (state.shape === 'donut') { - return CHART_NAMES.donut; - } - return CHART_NAMES.pie; + return CHART_NAMES[state.shape] ?? CHART_NAMES.pie; }, switchVisualizationType: (visualizationTypeId, state) => ({ @@ -122,6 +127,7 @@ export const getPieVisualization = ({ const sortedColumns: AccessorConfig[] = Array.from( new Set(originalOrder.concat(layer.groups)) ).map((accessor) => ({ columnId: accessor })); + if (sortedColumns.length > 0) { sortedColumns[0] = { columnId: sortedColumns[0].columnId, @@ -132,66 +138,53 @@ export const getPieVisualization = ({ }; } - if (state.shape === 'treemap') { - return { - groups: [ - { - groupId: 'groups', + const getSliceByGroup = (): VisualizationDimensionGroupConfig => { + const baseProps = { + required: true, + groupId: 'groups', + accessors: sortedColumns, + enableDimensionEditor: true, + filterOperations: bucketedOperations, + }; + + switch (state.shape) { + case 'mosaic': + case 'treemap': + return { + ...baseProps, groupLabel: i18n.translate('xpack.lens.pie.treemapGroupLabel', { defaultMessage: 'Group by', }), - layerId, - accessors: sortedColumns, supportsMoreColumns: sortedColumns.length < MAX_TREEMAP_BUCKETS, - filterOperations: bucketedOperations, - required: true, dataTestSubj: 'lnsPie_groupByDimensionPanel', - enableDimensionEditor: true, - }, - { - groupId: 'metric', - groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { - defaultMessage: 'Size by', + minDimensions: state.shape === 'mosaic' ? 2 : 0, + }; + default: + return { + ...baseProps, + groupLabel: i18n.translate('xpack.lens.pie.sliceGroupLabel', { + defaultMessage: 'Slice by', }), - layerId, - accessors: layer.metric ? [{ columnId: layer.metric }] : [], - supportsMoreColumns: !layer.metric, - filterOperations: numberMetricOperations, - required: true, - dataTestSubj: 'lnsPie_sizeByDimensionPanel', - }, - ], - }; - } + supportsMoreColumns: sortedColumns.length < MAX_PIE_BUCKETS, + dataTestSubj: 'lnsPie_sliceByDimensionPanel', + }; + } + }; + + const getMetricGroup = (): VisualizationDimensionGroupConfig => ({ + groupId: 'metric', + groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { + defaultMessage: 'Size by', + }), + accessors: layer.metric ? [{ columnId: layer.metric }] : [], + supportsMoreColumns: !layer.metric, + filterOperations: numberMetricOperations, + required: true, + dataTestSubj: 'lnsPie_sizeByDimensionPanel', + }); 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, - dataTestSubj: 'lnsPie_sliceByDimensionPanel', - enableDimensionEditor: true, - }, - { - groupId: 'metric', - groupLabel: i18n.translate('xpack.lens.pie.groupsizeLabel', { - defaultMessage: 'Size by', - }), - layerId, - accessors: layer.metric ? [{ columnId: layer.metric }] : [], - supportsMoreColumns: !layer.metric, - filterOperations: numberMetricOperations, - required: true, - dataTestSubj: 'lnsPie_sizeByDimensionPanel', - }, - ], + groups: [getSliceByGroup(), getMetricGroup()], }; }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 975e44f703959..8ff60b4d09fc2 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -469,6 +469,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { supportsMoreColumns: boolean; /** If required, a warning will appear if accessors are empty */ required?: boolean; + minDimensions?: number; dataTestSubj?: string; /**