[Lens][Table] Add color by terms with color mappings (#189895)

Adds support for coloring Table cells by terms with color mapping assignments. Supported for both `Rows` and `Metric` dimensions.
This commit is contained in:
Nick Partridge 2024-08-27 21:06:54 -05:00 committed by GitHub
parent 7944c1963d
commit a994629331
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 1933 additions and 842 deletions

View file

@ -15,22 +15,26 @@ import { Container } from './components/container/container';
import { ColorMapping } from './config';
import { uiReducer } from './state/ui';
export interface ColorMappingInputCategoricalData {
type: 'categories';
/** an ORDERED array of categories rendered in the visualization */
categories: Array<string | string[]>;
}
export interface ColorMappingInputContinuousData {
type: 'ranges';
min: number;
max: number;
bins: number;
}
/**
* A configuration object that is required to populate correctly the visible categories
* or the ranges in the CategoricalColorMapping component
*/
export type ColorMappingInputData =
| {
type: 'categories';
/** an ORDERED array of categories rendered in the visualization */
categories: Array<string | string[]>;
}
| {
type: 'ranges';
min: number;
max: number;
bins: number;
};
| ColorMappingInputCategoricalData
| ColorMappingInputContinuousData;
/**
* The props of the CategoricalColorMapping component

View file

@ -74,7 +74,7 @@ export function getColorFactory(
})
: [];
// find all categories that doesn't match with an assignment
// find all categories that don't match with an assignment
const notAssignedCategories =
data.type === 'categories'
? data.categories.filter((category) => {

View file

@ -41,7 +41,7 @@ export function rangeMatch(rule: ColorMapping.RuleRange, value: number) {
}
// TODO: move in some data/table related package
export const SPECIAL_TOKENS_STRING_CONVERTION = new Map([
export const SPECIAL_TOKENS_STRING_CONVERSION = new Map([
[
'__other__',
i18n.translate('coloring.colorMapping.terms.otherBucketLabel', {
@ -55,3 +55,9 @@ export const SPECIAL_TOKENS_STRING_CONVERTION = new Map([
}),
],
]);
/**
* Returns special string for sake of color mapping/syncing
*/
export const getSpecialString = (value: string) =>
SPECIAL_TOKENS_STRING_CONVERSION.get(value) ?? value;

View file

@ -121,7 +121,7 @@ export function Assignment({
css={
!disableDelete
? css`
color: ${euiThemeVars.euiTextSubduedColor};
color: ${euiThemeVars.euiTextColor};
transition: ${euiThemeVars.euiAnimSpeedFast} ease-in-out;
transition-property: color;
&:hover,

View file

@ -73,6 +73,7 @@ export const Match: React.FC<{
return (
<EuiFlexItem style={{ minWidth: 1, width: 1 }}>
<EuiComboBox
isClearable
data-test-subj={`lns-colorMapping-assignmentsItem${index}`}
fullWidth={true}
aria-label={i18n.translate('coloring.colorMapping.assignments.autoAssignedTermAriaLabel', {
@ -82,7 +83,7 @@ export const Match: React.FC<{
placeholder={i18n.translate(
'coloring.colorMapping.assignments.autoAssignedTermPlaceholder',
{
defaultMessage: 'Auto assigned',
defaultMessage: 'Auto assigning term',
}
)}
options={convertedOptions}
@ -103,7 +104,6 @@ export const Match: React.FC<{
}
}}
isCaseSensitive
isClearable={false}
compressed
/>
</EuiFlexItem>

View file

@ -7,14 +7,7 @@
*/
import React, { useState } from 'react';
import {
EuiButtonEmpty,
EuiPopoverTitle,
EuiTab,
EuiTabs,
EuiTitle,
EuiHorizontalRule,
} from '@elastic/eui';
import { EuiButtonEmpty, EuiPopoverTitle, EuiTab, EuiTabs, EuiHorizontalRule } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ColorMapping } from '../../config';
import { getPalette } from '../../palettes';
@ -56,22 +49,14 @@ export function ColorPicker({
>
<EuiTabs size="m" expand>
<EuiTab onClick={() => setTab('palette')} isSelected={tab === 'palette'}>
<EuiTitle size="xxxs">
<span>
{i18n.translate('coloring.colorMapping.colorPicker.paletteTabLabel', {
defaultMessage: 'Colors',
})}
</span>
</EuiTitle>
{i18n.translate('coloring.colorMapping.colorPicker.paletteTabLabel', {
defaultMessage: 'Colors',
})}
</EuiTab>
<EuiTab onClick={() => setTab('custom')} isSelected={tab === 'custom'}>
<EuiTitle size="xxxs">
<span>
{i18n.translate('coloring.colorMapping.colorPicker.customTabLabel', {
defaultMessage: 'Custom',
})}
</span>
</EuiTitle>
{i18n.translate('coloring.colorMapping.colorPicker.customTabLabel', {
defaultMessage: 'Custom',
})}
</EuiTab>
</EuiTabs>
</EuiPopoverTitle>

View file

@ -124,15 +124,14 @@ export const ColorSwatch = ({
css={css`
&::after {
content: '';
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-top: 4px solid ${colorIsDark ? 'white' : 'black'};
margin: 0;
bottom: 2px;
background-color: ${colorIsDark ? 'white' : 'black'};
// custom arrowDown svg
mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI3IiBoZWlnaHQ9IjQiIHZpZXdCb3g9IjAgMCA3IDQiPgogIDxwYXRoIGQ9Ik0uMTQ2LjE0N2EuNS41IDAgMCAxIC43MDggMEwzLjUgMi43OTQgNi4xNDYuMTQ3YS41LjUgMCAxIDEgLjcwOC43MDhsLTMgM2EuNS41IDAgMCAxLS43MDggMGwtMy0zYS41LjUgMCAwIDEgMC0uNzA4WiIvPgo8L3N2Zz4K');
height: 4px;
width: 7px;
bottom: 6px;
right: 4px;
position: absolute;
right: 2px;
}
`}
/>

View file

@ -12,8 +12,9 @@ import {
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiText,
EuiTitle,
EuiToolTip,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ColorMapping } from '../../config';
@ -50,13 +51,14 @@ export function PaletteColors({
<>
<EuiFlexGroup direction="column" style={{ padding: 8 }}>
<EuiFlexItem>
<EuiText size="s">
<strong>
<EuiTitle size="xxxs">
<h6>
{i18n.translate('coloring.colorMapping.colorPicker.paletteColorsLabel', {
defaultMessage: 'Palette colors',
})}
</strong>
</EuiText>
</h6>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup
direction="row"
gutterSize="s"
@ -84,22 +86,26 @@ export function PaletteColors({
<EuiHorizontalRule margin="xs" />
<EuiFlexGroup style={{ padding: 8, paddingTop: 0 }}>
<EuiFlexItem>
<EuiText size="s">
<strong>
<EuiTitle size="xxxs">
<h6>
{i18n.translate('coloring.colorMapping.colorPicker.themeAwareColorsLabel', {
defaultMessage: 'Neutral colors',
})}
</strong>
<EuiToolTip
position="bottom"
content={i18n.translate('coloring.colorMapping.colorPicker.themeAwareColorsTooltip', {
defaultMessage:
'The provided neutral colors are theme-aware and will change appropriately when switching between light and dark themes.',
})}
>
<EuiIcon tabIndex={0} type="questionInCircle" />
</EuiToolTip>
</EuiText>
<EuiToolTip
position="bottom"
content={i18n.translate(
'coloring.colorMapping.colorPicker.themeAwareColorsTooltip',
{
defaultMessage:
'The provided neutral colors are theme-aware and will change appropriately when switching between light and dark themes.',
}
)}
>
<EuiIcon tabIndex={0} type="questionInCircle" />
</EuiToolTip>
</h6>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup
direction="row"
gutterSize="s"

View file

@ -149,7 +149,7 @@ export function AssignmentsConfig({
return (
<EuiPanel
color="subdued"
borderRadius="none"
borderRadius="m"
hasShadow={false}
paddingSize="none"
css={css`
@ -195,7 +195,7 @@ export function AssignmentsConfig({
'coloring.colorMapping.container.mapValuesPromptDescription.mapValuesPromptDetail',
{
defaultMessage:
'Add new assignments to begin associating terms in your data with specified colors.',
'Add a new assignment to manually associate terms with specified colors.',
}
)}
</p>
@ -214,7 +214,6 @@ export function AssignmentsConfig({
</EuiButton>,
<EuiButtonEmpty
data-test-subj="lns-colorMapping-assignmentsPromptAddAll"
color="primary"
size="xs"
onClick={onClickAddAllCurrentCategories}
>
@ -228,13 +227,14 @@ export function AssignmentsConfig({
</EuiFlexGroup>
</div>
{assignments.length > 0 && <EuiHorizontalRule margin="none" />}
<div
css={css`
padding: ${euiThemeVars.euiPanelPaddingModifiers.paddingSmall};
overflow: hidden;
`}
>
{assignments.length > 0 && (
{assignments.length > 0 && (
<div
css={css`
padding: ${euiThemeVars.euiPanelPaddingModifiers.paddingSmall};
overflow: hidden;
`}
>
<EuiFlexGroup
direction="row"
alignItems="center"
@ -251,6 +251,7 @@ export function AssignmentsConfig({
button={
<EuiButtonIcon
iconType="boxesVertical"
color="text"
aria-label={i18n.translate(
'coloring.colorMapping.container.OpenAdditionalActionsButtonLabel',
{
@ -308,7 +309,9 @@ export function AssignmentsConfig({
setShowOtherActions(false);
dispatch(removeAllAssignments());
}}
color="danger"
css={css`
color: ${euiThemeVars.euiColorDanger};
`}
>
{i18n.translate(
'coloring.colorMapping.container.clearAllAssignmentsButtonLabel',
@ -322,8 +325,8 @@ export function AssignmentsConfig({
</EuiPopover>
)}
</EuiFlexGroup>
)}
</div>
</div>
)}
</EuiPanel>
);
}

View file

@ -81,6 +81,7 @@ export function Container({
>
<EuiButtonIcon
data-test-subj="lns-colorMapping-invertGradient"
color="text"
iconType="merge"
size="xs"
aria-label={i18n.translate(

View file

@ -107,6 +107,7 @@ export function ScaleMode({ getPaletteFn }: { getPaletteFn: ReturnType<typeof ge
>
<EuiButtonGroup
legend="Mode"
buttonSize="compressed"
data-test-subj="lns_colorMapping_scaleSwitch"
options={[
{

View file

@ -6,12 +6,17 @@
* Side Public License, v 1.
*/
export { CategoricalColorMapping, type ColorMappingProps } from './categorical_color_mapping';
export {
CategoricalColorMapping,
type ColorMappingProps,
type ColorMappingInputCategoricalData,
type ColorMappingInputContinuousData,
} from './categorical_color_mapping';
export type { ColorMappingInputData } from './categorical_color_mapping';
export type { ColorMapping } from './config';
export * from './palettes';
export * from './color/color_handling';
export { SPECIAL_TOKENS_STRING_CONVERTION } from './color/rule_matching';
export { SPECIAL_TOKENS_STRING_CONVERSION, getSpecialString } from './color/rule_matching';
export {
DEFAULT_COLOR_MAPPING_CONFIG,
DEFAULT_OTHER_ASSIGNMENT_INDEX,

View file

@ -77,9 +77,9 @@ export function PalettePicker({
}) {
const palettesToShow: EuiColorPalettePickerPaletteProps[] = palettes
.getAll()
.filter(({ internal, canDynamicColoring }) =>
showDynamicColorOnly ? canDynamicColoring : !internal
)
.filter(({ internal, canDynamicColoring }) => {
return showDynamicColorOnly ? canDynamicColoring && !internal : !internal;
})
.map(({ id, title, getCategoricalColors }) => {
const colors = getCategoricalColors(
activePalette?.params?.steps || DEFAULT_COLOR_STEPS,

View file

@ -55,7 +55,7 @@ pageLoadAssetSize:
expressionLegacyMetricVis: 23121
expressionMetric: 22238
expressionMetricVis: 23121
expressionPartitionVis: 29700
expressionPartitionVis: 44888
expressionRepeatImage: 22341
expressionRevealImage: 25675
expressions: 140958

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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DatatableRow } from '@kbn/expressions-plugin/common';
import { getColorCategories } from './color_categories';
const extensions = ['gz', 'css', '', 'rpm', 'deb', 'zip', null];
const getExtension = (i: number) => extensions[i % extensions.length];
const basicDatatableRows: DatatableRow[] = Array.from({ length: 30 }).map((_, i) => ({
count: i,
extension: getExtension(i),
}));
const isTransposedDatatableRows: DatatableRow[] = Array.from({ length: 30 }).map((_, i) => ({
count: i,
['safari---extension']: getExtension(i),
['chrome---extension']: getExtension(i + 1),
['firefox---extension']: getExtension(i + 2),
}));
describe('getColorCategories', () => {
it('should return all categories from datatable rows', () => {
expect(getColorCategories(basicDatatableRows, 'extension')).toEqual([
'gz',
'css',
'',
'rpm',
'deb',
'zip',
'null',
]);
});
it('should exclude selected categories from datatable rows', () => {
expect(getColorCategories(basicDatatableRows, 'extension', false, ['', null])).toEqual([
'gz',
'css',
'rpm',
'deb',
'zip',
]);
});
it('should return categories across all transpose columns of datatable rows', () => {
expect(getColorCategories(isTransposedDatatableRows, 'extension', true)).toEqual([
'gz',
'css',
'',
'rpm',
'deb',
'zip',
'null',
]);
});
it('should exclude selected categories across all transpose columns of datatable rows', () => {
expect(getColorCategories(isTransposedDatatableRows, 'extension', true, ['', null])).toEqual([
'gz',
'css',
'rpm',
'deb',
'zip',
]);
});
});

View file

@ -15,27 +15,38 @@ import { isMultiFieldKey } from '@kbn/data-plugin/common';
*/
export function getColorCategories(
rows: DatatableRow[],
accessor?: string
accessor?: string,
isTransposed?: boolean,
exclude?: any[]
): Array<string | string[]> {
return accessor
? rows.reduce<{ keys: Set<string>; categories: Array<string | string[]> }>(
(acc, r) => {
const value = r[accessor];
if (value === undefined) {
return acc;
}
const ids = isTransposed
? Object.keys(rows[0]).filter((key) => accessor && key.endsWith(accessor))
: accessor
? [accessor]
: [];
return rows
.flatMap((r) =>
ids
.map((id) => r[id])
.filter((v) => !(v === undefined || exclude?.includes(v)))
.map((v) => {
// The categories needs to be stringified in their unformatted version.
// We can't distinguish between a number and a string from a text input and the match should
// work with both numeric field values and string values.
const key = (isMultiFieldKey(value) ? [...value.keys] : [value]).map(String);
const key = (isMultiFieldKey(v) ? v.keys : [v]).map(String);
const stringifiedKeys = key.join(',');
if (!acc.keys.has(stringifiedKeys)) {
acc.keys.add(stringifiedKeys);
acc.categories.push(key.length === 1 ? key[0] : key);
}
return acc;
},
{ keys: new Set(), categories: [] }
).categories
: [];
return { key, stringifiedKeys };
})
)
.reduce<{ keys: Set<string>; categories: Array<string | string[]> }>(
(acc, { key, stringifiedKeys }) => {
if (!acc.keys.has(stringifiedKeys)) {
acc.keys.add(stringifiedKeys);
acc.categories.push(key.length === 1 ? key[0] : key);
}
return acc;
},
{ keys: new Set(), categories: [] }
).categories;
}

View file

@ -1747,8 +1747,8 @@ exports[`XYChart component it renders area 1`] = `
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -3302,8 +3302,8 @@ exports[`XYChart component it renders bar 1`] = `
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -4857,8 +4857,8 @@ exports[`XYChart component it renders horizontal bar 1`] = `
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -6412,8 +6412,8 @@ exports[`XYChart component it renders line 1`] = `
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -7967,8 +7967,8 @@ exports[`XYChart component it renders stacked area 1`] = `
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -9522,8 +9522,8 @@ exports[`XYChart component it renders stacked bar 1`] = `
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -11077,8 +11077,8 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = `
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -12858,8 +12858,8 @@ exports[`XYChart component split chart should render split chart if both, splitR
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -14646,8 +14646,8 @@ exports[`XYChart component split chart should render split chart if splitColumnA
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}
@ -16432,8 +16432,8 @@ exports[`XYChart component split chart should render split chart if splitRowAcce
minBarHeight={1}
paletteService={
Object {
"get": [Function],
"getAll": [Function],
"get": [MockFunction],
"getAll": [MockFunction],
}
}
shouldShowValueLabels={true}

View file

@ -48,8 +48,8 @@ interface Props {
endValue?: EndValue | undefined;
paletteService: PaletteRegistry;
formattedDatatables: DatatablesWithFormatInfo;
syncColors?: boolean;
timeZone?: string;
syncColors: boolean;
timeZone: string;
emphasizeFitting?: boolean;
fillOpacity?: number;
minBarHeight: number;

View file

@ -98,7 +98,6 @@ export const getAllSeries = (
/**
* This function joins every data series name available on each layer by the same color palette.
* The returned function `getRank` should return the position of a series name in this unified list by palette.
*
*/
export function getColorAssignments(
layers: CommonXYLayerConfig[],

View file

@ -28,7 +28,7 @@ import {
getPalette,
AVAILABLE_PALETTES,
NeutralPalette,
SPECIAL_TOKENS_STRING_CONVERTION,
SPECIAL_TOKENS_STRING_CONVERSION,
} from '@kbn/coloring';
import { getColorCategories } from '@kbn/chart-expressions-common';
import { isDataLayer } from '../../common/utils/layer_types_guards';
@ -53,10 +53,10 @@ type GetSeriesPropsFn = (config: {
colorAssignments: ColorAssignments;
columnToLabelMap: Record<string, string>;
paletteService: PaletteRegistry;
syncColors?: boolean;
yAxis?: GroupsConfiguration[number];
xAxis?: GroupsConfiguration[number];
timeZone?: string;
syncColors: boolean;
timeZone: string;
emphasizeFitting?: boolean;
fillOpacity?: number;
formattedDatatableInfo: DatatableWithFormatInfo;
@ -324,7 +324,7 @@ const getColor: GetColorFn = (
series,
{ layer, colorAssignments, paletteService, syncColors, getSeriesNameFn },
uiState,
singleTable
isSingleTable
) => {
const overwriteColor = getSeriesColor(layer, series.yAccessor as string);
if (overwriteColor !== null) {
@ -333,7 +333,7 @@ const getColor: GetColorFn = (
const name = getSeriesNameFn(series)?.toString() || '';
const overwriteColors: Record<string, string> = uiState?.get ? uiState.get('vis.colors', {}) : {};
const overwriteColors: Record<string, string> = uiState?.get?.('vis.colors', {}) ?? {};
if (Object.keys(overwriteColors).includes(name)) {
return overwriteColors[name];
@ -344,7 +344,7 @@ const getColor: GetColorFn = (
{
name,
totalSeriesAtDepth: colorAssignment.totalSeriesCount,
rankAtDepth: colorAssignment.getRank(singleTable ? 'commonLayerId' : layer.layerId, name),
rankAtDepth: colorAssignment.getRank(isSingleTable ? 'commonLayerId' : layer.layerId, name),
},
];
return paletteService.get(layer.palette.name).getCategoricalColor(
@ -493,7 +493,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({
// if colorMapping exist then we can apply it, if not let's use the legacy coloring method
layer.colorMapping && splitColumnIds.length > 0
? getColorSeriesAccessorFn(
JSON.parse(layer.colorMapping), // the color mapping is at this point just a strinfigied JSON
JSON.parse(layer.colorMapping), // the color mapping is at this point just a stringified JSON
getPalette(AVAILABLE_PALETTES, NeutralPalette),
isDarkMode,
{
@ -501,7 +501,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({
categories: getColorCategories(table.rows, splitColumnIds[0]),
},
splitColumnIds[0],
SPECIAL_TOKENS_STRING_CONVERTION
SPECIAL_TOKENS_STRING_CONVERSION
)
: (series) =>
getColor(

View file

@ -74,9 +74,10 @@ export const getPaletteRegistry = () => {
};
return {
get: (name: string) =>
name === 'custom' ? mockPalette3 : name !== 'default' ? mockPalette2 : mockPalette1,
getAll: () => [mockPalette1, mockPalette2, mockPalette3],
get: jest.fn((name: string) =>
name === 'custom' ? mockPalette3 : name !== 'default' ? mockPalette2 : mockPalette1
),
getAll: jest.fn(() => [mockPalette1, mockPalette2, mockPalette3]),
};
};

View file

@ -24,6 +24,8 @@ import {
EuiTitle,
EuiCallOut,
EuiSwitch,
EuiText,
EuiIconTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DashboardContainerInput } from '../../../../common';
@ -269,12 +271,47 @@ export const DashboardSettings = ({ onClose }: DashboardSettingsProps) => {
<>
<EuiFormRow>
<EuiSwitch
label={i18n.translate(
'dashboard.embeddableApi.showSettings.flyout.form.syncColorsBetweenPanelsSwitchLabel',
{
defaultMessage: 'Sync color palettes across panels',
}
)}
label={
<EuiText size="s">
{i18n.translate(
'dashboard.embeddableApi.showSettings.flyout.form.syncColorsBetweenPanelsSwitchLabel',
{
defaultMessage: 'Sync color palettes across panels',
}
)}{' '}
<EuiIconTip
color="subdued"
content={
<FormattedMessage
id="dashboard.embeddableApi.showSettings.flyout.form.syncColorsBetweenPanelsSwitchHelp"
defaultMessage="Only valid for {default} and {compatibility} palettes"
values={{
default: (
<strong>
{i18n.translate('dashboard.palettes.defaultPaletteLabel', {
defaultMessage: 'Default',
})}
</strong>
),
compatibility: (
<strong>
{i18n.translate('dashboard.palettes.kibanaPaletteLabel', {
defaultMessage: 'Compatibility',
})}
</strong>
),
}}
/>
}
iconProps={{
className: 'eui-alignTop',
}}
position="top"
size="s"
type="questionInCircle"
/>
</EuiText>
}
checked={dashboardSettingsState.syncColors}
onChange={(event) => updateDashboardSetting({ syncColors: event.target.checked })}
data-test-subj="dashboardSyncColorsCheckbox"

View file

@ -1,3 +1,7 @@
.ffString__emptyValue {
color: $euiColorDarkShade;
}
.lnsTableCell--colored .ffString__emptyValue {
color: unset;
}

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import type { ExecutionContext } from '@kbn/expressions-plugin/common';
import type { FormatFactory, RowHeightMode } from '../../types';
import type { ColumnConfigArg } from './datatable_column';
import type { DatatableColumnResult } from './datatable_column';
import type { DatatableExpressionFunction } from './types';
export interface SortingState {
@ -24,7 +24,7 @@ export interface PagingState {
export interface DatatableArgs {
title: string;
description?: string;
columns: ColumnConfigArg[];
columns: DatatableColumnResult[];
sortingColumnId: SortingState['columnId'];
sortingDirection: SortingState['direction'];
fitRowToContent?: boolean;

View file

@ -6,23 +6,25 @@
*/
import type { Direction } from '@elastic/eui';
import type { PaletteOutput, CustomPaletteParams } from '@kbn/coloring';
import type { PaletteOutput, CustomPaletteParams, ColorMapping } from '@kbn/coloring';
import type { CustomPaletteState } from '@kbn/charts-plugin/common';
import type { ExpressionFunctionDefinition, DatatableColumn } from '@kbn/expressions-plugin/common';
import type { SortingHint } from '../../types';
import { CollapseFunction } from '../collapse';
const LENS_DATATABLE_COLUMN = 'lens_datatable_column';
export type LensGridDirection = 'none' | Direction;
export interface ColumnConfig {
columns: ColumnConfigArg[];
export interface DatatableColumnConfig {
columns: DatatableColumnResult[];
sortingColumnId: string | undefined;
sortingDirection: LensGridDirection;
}
export type ColumnConfigArg = Omit<ColumnState, 'palette'> & {
type: 'lens_datatable_column';
export type DatatableColumnArgs = Omit<ColumnState, 'palette' | 'colorMapping'> & {
palette?: PaletteOutput<CustomPaletteState>;
colorMapping?: string;
summaryRowValue?: unknown;
sortingHint?: SortingHint;
};
@ -41,6 +43,7 @@ export interface ColumnState {
bucketValues?: Array<{ originalBucketColumn: DatatableColumn; value: unknown }>;
alignment?: 'left' | 'right' | 'center';
palette?: PaletteOutput<CustomPaletteParams>;
colorMapping?: ColorMapping.Config;
colorMode?: 'none' | 'cell' | 'text';
summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max';
summaryLabel?: string;
@ -48,18 +51,21 @@ export interface ColumnState {
isMetric?: boolean;
}
export type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' };
export type DatatableColumnFunction = ExpressionFunctionDefinition<
'lens_datatable_column',
export type DatatableColumnResult = DatatableColumnArgs & {
type: typeof LENS_DATATABLE_COLUMN;
};
export type DatatableColumnFn = ExpressionFunctionDefinition<
typeof LENS_DATATABLE_COLUMN,
null,
ColumnState & { sortingHint?: SortingHint },
DatatableColumnArgs,
DatatableColumnResult
>;
export const datatableColumn: DatatableColumnFunction = {
name: 'lens_datatable_column',
export const datatableColumn: DatatableColumnFn = {
name: LENS_DATATABLE_COLUMN,
aliases: [],
type: 'lens_datatable_column',
type: LENS_DATATABLE_COLUMN,
help: '',
inputTypes: ['null'],
args: {
@ -76,12 +82,16 @@ export const datatableColumn: DatatableColumnFunction = {
types: ['palette'],
help: '',
},
colorMapping: {
types: ['string'],
help: '',
},
summaryRow: { types: ['string'], help: '' },
summaryLabel: { types: ['string'], help: '' },
},
fn: function fn(input: unknown, args: ColumnState) {
fn: function fn(input, args) {
return {
type: 'lens_datatable_column',
type: LENS_DATATABLE_COLUMN,
...args,
};
},

View file

@ -72,6 +72,7 @@ export const datatableFn =
value: {
data: table,
untransposedData,
syncColors: context.isSyncColorsEnabled?.() ?? false,
args: {
...args,
title: (context.variables.embeddableTitle as string) ?? args.title,

View file

@ -7,5 +7,6 @@
export * from './datatable_column';
export * from './datatable';
export { isTransposeId, getOriginalId } from './transpose_helpers';
export type { DatatableProps, DatatableExpressionFunction } from './types';

View file

@ -72,7 +72,7 @@ describe('Summary row helpers', () => {
it(`should return formatted value for a ${op} summary function`, () => {
expect(
computeSummaryRowForColumn(
{ summaryRow: op, columnId: 'myColumn', type: 'lens_datatable_column' },
{ summaryRow: op, columnId: 'myColumn' },
mockNumericTable,
{
myColumn: customNumericFormatter,
@ -86,7 +86,7 @@ describe('Summary row helpers', () => {
it('should ignore the column formatter, rather return the raw value for count operation', () => {
expect(
computeSummaryRowForColumn(
{ summaryRow: 'count', columnId: 'myColumn', type: 'lens_datatable_column' },
{ summaryRow: 'count', columnId: 'myColumn' },
mockNumericTable,
{
myColumn: customNumericFormatter,
@ -99,7 +99,7 @@ describe('Summary row helpers', () => {
it('should only count non-null/empty values', () => {
expect(
computeSummaryRowForColumn(
{ summaryRow: 'count', columnId: 'myColumn', type: 'lens_datatable_column' },
{ summaryRow: 'count', columnId: 'myColumn' },
{ ...mockNumericTable, rows: [...mockNumericTable.rows, { myColumn: null }] },
{
myColumn: customNumericFormatter,
@ -112,7 +112,7 @@ describe('Summary row helpers', () => {
it('should count numeric arrays as valid and distinct values', () => {
expect(
computeSummaryRowForColumn(
{ summaryRow: 'count', columnId: 'myColumn', type: 'lens_datatable_column' },
{ summaryRow: 'count', columnId: 'myColumn' },
mockNumericTableWithArray,
{
myColumn: defaultFormatter,

View file

@ -8,15 +8,15 @@
import { i18n } from '@kbn/i18n';
import type { FieldFormat } from '@kbn/field-formats-plugin/common';
import type { Datatable } from '@kbn/expressions-plugin/common';
import { ColumnConfigArg } from './datatable_column';
import { DatatableColumnArgs } from './datatable_column';
import { getOriginalId } from './transpose_helpers';
import { isNumericFieldForDatatable } from './utils';
type SummaryRowType = Extract<ColumnConfigArg['summaryRow'], string>;
type SummaryRowType = Extract<DatatableColumnArgs['summaryRow'], string>;
export function getFinalSummaryConfiguration(
columnId: string,
columnArgs: Pick<ColumnConfigArg, 'summaryRow' | 'summaryLabel'> | undefined,
columnArgs: Pick<DatatableColumnArgs, 'summaryRow' | 'summaryLabel'> | undefined,
table: Datatable | undefined
) {
const isNumeric = isNumericFieldForDatatable(table, columnId);
@ -87,13 +87,13 @@ export function getSummaryRowOptions(): Array<{
/** @internal **/
export function computeSummaryRowForColumn(
columnArgs: ColumnConfigArg,
columnArgs: DatatableColumnArgs,
table: Datatable,
formatters: Record<string, FieldFormat>,
defaultFormatter: FieldFormat
) {
const summaryValue = computeFinalValue(columnArgs.summaryRow, columnArgs.columnId, table.rows);
// ignore the coluymn formatter for the count case
// ignore the column formatter for the count case
if (columnArgs.summaryRow === 'count') {
return defaultFormatter.convert(summaryValue);
}
@ -101,7 +101,7 @@ export function computeSummaryRowForColumn(
}
function computeFinalValue(
type: ColumnConfigArg['summaryRow'],
type: DatatableColumnArgs['summaryRow'],
columnId: string,
rows: Datatable['rows']
) {

View file

@ -8,16 +8,20 @@
import type { Datatable, DatatableColumn, DatatableRow } from '@kbn/expressions-plugin/common';
import type { FieldFormat } from '@kbn/field-formats-plugin/common';
import type { DatatableArgs } from './datatable';
import type { ColumnConfig, ColumnConfigArg } from './datatable_column';
import type { DatatableColumnConfig, DatatableColumnArgs } from './datatable_column';
const TRANSPOSE_SEPARATOR = '---';
const TRANSPOSE_VISUAL_SEPARATOR = '';
function getTransposeId(value: string, columnId: string) {
export function getTransposeId(value: string, columnId: string) {
return `${value}${TRANSPOSE_SEPARATOR}${columnId}`;
}
export function isTransposeId(id: string): boolean {
return id.split(TRANSPOSE_SEPARATOR).length > 1;
}
export function getOriginalId(id: string) {
if (id.includes(TRANSPOSE_SEPARATOR)) {
const idParts = id.split(TRANSPOSE_SEPARATOR);
@ -87,11 +91,11 @@ export function transposeTable(
function transposeRows(
firstTable: Datatable,
bucketsColumnArgs: ColumnConfigArg[],
bucketsColumnArgs: DatatableColumnArgs[],
formatters: Record<string, FieldFormat>,
transposedColumnFormatter: FieldFormat,
transposedColumnId: string,
metricsColumnArgs: ColumnConfigArg[]
metricsColumnArgs: DatatableColumnArgs[]
) {
const rowsByBucketColumns: Record<string, DatatableRow[]> = groupRowsByBucketColumns(
firstTable,
@ -113,8 +117,8 @@ function transposeRows(
*/
function updateColumnArgs(
args: DatatableArgs,
bucketsColumnArgs: ColumnConfig['columns'],
transposedColumnGroups: Array<ColumnConfig['columns']>
bucketsColumnArgs: DatatableColumnConfig['columns'],
transposedColumnGroups: Array<DatatableColumnConfig['columns']>
) {
args.columns = [...bucketsColumnArgs];
// add first column from each group, then add second column for each group, ...
@ -151,8 +155,8 @@ function getUniqueValues(table: Datatable, formatter: FieldFormat, columnId: str
*/
function transposeColumns(
args: DatatableArgs,
bucketsColumnArgs: ColumnConfig['columns'],
metricColumns: ColumnConfig['columns'],
bucketsColumnArgs: DatatableColumnConfig['columns'],
metricColumns: DatatableColumnConfig['columns'],
firstTable: Datatable,
uniqueValues: string[],
uniqueRawValues: unknown[],
@ -196,10 +200,10 @@ function transposeColumns(
*/
function mergeRowGroups(
rowsByBucketColumns: Record<string, DatatableRow[]>,
bucketColumns: ColumnConfigArg[],
bucketColumns: DatatableColumnArgs[],
formatter: FieldFormat,
transposedColumnId: string,
metricColumns: ColumnConfigArg[]
metricColumns: DatatableColumnArgs[]
) {
return Object.values(rowsByBucketColumns).map((rows) => {
const mergedRow: DatatableRow = {};
@ -222,7 +226,7 @@ function mergeRowGroups(
*/
function groupRowsByBucketColumns(
firstTable: Datatable,
bucketColumns: ColumnConfigArg[],
bucketColumns: DatatableColumnArgs[],
formatters: Record<string, FieldFormat>
) {
const rowsByBucketColumns: Record<string, DatatableRow[]> = {};

View file

@ -10,6 +10,7 @@ import type { DatatableArgs } from './datatable';
export interface DatatableProps {
data: Datatable;
syncColors: boolean;
untransposedData?: Datatable;
args: DatatableArgs;
}

View file

@ -8,10 +8,11 @@
import type { Datatable } from '@kbn/expressions-plugin/common';
import { getOriginalId } from './transpose_helpers';
export function isNumericFieldForDatatable(currentData: Datatable | undefined, accessor: string) {
const column = currentData?.columns.find(
(col) => col.id === accessor || getOriginalId(col.id) === accessor
);
return column?.meta.type === 'number';
export function isNumericFieldForDatatable(table: Datatable | undefined, accessor: string) {
return getFieldTypeFromDatatable(table, accessor) === 'number';
}
export function getFieldTypeFromDatatable(table: Datatable | undefined, accessor: string) {
return table?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor)
?.meta.type;
}

View file

@ -499,7 +499,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
position="left"
size="s"
type="dot"
color="warning"
color={euiTheme.colors.warning}
/>
</EuiFlexItem>
)}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getColorAccessorFn } from './color_mapping_accessor';
jest.mock('@kbn/coloring', () => ({
...jest.requireActual('@kbn/coloring'),
getColorFactory: jest
.fn()
.mockReturnValue((v: string | number) => (v === '123' ? 'blue' : 'red')),
}));
describe('getColorAccessorFn', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getColorAccessor = getColorAccessorFn('{}', {} as any, false);
it('should return null for null values', () => {
expect(getColorAccessor(null)).toBe(null);
});
it('should return null for undefined values', () => {
expect(getColorAccessor(undefined)).toBe(null);
});
it('should return stringified value for numbers', () => {
expect(getColorAccessor(123)).toBe('blue');
});
it('should return color for string values', () => {
expect(getColorAccessor('testing')).toBe('red');
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 {
AVAILABLE_PALETTES,
getColorFactory,
getPalette,
NeutralPalette,
ColorMappingInputCategoricalData,
} from '@kbn/coloring';
import { CellColorFn } from './get_cell_color_fn';
/**
* Return a color accessor function for XY charts depending on the split accessors received.
*/
export function getColorAccessorFn(
colorMapping: string,
data: ColorMappingInputCategoricalData,
isDarkMode: boolean
): CellColorFn {
const getColor = getColorFactory(
JSON.parse(colorMapping),
getPalette(AVAILABLE_PALETTES, NeutralPalette),
isDarkMode,
data
);
return (value) => {
if (value === undefined || value === null) return null;
return getColor(String(value));
};
}

View file

@ -0,0 +1,141 @@
/*
* 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, { MutableRefObject, useState } from 'react';
import {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSpacer,
EuiSwitch,
EuiText,
} from '@elastic/eui';
import {
ColorMapping,
DEFAULT_COLOR_MAPPING_CONFIG,
CategoricalColorMapping,
SPECIAL_TOKENS_STRING_CONVERSION,
AVAILABLE_PALETTES,
PaletteOutput,
PaletteRegistry,
CustomPaletteParams,
} from '@kbn/coloring';
import { i18n } from '@kbn/i18n';
import { trackUiCounterEvents } from '../../lens_ui_telemetry';
import { PalettePicker } from '../palette_picker';
import { PalettePanelContainer } from './palette_panel_container';
import { getColorStops } from './utils';
interface ColorMappingByTermsProps {
isDarkMode: boolean;
colorMapping?: ColorMapping.Config;
palette?: PaletteOutput<CustomPaletteParams>;
isInlineEditing?: boolean;
setPalette: (palette: PaletteOutput) => void;
setColorMapping: (colorMapping?: ColorMapping.Config) => void;
paletteService: PaletteRegistry;
panelRef: MutableRefObject<HTMLDivElement | null>;
categories: Array<string | string[]>;
}
export function ColorMappingByTerms({
isDarkMode,
colorMapping,
palette,
isInlineEditing,
setPalette,
setColorMapping,
paletteService,
panelRef,
categories,
}: ColorMappingByTermsProps) {
const [useNewColorMapping, setUseNewColorMapping] = useState(Boolean(colorMapping));
return (
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionLabel', {
defaultMessage: 'Color mapping',
})}
style={{ alignItems: 'center' }}
fullWidth
>
<PalettePanelContainer
palette={getColorStops(paletteService, isDarkMode, palette, colorMapping)}
siblingRef={panelRef}
title={
useNewColorMapping
? i18n.translate('xpack.lens.colorMapping.editColorMappingTitle', {
defaultMessage: 'Assign colors to terms',
})
: i18n.translate('xpack.lens.colorMapping.editColorsTitle', {
defaultMessage: 'Edit colors',
})
}
isInlineEditing={isInlineEditing}
>
<div
data-test-subj="lns-palettePanel-terms"
className="lnsPalettePanel__section lnsPalettePanel__section--shaded lnsIndexPatternDimensionEditor--padded"
>
<EuiFlexGroup direction="column" gutterSize="s" justifyContent="flexStart">
<EuiFlexItem>
<EuiSwitch
label={
<EuiText size="xs">
<span>
{i18n.translate('xpack.lens.colorMapping.tryLabel', {
defaultMessage: 'Use the new Color Mapping feature',
})}{' '}
<EuiBadge color="hollow">
{i18n.translate('xpack.lens.colorMapping.techPreviewLabel', {
defaultMessage: 'Tech preview',
})}
</EuiBadge>
</span>
</EuiText>
}
data-test-subj="lns_colorMappingOrLegacyPalette_switch"
compressed
checked={useNewColorMapping}
onChange={({ target: { checked } }) => {
trackUiCounterEvents(`color_mapping_switch_${checked ? 'enabled' : 'disabled'}`);
setColorMapping(checked ? { ...DEFAULT_COLOR_MAPPING_CONFIG } : undefined);
setUseNewColorMapping(checked);
}}
/>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem>
{useNewColorMapping ? (
<CategoricalColorMapping
isDarkMode={isDarkMode}
model={colorMapping ?? { ...DEFAULT_COLOR_MAPPING_CONFIG }}
onModelUpdate={setColorMapping}
specialTokens={SPECIAL_TOKENS_STRING_CONVERSION}
palettes={AVAILABLE_PALETTES}
data={{
type: 'categories',
categories,
}}
/>
) : (
<PalettePicker
palettes={paletteService}
activePalette={palette}
setPalette={setPalette}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</div>
</PalettePanelContainer>
</EuiFormRow>
);
}

View file

@ -0,0 +1,73 @@
/*
* 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, { MutableRefObject } from 'react';
import { EuiFormRow } from '@elastic/eui';
import {
PaletteOutput,
PaletteRegistry,
CustomizablePalette,
DataBounds,
CustomPaletteParams,
} from '@kbn/coloring';
import { i18n } from '@kbn/i18n';
import { PalettePanelContainer } from './palette_panel_container';
interface ColorMappingByValuesProps {
palette: PaletteOutput<CustomPaletteParams>;
isInlineEditing?: boolean;
setPalette: (palette: PaletteOutput<CustomPaletteParams>) => void;
paletteService: PaletteRegistry;
panelRef: MutableRefObject<HTMLDivElement | null>;
dataBounds?: DataBounds;
}
export function ColorMappingByValues<T>({
palette,
isInlineEditing,
setPalette,
paletteService,
panelRef,
dataBounds,
}: ColorMappingByValuesProps) {
const colors = palette.params?.stops?.map(({ color }) => color) ?? [];
return (
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionLabel', {
defaultMessage: 'Color mapping',
})}
style={{ alignItems: 'center' }}
fullWidth
>
<PalettePanelContainer
palette={colors}
siblingRef={panelRef}
title={i18n.translate('xpack.lens.colorMapping.editColorsTitle', {
defaultMessage: 'Edit colors',
})}
isInlineEditing={isInlineEditing}
>
<div
data-test-subj="lns-palettePanel-values"
className="lnsPalettePanel__section lnsPalettePanel__section--shaded lnsIndexPatternDimensionEditor--padded"
>
<CustomizablePalette
palettes={paletteService}
dataBounds={dataBounds}
activePalette={palette}
setPalette={(p) => {
setPalette(p);
}}
/>
</div>
</PalettePanelContainer>
</EuiFormRow>
);
}

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ColorMappingInputData,
PaletteOutput,
PaletteRegistry,
getSpecialString,
} from '@kbn/coloring';
import { CustomPaletteState } from '@kbn/charts-plugin/common';
import { getColorAccessorFn } from './color_mapping_accessor';
export type CellColorFn = (value?: number | string | null) => string | null;
export function getCellColorFn(
paletteService: PaletteRegistry,
data: ColorMappingInputData,
colorByTerms: boolean,
isDarkMode: boolean,
syncColors: boolean,
palette?: PaletteOutput<CustomPaletteState>,
colorMapping?: string
): CellColorFn {
if (!colorByTerms && palette && data.type === 'ranges') {
return (value) => {
if (value === null || value === undefined || typeof value !== 'number') return null;
return (
paletteService.get(palette.name).getColorForValue?.(value, palette.params, data) ?? null
);
};
}
if (colorByTerms && data.type === 'categories') {
if (colorMapping) {
return getColorAccessorFn(colorMapping, data, isDarkMode);
} else if (palette) {
return (category) => {
if (category === undefined || category === null) return null;
const strCategory = String(category); // can be a number as a string
return paletteService.get(palette.name).getCategoricalColor(
[
{
name: getSpecialString(strCategory), // needed to sync special categories (i.e. '')
rankAtDepth: Math.max(
data.categories.findIndex((v) => v === strCategory),
0
),
totalSeriesAtDepth: data.categories.length || 1,
},
],
{
maxDepth: 1,
totalSeries: data.categories.length || 1,
behindText: false,
syncColors,
},
palette?.params ?? { colors: [] }
);
};
}
}
return () => null;
}

View file

@ -6,7 +6,12 @@
*/
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { applyPaletteParams, findMinMaxByColumnId, getContrastColor } from './utils';
import {
applyPaletteParams,
findMinMaxByColumnId,
getContrastColor,
shouldColorByTerms,
} from './utils';
describe('applyPaletteParams', () => {
const paletteRegistry = chartPluginMock.createPaletteRegistry();
@ -108,3 +113,17 @@ describe('findMinMaxByColumnId', () => {
).toEqual({ b: { min: 2, max: 53 } });
});
});
describe('shouldColorByTerms', () => {
it('should return true if bucketed regardless of value', () => {
expect(shouldColorByTerms('number', true)).toBe(true);
});
it('should return false if not bucketed and numeric', () => {
expect(shouldColorByTerms('number', false)).toBe(false);
});
it('should return true if not bucketed and non-numeric', () => {
expect(shouldColorByTerms('string', false)).toBe(true);
});
});

View file

@ -17,8 +17,40 @@ import {
getPaletteStops,
CUSTOM_PALETTE,
enforceColorContrast,
ColorMapping,
getColorsFromMapping,
DEFAULT_FALLBACK_PALETTE,
} from '@kbn/coloring';
import { Datatable } from '@kbn/expressions-plugin/common';
import { Datatable, DatatableColumnType } from '@kbn/expressions-plugin/common';
import { DataType } from '../../types';
/**
* Returns array of colors for provided palette or colorMapping
*/
export function getColorStops(
paletteService: PaletteRegistry,
isDarkMode: boolean,
palette?: PaletteOutput<CustomPaletteParams>,
colorMapping?: ColorMapping.Config
): string[] {
return colorMapping
? getColorsFromMapping(isDarkMode, colorMapping)
: palette?.name === CUSTOM_PALETTE
? palette?.params?.stops?.map(({ color }) => color) ?? []
: paletteService
.get(palette?.name || DEFAULT_FALLBACK_PALETTE)
.getCategoricalColors(10, palette);
}
/**
* Bucketed numerical columns should be treated as categorical
*/
export function shouldColorByTerms(
dataType?: DataType | DatatableColumnType,
isBucketed?: boolean
) {
return isBucketed || dataType !== 'number';
}
export function getContrastColor(
color: string,
@ -37,11 +69,8 @@ export function getContrastColor(
return enforceColorContrast(color, backgroundColor) ? lightColor : darkColor;
}
export function getNumericValue(rowValue: number | number[] | undefined) {
if (rowValue == null || Array.isArray(rowValue)) {
return;
}
return rowValue;
export function getNumericValue(rowValue?: unknown) {
return typeof rowValue === 'number' ? rowValue : undefined;
}
export function applyPaletteParams<T extends PaletteOutput<CustomPaletteParams>>(

View file

@ -12,17 +12,14 @@ import { EuiColorPalettePicker, EuiColorPalettePickerPaletteProps } from '@elast
import { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function PalettePicker({
palettes,
activePalette,
setPalette,
}: {
interface PalettePickerProps<T> {
palettes: PaletteRegistry;
activePalette?: PaletteOutput;
activePalette?: PaletteOutput<T>;
setPalette: (palette: PaletteOutput) => void;
}) {
const paletteName = getActivePaletteName(activePalette?.name);
}
export function PalettePicker<T>({ palettes, activePalette, setPalette }: PalettePickerProps<T>) {
const paletteName = getActivePaletteName(activePalette?.name);
const palettesToShow: EuiColorPalettePickerPaletteProps[] = palettes
.getAll()
.filter(({ internal }) => !internal)
@ -34,6 +31,7 @@ export function PalettePicker({
palette: getCategoricalColors(10, id === paletteName ? activePalette?.params : undefined),
};
});
return (
<EuiFormRow
fullWidth

View file

@ -620,6 +620,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
const {
datasourceState: syncedDatasourceState,
visualizationState: syncedVisualizationState,
frame,
} = syncLinkedDimensions(
currentState,
visualizationMap,
@ -627,7 +628,11 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
payload.datasourceId
);
state.visualization.state = syncedVisualizationState;
const visualization = visualizationMap[state.visualization.activeId!];
state.visualization.state =
visualization.onDatasourceUpdate?.(syncedVisualizationState, frame) ??
syncedVisualizationState;
state.datasourceStates[payload.datasourceId].state = syncedDatasourceState;
})
.addCase(updateVisualizationState, (state, { payload }) => {
@ -1227,14 +1232,14 @@ function syncLinkedDimensions(
const linkedDimensions = activeVisualization.getLinkedDimensions?.(visualizationState);
const frame = selectFramePublicAPI({ lens: state }, datasourceMap);
const getDimensionGroups = (layerId: string) =>
activeVisualization.getConfiguration({
state: visualizationState,
layerId,
frame,
}).groups;
if (linkedDimensions) {
const getDimensionGroups = (layerId: string) =>
activeVisualization.getConfiguration({
state: visualizationState,
layerId,
frame,
}).groups;
const idAssuredLinks = linkedDimensions.map((link) => ({
...link,
to: { ...link.to, columnId: link.to.columnId ?? generateId() },
@ -1276,5 +1281,5 @@ function syncLinkedDimensions(
});
}
return { datasourceState, visualizationState };
return { datasourceState, visualizationState, frame };
}

View file

@ -1317,6 +1317,8 @@ export interface Visualization<T = unknown, P = T, ExtraAppendLayerArg = unknown
*/
onEditAction?: (state: T, event: LensEditEvent<LensEditSupportedActions>) => T;
onDatasourceUpdate?: (state: T, frame?: FramePublicAPI) => T;
/**
* Some visualization track indexPattern changes (i.e. annotations)
* This method makes it aware of the change and produces a new updated state

View file

@ -7,7 +7,7 @@
import { createDatatableUtilitiesMock } from '@kbn/data-plugin/common/mocks';
import { Datatable } from '@kbn/expressions-plugin/public';
import { getUniqueLabelGenerator, inferTimeField, renewIDs } from './utils';
import { getUniqueLabelGenerator, inferTimeField, isLensRange, renewIDs } from './utils';
const datatableUtilities = createDatatableUtilitiesMock();
@ -187,4 +187,24 @@ describe('utils', () => {
expect([' ', ' '].map(labelGenerator)).toEqual(['[Untitled]', '[Untitled] [1]']);
});
});
describe('isRange', () => {
it.each<[expected: boolean, input: unknown]>([
[true, { from: 0, to: 100, label: '' }],
[true, { from: 0, to: null, label: '' }],
[true, { from: null, to: 100, label: '' }],
[false, { from: 0, to: 100 }],
[false, { from: 0, to: null }],
[false, { from: null, to: 100 }],
[false, { from: 0 }],
[false, { to: 100 }],
[false, null],
[false, undefined],
[false, 123],
[false, 'string'],
[false, {}],
])('should return %s for %j', (expected, input) => {
expect(isLensRange(input)).toBe(expected);
});
});
});

View file

@ -39,6 +39,7 @@ import {
import type { DatasourceStates, VisualizationState } from './state_management';
import type { IndexPatternServiceAPI } from './data_views_service/service';
import { COLOR_MAPPING_OFF_BY_DEFAULT } from '../common/constants';
import type { RangeTypeLens } from './datasources/form_based/operations/definitions/ranges';
export function getVisualizeGeoFieldMessage(fieldType: string) {
return i18n.translate('xpack.lens.visualizeGeoFieldMessage', {
@ -47,6 +48,17 @@ export function getVisualizeGeoFieldMessage(fieldType: string) {
});
}
export const isLensRange = (range: unknown = {}): range is RangeTypeLens => {
if (!range || typeof range !== 'object') return false;
const { from, to, label } = range as RangeTypeLens;
return (
label !== undefined &&
(typeof from === 'number' || from === null) &&
(typeof to === 'number' || to === null)
);
};
export const getResolvedDateRange = function (timefilter: TimefilterContract) {
const { from, to } = timefilter.getTime();
return { fromDate: from, toDate: to };

View file

@ -10,13 +10,16 @@ import { DataContext } from './table_basic';
import { createGridCell } from './cell_value';
import type { FieldFormat } from '@kbn/field-formats-plugin/common';
import { Datatable } from '@kbn/expressions-plugin/public';
import { coreMock } from '@kbn/core/public/mocks';
import { DatatableArgs, ColumnConfigArg } from '../../../../common/expressions';
import { DatatableArgs } from '../../../../common/expressions';
import { DataContextType } from './types';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { render, screen } from '@testing-library/react';
import { getTransposeId } from '../../../../common/expressions/datatable/transpose_helpers';
describe('datatable cell renderer', () => {
const innerCellColorFnMock = jest.fn().mockReturnValue('blue');
const cellColorFnMock = jest.fn().mockReturnValue(innerCellColorFnMock);
const setCellProps = jest.fn();
const table: Datatable = {
type: 'datatable',
columns: [
@ -30,18 +33,16 @@ describe('datatable cell renderer', () => {
],
rows: [{ a: 123 }],
};
const { theme: setUpMockTheme } = coreMock.createSetup();
const CellRenderer = createGridCell(
{
a: { convert: (x) => `formatted ${x}` } as FieldFormat,
},
{ columns: [], sortingColumnId: '', sortingDirection: 'none' },
DataContext,
setUpMockTheme
false,
cellColorFnMock
);
const setCellProps = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
@ -101,7 +102,8 @@ describe('datatable cell renderer', () => {
},
{ columns: [], sortingColumnId: '', sortingDirection: 'none' },
DataContext,
setUpMockTheme,
false,
cellColorFnMock,
true
);
render(
@ -137,7 +139,8 @@ describe('datatable cell renderer', () => {
sortingDirection: 'none',
},
DataContext,
setUpMockTheme,
false,
cellColorFnMock,
true
);
render(
@ -156,9 +159,6 @@ describe('datatable cell renderer', () => {
});
describe('dynamic coloring', () => {
const paletteRegistry = chartPluginMock.createPaletteRegistry();
const customPalette = paletteRegistry.get('custom');
function getCellRenderer(columnConfig: DatatableArgs) {
return createGridCell(
{
@ -166,7 +166,8 @@ describe('datatable cell renderer', () => {
},
columnConfig,
DataContext,
setUpMockTheme
false,
cellColorFnMock
);
}
function getColumnConfiguration(): DatatableArgs {
@ -189,7 +190,7 @@ describe('datatable cell renderer', () => {
},
},
type: 'lens_datatable_column',
} as ColumnConfigArg,
},
],
sortingColumnId: '',
sortingDirection: 'none',
@ -207,7 +208,7 @@ describe('datatable cell renderer', () => {
<CellRendererWithPalette
rowIndex={0}
colIndex={0}
columnId="a"
columnId={columnConfig.columns[0].columnId}
setCellProps={setCellProps}
isExpandable={false}
isDetails={false}
@ -216,8 +217,7 @@ describe('datatable cell renderer', () => {
{
wrapper: DataContextProviderWrapper({
table,
minMaxByColumnId: { a: { min: 12, max: 155 /* > 123 */ } },
getColorForValue: customPalette.getColorForValue,
minMaxByColumnId: { a: { min: 12, max: 155 } },
...context,
}),
}
@ -241,6 +241,27 @@ describe('datatable cell renderer', () => {
});
});
it('should call getCellColor with full columnId of transpose column', () => {
const columnId = getTransposeId('test', 'a');
const columnConfig = getColumnConfiguration();
columnConfig.columns[0].colorMode = 'cell';
columnConfig.columns[0].columnId = columnId;
renderCellComponent(columnConfig, {
table: {
...table,
columns: [
{
...table.columns[0],
id: columnId,
},
],
},
});
expect(cellColorFnMock.mock.calls[0][0]).toBe(columnId);
});
it('should set the coloring of the text when enabled', () => {
const columnConfig = getColumnConfiguration();
columnConfig.columns[0].colorMode = 'text';
@ -252,14 +273,23 @@ describe('datatable cell renderer', () => {
});
});
it('should not color the cell when the value is an array', () => {
it('should not color the cell when color function returns null', () => {
setCellProps.mockClear();
innerCellColorFnMock.mockReturnValueOnce(null);
const columnConfig = getColumnConfiguration();
columnConfig.columns[0].colorMode = 'cell';
renderCellComponent(columnConfig, {
table: { ...table, rows: [{ a: [10, 123] }] },
});
renderCellComponent(columnConfig, {});
expect(setCellProps).not.toHaveBeenCalled();
});
it('should not color the cell when color function returns empty string', () => {
innerCellColorFnMock.mockReturnValueOnce('');
const columnConfig = getColumnConfiguration();
columnConfig.columns[0].colorMode = 'cell';
renderCellComponent(columnConfig, {});
expect(setCellProps).not.toHaveBeenCalled();
});

View file

@ -6,63 +6,73 @@
*/
import React, { useContext, useEffect } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { EuiDataGridCellValueElementProps, EuiLink } from '@elastic/eui';
import type { CoreSetup } from '@kbn/core/public';
import classNames from 'classnames';
import { PaletteOutput } from '@kbn/coloring';
import { CustomPaletteState } from '@kbn/charts-plugin/common';
import type { FormatFactory } from '../../../../common/types';
import { getOriginalId } from '../../../../common/expressions/datatable/transpose_helpers';
import type { ColumnConfig } from '../../../../common/expressions';
import type { DatatableColumnConfig } from '../../../../common/expressions';
import type { DataContextType } from './types';
import { getContrastColor, getNumericValue } from '../../../shared_components/coloring/utils';
import { getContrastColor } from '../../../shared_components/coloring/utils';
import { CellColorFn } from '../../../shared_components/coloring/get_cell_color_fn';
import { isLensRange } from '../../../utils';
const getParsedValue = (v: unknown) => {
if (v == null || typeof v === 'number') {
return v;
}
if (isLensRange(v)) {
return v.toString();
}
return String(v);
};
export const createGridCell = (
formatters: Record<string, ReturnType<FormatFactory>>,
columnConfig: ColumnConfig,
columnConfig: DatatableColumnConfig,
DataContext: React.Context<DataContextType>,
theme: CoreSetup['theme'],
isDarkMode: boolean,
getCellColor: (
originalId: string,
palette?: PaletteOutput<CustomPaletteState>,
colorMapping?: string
) => CellColorFn,
fitRowToContent?: boolean
) => {
return ({ rowIndex, columnId, setCellProps, isExpanded }: EuiDataGridCellValueElementProps) => {
const { table, alignments, minMaxByColumnId, getColorForValue, handleFilterClick } =
useContext(DataContext);
const IS_DARK_THEME: boolean = useObservable(theme.theme$, { darkMode: false }).darkMode;
const rowValue = table?.rows[rowIndex]?.[columnId];
const { table, alignments, handleFilterClick } = useContext(DataContext);
const rawRowValue = table?.rows[rowIndex]?.[columnId];
const rowValue = getParsedValue(rawRowValue);
const colIndex = columnConfig.columns.findIndex(({ columnId: id }) => id === columnId);
const { colorMode = 'none', palette, oneClickFilter } = columnConfig.columns[colIndex] || {};
const {
oneClickFilter,
colorMode = 'none',
palette,
colorMapping,
} = columnConfig.columns[colIndex] ?? {};
const filterOnClick = oneClickFilter && handleFilterClick;
const content = formatters[columnId]?.convert(rowValue, filterOnClick ? 'text' : 'html');
const content = formatters[columnId]?.convert(rawRowValue, filterOnClick ? 'text' : 'html');
const currentAlignment = alignments && alignments[columnId];
useEffect(() => {
let colorSet = false;
const originalId = getOriginalId(columnId);
if (minMaxByColumnId?.[originalId]) {
if (colorMode !== 'none' && palette?.params && getColorForValue) {
// workout the bucket the value belongs to
const color = getColorForValue(
getNumericValue(rowValue),
palette.params,
minMaxByColumnId[originalId]
);
if (color) {
const style = { [colorMode === 'cell' ? 'backgroundColor' : 'color']: color };
if (colorMode === 'cell' && color) {
style.color = getContrastColor(color, IS_DARK_THEME);
}
colorSet = true;
setCellProps({
style,
});
if (colorMode !== 'none' && (palette || colorMapping)) {
const color = getCellColor(columnId, palette, colorMapping)(rowValue);
if (color) {
const style = { [colorMode === 'cell' ? 'backgroundColor' : 'color']: color };
if (colorMode === 'cell' && color) {
style.color = getContrastColor(color, isDarkMode);
}
colorSet = true;
setCellProps({ style });
}
}
// Clean up styles when something changes, this avoids cell's styling to stick forever
// Checks isExpanded to prevent clearing style after expanding cell
if (colorMode !== 'none' && minMaxByColumnId?.[originalId] && colorSet && !isExpanded) {
if (colorSet && !isExpanded) {
return () => {
setCellProps({
style: {
@ -72,17 +82,7 @@ export const createGridCell = (
});
};
}
}, [
rowValue,
columnId,
setCellProps,
colorMode,
palette,
minMaxByColumnId,
getColorForValue,
IS_DARK_THEME,
isExpanded,
]);
}, [rowValue, columnId, setCellProps, colorMode, palette, colorMapping, isExpanded]);
if (filterOnClick) {
return (
@ -95,7 +95,7 @@ export const createGridCell = (
>
<EuiLink
onClick={() => {
handleFilterClick?.(columnId, rowValue, colIndex, rowIndex);
handleFilterClick?.(columnId, rawRowValue, colIndex, rowIndex);
}}
>
{content}
@ -103,6 +103,7 @@ export const createGridCell = (
</div>
);
}
return (
<div
/*
@ -113,6 +114,7 @@ export const createGridCell = (
data-test-subj="lnsTableCellContent"
className={classNames({
'lnsTableCell--multiline': fitRowToContent,
'lnsTableCell--colored': colorMode !== 'none',
[`lnsTableCell--${currentAlignment}`]: true,
})}
/>

View file

@ -18,7 +18,7 @@ import { EuiDataGridColumnCellAction } from '@elastic/eui/src/components/datagri
import { FILTER_CELL_ACTION_TYPE } from '@kbn/cell-actions/constants';
import type { FormatFactory } from '../../../../common/types';
import { RowHeightMode } from '../../../../common/types';
import type { ColumnConfig } from '../../../../common/expressions';
import type { DatatableColumnConfig } from '../../../../common/expressions';
import { LensCellValueAction } from '../../../types';
import { buildColumnsMetaLookup } from './helpers';
import { DEFAULT_HEADER_ROW_HEIGHT } from './constants';
@ -46,7 +46,7 @@ export const createGridColumns = (
) => void)
| undefined,
isReadOnly: boolean,
columnConfig: ColumnConfig,
columnConfig: DatatableColumnConfig,
visibleColumns: string[],
formatFactory: FormatFactory,
onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void,

View file

@ -6,28 +6,36 @@
*/
import React from 'react';
import type { PaletteRegistry } from '@kbn/coloring';
import { render, screen } from '@testing-library/react';
import { DEFAULT_COLOR_MAPPING_CONFIG, type PaletteRegistry } from '@kbn/coloring';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { getSelectedButtonInGroup } from '@kbn/test-eui-helpers';
import { EuiButtonGroupTestHarness } from '@kbn/test-eui-helpers';
import {
FramePublicAPI,
OperationDescriptor,
VisualizationDimensionEditorProps,
DatasourcePublicAPI,
DataType,
} from '../../../types';
import { DatatableVisualizationState } from '../visualization';
import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks';
import { TableDimensionEditor } from './dimension_editor';
import { ColumnState } from '../../../../common/expressions';
import { capitalize } from 'lodash';
describe('data table dimension editor', () => {
let frame: FramePublicAPI;
let state: DatatableVisualizationState;
let setState: (newState: DatatableVisualizationState) => void;
let btnGroups: {
colorMode: EuiButtonGroupTestHarness;
alignment: EuiButtonGroupTestHarness;
};
let mockOperationForFirstColumn: (overrides?: Partial<OperationDescriptor>) => void;
let props: VisualizationDimensionEditorProps<DatatableVisualizationState> & {
paletteService: PaletteRegistry;
isDarkMode: boolean;
};
function testState(): DatatableVisualizationState {
@ -42,7 +50,19 @@ describe('data table dimension editor', () => {
};
}
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
btnGroups = {
colorMode: new EuiButtonGroupTestHarness('lnsDatatable_dynamicColoring_groups'),
alignment: new EuiButtonGroupTestHarness('lnsDatatable_alignment_groups'),
};
state = testState();
frame = createMockFramePublicAPI();
frame.datasourceLayers = {
@ -63,21 +83,32 @@ describe('data table dimension editor', () => {
rows: [],
},
};
setState = jest.fn();
props = {
accessor: 'foo',
frame,
groupId: 'columns',
layerId: 'first',
state,
setState,
setState: jest.fn(),
isDarkMode: false,
paletteService: chartPluginMock.createPaletteRegistry(),
panelRef: React.createRef(),
addLayer: jest.fn(),
removeLayer: jest.fn(),
datasource: {} as DatasourcePublicAPI,
};
mockOperationForFirstColumn = (overrides: Partial<OperationDescriptor> = {}) => {
frame!.datasourceLayers!.first!.getOperationForColumnId = jest.fn().mockReturnValue({
label: 'label',
isBucketed: false,
dataType: 'string',
hasTimeShift: false,
hasReducedTimeRange: false,
...overrides,
} satisfies OperationDescriptor);
};
mockOperationForFirstColumn();
});
const renderTableDimensionEditor = (
@ -99,19 +130,19 @@ describe('data table dimension editor', () => {
it('should render default alignment', () => {
renderTableDimensionEditor();
expect(getSelectedButtonInGroup('lnsDatatable_alignment_groups')()).toHaveTextContent('Left');
expect(btnGroups.alignment.selected).toHaveTextContent('Left');
});
it('should render default alignment for number', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
mockOperationForFirstColumn({ dataType: 'number' });
renderTableDimensionEditor();
expect(getSelectedButtonInGroup('lnsDatatable_alignment_groups')()).toHaveTextContent('Right');
expect(btnGroups.alignment.selected).toHaveTextContent('Right');
});
it('should render specific alignment', () => {
state.columns[0].alignment = 'center';
renderTableDimensionEditor();
expect(getSelectedButtonInGroup('lnsDatatable_alignment_groups')()).toHaveTextContent('Center');
expect(btnGroups.alignment.selected).toHaveTextContent('Center');
});
it('should set state for the right column', () => {
@ -125,7 +156,8 @@ describe('data table dimension editor', () => {
];
renderTableDimensionEditor();
userEvent.click(screen.getByRole('button', { name: 'Center' }));
expect(setState).toHaveBeenCalledWith({
jest.advanceTimersByTime(256);
expect(props.setState).toHaveBeenCalledWith({
...state,
columns: [
{
@ -139,44 +171,49 @@ describe('data table dimension editor', () => {
});
});
it('should not show the dynamic coloring option for non numeric columns', () => {
renderTableDimensionEditor();
expect(screen.queryByTestId('lnsDatatable_dynamicColoring_groups')).not.toBeInTheDocument();
expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument();
});
it('should set the dynamic coloring default to "none"', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
state.columns[0].colorMode = undefined;
renderTableDimensionEditor();
expect(getSelectedButtonInGroup('lnsDatatable_dynamicColoring_groups')()).toHaveTextContent(
'None'
);
expect(btnGroups.colorMode.selected).toHaveTextContent('None');
expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument();
});
it('should show the dynamic palette display ony when colorMode is different from "none"', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
state.columns[0].colorMode = 'text';
renderTableDimensionEditor();
expect(getSelectedButtonInGroup('lnsDatatable_dynamicColoring_groups')()).toHaveTextContent(
'Text'
);
expect(screen.getByTestId('lns_dynamicColoring_edit')).toBeInTheDocument();
});
it.each<DataType>(['date'])(
'should not show the dynamic coloring option for "%s" columns',
(dataType) => {
mockOperationForFirstColumn({ dataType });
renderTableDimensionEditor();
expect(screen.queryByTestId('lnsDatatable_dynamicColoring_groups')).not.toBeInTheDocument();
expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument();
}
);
it.each<ColumnState['colorMode']>(['cell', 'text'])(
'should show the palette options ony when colorMode is "%s"',
(colorMode) => {
state.columns[0].colorMode = colorMode;
renderTableDimensionEditor();
expect(btnGroups.colorMode.selected).toHaveTextContent(capitalize(colorMode));
expect(screen.getByTestId('lns_dynamicColoring_edit')).toBeInTheDocument();
}
);
it.each<ColumnState['colorMode']>(['none', undefined])(
'should not show the palette options when colorMode is "%s"',
(colorMode) => {
state.columns[0].colorMode = colorMode;
renderTableDimensionEditor();
expect(btnGroups.colorMode.selected).toHaveTextContent(capitalize(colorMode ?? 'none'));
expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument();
}
);
it('should set the coloring mode to the right column', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
state.columns = [
{
columnId: 'foo',
},
{
columnId: 'bar',
},
];
state.columns = [{ columnId: 'foo' }, { columnId: 'bar' }];
renderTableDimensionEditor();
userEvent.click(screen.getByRole('button', { name: 'Cell' }));
expect(setState).toHaveBeenCalledWith({
jest.advanceTimersByTime(256);
expect(props.setState).toHaveBeenCalledWith({
...state,
columns: [
{
@ -191,31 +228,73 @@ describe('data table dimension editor', () => {
});
});
it('should open the palette panel when "Settings" link is clicked in the palette input', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
it.each<{ flyout: 'terms' | 'values'; isBucketed: boolean; dataType: DataType }>([
{ flyout: 'terms', isBucketed: true, dataType: 'number' },
{ flyout: 'terms', isBucketed: false, dataType: 'string' },
{ flyout: 'values', isBucketed: false, dataType: 'number' },
])(
'should show color by $flyout flyout when bucketing is $isBucketed with $dataType column',
({ flyout, isBucketed, dataType }) => {
state.columns[0].colorMode = 'cell';
mockOperationForFirstColumn({ isBucketed, dataType });
renderTableDimensionEditor();
userEvent.click(screen.getByLabelText('Edit colors'));
expect(screen.getByTestId(`lns-palettePanel-${flyout}`)).toBeInTheDocument();
}
);
it('should show the dynamic coloring option for a bucketed operation', () => {
state.columns[0].colorMode = 'cell';
mockOperationForFirstColumn({ isBucketed: true });
renderTableDimensionEditor();
userEvent.click(screen.getByLabelText('Edit colors'));
expect(screen.getByTestId('lns-palettePanelFlyout')).toBeInTheDocument();
expect(screen.queryByTestId('lnsDatatable_dynamicColoring_groups')).toBeInTheDocument();
expect(screen.queryByTestId('lns_dynamicColoring_edit')).toBeInTheDocument();
});
it('should not show the dynamic coloring option for a bucketed operation', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
const datasourceLayers = frame.datasourceLayers as Record<string, DatasourcePublicAPI>;
datasourceLayers.first.getOperationForColumnId = jest.fn(
() => ({ isBucketed: true } as OperationDescriptor)
);
it('should clear palette and colorMapping when colorMode is set to "none"', () => {
state.columns[0].colorMode = 'cell';
state.columns[0].palette = {
type: 'palette',
name: 'default',
};
state.columns[0].colorMapping = DEFAULT_COLOR_MAPPING_CONFIG;
renderTableDimensionEditor();
expect(screen.queryByTestId('lnsDatatable_dynamicColoring_groups')).not.toBeInTheDocument();
expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument();
act(() => {
// this throws an error about state update even in act()
btnGroups.colorMode.select('None');
});
jest.advanceTimersByTime(256);
expect(props.setState).toBeCalledWith({
...state,
columns: [
expect.objectContaining({
colorMode: 'none',
palette: undefined,
colorMapping: undefined,
}),
],
});
});
it('should not show the summary field for non numeric columns', () => {
renderTableDimensionEditor();
expect(screen.queryByTestId('lnsDatatable_summaryrow_function')).not.toBeInTheDocument();
expect(screen.queryByTestId('lnsDatatable_summaryrow_label')).not.toBeInTheDocument();
[true, false].forEach((isTransposed) => {
it(`should${isTransposed ? ' not' : ''} show hidden switch when column is${
!isTransposed ? ' not' : ''
} transposed`, () => {
state.columns[0].isTransposed = isTransposed;
renderTableDimensionEditor();
const hiddenSwitch = screen.queryByTestId('lns-table-column-hidden');
if (isTransposed) {
expect(hiddenSwitch).not.toBeInTheDocument();
} else {
expect(hiddenSwitch).toBeInTheDocument();
}
});
});
});

View file

@ -5,37 +5,40 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSwitch, EuiButtonGroup, htmlIdGenerator } from '@elastic/eui';
import { CustomizablePalette, PaletteRegistry } from '@kbn/coloring';
import { PaletteRegistry } from '@kbn/coloring';
import { getColorCategories } from '@kbn/chart-expressions-common';
import { useDebouncedValue } from '@kbn/visualization-utils';
import type { VisualizationDimensionEditorProps } from '../../../types';
import type { DatatableVisualizationState } from '../visualization';
import {
applyPaletteParams,
defaultPaletteParams,
PalettePanelContainer,
findMinMaxByColumnId,
shouldColorByTerms,
} from '../../../shared_components';
import { isNumericFieldForDatatable } from '../../../../common/expressions/datatable/utils';
import { getOriginalId } from '../../../../common/expressions/datatable/transpose_helpers';
import './dimension_editor.scss';
import { CollapseSetting } from '../../../shared_components/collapse_setting';
import { ColorMappingByValues } from '../../../shared_components/coloring/color_mapping_by_values';
import { ColorMappingByTerms } from '../../../shared_components/coloring/color_mapping_by_terms';
const idPrefix = htmlIdGenerator()();
type ColumnType = DatatableVisualizationState['columns'][number];
function updateColumnWith(
function updateColumn(
state: DatatableVisualizationState,
columnId: string,
newColumnProps: Partial<ColumnType>
newColumn: Partial<ColumnType>
) {
return state.columns.map((currentColumn) => {
if (currentColumn.columnId === columnId) {
return { ...currentColumn, ...newColumnProps };
return { ...currentColumn, ...newColumn };
} else {
return currentColumn;
}
@ -45,30 +48,41 @@ function updateColumnWith(
export function TableDimensionEditor(
props: VisualizationDimensionEditorProps<DatatableVisualizationState> & {
paletteService: PaletteRegistry;
isDarkMode: boolean;
}
) {
const { state, setState, frame, accessor, isInlineEditing } = props;
const column = state.columns.find(({ columnId }) => accessor === columnId);
const { frame, accessor, isInlineEditing, isDarkMode } = props;
const column = props.state.columns.find(({ columnId }) => accessor === columnId);
const { inputValue: localState, handleInputChange: setLocalState } =
useDebouncedValue<DatatableVisualizationState>({
value: props.state,
onChange: props.setState,
});
const updateColumnState = useCallback(
(columnId: string, newColumn: Partial<ColumnType>) => {
setLocalState({
...localState,
columns: updateColumn(localState, columnId, newColumn),
});
},
[setLocalState, localState]
);
if (!column) return null;
if (column.isTransposed) return null;
const currentData = frame.activeData?.[state.layerId];
// either read config state or use same logic as chart itself
const isNumeric = isNumericFieldForDatatable(currentData, accessor);
const currentAlignment = column?.alignment || (isNumeric ? 'right' : 'left');
const currentData = frame.activeData?.[localState.layerId];
const datasource = frame.datasourceLayers?.[localState.layerId];
const { dataType, isBucketed } = datasource?.getOperationForColumnId(accessor) ?? {};
const showColorByTerms = shouldColorByTerms(dataType, isBucketed);
const currentAlignment = column?.alignment || (dataType === 'number' ? 'right' : 'left');
const currentColorMode = column?.colorMode || 'none';
const hasDynamicColoring = currentColorMode !== 'none';
const showDynamicColoringFeature = dataType !== 'date';
const visibleColumnsCount = localState.columns.filter((c) => !c.hidden).length;
const datasource = frame.datasourceLayers[state.layerId];
const showDynamicColoringFeature = Boolean(
isNumeric && !datasource?.getOperationForColumnId(accessor)?.isBucketed
);
const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length;
const hasTransposedColumn = state.columns.some(({ isTransposed }) => isTransposed);
const hasTransposedColumn = localState.columns.some(({ isTransposed }) => isTransposed);
const columnsToCheck = hasTransposedColumn
? currentData?.columns.filter(({ id }) => getOriginalId(id) === accessor).map(({ id }) => id) ||
[]
@ -76,12 +90,13 @@ export function TableDimensionEditor(
const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData, getOriginalId);
const currentMinMax = minMaxByColumnId[accessor];
const activePalette = column?.palette || {
const activePalette = column?.palette ?? {
type: 'palette',
name: defaultPaletteParams.name,
name: showColorByTerms ? 'default' : defaultPaletteParams.name,
};
// need to tell the helper that the colorStops are required to display
const displayStops = applyPaletteParams(props.paletteService, activePalette, currentMinMax);
const categories = getColorCategories(currentData?.rows ?? [], accessor, false, [null]);
return (
<>
@ -125,10 +140,7 @@ export function TableDimensionEditor(
idSelected={`${idPrefix}${currentAlignment}`}
onChange={(id) => {
const newMode = id.replace(idPrefix, '') as ColumnType['alignment'];
setState({
...state,
columns: updateColumnWith(state, accessor, { alignment: newMode }),
});
updateColumnState(accessor, { alignment: newMode });
}}
/>
</EuiFormRow>
@ -187,45 +199,46 @@ export function TableDimensionEditor(
},
};
}
// clear up when switching to no coloring
if (column?.palette && newMode === 'none') {
if (newMode === 'none') {
params.palette = undefined;
params.colorMapping = undefined;
}
setState({
...state,
columns: updateColumnWith(state, accessor, params),
});
updateColumnState(accessor, params);
}}
/>
</EuiFormRow>
{hasDynamicColoring && (
<EuiFormRow
className="lnsDynamicColoringRow"
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.paletteTableGradient.label', {
defaultMessage: 'Color',
})}
>
<PalettePanelContainer
palette={displayStops.map(({ color }) => color)}
siblingRef={props.panelRef}
{hasDynamicColoring &&
(showColorByTerms ? (
<ColorMappingByTerms
isDarkMode={isDarkMode}
colorMapping={column.colorMapping}
palette={activePalette}
isInlineEditing={isInlineEditing}
>
<CustomizablePalette
palettes={props.paletteService}
activePalette={activePalette}
dataBounds={currentMinMax}
setPalette={(newPalette) => {
setState({
...state,
columns: updateColumnWith(state, accessor, { palette: newPalette }),
});
}}
/>
</PalettePanelContainer>
</EuiFormRow>
)}
setPalette={(palette) => {
updateColumnState(accessor, { palette });
}}
setColorMapping={(colorMapping) => {
updateColumnState(accessor, { colorMapping });
}}
paletteService={props.paletteService}
panelRef={props.panelRef}
categories={categories}
/>
) : (
<ColorMappingByValues
palette={activePalette}
isInlineEditing={isInlineEditing}
setPalette={(newPalette) => {
updateColumnState(accessor, { palette: newPalette });
}}
paletteService={props.paletteService}
panelRef={props.panelRef}
dataBounds={currentMinMax}
/>
))}
</>
)}
{!column.isTransposed && (
@ -247,8 +260,8 @@ export function TableDimensionEditor(
disabled={!column.hidden && visibleColumnsCount <= 1}
onChange={() => {
const newState = {
...state,
columns: state.columns.map((currentColumn) => {
...localState,
columns: localState.columns.map((currentColumn) => {
if (currentColumn.columnId === accessor) {
return {
...currentColumn,
@ -259,7 +272,7 @@ export function TableDimensionEditor(
}
}),
};
setState(newState);
setLocalState(newState);
}}
/>
</EuiFormRow>
@ -283,8 +296,8 @@ export function TableDimensionEditor(
disabled={column.hidden}
onChange={() => {
const newState = {
...state,
columns: state.columns.map((currentColumn) => {
...localState,
columns: localState.columns.map((currentColumn) => {
if (currentColumn.columnId === accessor) {
return {
...currentColumn,
@ -295,7 +308,7 @@ export function TableDimensionEditor(
}
}),
};
setState(newState);
setLocalState(newState);
}}
/>
</EuiFormRow>
@ -323,7 +336,7 @@ export function TableDimensionDataExtraEditor(
onChange={(collapseFn) => {
setState({
...state,
columns: updateColumnWith(state, accessor, { collapseFn }),
columns: updateColumn(state, accessor, { collapseFn }),
});
}}
/>

View file

@ -18,11 +18,11 @@ import {
import { DatatableVisualizationState } from '../visualization';
import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks';
import { TableDimensionEditorAdditionalSection } from './dimension_editor_addtional_section';
import { ColumnState } from '../../../../common/expressions';
describe('data table dimension editor additional section', () => {
let frame: FramePublicAPI;
let state: DatatableVisualizationState;
let setState: (newState: DatatableVisualizationState) => void;
let props: VisualizationDimensionEditorProps<DatatableVisualizationState> & {
paletteService: PaletteRegistry;
};
@ -34,6 +34,7 @@ describe('data table dimension editor additional section', () => {
columns: [
{
columnId: 'foo',
summaryRow: undefined,
},
],
};
@ -53,21 +54,20 @@ describe('data table dimension editor additional section', () => {
id: 'foo',
name: 'foo',
meta: {
type: 'string',
type: 'number',
},
},
],
rows: [],
},
};
setState = jest.fn();
props = {
accessor: 'foo',
frame,
groupId: 'columns',
layerId: 'first',
state,
setState,
setState: jest.fn(),
paletteService: chartPluginMock.createPaletteRegistry(),
panelRef: React.createRef(),
addLayer: jest.fn(),
@ -76,27 +76,50 @@ describe('data table dimension editor additional section', () => {
};
});
it('should set the summary row function default to "none"', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
render(<TableDimensionEditorAdditionalSection {...props} />);
const renderComponent = (
overrideProps?: Partial<
VisualizationDimensionEditorProps<DatatableVisualizationState> & {
paletteService: PaletteRegistry;
}
>
) => {
return render(<TableDimensionEditorAdditionalSection {...props} {...overrideProps} />);
};
it('should set the summary row fn default to "none"', () => {
state.columns[0].summaryRow = undefined;
renderComponent();
expect(screen.getByRole('combobox')).toHaveValue('None');
expect(screen.queryByTestId('lnsDatatable_summaryrow_label')).not.toBeInTheDocument();
});
it('should show the summary row label input ony when summary row is different from "none"', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
state.columns[0].summaryRow = 'sum';
render(<TableDimensionEditorAdditionalSection {...props} />);
expect(screen.getByRole('combobox')).toHaveValue('Sum');
expect(screen.getByTestId('lnsDatatable_summaryrow_label')).toHaveValue('Sum');
});
it.each<[summaryRow: ColumnState['summaryRow'], label: string]>([
['sum', 'Sum'],
['avg', 'Average'],
['count', 'Value count'],
['min', 'Minimum'],
['max', 'Maximum'],
])(
'should show the summary row label input ony when summary row fn is "%s"',
(summaryRow, label) => {
state.columns[0].summaryRow = summaryRow;
renderComponent();
expect(screen.getByRole('combobox')).toHaveValue(label);
expect(screen.getByTestId('lnsDatatable_summaryrow_label')).toHaveValue(label);
}
);
it("should show the correct summary row name when user's changes summary label", () => {
frame.activeData!.first.columns[0].meta.type = 'number';
state.columns[0].summaryRow = 'sum';
state.columns[0].summaryLabel = 'MySum';
render(<TableDimensionEditorAdditionalSection {...props} />);
renderComponent();
expect(screen.getByRole('combobox')).toHaveValue('Sum');
expect(screen.getByTestId('lnsDatatable_summaryrow_label')).toHaveValue('MySum');
});
it('should not show the summary field for non numeric columns', () => {
frame.activeData!.first.columns[0].meta.type = 'string';
expect(screen.queryByTestId('lnsDatatable_summaryrow_function')).not.toBeInTheDocument();
expect(screen.queryByTestId('lnsDatatable_summaryrow_label')).not.toBeInTheDocument();
});
});

View file

@ -21,7 +21,6 @@ import {
getFinalSummaryConfiguration,
getSummaryRowOptions,
} from '../../../../common/expressions/datatable/summary';
import { isNumericFieldForDatatable } from '../../../../common/expressions/datatable/utils';
import './dimension_editor.scss';
@ -76,7 +75,6 @@ export function TableDimensionEditorAdditionalSection(
const currentData = frame.activeData?.[state.layerId];
// either read config state or use same logic as chart itself
const isNumeric = isNumericFieldForDatatable(currentData, accessor);
// when switching from one operation to another, make sure to keep the configuration consistent
const { summaryRow, summaryLabel: fallbackSummaryLabel } = getFinalSummaryConfiguration(

View file

@ -17,9 +17,9 @@ import {
createGridHideHandler,
createTransposeColumnFilterHandler,
} from './table_actions';
import type { LensGridDirection, ColumnConfig } from '../../../../common/expressions';
import type { LensGridDirection, DatatableColumnConfig } from '../../../../common/expressions';
function getDefaultConfig(): ColumnConfig {
function getDefaultConfig(): DatatableColumnConfig {
return {
columns: [
{ columnId: 'a', type: 'lens_datatable_column' },

View file

@ -19,19 +19,15 @@ import { ClickTriggerEvent } from '@kbn/charts-plugin/public';
import { getSortingCriteria } from '@kbn/sort-predicates';
import { i18n } from '@kbn/i18n';
import type { LensResizeAction, LensSortAction, LensToggleAction } from './types';
import type {
ColumnConfig,
ColumnConfigArg,
LensGridDirection,
} from '../../../../common/expressions';
import type { DatatableColumnConfig, LensGridDirection } from '../../../../common/expressions';
import { getOriginalId } from '../../../../common/expressions/datatable/transpose_helpers';
import type { FormatFactory } from '../../../../common/types';
import { buildColumnsMetaLookup } from './helpers';
export const createGridResizeHandler =
(
columnConfig: ColumnConfig,
setColumnConfig: React.Dispatch<React.SetStateAction<ColumnConfig>>,
columnConfig: DatatableColumnConfig,
setColumnConfig: React.Dispatch<React.SetStateAction<DatatableColumnConfig>>,
onEditAction: (data: LensResizeAction['data']) => void
) =>
(eventData: { columnId: string; width: number | undefined }) => {
@ -59,8 +55,8 @@ export const createGridResizeHandler =
export const createGridHideHandler =
(
columnConfig: ColumnConfig,
setColumnConfig: React.Dispatch<React.SetStateAction<ColumnConfig>>,
columnConfig: DatatableColumnConfig,
setColumnConfig: React.Dispatch<React.SetStateAction<DatatableColumnConfig>>,
onEditAction: (data: LensToggleAction['data']) => void
) =>
(eventData: { columnId: string }) => {
@ -177,7 +173,7 @@ function getColumnType({
columnId,
lookup,
}: {
columnConfig: ColumnConfig;
columnConfig: DatatableColumnConfig;
columnId: string;
lookup: Record<
string,
@ -194,11 +190,7 @@ function getColumnType({
export const buildSchemaDetectors = (
columns: EuiDataGridColumn[],
columnConfig: {
columns: ColumnConfigArg[];
sortingColumnId: string | undefined;
sortingDirection: 'none' | 'asc' | 'desc';
},
columnConfig: DatatableColumnConfig,
table: Datatable,
formatters: Record<string, ReturnType<FormatFactory>>
): EuiDataGridSchemaDetector[] => {

View file

@ -20,6 +20,18 @@ import { DatatableComponent } from './table_basic';
import type { DatatableProps } from '../../../../common/expressions';
import { LENS_EDIT_PAGESIZE_ACTION } from './constants';
import { DatatableRenderProps } from './types';
import { PaletteOutput } from '@kbn/coloring';
import { CustomPaletteState } from '@kbn/charts-plugin/common';
import { getCellColorFn } from '../../../shared_components/coloring/get_cell_color_fn';
import { getTransposeId } from '../../../../common/expressions/datatable/transpose_helpers';
jest.mock('../../../shared_components/coloring/get_cell_color_fn', () => {
const mod = jest.requireActual('../../../shared_components/coloring/get_cell_color_fn');
return {
...mod,
getCellColorFn: jest.fn(mod.getCellColorFn),
};
});
const { theme: setUpMockTheme } = coreMock.createSetup();
@ -97,8 +109,12 @@ describe('DatatableComponent', () => {
args = sample.args;
});
afterEach(() => {
jest.clearAllMocks();
});
const renderDatatableComponent = (propsOverrides: Partial<DatatableRenderProps> = {}) => {
const props = {
const props: DatatableRenderProps = {
data,
args,
formatFactory: () => ({ convert: (x) => x } as IFieldFormat),
@ -108,6 +124,7 @@ describe('DatatableComponent', () => {
theme: setUpMockTheme,
renderMode: 'edit' as const,
interactive: true,
syncColors: false,
renderComplete,
...propsOverrides,
};
@ -345,9 +362,9 @@ describe('DatatableComponent', () => {
args: {
...args,
columns: [
{ columnId: 'a', alignment: 'center', type: 'lens_datatable_column' },
{ columnId: 'b', type: 'lens_datatable_column' },
{ columnId: 'c', type: 'lens_datatable_column' },
{ columnId: 'a', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' },
{ columnId: 'b', type: 'lens_datatable_column', colorMode: 'none' },
{ columnId: 'c', type: 'lens_datatable_column', colorMode: 'none' },
],
sortingColumnId: 'b',
sortingDirection: 'desc',
@ -358,44 +375,10 @@ describe('DatatableComponent', () => {
.map((cell) => cell.className);
expect(alignmentsClassNames).toEqual([
// set via args
'lnsTableCell--center',
// default for date
'lnsTableCell--left',
// default for number
'lnsTableCell--right',
'lnsTableCell--center', // set via args
'lnsTableCell--left', // default for date
'lnsTableCell--right', // default for number
]);
// <DatatableComponent
// data={data}
// args={{
// ...args,
// columns: [
// { columnId: 'a', alignment: 'center', type: 'lens_datatable_column' },
// { columnId: 'b', type: 'lens_datatable_column' },
// { columnId: 'c', type: 'lens_datatable_column' },
// ],
// sortingColumnId: 'b',
// sortingDirection: 'desc',
// }}
// formatFactory={() => ({ convert: (x) => x } as IFieldFormat)}
// dispatchEvent={onDispatchEvent}
// getType={jest.fn()}
// renderMode="view"
// paletteService={chartPluginMock.createPaletteRegistry()}
// theme={setUpMockTheme}
// interactive
// renderComplete={renderComplete}
// />
// );
// expect(wrapper.find(DataContext.Provider).prop('value').alignments).toEqual({
// // set via args
// a: 'center',
// // default for date
// b: 'left',
// // default for number
// c: 'right',
// });
});
test('it should refresh the table header when the datatable data changes', () => {
@ -633,4 +616,106 @@ describe('DatatableComponent', () => {
);
});
});
describe('renderCellValue', () => {
describe('getCellColor', () => {
const palette: PaletteOutput<CustomPaletteState> = {
type: 'palette',
name: 'default',
params: {
colors: [],
gradient: false,
stops: [],
range: 'number',
rangeMin: 0,
rangeMax: 100,
},
};
describe('caching', () => {
test('caches getCellColorFn by columnId', () => {
args.columns[0].palette = palette;
args.columns[0].colorMode = 'cell';
data.rows.push(
...[
{ a: 'pants', b: 1588024800000, c: 4 },
{ a: 'hat', b: 1588024800000, c: 5 },
{ a: 'bag', b: 1588024800000, c: 6 },
]
);
renderDatatableComponent();
expect(getCellColorFn).toBeCalledTimes(2); // 2 initial renders of table
});
test('caches getCellColorFn by columnId with transpose columns', () => {
const columnId1 = getTransposeId('a', 'test');
const columnId2 = getTransposeId('b', 'test');
renderDatatableComponent({
data: {
...data,
rows: [{ [columnId1]: 'shoe', [columnId2]: 'hat' }],
columns: [columnId1, columnId2].map((id) => ({
...data.columns[0],
id,
})),
},
args: {
...args,
columns: [columnId1, columnId2].map((columnId) => ({
...args.columns[0],
palette,
colorMode: 'cell',
columnId,
})),
},
});
expect(getCellColorFn).toBeCalledTimes(2); // 2 initial renders of table
});
});
const color = 'red';
test('should correctly color numerical values', () => {
args.columns[0].palette = palette;
args.columns[0].colorMode = 'cell';
(getCellColorFn as jest.Mock).mockReturnValue(() => color);
renderDatatableComponent();
const cellColors = screen
.queryAllByRole('gridcell')
.map((cell) => [cell.textContent, cell.style.backgroundColor]);
expect(cellColors).toEqual([
['shoes- a, column 1, row 1', 'red'],
['1588024800000- b, column 2, row 1', ''],
['3- c, column 3, row 1', ''],
]);
});
test('should correctly color string values', () => {
args.columns[2].palette = palette;
args.columns[2].colorMode = 'cell';
(getCellColorFn as jest.Mock).mockReturnValue(() => color);
renderDatatableComponent();
const cellColors = screen
.queryAllByRole('gridcell')
.map((cell) => [cell.textContent, cell.style.backgroundColor]);
expect(cellColors).toEqual([
['shoes- a, column 1, row 1', ''],
['1588024800000- b, column 2, row 1', ''],
['3- c, column 3, row 1', 'red'],
]);
});
});
});
});

View file

@ -6,7 +6,7 @@
*/
import './table_basic.scss';
import { CUSTOM_PALETTE } from '@kbn/coloring';
import { ColorMappingInputData, PaletteOutput } from '@kbn/coloring';
import React, {
useLayoutEffect,
useCallback,
@ -27,15 +27,17 @@ import {
EuiDataGridSorting,
EuiDataGridStyle,
} from '@elastic/eui';
import { EmptyPlaceholder } from '@kbn/charts-plugin/public';
import { CustomPaletteState, EmptyPlaceholder } from '@kbn/charts-plugin/public';
import { ClickTriggerEvent } from '@kbn/charts-plugin/public';
import { IconChartDatatable } from '@kbn/chart-icons';
import useObservable from 'react-use/lib/useObservable';
import { getColorCategories } from '@kbn/chart-expressions-common';
import type { LensTableRowContextMenuEvent } from '../../../types';
import type { FormatFactory } from '../../../../common/types';
import { RowHeightMode } from '../../../../common/types';
import type { LensGridDirection } from '../../../../common/expressions';
import { getOriginalId, isTransposeId, LensGridDirection } from '../../../../common/expressions';
import { VisualizationContainer } from '../../../visualization_container';
import { findMinMaxByColumnId } from '../../../shared_components';
import { findMinMaxByColumnId, shouldColorByTerms } from '../../../shared_components';
import type {
DataContextType,
DatatableRenderProps,
@ -55,8 +57,9 @@ import {
createTransposeColumnFilterHandler,
} from './table_actions';
import { getFinalSummaryConfiguration } from '../../../../common/expressions/datatable/summary';
import { getOriginalId } from '../../../../common/expressions/datatable/transpose_helpers';
import { DEFAULT_HEADER_ROW_HEIGHT, DEFAULT_HEADER_ROW_HEIGHT_LINES } from './constants';
import { getFieldTypeFromDatatable } from '../../../../common/expressions/datatable/utils';
import { CellColorFn, getCellColorFn } from '../../../shared_components/coloring/get_cell_color_fn';
export const DataContext = React.createContext<DataContextType>({});
@ -72,6 +75,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
const dataGridRef = useRef<EuiDataGridRefProps>(null);
const isInteractive = props.interactive;
const isDarkMode = useObservable(props.theme.theme$, { darkMode: false }).darkMode;
const [columnConfig, setColumnConfig] = useState({
columns: props.args.columns,
@ -144,7 +148,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions?.some((x) => x);
const { getType, dispatchEvent, renderMode, formatFactory } = props;
const { getType, dispatchEvent, renderMode, formatFactory, syncColors } = props;
const formatters: Record<string, ReturnType<FormatFactory>> = useMemo(
() =>
@ -220,7 +224,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
[onClickValue, untransposedDataRef, isInteractive]
);
const bucketColumns = useMemo(
const bucketedColumns = useMemo(
() =>
columnConfig.columns
.filter((_col, index) => {
@ -236,8 +240,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
const isEmpty =
firstLocalTable.rows.length === 0 ||
(bucketColumns.length &&
props.data.rows.every((row) => bucketColumns.every((col) => row[col] == null)));
(bucketedColumns.length &&
props.data.rows.every((row) => bucketedColumns.every((col) => row[col] == null)));
const visibleColumns = useMemo(
() =>
@ -302,7 +306,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
const columns: EuiDataGridColumn[] = useMemo(
() =>
createGridColumns(
bucketColumns,
bucketedColumns,
firstLocalTable,
handleFilterClick,
handleTransposedColumnClick,
@ -320,7 +324,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
props.columnFilterable
),
[
bucketColumns,
bucketedColumns,
firstLocalTable,
handleFilterClick,
handleTransposedColumnClick,
@ -385,17 +389,71 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
isInteractive,
]);
const renderCellValue = useMemo(
() =>
createGridCell(
formatters,
columnConfig,
DataContext,
props.theme,
props.args.fitRowToContent
),
[formatters, columnConfig, props.theme, props.args.fitRowToContent]
);
const renderCellValue = useMemo(() => {
const cellColorFnMap = new Map<string, CellColorFn>();
const getCellColor = (
columnId: string,
palette?: PaletteOutput<CustomPaletteState>,
colorMapping?: string
): CellColorFn => {
const originalId = getOriginalId(columnId); // workout what bucket the value belongs to
if (cellColorFnMap.has(originalId)) {
return cellColorFnMap.get(originalId)!;
}
const dataType = getFieldTypeFromDatatable(firstLocalTable, originalId);
const isBucketed = bucketedColumns.some((id) => id === columnId);
const colorByTerms = shouldColorByTerms(dataType, isBucketed);
const data: ColorMappingInputData = colorByTerms
? {
type: 'categories',
categories: getColorCategories(
firstLocalTable.rows,
originalId,
isTransposeId(columnId),
[null]
),
}
: {
type: 'ranges',
bins: 0,
...minMaxByColumnId[originalId],
};
const colorFn = getCellColorFn(
props.paletteService,
data,
colorByTerms,
isDarkMode,
syncColors,
palette,
colorMapping
);
cellColorFnMap.set(originalId, colorFn);
return colorFn;
};
return createGridCell(
formatters,
columnConfig,
DataContext,
isDarkMode,
getCellColor,
props.args.fitRowToContent
);
}, [
formatters,
columnConfig,
isDarkMode,
props.args.fitRowToContent,
props.paletteService,
firstLocalTable,
bucketedColumns,
minMaxByColumnId,
syncColors,
]);
const columnVisibility = useMemo(
() => ({
@ -471,7 +529,6 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
rowHasRowClickTriggerActions: props.rowHasRowClickTriggerActions,
alignments,
minMaxByColumnId,
getColorForValue: props.paletteService.get(CUSTOM_PALETTE).getColorForValue!,
handleFilterClick,
}}
>

View file

@ -7,7 +7,6 @@
import { CoreSetup } from '@kbn/core/public';
import type { PaletteRegistry } from '@kbn/coloring';
import { CustomPaletteState } from '@kbn/charts-plugin/public';
import type { IAggType } from '@kbn/data-plugin/public';
import type { Datatable, RenderMode } from '@kbn/expressions-plugin/common';
import type {
@ -82,9 +81,4 @@ export interface DataContextType {
rowIndex: number,
negate?: boolean
) => void;
getColorForValue?: (
value: number | undefined,
state: CustomPaletteState,
minMax: { min: number; max: number }
) => string | undefined;
}

View file

@ -18,7 +18,7 @@ const cellValueAction: LensCellValueAction = {
iconType: 'test-icon',
execute: () => {},
};
function sampleArgs() {
function sampleArgs(): DatatableProps {
const indexPatternId = 'indexPatternId';
const data: Datatable = {
type: 'datatable',
@ -80,13 +80,13 @@ function sampleArgs() {
sortingDirection: 'none',
};
return { data, args };
return { data, args, syncColors: false };
}
describe('datatable_expression', () => {
describe('datatable renders', () => {
test('it renders with the specified data and args', async () => {
const { data, args } = sampleArgs();
const { data, args, ...rest } = sampleArgs();
const result = await getDatatable(() => Promise.resolve((() => {}) as FormatFactory)).fn(
data,
args,
@ -96,7 +96,7 @@ describe('datatable_expression', () => {
expect(result).toEqual({
type: 'render',
as: 'lens_datatable_renderer',
value: { data, args },
value: { data, args, ...rest },
});
});
});

View file

@ -168,6 +168,7 @@ export const getDatatableRenderer = (dependencies: {
interactive={isInteractive()}
theme={dependencies.core.theme}
renderComplete={renderComplete}
syncColors={config.syncColors}
/>
</KibanaRenderContextProvider>,
domNode

View file

@ -44,7 +44,7 @@ export class DatatableVisualization {
})
);
return getDatatableVisualization({ paletteService: palettes, theme: core.theme });
return getDatatableVisualization({ paletteService: palettes, kibanaTheme: core.theme });
});
}
}

View file

@ -7,13 +7,7 @@
import { Ast } from '@kbn/interpreter';
import { buildExpression } from '@kbn/expressions-plugin/public';
import {
createMockDatasource,
createMockFramePublicAPI,
DatasourceMock,
generateActiveData,
} from '../../mocks';
import faker from 'faker';
import { createMockDatasource, createMockFramePublicAPI, DatasourceMock } from '../../mocks';
import { DatatableVisualizationState, getDatatableVisualization } from './visualization';
import {
Operation,
@ -27,6 +21,20 @@ import { RowHeightMode } from '../../../common/types';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { themeServiceMock } from '@kbn/core/public/mocks';
import { ColorMapping, CUSTOM_PALETTE, CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import {
ColumnState,
DatatableColumnFn,
DatatableExpressionFunction,
} from '../../../common/expressions';
import { getColorStops } from '../../shared_components/coloring';
jest.mock('../../shared_components/coloring', () => {
return {
...jest.requireActual('../../shared_components/coloring'),
getColorStops: jest.fn().mockReturnValue([]),
};
});
function mockFrame(): FramePublicAPI {
return {
@ -35,12 +43,18 @@ function mockFrame(): FramePublicAPI {
};
}
const datatableVisualization = getDatatableVisualization({
const mockServices = {
paletteService: chartPluginMock.createPaletteRegistry(),
theme: themeServiceMock.createStartContract(),
});
kibanaTheme: themeServiceMock.createStartContract(),
};
const datatableVisualization = getDatatableVisualization(mockServices);
describe('Datatable Visualization', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('#initialize', () => {
it('should initialize from the empty state', () => {
expect(datatableVisualization.initialize(() => 'aaa', undefined)).toEqual({
@ -417,64 +431,122 @@ describe('Datatable Visualization', () => {
});
describe('with palette', () => {
const mockStops = ['red', 'white', 'blue'];
const datasource = createMockDatasource('test');
let params: VisualizationConfigProps<DatatableVisualizationState>;
beforeEach(() => {
const datasource = createMockDatasource('test');
datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'b', fields: [] }]);
params = {
layerId: 'a',
state: {
layerId: 'a',
layerType: LayerTypes.DATA,
columns: [
{
columnId: 'b',
palette: {
type: 'palette' as const,
name: '',
params: { stops: [{ color: 'blue', stop: 0 }] },
},
},
],
},
frame: {
...mockFrame(),
activeData: generateActiveData([
{
id: 'a',
rows: Array(3).fill({
b: faker.random.number(),
}),
},
]),
datasourceLayers: { a: datasource.publicAPIMock },
},
};
(getColorStops as jest.Mock).mockReturnValue(mockStops);
});
it('does include palette for accessor config if the values are numeric and palette exists', () => {
expect(datatableVisualization.getConfiguration(params).groups[2].accessors).toEqual([
{ columnId: 'b', palette: ['blue'], triggerIconType: 'colorBy' },
]);
describe('rows', () => {
beforeEach(() => {
datasource.publicAPIMock.getOperationForColumnId.mockReturnValueOnce({
dataType: 'string',
isBucketed: true,
label: 'label',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
datasource.publicAPIMock.getTableSpec.mockReturnValue([
{ columnId: 'b', fields: [] },
{ columnId: 'c', fields: [] },
]);
params = {
layerId: 'a',
state: {
layerId: 'a',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b' }, { columnId: 'c' }],
},
frame: {
...mockFrame(),
datasourceLayers: { a: datasource.publicAPIMock },
},
};
});
it.each<ColumnState['colorMode']>(['cell', 'text'])(
'should include palette if colorMode is %s and has stops',
(colorMode) => {
params.state.columns[0].colorMode = colorMode;
expect(datatableVisualization.getConfiguration(params).groups[0].accessors).toEqual([
{ columnId: 'b', palette: mockStops, triggerIconType: 'colorBy' },
]);
}
);
it.each<ColumnState['colorMode']>(['cell', 'text'])(
'should not include palette if colorMode is %s but stops is empty',
(colorMode) => {
(getColorStops as jest.Mock).mockReturnValue([]);
params.state.columns[0].colorMode = colorMode;
expect(datatableVisualization.getConfiguration(params).groups[0].accessors).toEqual([
{ columnId: 'b' },
]);
}
);
it.each<ColumnState['colorMode']>(['none', undefined])(
'should not include palette if colorMode is %s even if stops exist',
(colorMode) => {
params.state.columns[0].colorMode = colorMode;
expect(datatableVisualization.getConfiguration(params).groups[0].accessors).toEqual([
{ columnId: 'b' },
]);
}
);
});
it('does not include palette for accessor config if the values are not numeric and palette exists', () => {
params.frame.activeData = generateActiveData([
{
id: 'a',
rows: Array(3).fill({
b: faker.random.word(),
}),
},
]);
expect(datatableVisualization.getConfiguration(params).groups[2].accessors).toEqual([
{ columnId: 'b' },
]);
});
it('does not include palette for accessor config if the values are numeric but palette exists', () => {
params.state.columns[0].palette = undefined;
expect(datatableVisualization.getConfiguration(params).groups[2].accessors).toEqual([
{ columnId: 'b' },
]);
describe('metrics', () => {
beforeEach(() => {
datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'b', fields: [] }]);
params = {
layerId: 'a',
state: {
layerId: 'a',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b' }],
},
frame: {
...mockFrame(),
datasourceLayers: { a: datasource.publicAPIMock },
},
};
});
it.each<ColumnState['colorMode']>(['cell', 'text'])(
'should include palette if colorMode is %s and has stops',
(colorMode) => {
params.state.columns[0].colorMode = colorMode;
expect(datatableVisualization.getConfiguration(params).groups[2].accessors).toEqual([
{ columnId: 'b', palette: mockStops, triggerIconType: 'colorBy' },
]);
}
);
it.each<ColumnState['colorMode']>(['cell', 'text'])(
'should not include palette if colorMode is %s but stops is empty',
(colorMode) => {
(getColorStops as jest.Mock).mockReturnValue([]);
params.state.columns[0].colorMode = colorMode;
expect(datatableVisualization.getConfiguration(params).groups[2].accessors).toEqual([
{ columnId: 'b' },
]);
}
);
it.each<ColumnState['colorMode']>(['none', undefined])(
'should not include palette if colorMode is %s even if stops exist',
(colorMode) => {
params.state.columns[0].colorMode = colorMode;
expect(datatableVisualization.getConfiguration(params).groups[2].accessors).toEqual([
{ columnId: 'b' },
]);
}
);
});
});
@ -629,13 +701,12 @@ describe('Datatable Visualization', () => {
datatableVisualization.toExpression(
state,
frame.datasourceLayers,
{},
{ '1': { type: 'expression', chain: [] } }
) as Ast
).findFunction('lens_datatable')[0].arguments;
).findFunction<DatatableExpressionFunction>('lens_datatable')[0].arguments;
const defaultExpressionTableState = {
const defaultExpressionTableState: DatatableVisualizationState = {
layerId: 'a',
layerType: LayerTypes.DATA,
columns: [{ columnId: 'b' }, { columnId: 'c' }],
@ -881,6 +952,104 @@ describe('Datatable Visualization', () => {
})
);
});
describe('palette/colorMapping/colorMode', () => {
const colorMapping: ColorMapping.Config = {
paletteId: 'default',
colorMode: { type: 'categorical' },
assignments: [],
specialAssignments: [],
};
const palette: PaletteOutput<CustomPaletteParams> = {
type: 'palette',
name: 'default',
};
const colorExpressionTableState = (
colorMode?: 'cell' | 'text' | 'none'
): DatatableVisualizationState => ({
...defaultExpressionTableState,
columns: [{ columnId: 'b', colorMapping, palette, colorMode }],
});
beforeEach(() => {
datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'b', fields: [] }]);
});
it.each<[DataType, string]>([
['string', palette.name],
['number', CUSTOM_PALETTE], // required to property handle toExpression
])(
'should call paletteService.get with correct palette name for %s dataType',
(dataType, paletteName) => {
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType,
isBucketed: false,
label: 'label',
hasTimeShift: false,
hasReducedTimeRange: false,
});
getDatatableExpressionArgs(colorExpressionTableState());
expect(mockServices.paletteService.get).toBeCalledWith(paletteName);
}
);
describe.each<'cell' | 'text' | 'none' | undefined>(['cell', 'text', 'none', undefined])(
'colorMode - %s',
(colorMode) => {
it.each<{ dataType: DataType; disallowed?: boolean }>([
// allowed types
{ dataType: 'document' },
{ dataType: 'ip' },
{ dataType: 'histogram' },
{ dataType: 'geo_point' },
{ dataType: 'geo_shape' },
{ dataType: 'counter' },
{ dataType: 'gauge' },
{ dataType: 'murmur3' },
{ dataType: 'string' },
{ dataType: 'number' },
{ dataType: 'boolean' },
// disallowed types
{ dataType: 'date', disallowed: true },
])(
'should apply correct palette, colorMapping & colorMode for $dataType',
({ dataType, disallowed = false }) => {
datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({
dataType,
isBucketed: false,
label: 'label',
hasTimeShift: false,
hasReducedTimeRange: false,
});
const expression = datatableVisualization.toExpression(
colorExpressionTableState(colorMode),
frame.datasourceLayers,
{},
{ '1': { type: 'expression', chain: [] } }
) as Ast;
const columnArgs =
buildExpression(expression).findFunction<DatatableColumnFn>(
'lens_datatable_column'
)[0].arguments;
if (disallowed) {
expect(columnArgs.colorMode).toEqual(['none']);
expect(columnArgs.palette).toBeUndefined();
expect(columnArgs.colorMapping).toBeUndefined();
} else {
expect(columnArgs.colorMode).toEqual([colorMode ?? 'none']);
expect(columnArgs.palette).toEqual([expect.any(Object)]);
expect(columnArgs.colorMapping).toEqual([expect.any(String)]);
}
}
);
}
);
});
});
describe('#onEditAction', () => {

View file

@ -8,13 +8,13 @@
import React from 'react';
import { Ast } from '@kbn/interpreter';
import { i18n } from '@kbn/i18n';
import { PaletteRegistry, CUSTOM_PALETTE } from '@kbn/coloring';
import { PaletteRegistry, CUSTOM_PALETTE, PaletteOutput, CustomPaletteParams } from '@kbn/coloring';
import { ThemeServiceStart } from '@kbn/core/public';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import { IconChartDatatable } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { buildExpression, buildExpressionFunction } from '@kbn/expressions-plugin/common';
import { isNumericFieldForDatatable } from '../../../common/expressions/datatable/utils';
import useObservable from 'react-use/lib/useObservable';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import type {
SuggestionRequest,
@ -28,13 +28,14 @@ import { TableDimensionEditorAdditionalSection } from './components/dimension_ed
import type { LayerType } from '../../../common/types';
import { RowHeightMode } from '../../../common/types';
import { getDefaultSummaryLabel } from '../../../common/expressions/datatable/summary';
import type {
ColumnState,
SortingState,
PagingState,
CollapseExpressionFunction,
DatatableColumnFunction,
DatatableExpressionFunction,
import {
type ColumnState,
type SortingState,
type PagingState,
type CollapseExpressionFunction,
type DatatableColumnFn,
type DatatableExpressionFunction,
getOriginalId,
} from '../../../common/expressions';
import { DataTableToolbar } from './components/toolbar';
import {
@ -42,6 +43,14 @@ import {
DEFAULT_HEADER_ROW_HEIGHT_LINES,
DEFAULT_ROW_HEIGHT,
} from './components/constants';
import {
applyPaletteParams,
defaultPaletteParams,
findMinMaxByColumnId,
getColorStops,
shouldColorByTerms,
} from '../../shared_components';
import { getColorMappingTelemetryEvents } from '../../lens_ui_telemetry/color_telemetry_helpers';
export interface DatatableVisualizationState {
columns: ColumnState[];
layerId: string;
@ -60,10 +69,10 @@ const visualizationLabel = i18n.translate('xpack.lens.datatable.label', {
export const getDatatableVisualization = ({
paletteService,
theme,
kibanaTheme,
}: {
paletteService: PaletteRegistry;
theme: ThemeServiceStart;
kibanaTheme: ThemeServiceStart;
}): Visualization<DatatableVisualizationState> => ({
id: 'lnsDatatable',
@ -115,6 +124,56 @@ export const getDatatableVisualization = ({
);
},
onDatasourceUpdate(state, frame) {
const datasource = frame?.datasourceLayers?.[state.layerId];
const paletteMap = new Map(
paletteService
.getAll()
.filter((p) => !p.internal)
.map((p) => [p.id, p])
);
const hasTransposedColumn = state.columns.some(({ isTransposed }) => isTransposed);
const columns = state.columns.map((column) => {
if (column.palette) {
const accessor = column.columnId;
const currentData = frame?.activeData?.[state.layerId];
const { dataType, isBucketed } = datasource?.getOperationForColumnId(column.columnId) ?? {};
const showColorByTerms = shouldColorByTerms(dataType, isBucketed);
const palette = paletteMap.get(column.palette?.name ?? '');
const columnsToCheck = hasTransposedColumn
? currentData?.columns
.filter(({ id }) => getOriginalId(id) === accessor)
.map(({ id }) => id) || []
: [accessor];
const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData, getOriginalId);
if (palette && !showColorByTerms && !palette?.canDynamicColoring) {
const newPalette: PaletteOutput<CustomPaletteParams> = {
type: 'palette',
name: showColorByTerms ? 'default' : defaultPaletteParams.name,
};
return {
...column,
palette: {
...newPalette,
params: {
stops: applyPaletteParams(paletteService, newPalette, minMaxByColumnId[accessor]),
},
},
};
}
}
return column;
});
return {
...state,
columns,
};
},
getSuggestions({
table,
state,
@ -197,15 +256,16 @@ export const getDatatableVisualization = ({
},
/*
Datatable works differently on text based datasource and form based
- Form based: It relies on the isBucketed flag to identify groups. It allows only numeric fields
Datatable works differently on text-based datasource and form-based
- Form-based: It relies on the isBucketed flag to identify groups. It allows only numeric fields
on the Metrics dimension
- Text based: It relies on the isMetric flag to identify groups. It allows all type of fields
- Text-based: It relies on the isMetric flag to identify groups. It allows all type of fields
on the Metric dimension in cases where there are no numeric columns
**/
getConfiguration({ state, frame, layerId }) {
getConfiguration({ state, frame }) {
const isDarkMode = kibanaTheme.getTheme().darkMode;
const { sortedColumns, datasource } =
getDataSourceAndSortedColumns(state, frame.datasourceLayers, layerId) || {};
getDataSourceAndSortedColumns(state, frame.datasourceLayers) || {};
const columnMap: Record<string, ColumnState> = {};
state.columns.forEach((column) => {
@ -245,14 +305,29 @@ export const getDatatableVisualization = ({
}
return datasource!.getOperationForColumnId(c)?.isBucketed && !column?.isTransposed;
})
.map((accessor) => ({
columnId: accessor,
triggerIconType: columnMap[accessor].hidden
? 'invisible'
: columnMap[accessor].collapseFn
? 'aggregate'
: undefined,
})),
.map((accessor) => {
const {
colorMode = 'none',
palette,
colorMapping,
hidden,
collapseFn,
} = columnMap[accessor] ?? {};
const stops = getColorStops(paletteService, isDarkMode, palette, colorMapping);
const hasColoring = colorMode !== 'none' && stops.length > 0;
return {
columnId: accessor,
triggerIconType: hidden
? 'invisible'
: hasColoring
? 'colorBy'
: collapseFn
? 'aggregate'
: undefined,
palette: hasColoring ? stops : undefined,
};
}),
supportsMoreColumns: true,
filterOperations: (op) => op.isBucketed,
dataTestSubj: 'lnsDatatable_rows',
@ -319,22 +394,19 @@ export const getDatatableVisualization = ({
return !operation?.isBucketed;
})
.map((accessor) => {
const columnConfig = columnMap[accessor];
const stops = columnConfig?.palette?.params?.stops;
const isNumeric = Boolean(
accessor && isNumericFieldForDatatable(frame.activeData?.[state.layerId], accessor)
);
const hasColoring = Boolean(columnConfig?.colorMode !== 'none' && stops);
const {
colorMode = 'none',
palette,
colorMapping,
hidden,
} = columnMap[accessor] ?? {};
const stops = getColorStops(paletteService, isDarkMode, palette, colorMapping);
const hasColoring = colorMode !== 'none' && stops.length > 0;
return {
columnId: accessor,
triggerIconType: columnConfig?.hidden
? 'invisible'
: hasColoring && isNumeric
? 'colorBy'
: undefined,
palette:
hasColoring && isNumeric && stops ? stops.map(({ color }) => color) : undefined,
triggerIconType: hidden ? 'invisible' : hasColoring ? 'colorBy' : undefined,
palette: hasColoring ? stops : undefined,
};
}),
supportsMoreColumns: true,
@ -386,7 +458,11 @@ export const getDatatableVisualization = ({
};
},
DimensionEditorComponent(props) {
return <TableDimensionEditor {...props} paletteService={paletteService} />;
const isDarkMode = useObservable(kibanaTheme.theme$, { darkMode: false }).darkMode;
return (
<TableDimensionEditor {...props} isDarkMode={isDarkMode} paletteService={paletteService} />
);
},
DimensionEditorAdditionalSectionComponent(props) {
@ -421,7 +497,7 @@ export const getDatatableVisualization = ({
datasourceExpressionsByLayers = {}
): Ast | null {
const { sortedColumns, datasource } =
getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {};
getDataSourceAndSortedColumns(state, datasourceLayers) || {};
const isTextBasedLanguage = datasource?.isTextBasedLanguage();
if (
@ -481,23 +557,20 @@ export const getDatatableVisualization = ({
: [],
reverse: false, // managed at UI level
};
const sortingHint = datasource!.getOperationForColumnId(column.columnId)!.sortingHint;
const { dataType, isBucketed, sortingHint, inMetricDimension } =
datasource?.getOperationForColumnId(column.columnId) ?? {};
const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none';
const canColor =
datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number';
const canColor = dataType !== 'date';
const colorByTerms = shouldColorByTerms(dataType, isBucketed);
let isTransposable =
!isTextBasedLanguage &&
!datasource!.getOperationForColumnId(column.columnId)?.isBucketed;
if (isTextBasedLanguage) {
const operation = datasource!.getOperationForColumnId(column.columnId);
isTransposable = Boolean(column?.isMetric || operation?.inMetricDimension);
isTransposable = Boolean(column?.isMetric || inMetricDimension);
}
const datatableColumnFn = buildExpressionFunction<DatatableColumnFunction>(
const datatableColumnFn = buildExpressionFunction<DatatableColumnFn>(
'lens_datatable_column',
{
columnId: column.columnId,
@ -507,8 +580,15 @@ export const getDatatableVisualization = ({
isTransposed: column.isTransposed,
transposable: isTransposable,
alignment: column.alignment,
colorMode: canColor && column.colorMode ? column.colorMode : 'none',
palette: paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams),
colorMode: canColor ? column.colorMode ?? 'none' : 'none',
palette: !canColor
? undefined
: paletteService
// The by value palette is a pseudo custom palette that is only custom from params level
.get(colorByTerms ? column.palette?.name || CUSTOM_PALETTE : CUSTOM_PALETTE)
.toExpression(paletteParams),
colorMapping:
canColor && column.colorMapping ? JSON.stringify(column.colorMapping) : undefined,
summaryRow: hasNoSummaryRow ? undefined : column.summaryRow!,
summaryLabel: hasNoSummaryRow
? undefined
@ -537,6 +617,15 @@ export const getDatatableVisualization = ({
};
},
getTelemetryEventsOnSave(state, prevState) {
const colorMappingEvents = state.columns.flatMap((col) => {
const prevColumn = prevState?.columns?.find((prevCol) => prevCol.columnId === col.columnId);
return getColorMappingTelemetryEvents(col.colorMapping, prevColumn?.colorMapping);
});
return colorMappingEvents;
},
getRenderEventCounters(state) {
const events = {
color_by_value: false,
@ -642,8 +731,7 @@ export const getDatatableVisualization = ({
},
getSortedColumns(state, datasourceLayers) {
const { sortedColumns } =
getDataSourceAndSortedColumns(state, datasourceLayers || {}, state.layerId) || {};
const { sortedColumns } = getDataSourceAndSortedColumns(state, datasourceLayers || {}) || {};
return sortedColumns;
},
@ -696,8 +784,7 @@ export const getDatatableVisualization = ({
function getDataSourceAndSortedColumns(
state: DatatableVisualizationState,
datasourceLayers: DatasourceLayers,
layerId: string
datasourceLayers: DatasourceLayers
) {
const datasource = datasourceLayers[state.layerId];
const originalOrder = datasource?.getTableSpec().map(({ columnId }) => columnId);

View file

@ -110,13 +110,16 @@ export function GaugeDimensionEditor(
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color',
defaultMessage: 'Color mapping',
})}
>
<PalettePanelContainer
palette={displayStops.map(({ color }) => color)}
siblingRef={props.panelRef}
isInlineEditing={isInlineEditing}
title={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color mapping',
})}
>
<CustomizablePalette
palettes={props.paletteService}

View file

@ -133,13 +133,16 @@ export function MetricDimensionEditor(
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color',
defaultMessage: 'Color mapping',
})}
>
<PalettePanelContainer
palette={displayStops.map(({ color }) => color)}
siblingRef={props.panelRef}
isInlineEditing={isInlineEditing}
title={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color mapping',
})}
>
<CustomizablePalette
palettes={props.paletteService}

View file

@ -136,7 +136,7 @@ describe('dimension editor', () => {
/>
);
const colorModeGroup = screen.queryByRole('group', { name: /color mode/i });
const colorModeGroup = screen.queryByRole('group', { name: /Color by value/i });
const staticColorPicker = screen.queryByTestId(SELECTORS.COLOR_PICKER);
const typeColor = (color: string) => {
@ -170,6 +170,7 @@ describe('dimension editor', () => {
expect(screen.queryByTestId(SELECTORS.MAX_EDITOR)).not.toBeInTheDocument();
expect(screen.queryByTestId(SELECTORS.BREAKDOWN_EDITOR)).not.toBeInTheDocument();
});
it('Color mode switch is shown when the primary metric is numeric', () => {
const { colorModeGroup } = renderPrimaryMetricEditor();
expect(colorModeGroup).toBeInTheDocument();

View file

@ -258,15 +258,15 @@ function PrimaryMetricEditor(props: SubProps) {
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.dynamicColoring.label', {
defaultMessage: 'Color mode',
label={i18n.translate('xpack.lens.metric.colorByValue.label', {
defaultMessage: 'Color by value',
})}
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.colorMode.label', {
defaultMessage: 'Color mode',
legend={i18n.translate('xpack.lens.metric.colorByValue.label', {
defaultMessage: 'Color by value',
})}
data-test-subj="lnsMetric_color_mode_buttons"
options={[
@ -319,7 +319,7 @@ function PrimaryMetricEditor(props: SubProps) {
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color',
defaultMessage: 'Color mapping',
})}
>
<PalettePanelContainer

View file

@ -13,7 +13,7 @@ import {
DEFAULT_COLOR_MAPPING_CONFIG,
PaletteRegistry,
ColorMapping,
SPECIAL_TOKENS_STRING_CONVERTION,
SPECIAL_TOKENS_STRING_CONVERSION,
AVAILABLE_PALETTES,
getColorsFromMapping,
} from '@kbn/coloring';
@ -127,7 +127,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
{props.accessor === firstNonCollapsedColumnId && (
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionlabel', {
label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionLabel', {
defaultMessage: 'Color mapping',
})}
style={{ alignItems: 'center' }}
@ -139,7 +139,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
title={
useNewColorMapping
? i18n.translate('xpack.lens.colorMapping.editColorMappingTitle', {
defaultMessage: 'Edit colors by term mapping',
defaultMessage: 'Assign colors to terms',
})
: i18n.translate('xpack.lens.colorMapping.editColorsTitle', {
defaultMessage: 'Edit colors',
@ -188,7 +188,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
type: 'categories',
categories: splitCategories,
}}
specialTokens={SPECIAL_TOKENS_STRING_CONVERTION}
specialTokens={SPECIAL_TOKENS_STRING_CONVERSION}
/>
) : (
<PalettePicker

View file

@ -11,7 +11,7 @@ import {
CategoricalColorMapping,
DEFAULT_COLOR_MAPPING_CONFIG,
ColorMapping,
SPECIAL_TOKENS_STRING_CONVERTION,
SPECIAL_TOKENS_STRING_CONVERSION,
PaletteOutput,
AVAILABLE_PALETTES,
getColorsFromMapping,
@ -82,7 +82,7 @@ export function TagsDimensionEditor({
return (
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionlabel', {
label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionLabel', {
defaultMessage: 'Color mapping',
})}
style={{ alignItems: 'center' }}
@ -94,7 +94,7 @@ export function TagsDimensionEditor({
title={
useNewColorMapping
? i18n.translate('xpack.lens.colorMapping.editColorMappingTitle', {
defaultMessage: 'Edit colors by term mapping',
defaultMessage: 'Assign colors to terms',
})
: i18n.translate('xpack.lens.colorMapping.editColorsTitle', {
defaultMessage: 'Edit colors',
@ -141,7 +141,7 @@ export function TagsDimensionEditor({
type: 'categories',
categories: splitCategories,
}}
specialTokens={SPECIAL_TOKENS_STRING_CONVERTION}
specialTokens={SPECIAL_TOKENS_STRING_CONVERSION}
/>
) : (
<PalettePicker

View file

@ -704,14 +704,14 @@ export const getXyVisualization = ({
paletteService,
};
const darkMode: boolean = useObservable(kibanaTheme.theme$, { darkMode: false }).darkMode;
const isDarkMode: boolean = useObservable(kibanaTheme.theme$, { darkMode: false }).darkMode;
const layer = props.state.layers.find((l) => l.layerId === props.layerId)!;
const dimensionEditor = isReferenceLayer(layer) ? (
<ReferenceLinePanel {...allProps} />
) : isAnnotationsLayer(layer) ? (
<AnnotationsPanel {...allProps} dataViewsService={dataViewsService} />
) : (
<DataDimensionEditor {...allProps} darkMode={darkMode} />
<DataDimensionEditor {...allProps} isDarkMode={isDarkMode} />
);
return dimensionEditor;

View file

@ -5,81 +5,68 @@
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useDebouncedValue } from '@kbn/visualization-utils';
import { ColorPicker } from '@kbn/visualization-ui-components';
import {
EuiBadge,
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSpacer,
EuiSwitch,
EuiText,
htmlIdGenerator,
} from '@elastic/eui';
import {
PaletteRegistry,
ColorMapping,
DEFAULT_COLOR_MAPPING_CONFIG,
CategoricalColorMapping,
PaletteOutput,
SPECIAL_TOKENS_STRING_CONVERTION,
AVAILABLE_PALETTES,
getColorsFromMapping,
} from '@kbn/coloring';
import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui';
import { PaletteRegistry, ColorMapping, PaletteOutput } from '@kbn/coloring';
import { getColorCategories } from '@kbn/chart-expressions-common';
import type { ValuesType } from 'utility-types';
import type { VisualizationDimensionEditorProps } from '../../../types';
import { State, XYState, XYDataLayerConfig, YConfig, YAxisMode } from '../types';
import { FormatFactory } from '../../../../common/types';
import { getSeriesColor, isHorizontalChart } from '../state_helpers';
import { PalettePanelContainer, PalettePicker } from '../../../shared_components';
import { getDataLayers } from '../visualization_helpers';
import { CollapseSetting } from '../../../shared_components/collapse_setting';
import { getSortedAccessors } from '../to_expression';
import { getColorAssignments, getAssignedColorConfig } from '../color_assignment';
import { trackUiCounterEvents } from '../../../lens_ui_telemetry';
type UnwrapArray<T> = T extends Array<infer P> ? P : T;
export function updateLayer(
state: State,
layer: UnwrapArray<State['layers']>,
index: number
): State {
const newLayers = [...state.layers];
newLayers[index] = layer;
return {
...state,
layers: newLayers,
};
}
import { ColorMappingByTerms } from '../../../shared_components/coloring/color_mapping_by_terms';
export const idPrefix = htmlIdGenerator()();
function updateLayer(
state: State,
index: number,
layer: ValuesType<State['layers']>,
newLayer: Partial<ValuesType<State['layers']>>
): State['layers'] {
const newLayers = [...state.layers];
newLayers[index] = {
...layer,
...newLayer,
} as ValuesType<State['layers']>;
return newLayers;
}
export function DataDimensionEditor(
props: VisualizationDimensionEditorProps<State> & {
formatFactory: FormatFactory;
paletteService: PaletteRegistry;
darkMode: boolean;
isDarkMode: boolean;
}
) {
const { state, layerId, accessor, darkMode, isInlineEditing } = props;
const { state, layerId, accessor, isDarkMode, isInlineEditing } = props;
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index] as XYDataLayerConfig;
const canUseColorMapping = layer.colorMapping ? true : false;
const [useNewColorMapping, setUseNewColorMapping] = useState(canUseColorMapping);
const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue<XYState>({
value: props.state,
onChange: props.setState,
});
const updateLayerState = useCallback(
(layerIndex: number, newLayer: Partial<ValuesType<State['layers']>>) => {
setLocalState({
...localState,
layers: updateLayer(localState, layerIndex, layer, newLayer),
});
},
[layer, setLocalState, localState]
);
const localYConfig = layer?.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor);
const axisMode = localYConfig?.axisMode || 'auto';
@ -100,22 +87,22 @@ export function DataDimensionEditor(
...yConfig,
});
}
setLocalState(updateLayer(localState, { ...layer, yConfig: newYConfigs }, index));
updateLayerState(index, { yConfig: newYConfigs });
},
[accessor, index, localState, layer, setLocalState]
[layer.yConfig, updateLayerState, index, accessor]
);
const setColorMapping = useCallback(
(colorMapping?: ColorMapping.Config) => {
setLocalState(updateLayer(localState, { ...layer, colorMapping }, index));
updateLayerState(index, { colorMapping });
},
[index, localState, layer, setLocalState]
[updateLayerState, index]
);
const setPalette = useCallback(
(palette: PaletteOutput) => {
setLocalState(updateLayer(localState, { ...layer, palette }, index));
updateLayerState(index, { palette });
},
[index, localState, layer, setLocalState]
[updateLayerState, index]
);
const overwriteColor = getSeriesColor(layer, accessor);
@ -143,105 +130,28 @@ export function DataDimensionEditor(
).color;
}, [props.frame, props.paletteService, state.layers, accessor, props.formatFactory, layer]);
const localLayer: XYDataLayerConfig = layer;
const colors = layer.colorMapping
? getColorsFromMapping(props.darkMode, layer.colorMapping)
: props.paletteService
.get(layer.palette?.name || 'default')
.getCategoricalColors(10, layer.palette);
const table = props.frame.activeData?.[layer.layerId];
const { splitAccessor } = layer;
const splitCategories = getColorCategories(table?.rows ?? [], splitAccessor);
if (props.groupId === 'breakdown' && !layer.collapseFn) {
return (
<EuiFormRow
display="columnCompressed"
label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionlabel', {
defaultMessage: 'Color mapping',
})}
style={{ alignItems: 'center' }}
fullWidth
>
<PalettePanelContainer
palette={colors}
siblingRef={props.panelRef}
title={
useNewColorMapping
? i18n.translate('xpack.lens.colorMapping.editColorMappingTitle', {
defaultMessage: 'Edit colors by term mapping',
})
: i18n.translate('xpack.lens.colorMapping.editColorsTitle', {
defaultMessage: 'Edit colors',
})
}
isInlineEditing={isInlineEditing}
>
<div className="lnsPalettePanel__section lnsPalettePanel__section--shaded lnsIndexPatternDimensionEditor--padded">
<EuiFlexGroup direction="column" gutterSize="s" justifyContent="flexStart">
<EuiFlexItem>
<EuiSwitch
label={
<EuiText size="xs">
<span>
{i18n.translate('xpack.lens.colorMapping.tryLabel', {
defaultMessage: 'Use the new Color Mapping feature',
})}{' '}
<EuiBadge color="hollow">
{i18n.translate('xpack.lens.colorMapping.techPreviewLabel', {
defaultMessage: 'Tech preview',
})}
</EuiBadge>
</span>
</EuiText>
}
data-test-subj="lns_colorMappingOrLegacyPalette_switch"
compressed
checked={useNewColorMapping}
onChange={({ target: { checked } }) => {
trackUiCounterEvents(
`color_mapping_switch_${checked ? 'enabled' : 'disabled'}`
);
setColorMapping(checked ? { ...DEFAULT_COLOR_MAPPING_CONFIG } : undefined);
setUseNewColorMapping(checked);
}}
/>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem>
{canUseColorMapping || useNewColorMapping ? (
<CategoricalColorMapping
isDarkMode={darkMode}
model={layer.colorMapping ?? { ...DEFAULT_COLOR_MAPPING_CONFIG }}
onModelUpdate={(model: ColorMapping.Config) => setColorMapping(model)}
palettes={AVAILABLE_PALETTES}
data={{
type: 'categories',
categories: splitCategories,
}}
specialTokens={SPECIAL_TOKENS_STRING_CONVERTION}
/>
) : (
<PalettePicker
palettes={props.paletteService}
activePalette={localLayer?.palette}
setPalette={(newPalette) => {
setPalette(newPalette);
}}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</div>
</PalettePanelContainer>
</EuiFormRow>
<ColorMappingByTerms
isDarkMode={isDarkMode}
colorMapping={layer.colorMapping}
palette={layer.palette}
isInlineEditing={isInlineEditing}
setPalette={setPalette}
setColorMapping={setColorMapping}
paletteService={props.paletteService}
panelRef={props.panelRef}
categories={splitCategories}
/>
);
}
const isHorizontal = isHorizontalChart(state.layers);
const disabledMessage = Boolean(!localLayer.collapseFn && localLayer.splitAccessor)
const disabledMessage = Boolean(!layer.collapseFn && layer.splitAccessor)
? i18n.translate('xpack.lens.xyChart.colorPicker.tooltip.disabled', {
defaultMessage:
'You are unable to apply custom colors to individual series when the layer includes a "Break down by" field.',
@ -329,13 +239,23 @@ export function DataDimensionEditorDataSectionExtra(
onChange: props.setState,
});
const updateLayerState = useCallback(
(layerIndex: number, newLayer: Partial<ValuesType<State['layers']>>) => {
setLocalState({
...localState,
layers: updateLayer(localState, layerIndex, layer, newLayer),
});
},
[layer, setLocalState, localState]
);
if (props.groupId === 'breakdown') {
return (
<>
<CollapseSetting
value={layer.collapseFn || ''}
onChange={(collapseFn) => {
setLocalState(updateLayer(localState, { ...layer, collapseFn }, index));
updateLayerState(index, { collapseFn });
}}
/>
</>

View file

@ -272,7 +272,7 @@ describe('XY Config panels', () => {
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
darkMode={false}
isDarkMode={false}
/>
);
@ -300,7 +300,7 @@ describe('XY Config panels', () => {
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
darkMode={false}
isDarkMode={false}
/>
);
@ -349,7 +349,7 @@ describe('XY Config panels', () => {
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
darkMode={false}
isDarkMode={false}
/>
);
@ -395,7 +395,7 @@ describe('XY Config panels', () => {
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
darkMode={false}
isDarkMode={false}
/>
);
@ -441,7 +441,7 @@ describe('XY Config panels', () => {
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
darkMode={false}
isDarkMode={false}
/>
);

View file

@ -25109,7 +25109,7 @@
"xpack.lens.collapse.min": "Min.",
"xpack.lens.collapse.none": "Aucun",
"xpack.lens.collapse.sum": "Somme",
"xpack.lens.colorMapping.editColorMappingSectionlabel": "Mapping des couleurs",
"xpack.lens.colorMapping.editColorMappingSectionLabel": "Mapping des couleurs",
"xpack.lens.colorMapping.editColorMappingTitle": "Modifier les couleurs par mapping de terme",
"xpack.lens.colorMapping.editColors": "Modifier les couleurs",
"xpack.lens.colorMapping.editColorsTitle": "Modifier les couleurs",
@ -25737,9 +25737,7 @@
"xpack.lens.metric.breakdownBy": "Répartir par",
"xpack.lens.metric.color": "Couleur",
"xpack.lens.metric.colorMode.dynamic": "Dynamique",
"xpack.lens.metric.colorMode.label": "Mode couleur",
"xpack.lens.metric.colorMode.static": "Statique",
"xpack.lens.metric.dynamicColoring.label": "Mode couleur",
"xpack.lens.metric.groupLabel": "Valeur dobjectif et unique",
"xpack.lens.metric.headingLabel": "Valeur",
"xpack.lens.metric.icon": "Décoration de licône",
@ -25795,7 +25793,6 @@
"xpack.lens.paletteHeatmapGradient.label": "Couleur",
"xpack.lens.paletteMetricGradient.label": "Couleur",
"xpack.lens.palettePicker.label": "Palette",
"xpack.lens.paletteTableGradient.label": "Couleur",
"xpack.lens.pie.addLayer": "Visualisation",
"xpack.lens.pie.arrayValues": "Les dimensions suivantes contiennent des valeurs de tableau : {label}. Le rendu de votre visualisation peut ne pas se présenter comme attendu.",
"xpack.lens.pie.collapsedDimensionsDontCount": "(Les dimensions réduites ne sont pas concernées par cette limite.)",

View file

@ -25098,7 +25098,7 @@
"xpack.lens.collapse.min": "最低",
"xpack.lens.collapse.none": "なし",
"xpack.lens.collapse.sum": "合計",
"xpack.lens.colorMapping.editColorMappingSectionlabel": "カラーマッピング",
"xpack.lens.colorMapping.editColorMappingSectionLabel": "カラーマッピング",
"xpack.lens.colorMapping.editColorMappingTitle": "用語マッピングで色を編集",
"xpack.lens.colorMapping.editColors": "色を編集",
"xpack.lens.colorMapping.editColorsTitle": "色を編集",
@ -25725,9 +25725,7 @@
"xpack.lens.metric.breakdownBy": "分類する",
"xpack.lens.metric.color": "色",
"xpack.lens.metric.colorMode.dynamic": "動的",
"xpack.lens.metric.colorMode.label": "色モード",
"xpack.lens.metric.colorMode.static": "静的",
"xpack.lens.metric.dynamicColoring.label": "色モード",
"xpack.lens.metric.groupLabel": "目標値と単一の値",
"xpack.lens.metric.headingLabel": "値",
"xpack.lens.metric.icon": "アイコン装飾",
@ -25783,7 +25781,6 @@
"xpack.lens.paletteHeatmapGradient.label": "色",
"xpack.lens.paletteMetricGradient.label": "色",
"xpack.lens.palettePicker.label": "パレット",
"xpack.lens.paletteTableGradient.label": "色",
"xpack.lens.pie.addLayer": "ビジュアライゼーション",
"xpack.lens.pie.arrayValues": "次のディメンションには配列値があります:{label}。可視化が想定通りに表示されない場合があります。",
"xpack.lens.pie.collapsedDimensionsDontCount": "(折りたたまれたディメンションはこの制限に対してカウントされません。)",

View file

@ -25129,7 +25129,7 @@
"xpack.lens.collapse.min": "最小值",
"xpack.lens.collapse.none": "无",
"xpack.lens.collapse.sum": "求和",
"xpack.lens.colorMapping.editColorMappingSectionlabel": "颜色映射",
"xpack.lens.colorMapping.editColorMappingSectionLabel": "颜色映射",
"xpack.lens.colorMapping.editColorMappingTitle": "按词映射编辑颜色",
"xpack.lens.colorMapping.editColors": "编辑颜色",
"xpack.lens.colorMapping.editColorsTitle": "编辑颜色",
@ -25757,9 +25757,7 @@
"xpack.lens.metric.breakdownBy": "细分方式",
"xpack.lens.metric.color": "颜色",
"xpack.lens.metric.colorMode.dynamic": "动态",
"xpack.lens.metric.colorMode.label": "颜色模式",
"xpack.lens.metric.colorMode.static": "静态",
"xpack.lens.metric.dynamicColoring.label": "颜色模式",
"xpack.lens.metric.groupLabel": "目标值和单值",
"xpack.lens.metric.headingLabel": "值",
"xpack.lens.metric.icon": "图标装饰",
@ -25815,7 +25813,6 @@
"xpack.lens.paletteHeatmapGradient.label": "颜色",
"xpack.lens.paletteMetricGradient.label": "颜色",
"xpack.lens.palettePicker.label": "调色板",
"xpack.lens.paletteTableGradient.label": "颜色",
"xpack.lens.pie.addLayer": "可视化",
"xpack.lens.pie.arrayValues": "以下维度包含数组值:{label}。您的可视化可能无法正常渲染。",
"xpack.lens.pie.collapsedDimensionsDontCount": "(折叠的维度不计入此限制。)",

View file

@ -88,7 +88,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
});
describe('nick by reference', () => {
describe('by reference', () => {
const VIS_LIBRARY_DESCRIPTION = 'Vis library description';
let count = 0;

View file

@ -7,6 +7,7 @@
import { DebugState } from '@elastic/charts';
import expect from '@kbn/expect';
import chroma from 'chroma-js';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
@ -51,73 +52,104 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.savedObjects.cleanStandardList();
});
it('should sync colors on dashboard by default', async function () {
it('should sync colors on dashboard for legacy default palette', async function () {
await PageObjects.dashboard.navigateToApp();
await elasticChart.setNewChartUiDebugFlag(true);
await PageObjects.dashboard.clickCreateDashboardPrompt();
// create non-filtered xy chart
await dashboardAddPanel.clickCreateNewLink();
await PageObjects.lens.goToTimeRange();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'count',
field: 'Records',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension',
operation: 'terms',
field: 'geo.src',
palette: { mode: 'legacy', id: 'default' },
});
await PageObjects.lens.save('vis1', false, true);
await PageObjects.lens.saveAndReturn();
await PageObjects.header.waitUntilLoadingHasFinished();
await dashboardAddPanel.clickCreateNewLink();
// create filtered xy chart
await dashboardAddPanel.clickCreateNewLink();
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
operation: 'count',
field: 'Records',
});
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension',
operation: 'terms',
field: 'geo.src',
palette: { mode: 'legacy', id: 'default' },
});
await filterBar.addFilter({ field: 'geo.src', operation: 'is not', value: 'CN' });
await PageObjects.lens.saveAndReturn();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.lens.save('vis2', false, true);
// create datatable vis
await dashboardAddPanel.clickCreateNewLink();
await PageObjects.lens.switchToVisualization('lnsDatatable');
await PageObjects.lens.configureDimension({
dimension: 'lnsDatatable_rows > lns-empty-dimension',
operation: 'terms',
field: 'geo.src',
keepOpen: true,
});
await PageObjects.lens.setTermsNumberOfValues(5);
await PageObjects.lens.setTableDynamicColoring('cell');
await PageObjects.lens.setPalette('default', true);
await PageObjects.lens.closeDimensionEditor();
await PageObjects.lens.configureDimension({
dimension: 'lnsDatatable_metrics > lns-empty-dimension',
operation: 'count',
field: 'Records',
});
await PageObjects.lens.saveAndReturn();
// Set dashboard to sync colors
await PageObjects.dashboard.openSettingsFlyout();
await dashboardSettings.toggleSyncColors(true);
await dashboardSettings.clickApplyButton();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.dashboard.waitForRenderComplete();
const colorMapping1 = getColorMapping(await PageObjects.dashboard.getPanelChartDebugState(0));
const colorMapping2 = getColorMapping(await PageObjects.dashboard.getPanelChartDebugState(1));
const colorMappings1 = Object.entries(
getColorMapping(await PageObjects.dashboard.getPanelChartDebugState(0))
);
const colorMappings2 = Object.entries(
getColorMapping(await PageObjects.dashboard.getPanelChartDebugState(1))
);
expect(Object.keys(colorMapping1)).to.have.length(6);
expect(Object.keys(colorMapping1)).to.have.length(6);
const panel1Keys = ['CN'];
const panel2Keys = ['PK'];
const sharedKeys = ['IN', 'US', 'ID', 'BR', 'Other'];
// colors for keys exclusive to panel 1 should not occur in panel 2
panel1Keys.forEach((panel1Key) => {
const assignedColor = colorMapping1[panel1Key];
expect(Object.values(colorMapping2)).not.to.contain(assignedColor);
const els = await PageObjects.lens.getDatatableCellsByColumn(0);
const colorMappings3 = await Promise.all(
els.map(async (el) => [
await el.getVisibleText(),
chroma((await PageObjects.lens.getStylesFromCell(el))['background-color']).hex(), // eui converts hex to rgb
])
);
expect(colorMappings1).to.have.length(6);
expect(colorMappings2).to.have.length(6);
expect(colorMappings3).to.have.length(6);
const mergedColorAssignments = new Map<string, Set<string>>();
[...colorMappings1, ...colorMappings2, ...colorMappings3].forEach(([key, color]) => {
if (!mergedColorAssignments.has(key)) mergedColorAssignments.set(key, new Set());
mergedColorAssignments.get(key)?.add(color);
});
// colors for keys exclusive to panel 2 should not occur in panel 1
panel2Keys.forEach((panel2Key) => {
const assignedColor = colorMapping2[panel2Key];
expect(Object.values(colorMapping1)).not.to.contain(assignedColor);
});
// colors for keys used in both panels should be synced
sharedKeys.forEach((sharedKey) => {
expect(colorMapping1[sharedKey]).to.eql(colorMapping2[sharedKey]);
// Each key should have only been assigned one color across all 3 visualizations
mergedColorAssignments.forEach((colors, key) => {
expect(colors.size).eql(
1,
`Key "${key}" was assigned multiple colors: ${JSON.stringify([...colors])}`
);
});
});

View file

@ -173,7 +173,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
},
/**
* Changes the specified dimension to the specified operation and (optinally) field.
* Changes the specified dimension to the specified operation and optionally the field.
*
* @param opts.dimension - the selector of the dimension being changed
* @param opts.operation - the desired operation ID for the dimension
@ -1129,8 +1129,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
return el.getVisibleText();
},
async getDatatableCellStyle(rowIndex = 0, colIndex = 0) {
const el = await this.getDatatableCell(rowIndex, colIndex);
async getStylesFromCell(el: WebElementWrapper) {
const styleString = (await el.getAttribute('style')) ?? '';
return styleString.split(';').reduce<Record<string, string>>((memo, cssLine) => {
const [prop, value] = cssLine.split(':');
@ -1141,6 +1140,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
}, {});
},
async getDatatableCellStyle(rowIndex = 0, colIndex = 0) {
const el = await this.getDatatableCell(rowIndex, colIndex);
return this.getStylesFromCell(el);
},
async getDatatableCellSpanStyle(rowIndex = 0, colIndex = 0) {
const el = await (await this.getDatatableCell(rowIndex, colIndex)).findByCssSelector('span');
const styleString = (await el.getAttribute('style')) ?? '';
@ -1174,6 +1178,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
);
},
async getDatatableCellsByColumn(colIndex = 0) {
return await find.allByCssSelector(
`[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRowCell"][data-gridcell-column-index="${colIndex}"]`
);
},
async isDatatableHeaderSorted(index = 0) {
return find.existsByCssSelector(
`[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${
@ -1240,10 +1250,15 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
async setPalette(paletteId: string, isLegacy: boolean) {
await testSubjects.click('lns_colorEditing_trigger');
// This action needs to be slowed WAY down, otherwise it will not correctly set the palette
await PageObjects.common.sleep(200);
await testSubjects.setEuiSwitch(
'lns_colorMappingOrLegacyPalette_switch',
isLegacy ? 'uncheck' : 'check'
);
await PageObjects.common.sleep(200);
if (isLegacy) {
await testSubjects.click('lns-palettePicker');
await find.clickByCssSelector(`#${paletteId}`);
@ -1251,6 +1266,8 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await testSubjects.click('kbnColoring_ColorMapping_PalettePicker');
await testSubjects.click(`kbnColoring_ColorMapping_Palette-${paletteId}`);
}
await PageObjects.common.sleep(200);
await this.closePaletteEditor();
},