[Lens] Color in dimension trigger (#76871) (#83524)

This commit is contained in:
Joe Reuter 2020-11-17 12:05:27 +01:00 committed by GitHub
parent c0c398a185
commit 495fc8b8e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 604 additions and 128 deletions

View file

@ -22,7 +22,7 @@ import { PaletteService } from './service';
import { PaletteDefinition, SeriesLayer } from './types';
export const getPaletteRegistry = () => {
const mockPalette: jest.Mocked<PaletteDefinition> = {
const mockPalette1: jest.Mocked<PaletteDefinition> = {
id: 'default',
title: 'My Palette',
getColor: jest.fn((_: SeriesLayer[]) => 'black'),
@ -41,9 +41,28 @@ export const getPaletteRegistry = () => {
})),
};
const mockPalette2: jest.Mocked<PaletteDefinition> = {
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],
};
};

View file

@ -284,7 +284,7 @@ describe('Datatable Visualization', () => {
state: { layers: [layer] },
frame,
}).groups[1].accessors
).toEqual(['c', 'b']);
).toEqual([{ columnId: 'c' }, { columnId: 'b' }]);
});
});

View file

@ -149,9 +149,9 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
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<DatatableVisualizationState>
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,

View file

@ -47,7 +47,7 @@
// Drop area will be replacing existing content
.lnsDragDrop-isReplacing {
&,
.lnsLayerPanel__triggerLink {
.lnsLayerPanel__triggerText {
text-decoration: line-through;
}
}

View file

@ -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 = (
<EuiFlexItem grow={false}>
{accessorConfig.triggerIcon === 'color' && accessorConfig.color && (
<EuiIcon
{...baseIconProps}
color={accessorConfig.color}
type="stopFilled"
aria-label={i18n.translate('xpack.lens.editorFrame.colorIndicatorLabel', {
defaultMessage: 'Color of this dimension: {hex}',
values: {
hex: accessorConfig.color,
},
})}
/>
)}
{accessorConfig.triggerIcon === 'disabled' && (
<EuiIcon
{...baseIconProps}
type="stopSlash"
color="subdued"
aria-label={i18n.translate('xpack.lens.editorFrame.noColorIndicatorLabel', {
defaultMessage: 'This dimension does not have an individual color',
})}
/>
)}
{accessorConfig.triggerIcon === 'colorBy' && (
<EuiIcon
{...baseIconProps}
type="brush"
color="text"
aria-label={i18n.translate('xpack.lens.editorFrame.paletteColorIndicatorLabel', {
defaultMessage: 'This dimension is using a palette',
})}
/>
)}
</EuiFlexItem>
);
}
return (
<EuiFlexGroup gutterSize="none" alignItems="center">
{indicatorIcon}
<EuiFlexItem>{children}</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -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);
}
}

View file

@ -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',

View file

@ -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 (
<EuiFormRow
className={
@ -207,7 +214,8 @@ export function LayerPanel(
>
<>
<ReorderProvider id={group.groupId} className={'lnsLayerPanel__group'}>
{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(
}}
>
<div className="lnsLayerPanel__dimension">
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessor,
filterOperations: group.filterOperations,
onClick: () => {
if (activeId) {
setActiveDimension(initialActiveDimensionState);
} else {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: accessor,
});
}
},
<EuiLink
className="lnsLayerPanel__dimensionLink"
onClick={() => {
if (activeId) {
setActiveDimension(initialActiveDimensionState);
} else {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: accessor,
});
}
}}
/>
aria-label={triggerLinkA11yText}
title={triggerLinkA11yText}
>
<ColorIndicator accessorConfig={accessorConfig}>
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessor,
filterOperations: group.filterOperations,
}}
/>
</ColorIndicator>
</EuiLink>
<EuiButtonIcon
className="lnsLayerPanel__dimensionRemove"
data-test-subj="indexPattern-dimension-remove"
@ -356,6 +374,7 @@ export function LayerPanel(
);
}}
/>
<PaletteIndicator accessorConfig={accessorConfig} />
</div>
</DragDrop>
);
@ -409,12 +428,12 @@ export function LayerPanel(
>
<div className="lnsLayerPanel__dimension lnsLayerPanel__dimension--empty">
<EuiButtonEmpty
className="lnsLayerPanel__triggerLink"
className="lnsLayerPanel__triggerText"
color="text"
size="xs"
iconType="plusInCircleFilled"
contentProps={{
className: 'lnsLayerPanel__triggerLinkContent',
className: 'lnsLayerPanel__triggerTextContent',
}}
data-test-subj="lns-empty-dimension"
onClick={() => {

View file

@ -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 (
<EuiFlexGroup className="lnsLayerPanel__paletteContainer" gutterSize="none" alignItems="center">
{accessorConfig.palette.map((color) => (
<EuiFlexItem
key={color}
className="lnsLayerPanel__paletteColor"
grow={true}
style={{
backgroundColor: color,
}}
/>
))}
</EuiFlexGroup>
);
}

View file

@ -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 (
<EuiToolTip
@ -86,14 +82,12 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
}
anchorClassName="eui-displayBlock"
>
<EuiLink
<EuiText
size="s"
color="danger"
id={columnId}
className="lnsLayerPanel__triggerLink"
onClick={props.onClick}
className="lnsLayerPanel__triggerText"
data-test-subj="lns-dimensionTrigger"
aria-label={triggerLinkA11yText}
title={triggerLinkA11yText}
>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
@ -101,26 +95,24 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
</EuiFlexItem>
<EuiFlexItem grow={true}>{selectedColumn.label}</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
</EuiText>
</EuiToolTip>
);
}
return (
<EuiLink
<EuiText
size="s"
id={columnId}
className="lnsLayerPanel__triggerLink"
onClick={props.onClick}
className="lnsLayerPanel__triggerText"
data-test-subj="lns-dimensionTrigger"
aria-label={triggerLinkA11yText}
title={triggerLinkA11yText}
>
<EuiFlexItem grow={true}>
<span>
<span className="lnsLayerPanel__triggerLinkLabel">{formattedLabel}</span>
<span className="lnsLayerPanel__triggerTextLabel">{formattedLabel}</span>
</span>
</EuiFlexItem>
</EuiLink>
</EuiText>
);
};

View file

@ -96,7 +96,7 @@ export const metricVisualization: Visualization<State> = {
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',
},

View file

@ -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,

View file

@ -242,7 +242,6 @@ export type DatasourceDimensionEditorProps<T = unknown> = DatasourceDimensionPro
export type DatasourceDimensionTriggerProps<T> = DatasourceDimensionProps<T> & {
dragDropContext: DragContextState;
onClick: () => void;
};
export interface DatasourceLayerPanelProps<T> {
@ -341,12 +340,19 @@ export type VisualizationDimensionEditorProps<T = unknown> = 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;

View file

@ -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);
});
});
});

View file

@ -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<string, Datatable> },
formatFactory: FormatFactory
) {
const layersPerPalette: Record<string, LayerConfig[]> = {};
): ColorAssignments {
const layersPerPalette: Record<string, LayerColorConfig[]> = {};
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)
);
},

View file

@ -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');
});
});

View file

@ -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<LensPluginStartDependencies, void>,
{ 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<number>(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
})
);
return getXyVisualization({ paletteService: palettes });
return getXyVisualization({ paletteService: palettes, data });
});
}
}

View file

@ -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<string, string> = {};
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']

View file

@ -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<typeof createMockDatasource>;
let frame: ReturnType<typeof createMockFramePublicAPI>;

View file

@ -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<string, string> = {};
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 &&

View file

@ -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<LayerConfig>) {
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<LayerConfig>) {
return callConfig(layerConfigOverride).find(({ groupId }) => groupId === 'y');
}
function callConfigForBreakdownConfigs(layerConfigOverride: Partial<LayerConfig>) {
return callConfig(layerConfigOverride).find(({ groupId }) => groupId === 'breakdown');
}
function callConfigAndFindYConfig(
layerConfigOverride: Partial<LayerConfig>,
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', () => {

View file

@ -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<State> => ({
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[],

View file

@ -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', () => {