diff --git a/src/plugins/charts/public/services/palettes/mock.ts b/src/plugins/charts/public/services/palettes/mock.ts index a7ec3cc16ce6f5..2e45d93999b8e4 100644 --- a/src/plugins/charts/public/services/palettes/mock.ts +++ b/src/plugins/charts/public/services/palettes/mock.ts @@ -22,7 +22,7 @@ import { PaletteService } from './service'; import { PaletteDefinition, SeriesLayer } from './types'; export const getPaletteRegistry = () => { - const mockPalette: jest.Mocked = { + const mockPalette1: jest.Mocked = { id: 'default', title: 'My Palette', getColor: jest.fn((_: SeriesLayer[]) => 'black'), @@ -41,9 +41,28 @@ export const getPaletteRegistry = () => { })), }; + const mockPalette2: jest.Mocked = { + id: 'mocked', + title: 'Mocked Palette', + getColor: jest.fn((_: SeriesLayer[]) => 'blue'), + getColors: jest.fn((num: number) => ['blue', 'yellow']), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['mocked'], + }, + }, + ], + })), + }; + return { - get: (_: string) => mockPalette, - getAll: () => [mockPalette], + get: (name: string) => (name !== 'default' ? mockPalette2 : mockPalette1), + getAll: () => [mockPalette1, mockPalette2], }; }; diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index d82c7b092c38af..0af8e01d7290d1 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -284,7 +284,7 @@ describe('Datatable Visualization', () => { state: { layers: [layer] }, frame, }).groups[1].accessors - ).toEqual(['c', 'b']); + ).toEqual([{ columnId: 'c' }, { columnId: 'b' }]); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index e0f6ae31719caa..8b5d2d7d733484 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -149,9 +149,9 @@ export const datatableVisualization: Visualization defaultMessage: 'Break down by', }), layerId: state.layers[0].layerId, - accessors: sortedColumns.filter( - (c) => datasource!.getOperationForColumnId(c)?.isBucketed - ), + accessors: sortedColumns + .filter((c) => datasource!.getOperationForColumnId(c)?.isBucketed) + .map((accessor) => ({ columnId: accessor })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, dataTestSubj: 'lnsDatatable_column', @@ -162,9 +162,9 @@ export const datatableVisualization: Visualization defaultMessage: 'Metrics', }), layerId: state.layers[0].layerId, - accessors: sortedColumns.filter( - (c) => !datasource!.getOperationForColumnId(c)?.isBucketed - ), + accessors: sortedColumns + .filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed) + .map((accessor) => ({ columnId: accessor })), supportsMoreColumns: true, filterOperations: (op) => !op.isBucketed, required: true, diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index 8766c9f0acabf6..ded0b4552a4e55 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -47,7 +47,7 @@ // Drop area will be replacing existing content .lnsDragDrop-isReplacing { &, - .lnsLayerPanel__triggerLink { + .lnsLayerPanel__triggerText { text-decoration: line-through; } } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx new file mode 100644 index 00000000000000..5ee1139ff09a2d --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx @@ -0,0 +1,71 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { AccessorConfig } from '../../../types'; + +export function ColorIndicator({ + accessorConfig, + children, +}: { + accessorConfig: AccessorConfig; + children: React.ReactChild; +}) { + let indicatorIcon = null; + if (accessorConfig.triggerIcon && accessorConfig.triggerIcon !== 'none') { + const baseIconProps = { + size: 's', + className: 'lnsLayerPanel__colorIndicator', + } as const; + + indicatorIcon = ( + + {accessorConfig.triggerIcon === 'color' && accessorConfig.color && ( + + )} + {accessorConfig.triggerIcon === 'disabled' && ( + + )} + {accessorConfig.triggerIcon === 'colorBy' && ( + + )} + + ); + } + + return ( + + {indicatorIcon} + {children} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index b98d5b748edb81..a1a072be77f810 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -52,6 +52,7 @@ align-items: center; overflow: hidden; min-height: $euiSizeXXL; + position: relative; // NativeRenderer is messing this up > div { @@ -80,28 +81,18 @@ margin-right: $euiSizeS; } -.lnsLayerPanel__triggerLink { +.lnsLayerPanel__triggerText { width: 100%; padding: $euiSizeS; min-height: $euiSizeXXL - 2; word-break: break-word; - - &:focus { - background-color: transparent !important; // sass-lint:disable-line no-important - outline: none !important; // sass-lint:disable-line no-important - } - - &:focus .lnsLayerPanel__triggerLinkLabel, - &:focus-within .lnsLayerPanel__triggerLinkLabel { - background-color: transparentize($euiColorVis1, .9); - } } -.lnsLayerPanel__triggerLinkLabel { +.lnsLayerPanel__triggerTextLabel { transition: background-color $euiAnimSpeedFast ease-in-out; } -.lnsLayerPanel__triggerLinkContent { +.lnsLayerPanel__triggerTextContent { // Make EUI button content not centered justify-content: flex-start; padding: 0 !important; // sass-lint:disable-line no-important @@ -111,3 +102,32 @@ .lnsLayerPanel__styleEditor { padding: 0 $euiSizeS $euiSizeS; } + +.lnsLayerPanel__colorIndicator { + margin-left: $euiSizeS; +} + +.lnsLayerPanel__paletteContainer { + position: absolute; + bottom: 0; + left: 0; + right: 0; +} + +.lnsLayerPanel__paletteColor { + height: $euiSizeXS; +} + +.lnsLayerPanel__dimensionLink { + width: 100%; + + &:focus { + background-color: transparent !important; // sass-lint:disable-line no-important + outline: none !important; // sass-lint:disable-line no-important + } + + &:focus .lnsLayerPanel__triggerTextLabel, + &:focus-within .lnsLayerPanel__triggerTextLabel { + background-color: transparentize($euiColorVis1, .9); + } +} \ No newline at end of file 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 f440042801ca67..37dc039df498b3 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 @@ -137,7 +137,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['x'], + accessors: [{ columnId: 'x' }], filterOperations: () => true, supportsMoreColumns: false, dataTestSubj: 'lnsGroup', @@ -177,7 +177,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['x'], + accessors: [{ columnId: 'x' }], filterOperations: () => true, supportsMoreColumns: false, dataTestSubj: 'lnsGroup', @@ -209,7 +209,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['newid'], + accessors: [{ columnId: 'newid' }], filterOperations: () => true, supportsMoreColumns: true, dataTestSubj: 'lnsGroup', @@ -257,7 +257,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['newid'], + accessors: [{ columnId: 'newid' }], filterOperations: () => true, supportsMoreColumns: false, dataTestSubj: 'lnsGroup', @@ -302,7 +302,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['newid'], + accessors: [{ columnId: 'newid' }], filterOperations: () => true, supportsMoreColumns: false, dataTestSubj: 'lnsGroup', @@ -377,7 +377,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['a'], + accessors: [{ columnId: 'a' }], filterOperations: () => true, supportsMoreColumns: true, dataTestSubj: 'lnsGroup', @@ -416,7 +416,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['a'], + accessors: [{ columnId: 'a' }], filterOperations: () => true, supportsMoreColumns: false, dataTestSubj: 'lnsGroupA', @@ -424,7 +424,7 @@ describe('LayerPanel', () => { { groupLabel: 'B', groupId: 'b', - accessors: ['b'], + accessors: [{ columnId: 'b' }], filterOperations: () => true, supportsMoreColumns: true, dataTestSubj: 'lnsGroupB', @@ -480,7 +480,7 @@ describe('LayerPanel', () => { { groupLabel: 'A', groupId: 'a', - accessors: ['a', 'b'], + accessors: [{ columnId: 'a' }, { columnId: 'b' }], filterOperations: () => true, supportsMoreColumns: true, dataTestSubj: 'lnsGroup', 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 f780f9c3f22d76..f5b31fb8811672 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 @@ -14,6 +14,7 @@ import { EuiFlexItem, EuiButtonEmpty, EuiFormRow, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -25,6 +26,8 @@ import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; import { ConfigPanelWrapperProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; +import { ColorIndicator } from './color_indicator'; +import { PaletteIndicator } from './palette_indicator'; const initialActiveDimensionState = { isNew: false, @@ -181,6 +184,10 @@ export function LayerPanel( const newId = generateId(); const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + const triggerLinkA11yText = i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Click to edit configuration or drag to move', + }); + return ( <> - {group.accessors.map((accessor) => { + {group.accessors.map((accessorConfig) => { + const accessor = accessorConfig.columnId; const { dragging } = dragDropContext; const dragType = isDraggedOperation(dragging) && accessor === dragging.columnId @@ -253,7 +261,9 @@ export function LayerPanel( dragType={dragType} dropType={dropType} data-test-subj={group.dataTestSubj} - itemsInGroup={group.accessors} + itemsInGroup={group.accessors.map((a) => + typeof a === 'string' ? a : a.columnId + )} className={'lnsLayerPanel__dimensionContainer'} value={{ columnId: accessor, @@ -304,25 +314,33 @@ export function LayerPanel( }} >
- { - if (activeId) { - setActiveDimension(initialActiveDimensionState); - } else { - setActiveDimension({ - isNew: false, - activeGroup: group, - activeId: accessor, - }); - } - }, + { + if (activeId) { + setActiveDimension(initialActiveDimensionState); + } else { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: accessor, + }); + } }} - /> + aria-label={triggerLinkA11yText} + title={triggerLinkA11yText} + > + + + + +
); @@ -409,12 +428,12 @@ export function LayerPanel( >
{ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/palette_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/palette_indicator.tsx new file mode 100644 index 00000000000000..7e65fe7025932c --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/palette_indicator.tsx @@ -0,0 +1,27 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { AccessorConfig } from '../../../types'; + +export function PaletteIndicator({ accessorConfig }: { accessorConfig: AccessorConfig }) { + if (accessorConfig.triggerIcon !== 'colorBy' || !accessorConfig.palette) return null; + return ( + + {accessorConfig.palette.map((color) => ( + + ))} + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index baf4f6bb9a6a3f..94018bd84b5174 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -6,7 +6,7 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiLink, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiText, EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { DatasourceDimensionTriggerProps, DatasourceDimensionEditorProps } from '../../types'; @@ -66,10 +66,6 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens } const formattedLabel = wrapOnDot(uniqueLabel); - const triggerLinkA11yText = i18n.translate('xpack.lens.configure.editConfig', { - defaultMessage: 'Click to edit configuration or drag to move', - }); - if (currentFieldIsInvalid) { return ( - @@ -101,26 +95,24 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens {selectedColumn.label} - + ); } return ( - - {formattedLabel} + {formattedLabel} - + ); }; diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index b75ac89d7e4d86..d8c475734e67e0 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -96,7 +96,7 @@ export const metricVisualization: Visualization = { groupId: 'metric', groupLabel: i18n.translate('xpack.lens.metric.label', { defaultMessage: 'Metric' }), layerId: props.state.layerId, - accessors: props.state.accessor ? [props.state.accessor] : [], + accessors: props.state.accessor ? [{ columnId: props.state.accessor }] : [], supportsMoreColumns: !props.state.accessor, filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number', }, diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 62e99396edbc72..91f0ddb54ad418 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -9,7 +9,7 @@ import { render } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { PaletteRegistry } from 'src/plugins/charts/public'; -import { Visualization, OperationMetadata } from '../types'; +import { Visualization, OperationMetadata, AccessorConfig } from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; import { LayerState, PieVisualizationState } from './types'; import { suggestions } from './suggestions'; @@ -113,7 +113,18 @@ export const getPieVisualization = ({ .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))); + 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, + triggerIcon: 'colorBy', + palette: paletteService + .get(state.palette?.name || 'default') + .getColors(10, state.palette?.params), + }; + } if (state.shape === 'treemap') { return { @@ -137,7 +148,7 @@ export const getPieVisualization = ({ defaultMessage: 'Size by', }), layerId, - accessors: layer.metric ? [layer.metric] : [], + accessors: layer.metric ? [{ columnId: layer.metric }] : [], supportsMoreColumns: !layer.metric, filterOperations: numberMetricOperations, required: true, @@ -168,7 +179,7 @@ export const getPieVisualization = ({ defaultMessage: 'Size by', }), layerId, - accessors: layer.metric ? [layer.metric] : [], + accessors: layer.metric ? [{ columnId: layer.metric }] : [], supportsMoreColumns: !layer.metric, filterOperations: numberMetricOperations, required: true, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index b8bceb5454bc8e..225fedb987c76a 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -242,7 +242,6 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro export type DatasourceDimensionTriggerProps = DatasourceDimensionProps & { dragDropContext: DragContextState; - onClick: () => void; }; export interface DatasourceLayerPanelProps { @@ -341,12 +340,19 @@ export type VisualizationDimensionEditorProps = VisualizationConfig setState: (newState: T) => void; }; +export interface AccessorConfig { + columnId: string; + triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none'; + color?: string; + palette?: string[]; +} + export type VisualizationDimensionGroupConfig = SharedDimensionProps & { groupLabel: string; /** ID is passed back to visualization. For example, `x` */ groupId: string; - accessors: string[]; + accessors: AccessorConfig[]; supportsMoreColumns: boolean; /** If required, a warning will appear if accessors are empty */ required?: boolean; diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts index b59e09e8c19768..666b0d50982185 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts @@ -128,6 +128,31 @@ describe('color_assignment', () => { expect(assignments.palette2.totalSeriesCount).toEqual(2 * 3); expect(formatMock).toHaveBeenCalledWith(complexObject); }); + + it('should handle missing tables', () => { + const assignments = getColorAssignments(layers, { ...data, tables: {} }, formatFactory); + // if there is no data, just assume a single split + expect(assignments.palette1.totalSeriesCount).toEqual(2); + }); + + it('should handle missing columns', () => { + const assignments = getColorAssignments( + layers, + { + ...data, + tables: { + ...data.tables, + '1': { + ...data.tables['1'], + columns: [], + }, + }, + }, + formatFactory + ); + // if the split column is missing, just assume a single split + expect(assignments.palette1.totalSeriesCount).toEqual(2); + }); }); describe('getRank', () => { @@ -178,5 +203,30 @@ describe('color_assignment', () => { // 3 series in front of (complex object)/y1 - abc/y1, abc/y2 expect(assignments.palette1.getRank(layers[0], 'formatted', 'y1')).toEqual(2); }); + + it('should handle missing tables', () => { + const assignments = getColorAssignments(layers, { ...data, tables: {} }, formatFactory); + // if there is no data, assume it is the first splitted series. One series in front - 0/y1 + expect(assignments.palette1.getRank(layers[0], '2', 'y2')).toEqual(1); + }); + + it('should handle missing columns', () => { + const assignments = getColorAssignments( + layers, + { + ...data, + tables: { + ...data.tables, + '1': { + ...data.tables['1'], + columns: [], + }, + }, + }, + formatFactory + ); + // if the split column is missing, assume it is the first splitted series. One series in front - 0/y1 + expect(assignments.palette1.getRank(layers[0], '2', 'y2')).toEqual(1); + }); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 5f72dd1b0453bc..68c47e11acfc0a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -5,20 +5,36 @@ */ import { uniq, mapValues } from 'lodash'; -import { FormatFactory, LensMultiTable } from '../types'; -import { LayerArgs, LayerConfig } from './types'; +import { PaletteOutput } from 'src/plugins/charts/public'; +import { Datatable } from 'src/plugins/expressions'; +import { FormatFactory } from '../types'; const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; +interface LayerColorConfig { + palette?: PaletteOutput; + splitAccessor?: string; + accessors: string[]; + layerId: string; +} + +export type ColorAssignments = Record< + string, + { + totalSeriesCount: number; + getRank(layer: LayerColorConfig, seriesKey: string, yAccessor: string): number; + } +>; + export function getColorAssignments( - layers: LayerArgs[], - data: LensMultiTable, + layers: LayerColorConfig[], + data: { tables: Record }, formatFactory: FormatFactory -) { - const layersPerPalette: Record = {}; +): ColorAssignments { + const layersPerPalette: Record = {}; layers.forEach((layer) => { - const palette = layer.palette?.name || 'palette'; + const palette = layer.palette?.name || 'default'; if (!layersPerPalette[palette]) { layersPerPalette[palette] = []; } @@ -31,18 +47,21 @@ export function getColorAssignments( return { numberOfSeries: layer.accessors.length, splits: [] }; } const splitAccessor = layer.splitAccessor; - const column = data.tables[layer.layerId].columns.find(({ id }) => id === splitAccessor)!; - const splits = uniq( - data.tables[layer.layerId].rows.map((row) => { - let value = row[splitAccessor]; - if (value && !isPrimitive(value)) { - value = formatFactory(column.meta.params).convert(value); - } else { - value = String(value); - } - return value; - }) - ); + const column = data.tables[layer.layerId]?.columns.find(({ id }) => id === splitAccessor); + const splits = + !column || !data.tables[layer.layerId] + ? [] + : uniq( + data.tables[layer.layerId].rows.map((row) => { + let value = row[splitAccessor]; + if (value && !isPrimitive(value)) { + value = formatFactory(column.meta.params).convert(value); + } else { + value = String(value); + } + return value; + }) + ); return { numberOfSeries: (splits.length || 1) * layer.accessors.length, splits }; }); const totalSeriesCount = seriesPerLayer.reduce( @@ -51,18 +70,17 @@ export function getColorAssignments( ); return { totalSeriesCount, - getRank(layer: LayerArgs, seriesKey: string, yAccessor: string) { + getRank(layer: LayerColorConfig, seriesKey: string, yAccessor: string) { const layerIndex = paletteLayers.indexOf(layer); const currentSeriesPerLayer = seriesPerLayer[layerIndex]; + const splitRank = currentSeriesPerLayer.splits.indexOf(seriesKey); return ( (layerIndex === 0 ? 0 : seriesPerLayer .slice(0, layerIndex) .reduce((sum, perLayer) => sum + perLayer.numberOfSeries, 0)) + - (layer.splitAccessor - ? currentSeriesPerLayer.splits.indexOf(seriesKey) * layer.accessors.length - : 0) + + (layer.splitAccessor && splitRank !== -1 ? splitRank * layer.accessors.length : 0) + layer.accessors.indexOf(yAccessor) ); }, diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 1bcae4d09e7e7e..a4c1e1bd4ba167 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -1386,13 +1386,13 @@ describe('xy_expression', () => { yAccessor: 'a', seriesKeys: ['a'], }) - ).toEqual('black'); + ).toEqual('blue'); expect( (component.find(LineSeries).at(1).prop('color') as Function)!({ yAccessor: 'c', seriesKeys: ['c'], }) - ).toEqual('black'); + ).toEqual('blue'); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 4891a51b3124b4..5e5eef2f01c177 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -10,6 +10,7 @@ import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public' import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; import { EditorFrameSetup, FormatFactory } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; +import { LensPluginStartDependencies } from '../plugin'; export interface XyVisualizationPluginSetupPlugins { expressions: ExpressionsSetup; @@ -31,7 +32,7 @@ export class XyVisualization { constructor() {} setup( - core: CoreSetup, + core: CoreSetup, { expressions, formatFactory, editorFrame, charts }: XyVisualizationPluginSetupPlugins ) { editorFrame.registerVisualization(async () => { @@ -46,6 +47,7 @@ export class XyVisualization { getXyChartRenderer, getXyVisualization, } = await import('../async_services'); + const [, { data }] = await core.getStartServices(); const palettes = await charts.palettes.getPalettes(); expressions.registerFunction(() => legendConfig); expressions.registerFunction(() => yAxisConfig); @@ -64,7 +66,7 @@ export class XyVisualization { histogramBarTarget: core.uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), }) ); - return getXyVisualization({ paletteService: palettes }); + return getXyVisualization({ paletteService: palettes, data }); }); } } diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index bf4ffaa36a8700..bd479062e2a064 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -5,7 +5,7 @@ */ import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { FramePublicAPI } from '../types'; +import { FramePublicAPI, DatasourcePublicAPI } from '../types'; import { SeriesType, visualizationTypes, LayerConfig, YConfig, ValidLayer } from './types'; export function isHorizontalSeries(seriesType: SeriesType) { @@ -39,6 +39,18 @@ export const getSeriesColor = (layer: LayerConfig, accessor: string) => { ); }; +export const getColumnToLabelMap = (layer: LayerConfig, datasource: DatasourcePublicAPI) => { + const columnToLabel: Record = {}; + + layer.accessors.concat(layer.splitAccessor ? [layer.splitAccessor] : []).forEach((accessor) => { + const operation = datasource.getOperationForColumnId(accessor); + if (operation?.label) { + columnToLabel[accessor] = operation.label; + } + }); + return columnToLabel; +}; + export function hasHistogramSeries( layers: ValidLayer[] = [], datasourceLayers?: FramePublicAPI['datasourceLayers'] diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index 05a4b7f460adb9..a715e4359da471 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -10,10 +10,12 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { getXyVisualization } from './xy_visualization'; import { Operation } from '../types'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; describe('#toExpression', () => { const xyVisualization = getXyVisualization({ paletteService: chartPluginMock.createPaletteRegistry(), + data: dataPluginMock.createStartContract(), }); let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index df773146cde4db..fda7c93af03a50 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -9,6 +9,7 @@ import { ScaleType } from '@elastic/charts'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { State, ValidLayer, LayerConfig } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; +import { getColumnToLabelMap } from './state_helpers'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: LayerConfig) => { const originalOrder = datasource @@ -196,17 +197,7 @@ export const buildExpression = ( ], valueLabels: [state?.valueLabels || 'hide'], layers: validLayers.map((layer) => { - const columnToLabel: Record = {}; - - const datasource = datasourceLayers[layer.layerId]; - layer.accessors - .concat(layer.splitAccessor ? [layer.splitAccessor] : []) - .forEach((accessor) => { - const operation = datasource.getOperationForColumnId(accessor); - if (operation?.label) { - columnToLabel[accessor] = operation.label; - } - }); + const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layer.layerId]); const xAxisOperation = datasourceLayers && diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 5127e5c2c2597a..546cf06d4014e4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -7,10 +7,11 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation } from '../types'; -import { State, SeriesType } from './types'; +import { State, SeriesType, LayerConfig } from './types'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; import { LensIconChartBar } from '../assets/chart_bar'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; function exampleState(): State { return { @@ -28,9 +29,12 @@ function exampleState(): State { ], }; } +const paletteServiceMock = chartPluginMock.createPaletteRegistry(); +const dataMock = dataPluginMock.createStartContract(); const xyVisualization = getXyVisualization({ - paletteService: chartPluginMock.createPaletteRegistry(), + paletteService: paletteServiceMock, + data: dataMock, }); describe('xy_visualization', () => { @@ -307,6 +311,14 @@ describe('xy_visualization', () => { frame.datasourceLayers = { first: mockDatasource.publicAPIMock, }; + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; }); it('should return options for 3 dimensions', () => { @@ -408,6 +420,145 @@ describe('xy_visualization', () => { ]; expect(ops.filter(filterOperations).map((x) => x.dataType)).toEqual(['number']); }); + + describe('color assignment', () => { + function callConfig(layerConfigOverride: Partial) { + const baseState = exampleState(); + const options = xyVisualization.getConfiguration({ + state: { + ...baseState, + layers: [ + { + ...baseState.layers[0], + splitAccessor: undefined, + ...layerConfigOverride, + }, + ], + }, + frame, + layerId: 'first', + }).groups; + return options; + } + + function callConfigForYConfigs(layerConfigOverride: Partial) { + return callConfig(layerConfigOverride).find(({ groupId }) => groupId === 'y'); + } + + function callConfigForBreakdownConfigs(layerConfigOverride: Partial) { + return callConfig(layerConfigOverride).find(({ groupId }) => groupId === 'breakdown'); + } + + function callConfigAndFindYConfig( + layerConfigOverride: Partial, + assertionAccessor: string + ) { + const accessorConfig = callConfigForYConfigs(layerConfigOverride)?.accessors.find( + (accessor) => typeof accessor !== 'string' && accessor.columnId === assertionAccessor + ); + if (!accessorConfig || typeof accessorConfig === 'string') { + throw new Error('could not find accessor'); + } + return accessorConfig; + } + + it('should pass custom y color in accessor config', () => { + const accessorConfig = callConfigAndFindYConfig( + { + yConfig: [ + { + forAccessor: 'b', + color: 'red', + }, + ], + }, + 'b' + ); + expect(accessorConfig.triggerIcon).toEqual('color'); + expect(accessorConfig.color).toEqual('red'); + }); + + it('should query palette to fill in colors for other dimensions', () => { + const palette = paletteServiceMock.get('default'); + (palette.getColor as jest.Mock).mockClear(); + const accessorConfig = callConfigAndFindYConfig({}, 'c'); + expect(accessorConfig.triggerIcon).toEqual('color'); + // black is the color returned from the palette mock + expect(accessorConfig.color).toEqual('black'); + expect(palette.getColor).toHaveBeenCalledWith( + [ + { + name: 'c', + // rank 1 because it's the second y metric + rankAtDepth: 1, + totalSeriesAtDepth: 2, + }, + ], + { maxDepth: 1, totalSeries: 2 }, + undefined + ); + }); + + it('should pass name of current series along', () => { + (frame.datasourceLayers.first.getOperationForColumnId as jest.Mock).mockReturnValue({ + label: 'Overwritten label', + }); + const palette = paletteServiceMock.get('default'); + (palette.getColor as jest.Mock).mockClear(); + callConfigAndFindYConfig({}, 'c'); + expect(palette.getColor).toHaveBeenCalledWith( + [ + expect.objectContaining({ + name: 'Overwritten label', + }), + ], + expect.anything(), + undefined + ); + }); + + it('should use custom palette if layer contains palette', () => { + const palette = paletteServiceMock.get('mock'); + callConfigAndFindYConfig( + { + palette: { type: 'palette', name: 'mock', params: {} }, + }, + 'c' + ); + expect(palette.getColor).toHaveBeenCalled(); + }); + + it('should not show any indicator as long as there is no data', () => { + frame.activeData = undefined; + const yConfigs = callConfigForYConfigs({}); + expect(yConfigs!.accessors.length).toEqual(2); + yConfigs!.accessors.forEach((accessor) => { + expect(accessor.triggerIcon).toBeUndefined(); + }); + }); + + it('should show disable icon for splitted series', () => { + const accessorConfig = callConfigAndFindYConfig( + { + splitAccessor: 'd', + }, + 'b' + ); + expect(accessorConfig.triggerIcon).toEqual('disabled'); + }); + + it('should show current palette for break down by dimension', () => { + const palette = paletteServiceMock.get('mock'); + const customColors = ['yellow', 'green']; + (palette.getColors as jest.Mock).mockReturnValue(customColors); + const breakdownConfig = callConfigForBreakdownConfigs({ + palette: { type: 'palette', name: 'mock', params: {} }, + splitAccessor: 'd', + }); + const accessorConfig = breakdownConfig!.accessors[0]; + expect(typeof accessorConfig !== 'string' && accessorConfig.palette).toEqual(customColors); + }); + }); }); describe('#getErrorMessages', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 1f135929dac21a..5748e649c181e2 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -10,16 +10,24 @@ import { render } from 'react-dom'; import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { PaletteRegistry } from 'src/plugins/charts/public'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; import { getSuggestions } from './xy_suggestions'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; -import { Visualization, OperationMetadata, VisualizationType } from '../types'; +import { + Visualization, + OperationMetadata, + VisualizationType, + AccessorConfig, + FramePublicAPI, +} from '../types'; import { State, SeriesType, visualizationTypes, LayerConfig } from './types'; -import { isHorizontalChart } from './state_helpers'; +import { getColumnToLabelMap, isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; import { LensIconChartMixedXy } from '../assets/chart_mixed_xy'; import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal'; +import { ColorAssignments, getColorAssignments } from './color_assignment'; const defaultIcon = LensIconChartBarStacked; const defaultSeriesType = 'bar_stacked'; @@ -76,8 +84,10 @@ function getDescription(state?: State) { export const getXyVisualization = ({ paletteService, + data, }: { paletteService: PaletteRegistry; + data: DataPublicPluginStart; }): Visualization => ({ id: 'lnsXY', @@ -168,7 +178,25 @@ export const getXyVisualization = ({ const datasource = frame.datasourceLayers[layer.layerId]; - const sortedAccessors = getSortedAccessors(datasource, layer); + const sortedAccessors: string[] = getSortedAccessors(datasource, layer); + let mappedAccessors: AccessorConfig[] = sortedAccessors.map((accessor) => ({ + columnId: accessor, + })); + + if (frame.activeData) { + const colorAssignments = getColorAssignments( + state.layers, + { tables: frame.activeData }, + data.fieldFormats.deserialize + ); + mappedAccessors = getAccessorColorConfig( + colorAssignments, + frame, + layer, + sortedAccessors, + paletteService + ); + } const isHorizontal = isHorizontalChart(state.layers); return { @@ -176,7 +204,7 @@ export const getXyVisualization = ({ { groupId: 'x', groupLabel: getAxisName('x', { isHorizontal }), - accessors: layer.xAccessor ? [layer.xAccessor] : [], + accessors: layer.xAccessor ? [{ columnId: layer.xAccessor }] : [], filterOperations: isBucketed, supportsMoreColumns: !layer.xAccessor, dataTestSubj: 'lnsXY_xDimensionPanel', @@ -184,7 +212,7 @@ export const getXyVisualization = ({ { groupId: 'y', groupLabel: getAxisName('y', { isHorizontal }), - accessors: sortedAccessors, + accessors: mappedAccessors, filterOperations: isNumericMetric, supportsMoreColumns: true, required: true, @@ -196,7 +224,17 @@ export const getXyVisualization = ({ groupLabel: i18n.translate('xpack.lens.xyChart.splitSeries', { defaultMessage: 'Break down by', }), - accessors: layer.splitAccessor ? [layer.splitAccessor] : [], + accessors: layer.splitAccessor + ? [ + { + columnId: layer.splitAccessor, + triggerIcon: 'colorBy', + palette: paletteService + .get(layer.palette?.name || 'default') + .getColors(10, layer.palette?.params), + }, + ] + : [], filterOperations: isBucketed, supportsMoreColumns: !layer.splitAccessor, dataTestSubj: 'lnsXY_splitDimensionPanel', @@ -333,6 +371,51 @@ export const getXyVisualization = ({ }, }); +function getAccessorColorConfig( + colorAssignments: ColorAssignments, + frame: FramePublicAPI, + layer: LayerConfig, + sortedAccessors: string[], + paletteService: PaletteRegistry +): AccessorConfig[] { + const layerContainsSplits = Boolean(layer.splitAccessor); + const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' }; + const totalSeriesCount = colorAssignments[currentPalette.name].totalSeriesCount; + return sortedAccessors.map((accessor) => { + const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); + if (layerContainsSplits) { + return { + columnId: accessor as string, + triggerIcon: 'disabled', + }; + } + const columnToLabel = getColumnToLabelMap(layer, frame.datasourceLayers[layer.layerId]); + const rank = colorAssignments[currentPalette.name].getRank( + layer, + columnToLabel[accessor] || accessor, + accessor + ); + const customColor = + currentYConfig?.color || + paletteService.get(currentPalette.name).getColor( + [ + { + name: columnToLabel[accessor] || accessor, + rankAtDepth: rank, + totalSeriesAtDepth: totalSeriesCount, + }, + ], + { maxDepth: 1, totalSeries: totalSeriesCount }, + currentPalette.params + ); + return { + columnId: accessor as string, + triggerIcon: customColor ? 'color' : 'disabled', + color: customColor ? customColor : undefined, + }; + }); +} + function validateLayersForDimension( dimension: string, layers: LayerConfig[], 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 e8c32821460974..d214554de340cc 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 @@ -15,12 +15,14 @@ import { State, XYState, visualizationTypes } from './types'; import { generateId } from '../id_generator'; import { getXyVisualization } from './xy_visualization'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { PaletteOutput } from 'src/plugins/charts/public'; jest.mock('../id_generator'); const xyVisualization = getXyVisualization({ paletteService: chartPluginMock.createPaletteRegistry(), + data: dataPluginMock.createStartContract(), }); describe('xy_suggestions', () => {