diff --git a/src/plugins/charts/common/palette.test.ts b/src/plugins/charts/common/palette.test.ts index 0a26d71a9b9d5..86ba74d409cc6 100644 --- a/src/plugins/charts/common/palette.test.ts +++ b/src/plugins/charts/common/palette.test.ts @@ -12,13 +12,14 @@ import { systemPalette, PaletteOutput, CustomPaletteState, + CustomPaletteArguments, } from './palette'; import { functionWrapper } from 'src/plugins/expressions/common/expression_functions/specs/tests/utils'; describe('palette', () => { const fn = functionWrapper(palette()) as ( context: null, - args?: { color?: string[]; gradient?: boolean; reverse?: boolean } + args?: Partial ) => PaletteOutput; it('results a palette', () => { @@ -39,6 +40,18 @@ describe('palette', () => { }); }); + describe('stop', () => { + it('sets stops', () => { + const result = fn(null, { color: ['red', 'green', 'blue'], stop: [1, 2, 3] }); + expect(result.params!.stops).toEqual([1, 2, 3]); + }); + + it('defaults to pault_tor_14 colors', () => { + const result = fn(null); + expect(result.params!.colors).toEqual(defaultCustomColors); + }); + }); + describe('gradient', () => { it('sets gradient', () => { let result = fn(null, { gradient: true }); @@ -69,6 +82,16 @@ describe('palette', () => { const result = fn(null); expect(result.params!.colors).toEqual(defaultCustomColors); }); + + it('keeps the stops order pristine when set', () => { + const stops = [1, 2, 3]; + const result = fn(null, { + color: ['red', 'green', 'blue'], + stop: [1, 2, 3], + reverse: true, + }); + expect(result.params!.stops).toEqual(stops); + }); }); }); }); diff --git a/src/plugins/charts/common/palette.ts b/src/plugins/charts/common/palette.ts index c9232b22cfae1..78c6fcc812028 100644 --- a/src/plugins/charts/common/palette.ts +++ b/src/plugins/charts/common/palette.ts @@ -14,11 +14,21 @@ export interface CustomPaletteArguments { color?: string[]; gradient: boolean; reverse?: boolean; + stop?: number[]; + range?: 'number' | 'percent'; + rangeMin?: number; + rangeMax?: number; + continuity?: 'above' | 'below' | 'all' | 'none'; } export interface CustomPaletteState { colors: string[]; gradient: boolean; + stops: number[]; + range: 'number' | 'percent'; + rangeMin: number; + rangeMax: number; + continuity?: 'above' | 'below' | 'all' | 'none'; } export interface SystemPaletteArguments { @@ -83,6 +93,35 @@ export function palette(): ExpressionFunctionDefinition< }), required: false, }, + stop: { + multi: true, + types: ['number'], + help: i18n.translate('charts.functions.palette.args.stopHelpText', { + defaultMessage: + 'The palette color stops. When used, it must be associated with each color.', + }), + required: false, + }, + continuity: { + types: ['string'], + options: ['above', 'below', 'all', 'none'], + default: 'above', + help: '', + }, + rangeMin: { + types: ['number'], + help: '', + }, + rangeMax: { + types: ['number'], + help: '', + }, + range: { + types: ['string'], + options: ['number', 'percent'], + default: 'percent', + help: '', + }, gradient: { types: ['boolean'], default: false, @@ -101,15 +140,32 @@ export function palette(): ExpressionFunctionDefinition< }, }, fn: (input, args) => { - const { color, reverse, gradient } = args; + const { + color, + continuity, + reverse, + gradient, + stop, + range, + rangeMin = 0, + rangeMax = 100, + } = args; const colors = ([] as string[]).concat(color || defaultCustomColors); - + const stops = ([] as number[]).concat(stop || []); + if (stops.length > 0 && colors.length !== stops.length) { + throw Error('When stop is used, each color must have an associated stop value.'); + } return { type: 'palette', name: 'custom', params: { colors: reverse ? colors.reverse() : colors, + stops, + range: range ?? 'percent', gradient, + continuity, + rangeMin, + rangeMax, }, }; }, diff --git a/src/plugins/charts/public/services/palettes/helpers.test.ts b/src/plugins/charts/public/services/palettes/helpers.test.ts new file mode 100644 index 0000000000000..90f5745570cc8 --- /dev/null +++ b/src/plugins/charts/public/services/palettes/helpers.test.ts @@ -0,0 +1,307 @@ +/* + * 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 { workoutColorForValue } from './helpers'; +import { CustomPaletteState } from '../..'; + +describe('workoutColorForValue', () => { + it('should return no color for empty value', () => { + expect( + workoutColorForValue( + undefined, + { + continuity: 'above', + colors: ['red', 'green', 'blue', 'yellow'], + range: 'number', + gradient: false, + rangeMin: 0, + rangeMax: 200, + stops: [], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + + describe('range: "number"', () => { + const DEFAULT_PROPS: CustomPaletteState = { + continuity: 'above', + colors: ['red', 'green', 'blue', 'yellow'], + range: 'number', + gradient: false, + rangeMin: 0, + rangeMax: 200, + stops: [], + }; + it('find the right color for predefined palettes', () => { + expect(workoutColorForValue(123, DEFAULT_PROPS, { min: 0, max: 200 })).toBe('blue'); + }); + + it('find the right color for custom stops palettes', () => { + expect( + workoutColorForValue( + 50, + { + ...DEFAULT_PROPS, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('blue'); + }); + + it('find the right color for custom stops palettes when value is higher than rangeMax', () => { + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + }); + + it('returns no color if the value if higher than rangeMax and continuity is nor "above" or "all"', () => { + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + + it('find the right color for custom stops palettes when value is lower than rangeMin', () => { + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + }); + + it('returns no color if the value if lower than rangeMin and continuity is nor "below" or "all"', () => { + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + }); + + describe('range: "percent"', () => { + const DEFAULT_PROPS: CustomPaletteState = { + continuity: 'above', + colors: ['red', 'green', 'blue', 'yellow'], + range: 'percent', + gradient: false, + rangeMin: 0, + rangeMax: 100, + stops: [], + }; + it('find the right color for predefined palettes', () => { + expect(workoutColorForValue(123, DEFAULT_PROPS, { min: 0, max: 200 })).toBe('blue'); + }); + + it('find the right color for custom stops palettes', () => { + expect( + workoutColorForValue( + 113, + { + ...DEFAULT_PROPS, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('blue'); // 113/200 ~ 56% + }); + + it('find the right color for custom stops palettes when value is higher than rangeMax', () => { + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + expect( + workoutColorForValue( + 123, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('yellow'); + }); + + it('returns no color if the value if higher than rangeMax and continuity is nor "above" or "all"', () => { + expect( + workoutColorForValue( + 190, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 190, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMax: 90, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + + it('find the right color for custom stops palettes when value is lower than rangeMin', () => { + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'below', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + expect( + workoutColorForValue( + 10, + { + ...DEFAULT_PROPS, + continuity: 'all', + rangeMin: 20, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBe('red'); + }); + + it('returns no color if the value if lower than rangeMin and continuity is nor "below" or "all"', () => { + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + continuity: 'above', + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + expect( + workoutColorForValue( + 0, + { + ...DEFAULT_PROPS, + continuity: 'none', + rangeMin: 10, + rangeMax: 100, + stops: [20, 40, 60, 80], + }, + { min: 0, max: 200 } + ) + ).toBeUndefined(); + }); + }); +}); diff --git a/src/plugins/charts/public/services/palettes/helpers.ts b/src/plugins/charts/public/services/palettes/helpers.ts new file mode 100644 index 0000000000000..d4b1e98f94cc8 --- /dev/null +++ b/src/plugins/charts/public/services/palettes/helpers.ts @@ -0,0 +1,101 @@ +/* + * 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 { CustomPaletteState } from '../..'; + +function findColorSegment( + value: number, + comparison: (value: number, bucket: number) => number, + colors: string[], + rangeMin: number, + rangeMax: number +) { + // assume uniform distribution within the provided range, can ignore stops + const step = (rangeMax - rangeMin) / colors.length; + + // what about values in range + const index = colors.findIndex((c, i) => comparison(value, rangeMin + (1 + i) * step) <= 0); + return colors[index] || colors[0]; +} + +function findColorsByStops( + value: number, + comparison: (value: number, bucket: number) => number, + colors: string[], + stops: number[] +) { + const index = stops.findIndex((s) => comparison(value, s) < 0); + return colors[index] || colors[0]; +} + +function getNormalizedValueByRange( + value: number, + { range }: CustomPaletteState, + minMax: { min: number; max: number } +) { + let result = value; + if (range === 'percent') { + result = (100 * (value - minMax.min)) / (minMax.max - minMax.min); + } + // for a range of 1 value the formulas above will divide by 0, so here's a safety guard + if (Number.isNaN(result)) { + return 1; + } + return result; +} + +/** + * When stops are empty, it is assumed a predefined palette, so colors are distributed uniformly in the whole data range + * When stops are passed, then rangeMin/rangeMax are used as reference for user defined limits: + * continuity is defined over rangeMin/rangeMax, not these stops values (rangeMin/rangeMax are computed from user's stop inputs) + */ +export function workoutColorForValue( + value: number | undefined, + params: CustomPaletteState, + minMax: { min: number; max: number } +) { + if (value == null) { + return; + } + const { colors, stops, range = 'percent', continuity = 'above', rangeMax, rangeMin } = params; + // ranges can be absolute numbers or percentages + // normalized the incoming value to the same format as range to make easier comparisons + const normalizedValue = getNormalizedValueByRange(value, params, minMax); + const dataRangeArguments = range === 'percent' ? [0, 100] : [minMax.min, minMax.max]; + const comparisonFn = (v: number, threshold: number) => v - threshold; + + // if steps are defined consider the specific rangeMax/Min as data boundaries + const maxRange = stops.length ? rangeMax : dataRangeArguments[1]; + const minRange = stops.length ? rangeMin : dataRangeArguments[0]; + + // in case of shorter rangers, extends the steps on the sides to cover the whole set + if (comparisonFn(normalizedValue, maxRange) > 0) { + if (continuity === 'above' || continuity === 'all') { + return colors[colors.length - 1]; + } + return; + } + if (comparisonFn(normalizedValue, minRange) < 0) { + if (continuity === 'below' || continuity === 'all') { + return colors[0]; + } + return; + } + + if (stops.length) { + return findColorsByStops(normalizedValue, comparisonFn, colors, stops); + } + + return findColorSegment( + normalizedValue, + comparisonFn, + colors, + dataRangeArguments[0], + dataRangeArguments[1] + ); +} diff --git a/src/plugins/charts/public/services/palettes/mock.ts b/src/plugins/charts/public/services/palettes/mock.ts index 1c112ec800c92..e94f47477ab11 100644 --- a/src/plugins/charts/public/services/palettes/mock.ts +++ b/src/plugins/charts/public/services/palettes/mock.ts @@ -14,8 +14,8 @@ export const getPaletteRegistry = () => { const mockPalette1: jest.Mocked = { id: 'default', title: 'My Palette', - getColor: jest.fn((_: SeriesLayer[]) => 'black'), - getColors: jest.fn((num: number) => ['red', 'black']), + getCategoricalColor: jest.fn((_: SeriesLayer[]) => 'black'), + getCategoricalColors: jest.fn((num: number) => ['red', 'black']), toExpression: jest.fn(() => ({ type: 'expression', chain: [ @@ -33,8 +33,32 @@ export const getPaletteRegistry = () => { const mockPalette2: jest.Mocked = { id: 'mocked', title: 'Mocked Palette', - getColor: jest.fn((_: SeriesLayer[]) => 'blue'), - getColors: jest.fn((num: number) => ['blue', 'yellow']), + getCategoricalColor: jest.fn((_: SeriesLayer[]) => 'blue'), + getCategoricalColors: jest.fn((num: number) => ['blue', 'yellow']), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['mocked'], + }, + }, + ], + })), + }; + + const mockPalette3: jest.Mocked = { + id: 'custom', + title: 'Custom Mocked Palette', + getCategoricalColor: jest.fn((_: SeriesLayer[]) => 'blue'), + getCategoricalColors: jest.fn((num: number) => ['blue', 'yellow']), + getColorForValue: jest.fn( + (num: number | undefined, state: unknown, minMax: { min: number; max: number }) => + num == null || num < 1 ? undefined : 'blue' + ), + canDynamicColoring: true, toExpression: jest.fn(() => ({ type: 'expression', chain: [ @@ -50,8 +74,9 @@ export const getPaletteRegistry = () => { }; return { - get: (name: string) => (name !== 'default' ? mockPalette2 : mockPalette1), - getAll: () => [mockPalette1, mockPalette2], + get: (name: string) => + name === 'custom' ? mockPalette3 : name !== 'default' ? mockPalette2 : mockPalette1, + getAll: () => [mockPalette1, mockPalette2, mockPalette3], }; }; diff --git a/src/plugins/charts/public/services/palettes/palettes.test.tsx b/src/plugins/charts/public/services/palettes/palettes.test.tsx index 8f495df7f882a..8cb477b0e0838 100644 --- a/src/plugins/charts/public/services/palettes/palettes.test.tsx +++ b/src/plugins/charts/public/services/palettes/palettes.test.tsx @@ -19,14 +19,14 @@ describe('palettes', () => { it('should return different colors based on behind text flag', () => { const palette = palettes.default; - const color1 = palette.getColor([ + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, totalSeriesAtDepth: 5, }, ]); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -44,14 +44,14 @@ describe('palettes', () => { it('should return different colors based on rank at current series', () => { const palette = palettes.default; - const color1 = palette.getColor([ + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, totalSeriesAtDepth: 5, }, ]); - const color2 = palette.getColor([ + const color2 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 1, @@ -64,7 +64,7 @@ describe('palettes', () => { it('should return the same color for different positions on outer series layers', () => { const palette = palettes.default; - const color1 = palette.getColor([ + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, @@ -76,7 +76,7 @@ describe('palettes', () => { totalSeriesAtDepth: 2, }, ]); - const color2 = palette.getColor([ + const color2 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, @@ -96,7 +96,7 @@ describe('palettes', () => { it('should return different colors based on behind text flag', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -108,7 +108,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -127,7 +127,7 @@ describe('palettes', () => { it('should return different colors for different keys', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -139,7 +139,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'def', @@ -157,7 +157,7 @@ describe('palettes', () => { it('should return the same color for the same key, irregardless of rank', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'hij', @@ -169,7 +169,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'hij', @@ -187,7 +187,7 @@ describe('palettes', () => { it('should return the same color for different positions on outer series layers', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'klm', @@ -204,7 +204,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'klm', @@ -227,7 +227,7 @@ describe('palettes', () => { it('should return the same index of the behind text palette for same key', () => { const palette = palettes.default; - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'klm', @@ -244,7 +244,7 @@ describe('palettes', () => { syncColors: true, } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'klm', @@ -273,15 +273,15 @@ describe('palettes', () => { const palette = palettes.warm; it('should use the whole gradient', () => { - const wholePalette = palette.getColors(10); - const color1 = palette.getColor([ + const wholePalette = palette.getCategoricalColors(10); + const color1 = palette.getCategoricalColor([ { name: 'abc', rankAtDepth: 0, totalSeriesAtDepth: 10, }, ]); - const color2 = palette.getColor([ + const color2 = palette.getCategoricalColor([ { name: 'def', rankAtDepth: 9, @@ -304,7 +304,7 @@ describe('palettes', () => { describe('syncColors: false', () => { it('should not query legacy color service', () => { - palette.getColor( + palette.getCategoricalColor( [ { name: 'abc', @@ -323,7 +323,7 @@ describe('palettes', () => { it('should respect the advanced settings color mapping', () => { const configColorGetter = colorsServiceMock.mappedColors.getColorFromConfig as jest.Mock; configColorGetter.mockImplementation(() => 'blue'); - const result = palette.getColor( + const result = palette.getCategoricalColor( [ { name: 'abc', @@ -345,7 +345,7 @@ describe('palettes', () => { }); it('should return a color from the legacy palette based on position of first series', () => { - const result = palette.getColor( + const result = palette.getCategoricalColor( [ { name: 'abc', @@ -368,7 +368,7 @@ describe('palettes', () => { describe('syncColors: true', () => { it('should query legacy color service', () => { - palette.getColor( + palette.getCategoricalColor( [ { name: 'abc', @@ -387,7 +387,7 @@ describe('palettes', () => { it('should respect the advanced settings color mapping', () => { const configColorGetter = colorsServiceMock.mappedColors.getColorFromConfig as jest.Mock; configColorGetter.mockImplementation(() => 'blue'); - const result = palette.getColor( + const result = palette.getCategoricalColor( [ { name: 'abc', @@ -409,7 +409,7 @@ describe('palettes', () => { }); it('should always use root series', () => { - palette.getColor( + palette.getCategoricalColor( [ { name: 'abc', @@ -437,7 +437,7 @@ describe('palettes', () => { describe('custom palette', () => { const palette = palettes.custom; it('should return different colors based on rank at current series', () => { - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -450,7 +450,7 @@ describe('palettes', () => { colors: ['#00ff00', '#000000'], } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -467,7 +467,7 @@ describe('palettes', () => { }); it('should return the same color for different positions on outer series layers', () => { - const color1 = palette.getColor( + const color1 = palette.getCategoricalColor( [ { name: 'abc', @@ -485,7 +485,7 @@ describe('palettes', () => { colors: ['#00ff00', '#000000'], } ); - const color2 = palette.getColor( + const color2 = palette.getCategoricalColor( [ { name: 'abc', @@ -507,7 +507,7 @@ describe('palettes', () => { }); it('should use passed in colors', () => { - const color = palette.getColor( + const color = palette.getCategoricalColor( [ { name: 'abc', @@ -523,5 +523,56 @@ describe('palettes', () => { ); expect(color).toEqual('#00ff00'); }); + + // just an integration test here. More in depth tests on the subject can be found on the helper file + it('should return a color for the given value with its domain', () => { + expect( + palette.getColorForValue!( + 0, + { colors: ['red', 'green', 'blue'], stops: [], gradient: false }, + { min: 0, max: 100 } + ) + ).toBe('red'); + }); + + it('should return a color for the given value with its domain based on custom stops', () => { + expect( + palette.getColorForValue!( + 60, + { + colors: ['red', 'green', 'blue'], + stops: [10, 50, 100], + range: 'percent', + gradient: false, + rangeMin: 0, + rangeMax: 100, + }, + { min: 0, max: 100 } + ) + ).toBe('blue'); + }); + + // just make sure to not have broken anything + it('should work with only legacy arguments, filling with default values the new ones', () => { + expect(palette.toExpression({ colors: [], gradient: false })).toEqual({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'palette', + arguments: { + color: [], + gradient: [false], + reverse: [false], + continuity: ['above'], + stop: [], + range: ['percent'], + rangeMax: [], + rangeMin: [], + }, + }, + ], + }); + }); }); }); diff --git a/src/plugins/charts/public/services/palettes/palettes.tsx b/src/plugins/charts/public/services/palettes/palettes.tsx index b11d598c1c1cb..65e3f9a84203d 100644 --- a/src/plugins/charts/public/services/palettes/palettes.tsx +++ b/src/plugins/charts/public/services/palettes/palettes.tsx @@ -30,6 +30,7 @@ import { lightenColor } from './lighten_color'; import { ChartColorConfiguration, PaletteDefinition, SeriesLayer } from './types'; import { LegacyColorsService } from '../legacy_colors'; import { MappedColors } from '../mapped_colors'; +import { workoutColorForValue } from './helpers'; function buildRoundRobinCategoricalWithMappedColors(): Omit { const colors = euiPaletteColorBlind({ rotations: 2 }); @@ -64,8 +65,8 @@ function buildRoundRobinCategoricalWithMappedColors(): Omit euiPaletteColorBlind(), + getCategoricalColor: getColor, + getCategoricalColors: () => euiPaletteColorBlind(), toExpression: () => ({ type: 'expression', chain: [ @@ -102,8 +103,9 @@ function buildGradient( } return { id, - getColor, - getColors: colors, + getCategoricalColor: getColor, + getCategoricalColors: colors, + canDynamicColoring: true, toExpression: () => ({ type: 'expression', chain: [ @@ -141,8 +143,8 @@ function buildSyncedKibanaPalette( } return { id: 'kibana_palette', - getColor, - getColors: () => colors.seedColors.slice(0, 10), + getCategoricalColor: getColor, + getCategoricalColors: () => colors.seedColors.slice(0, 10), toExpression: () => ({ type: 'expression', chain: [ @@ -161,7 +163,24 @@ function buildSyncedKibanaPalette( function buildCustomPalette(): PaletteDefinition { return { id: 'custom', - getColor: ( + getColorForValue: ( + value, + params: { + colors: string[]; + range: 'number' | 'percent'; + continuity: 'above' | 'below' | 'none' | 'all'; + gradient: boolean; + /** Stops values mark where colors end (non-inclusive value) */ + stops: number[]; + /** Important: specify rangeMin/rangeMax if custom stops are defined! */ + rangeMax: number; + rangeMin: number; + }, + dataBounds + ) => { + return workoutColorForValue(value, params, dataBounds); + }, + getCategoricalColor: ( series: SeriesLayer[], chartConfiguration: ChartColorConfiguration = { behindText: false }, { colors, gradient }: { colors: string[]; gradient: boolean } @@ -179,10 +198,48 @@ function buildCustomPalette(): PaletteDefinition { }, internal: true, title: i18n.translate('charts.palettes.customLabel', { defaultMessage: 'Custom' }), - getColors: (size: number, { colors, gradient }: { colors: string[]; gradient: boolean }) => { + getCategoricalColors: ( + size: number, + { + colors, + gradient, + stepped, + stops, + }: { colors: string[]; gradient: boolean; stepped: boolean; stops: number[] } = { + colors: [], + gradient: false, + stepped: false, + stops: [], + } + ) => { + if (stepped) { + const range = stops[stops.length - 1] - stops[0]; + const offset = stops[0]; + const finalStops = [...stops.map((stop) => (stop - offset) / range)]; + return chroma.scale(colors).domain(finalStops).colors(size); + } return gradient ? chroma.scale(colors).colors(size) : colors; }, - toExpression: ({ colors, gradient }: { colors: string[]; gradient: boolean }) => ({ + canDynamicColoring: false, + toExpression: ({ + colors, + gradient, + stops = [], + rangeMax, + rangeMin, + rangeType = 'percent', + continuity = 'above', + reverse = false, + }: { + colors: string[]; + gradient: boolean; + stops: number[]; + rangeMax?: number; + rangeMin?: number; + rangeType: 'percent' | 'number'; + continuity?: 'all' | 'none' | 'above' | 'below'; + reverse?: boolean; + }) => ({ type: 'expression', chain: [ { @@ -191,6 +248,12 @@ function buildCustomPalette(): PaletteDefinition { arguments: { color: colors, gradient: [gradient], + reverse: [reverse], + continuity: [continuity], + stop: stops, + range: [rangeType], + rangeMax: rangeMax == null ? [] : [rangeMax], + rangeMin: rangeMin == null ? [] : [rangeMin], }, }, ], diff --git a/src/plugins/charts/public/services/palettes/types.ts b/src/plugins/charts/public/services/palettes/types.ts index 3d2a6b032f63e..6f13f62178364 100644 --- a/src/plugins/charts/public/services/palettes/types.ts +++ b/src/plugins/charts/public/services/palettes/types.ts @@ -79,22 +79,12 @@ export interface PaletteDefinition { * @param state The internal state of the palette */ toExpression: (state?: T) => Ast; - /** - * Renders the UI for editing the internal state of the palette. - * Not each palette has to feature an internal state, so this is an optional property. - * @param domElement The dom element to the render the editor UI into - * @param props Current state and state setter to issue updates - */ - renderEditor?: ( - domElement: Element, - props: { state?: T; setState: (updater: (oldState: T) => T) => void } - ) => void; /** * Color a series according to the internal rules of the palette. * @param series The current series along with its ancestors. * @param state The internal state of the palette */ - getColor: ( + getCategoricalColor: ( series: SeriesLayer[], chartConfiguration?: ChartColorConfiguration, state?: T @@ -103,7 +93,20 @@ export interface PaletteDefinition { * Get a spectrum of colors of the current palette. * This can be used if the chart wants to control color assignment locally. */ - getColors: (size: number, state?: T) => string[]; + getCategoricalColors: (size: number, state?: T) => string[]; + /** + * Define whether a palette supports dynamic coloring (i.e. gradient colors mapped to number values) + */ + canDynamicColoring?: boolean; + /** + * Get the assigned color for the given value based on its data domain and state settings. + * This can be used for dynamic coloring based on uniform color distribution or custom stops. + */ + getColorForValue?: ( + value: number | undefined, + state: T, + { min, max }: { min: number; max: number } + ) => string | undefined; } export interface PaletteRegistry { diff --git a/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx b/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx index b09a806e8fc25..9249edef8af92 100644 --- a/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/palette_picker.tsx @@ -39,12 +39,12 @@ export function PalettePicker({ palettes={palettes .getAll() .filter(({ internal }) => !internal) - .map(({ id, title, getColors }) => { + .map(({ id, title, getCategoricalColors }) => { return { value: id, title, type: 'fixed', - palette: getColors( + palette: getCategoricalColors( 10, id === activePalette?.name ? activePalette?.params : undefined ), diff --git a/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx b/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx index 20c0b40bb2e54..749d6ca62bfa9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/palette_picker.tsx @@ -33,12 +33,12 @@ export function PalettePicker({ activePalette, palettes, setPalette, color }: Pa ...palettes .getAll() .filter(({ internal }) => !internal) - .map(({ id, title, getColors }) => { + .map(({ id, title, getCategoricalColors }) => { return { value: id, title, type: 'fixed' as const, - palette: getColors(10), + palette: getCategoricalColors(10), }; }), { @@ -49,7 +49,7 @@ export function PalettePicker({ activePalette, palettes, setPalette, color }: Pa type: 'fixed', palette: palettes .get('custom') - .getColors(10, { colors: [color, finalGradientColor], gradient: true }), + .getCategoricalColors(10, { colors: [color, finalGradientColor], gradient: true }), }, { value: PALETTES.RAINBOW, @@ -59,7 +59,7 @@ export function PalettePicker({ activePalette, palettes, setPalette, color }: Pa type: 'fixed', palette: palettes .get('custom') - .getColors(10, { colors: rainbowColors.slice(0, 10), gradient: false }), + .getCategoricalColors(10, { colors: rainbowColors.slice(0, 10), gradient: false }), }, ]} onChange={(newPalette) => { diff --git a/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts b/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts index adcf1f3ad63cd..028ce3d028997 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts +++ b/src/plugins/vis_type_timeseries/public/application/lib/get_split_by_terms_color.ts @@ -58,7 +58,7 @@ export const getSplitByTermsColor = ({ } : seriesPalette.params; - const outputColor = palettesRegistry?.get(paletteName || 'default').getColor( + const outputColor = palettesRegistry?.get(paletteName || 'default').getCategoricalColor( [ { name: seriesName || emptyLabel, diff --git a/src/plugins/vis_type_xy/public/vis_component.tsx b/src/plugins/vis_type_xy/public/vis_component.tsx index 5da5ffcc637c6..dd88822f7f0f3 100644 --- a/src/plugins/vis_type_xy/public/vis_component.tsx +++ b/src/plugins/vis_type_xy/public/vis_component.tsx @@ -245,7 +245,7 @@ const VisComponent = (props: VisComponentProps) => { if (Object.keys(overwriteColors).includes(seriesName)) { return overwriteColors[seriesName]; } - const outputColor = palettesRegistry?.get(visParams.palette.name).getColor( + const outputColor = palettesRegistry?.get(visParams.palette.name).getCategoricalColor( [ { name: seriesName, diff --git a/x-pack/plugins/canvas/public/functions/pie.test.js b/x-pack/plugins/canvas/public/functions/pie.test.js index 915d8525079db..b1c1746340892 100644 --- a/x-pack/plugins/canvas/public/functions/pie.test.js +++ b/x-pack/plugins/canvas/public/functions/pie.test.js @@ -18,7 +18,7 @@ describe('pie', () => { const fn = functionWrapper( pieFunctionFactory({ get: () => ({ - getColors: () => ['red', 'black'], + getCategoricalColors: () => ['red', 'black'], }), }) ); @@ -59,7 +59,7 @@ describe('pie', () => { const mockedFn = functionWrapper( pieFunctionFactory({ get: () => ({ - getColors: mockedColors, + getCategoricalColors: mockedColors, }), }) ); diff --git a/x-pack/plugins/canvas/public/functions/pie.ts b/x-pack/plugins/canvas/public/functions/pie.ts index 0840667302ebe..a91dc16b770c9 100644 --- a/x-pack/plugins/canvas/public/functions/pie.ts +++ b/x-pack/plugins/canvas/public/functions/pie.ts @@ -173,7 +173,7 @@ export function pieFunctionFactory( canvas: false, colors: paletteService .get(palette.name || 'custom') - .getColors(data.length, palette.params), + .getCategoricalColors(data.length, palette.params), legend: getLegendConfig(legend, data.length), grid: { show: false, diff --git a/x-pack/plugins/canvas/public/functions/plot.test.js b/x-pack/plugins/canvas/public/functions/plot.test.js index 849752d2c984b..5ed858961d798 100644 --- a/x-pack/plugins/canvas/public/functions/plot.test.js +++ b/x-pack/plugins/canvas/public/functions/plot.test.js @@ -21,7 +21,7 @@ describe('plot', () => { const fn = functionWrapper( plotFunctionFactory({ get: () => ({ - getColors: () => ['red', 'black'], + getCategoricalColors: () => ['red', 'black'], }), }) ); @@ -121,7 +121,7 @@ describe('plot', () => { const mockedFn = functionWrapper( plotFunctionFactory({ get: () => ({ - getColors: mockedColors, + getCategoricalColors: mockedColors, }), }) ); diff --git a/x-pack/plugins/canvas/public/functions/plot/index.ts b/x-pack/plugins/canvas/public/functions/plot/index.ts index c0c73c3a21bc6..477c704190146 100644 --- a/x-pack/plugins/canvas/public/functions/plot/index.ts +++ b/x-pack/plugins/canvas/public/functions/plot/index.ts @@ -144,7 +144,7 @@ export function plotFunctionFactory( canvas: false, colors: paletteService .get(args.palette.name || 'custom') - .getColors(data.length, args.palette.params), + .getCategoricalColors(data.length, args.palette.params), legend: getLegendConfig(args.legend, data.length), grid: gridConfig, xaxis: getFlotAxisConfig('x', args.xaxis, { diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index afc69c2e8861f..a4be46f61990b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -13,6 +13,13 @@ exports[`DatatableComponent it renders actions column when there are row actions "b": "left", "c": "right", }, + "getColorForValue": [MockFunction], + "minMaxByColumnId": Object { + "c": Object { + "max": 3, + "min": 3, + }, + }, "rowHasRowClickTriggerActions": Array [ true, true, @@ -244,6 +251,13 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "b": "left", "c": "right", }, + "getColorForValue": [MockFunction], + "minMaxByColumnId": Object { + "c": Object { + "max": 3, + "min": 3, + }, + }, "rowHasRowClickTriggerActions": undefined, "table": Object { "columns": Array [ @@ -462,6 +476,13 @@ exports[`DatatableComponent it should not render actions on header when it is in "b": "left", "c": "right", }, + "getColorForValue": [MockFunction], + "minMaxByColumnId": Object { + "c": Object { + "max": 3, + "min": 3, + }, + }, "rowHasRowClickTriggerActions": Array [ false, false, diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx index 9bc982ebd9944..67255dc8a953e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx @@ -11,6 +11,12 @@ import { DataContext } from './table_basic'; import { createGridCell } from './cell_value'; import { FieldFormat } from 'src/plugins/data/public'; import { Datatable } from 'src/plugins/expressions/public'; +import { IUiSettingsClient } from 'kibana/public'; +import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; +import { Args, ColumnConfigArg } from '../expression'; +import { DataContextType } from './types'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; describe('datatable cell renderer', () => { const table: Datatable = { @@ -30,7 +36,9 @@ describe('datatable cell renderer', () => { { a: { convert: (x) => `formatted ${x}` } as FieldFormat, }, - DataContext + { columns: [], sortingColumnId: '', sortingDirection: 'none' }, + DataContext, + ({ get: jest.fn() } as unknown) as IUiSettingsClient ); it('renders formatted value', () => { @@ -78,4 +86,111 @@ describe('datatable cell renderer', () => { ); expect(cell.find('.lnsTableCell').prop('className')).toContain('--right'); }); + + describe('dynamic coloring', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + const customPalette = paletteRegistry.get('custom'); + + function getCellRenderer(columnConfig: Args) { + return createGridCell( + { + a: { convert: (x) => `formatted ${x}` } as FieldFormat, + }, + columnConfig, + DataContext, + ({ get: jest.fn() } as unknown) as IUiSettingsClient + ); + } + function getColumnConfiguration(): Args { + return { + title: 'myData', + columns: [ + { + columnId: 'a', + colorMode: 'none', + palette: { + type: 'palette', + name: 'custom', + params: { + colors: ['#aaa', '#bbb', '#ccc', '#ddd', '#eee'], + gradient: false, + stops: [20, 40, 60, 80, 100], + range: 'percent', + rangeMin: 0, + rangeMax: 100, + }, + }, + type: 'lens_datatable_column', + } as ColumnConfigArg, + ], + sortingColumnId: '', + sortingDirection: 'none', + }; + } + + function flushEffect(component: ReactWrapper) { + return act(async () => { + await component; + await new Promise((r) => setImmediate(r)); + component.update(); + }); + } + + async function renderCellComponent(columnConfig: Args, context: Partial = {}) { + const CellRendererWithPalette = getCellRenderer(columnConfig); + const setCellProps = jest.fn(); + + const cell = mountWithIntl( + 123 */ } }, + getColorForValue: customPalette.getColorForValue, + ...context, + }} + > + + + ); + + await flushEffect(cell); + + return { setCellProps, cell }; + } + + it('ignores coloring when colorMode is set to "none"', async () => { + const { setCellProps } = await renderCellComponent(getColumnConfiguration()); + + expect(setCellProps).not.toHaveBeenCalled(); + }); + + it('should set the coloring of the cell when enabled', async () => { + const columnConfig = getColumnConfiguration(); + columnConfig.columns[0].colorMode = 'cell'; + + const { setCellProps } = await renderCellComponent(columnConfig, {}); + + expect(setCellProps).toHaveBeenCalledWith({ + style: expect.objectContaining({ backgroundColor: 'blue' }), + }); + }); + + it('should set the coloring of the text when enabled', async () => { + const columnConfig = getColumnConfiguration(); + columnConfig.columns[0].colorMode = 'text'; + + const { setCellProps } = await renderCellComponent(columnConfig, {}); + + expect(setCellProps).toHaveBeenCalledWith({ + style: expect.objectContaining({ color: 'blue' }), + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx index 2261dd06b532b..a6c50f00cb77f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -5,30 +5,74 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { IUiSettingsClient } from 'kibana/public'; import type { FormatFactory } from '../../types'; import type { DataContextType } from './types'; +import { ColumnConfig } from './table_basic'; +import { getContrastColor } from '../../shared_components/coloring/utils'; +import { getOriginalId } from '../transpose_helpers'; export const createGridCell = ( formatters: Record>, - DataContext: React.Context -) => ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { - const { table, alignments } = useContext(DataContext); - const rowValue = table?.rows[rowIndex][columnId]; - const content = formatters[columnId]?.convert(rowValue, 'html'); - const currentAlignment = alignments && alignments[columnId]; - const alignmentClassName = `lnsTableCell--${currentAlignment}`; + columnConfig: ColumnConfig, + DataContext: React.Context, + uiSettings: IUiSettingsClient +) => { + // Changing theme requires a full reload of the page, so we can cache here + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + return ({ rowIndex, columnId, setCellProps }: EuiDataGridCellValueElementProps) => { + const { table, alignments, minMaxByColumnId, getColorForValue } = useContext(DataContext); + const rowValue = table?.rows[rowIndex][columnId]; + const content = formatters[columnId]?.convert(rowValue, 'html'); + const currentAlignment = alignments && alignments[columnId]; + const alignmentClassName = `lnsTableCell--${currentAlignment}`; - return ( -
- ); + const { colorMode, palette } = + columnConfig.columns.find(({ columnId: id }) => id === columnId) || {}; + + useEffect(() => { + const originalId = getOriginalId(columnId); + if (minMaxByColumnId?.[originalId]) { + if (colorMode !== 'none' && palette?.params && getColorForValue) { + // workout the bucket the value belongs to + const color = getColorForValue(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); + } + setCellProps({ + style, + }); + } + } + } + // make sure to clean it up when something change + // this avoids cell's styling to stick forever + return () => { + if (minMaxByColumnId?.[originalId]) { + setCellProps({ + style: { + backgroundColor: undefined, + color: undefined, + }, + }); + } + }; + }, [rowValue, columnId, setCellProps, colorMode, palette, minMaxByColumnId, getColorForValue]); + + return ( +
+ ); + }; }; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.scss b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.scss new file mode 100644 index 0000000000000..504adb05e57d7 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.scss @@ -0,0 +1,7 @@ +.lnsDynamicColoringRow { + align-items: center; +} + +.lnsDynamicColoringClickable { + cursor: pointer; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index e0d31a3ed0201..88948e9a7615b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -12,12 +12,18 @@ import { DatatableVisualizationState } from '../visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; import { mountWithIntl } from '@kbn/test/jest'; import { TableDimensionEditor } from './dimension_editor'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { PaletteRegistry } from 'src/plugins/charts/public'; +import { PalettePanelContainer } from './palette_panel_container'; +import { act } from 'react-dom/test-utils'; describe('data table dimension editor', () => { let frame: FramePublicAPI; let state: DatatableVisualizationState; let setState: (newState: DatatableVisualizationState) => void; - let props: VisualizationDimensionEditorProps; + let props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + }; function testState(): DatatableVisualizationState { return { @@ -59,6 +65,8 @@ describe('data table dimension editor', () => { layerId: 'first', state, setState, + paletteService: chartPluginMock.createPaletteRegistry(), + panelRef: React.createRef(), }; }); @@ -72,17 +80,23 @@ describe('data table dimension editor', () => { it('should render default alignment for number', () => { frame.activeData!.first.columns[0].meta.type = 'number'; const instance = mountWithIntl(); - expect(instance.find(EuiButtonGroup).prop('idSelected')).toEqual( - expect.stringContaining('right') - ); + expect( + instance + .find('[data-test-subj="lnsDatatable_alignment_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('right')); }); it('should render specific alignment', () => { state.columns[0].alignment = 'center'; const instance = mountWithIntl(); - expect(instance.find(EuiButtonGroup).prop('idSelected')).toEqual( - expect.stringContaining('center') - ); + expect( + instance + .find('[data-test-subj="lnsDatatable_alignment_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('center')); }); it('should set state for the right column', () => { @@ -95,7 +109,10 @@ describe('data table dimension editor', () => { }, ]; const instance = mountWithIntl(); - instance.find(EuiButtonGroup).prop('onChange')('center'); + instance + .find('[data-test-subj="lnsDatatable_alignment_groups"]') + .find(EuiButtonGroup) + .prop('onChange')('center'); expect(setState).toHaveBeenCalledWith({ ...state, columns: [ @@ -109,4 +126,90 @@ describe('data table dimension editor', () => { ], }); }); + + it('should not show the dynamic coloring option for non numeric columns', () => { + const instance = mountWithIntl(); + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]').exists()).toBe( + false + ); + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_palette"]').exists()).toBe( + false + ); + }); + + it('should set the dynamic coloring default to "none"', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + const instance = mountWithIntl(); + expect( + instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('none')); + + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_palette"]').exists()).toBe( + false + ); + }); + + 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'; + const instance = mountWithIntl(); + expect( + instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]') + .find(EuiButtonGroup) + .prop('idSelected') + ).toEqual(expect.stringContaining('text')); + + expect(instance.find('[data-test-subj="lnsDatatable_dynamicColoring_palette"]').exists()).toBe( + true + ); + }); + + it('should set the coloring mode to the right column', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + state.columns = [ + { + columnId: 'foo', + }, + { + columnId: 'bar', + }, + ]; + const instance = mountWithIntl(); + instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_groups"]') + .find(EuiButtonGroup) + .prop('onChange')('cell'); + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: [ + { + columnId: 'foo', + colorMode: 'cell', + palette: expect.objectContaining({ type: 'palette' }), + }, + { + columnId: 'bar', + }, + ], + }); + }); + + it('should open the palette panel when "Settings" link is clicked in the palette input', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + state.columns[0].colorMode = 'cell'; + const instance = mountWithIntl(); + + act(() => + (instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_trigger"]') + .first() + .prop('onClick') as () => void)?.() + ); + + expect(instance.find(PalettePanelContainer).exists()).toBe(true); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx index a750744811790..76c47a9c743c5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -5,36 +5,91 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiSwitch, EuiButtonGroup, htmlIdGenerator } from '@elastic/eui'; +import { + EuiFormRow, + EuiSwitch, + EuiButtonGroup, + htmlIdGenerator, + EuiColorPaletteDisplay, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmpty, +} from '@elastic/eui'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { VisualizationDimensionEditorProps } from '../../types'; import { DatatableVisualizationState } from '../visualization'; import { getOriginalId } from '../transpose_helpers'; +import { + CustomizablePalette, + applyPaletteParams, + defaultPaletteParams, + FIXED_PROGRESSION, + getStopsForFixedMode, +} from '../../shared_components/'; +import { PalettePanelContainer } from './palette_panel_container'; +import { findMinMaxByColumnId } from './shared_utils'; +import './dimension_editor.scss'; const idPrefix = htmlIdGenerator()(); +type ColumnType = DatatableVisualizationState['columns'][number]; + +function updateColumnWith( + state: DatatableVisualizationState, + columnId: string, + newColumnProps: Partial +) { + return state.columns.map((currentColumn) => { + if (currentColumn.columnId === columnId) { + return { ...currentColumn, ...newColumnProps }; + } else { + return currentColumn; + } + }); +} + export function TableDimensionEditor( - props: VisualizationDimensionEditorProps + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } ) { const { state, setState, frame, accessor } = props; const column = state.columns.find(({ columnId }) => accessor === columnId); + const [isPaletteOpen, setIsPaletteOpen] = useState(false); 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 currentAlignment = - column?.alignment || - (frame.activeData && - frame.activeData[state.layerId]?.columns.find( - (col) => col.id === accessor || getOriginalId(col.id) === accessor - )?.meta.type === 'number' - ? 'right' - : 'left'); + const isNumericField = + currentData?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor) + ?.meta.type === 'number'; + + const currentAlignment = column?.alignment || (isNumericField ? 'right' : 'left'); + const currentColorMode = column?.colorMode || 'none'; + const hasDynamicColoring = currentColorMode !== 'none'; const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; + const hasTransposedColumn = state.columns.some(({ isTransposed }) => isTransposed); + const columnsToCheck = hasTransposedColumn + ? currentData?.columns.filter(({ id }) => getOriginalId(id) === accessor).map(({ id }) => id) || + [] + : [accessor]; + const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData); + const currentMinMax = minMaxByColumnId[accessor]; + + const activePalette = column?.palette || { + type: 'palette', + name: defaultPaletteParams.name, + }; + // need to tell the helper that the colorStops are required to display + const displayStops = applyPaletteParams(props.paletteService, activePalette, currentMinMax); + return ( <> { - const newMode = id.replace(idPrefix, '') as 'left' | 'right' | 'center'; - const newColumns = state.columns.map((currentColumn) => { - if (currentColumn.columnId === accessor) { - return { - ...currentColumn, - alignment: newMode, - }; - } else { - return currentColumn; - } + const newMode = id.replace(idPrefix, '') as ColumnType['alignment']; + setState({ + ...state, + columns: updateColumnWith(state, accessor, { alignment: newMode }), }); - setState({ ...state, columns: newColumns }); }} /> @@ -127,6 +175,135 @@ export function TableDimensionEditor( /> )} + {isNumericField && ( + <> + + { + const newMode = id.replace(idPrefix, '') as ColumnType['colorMode']; + const params: Partial = { + colorMode: newMode, + }; + if (!column?.palette && newMode !== 'none') { + params.palette = { + ...activePalette, + params: { + ...activePalette.params, + // that's ok, at first open we're going to throw them away and recompute + stops: displayStops, + }, + }; + } + // clear up when switching to no coloring + if (column?.palette && newMode === 'none') { + params.palette = undefined; + } + setState({ + ...state, + columns: updateColumnWith(state, accessor, params), + }); + }} + /> + + {hasDynamicColoring && ( + + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + /> + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + size="xs" + flush="both" + > + {i18n.translate('xpack.lens.paletteTableGradient.customize', { + defaultMessage: 'Edit', + })} + + setIsPaletteOpen(!isPaletteOpen)} + > + { + setState({ + ...state, + columns: updateColumnWith(state, accessor, { palette: newPalette }), + }); + }} + /> + + + + + )} + + )} ); } diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.scss b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.scss new file mode 100644 index 0000000000000..db14d064d1881 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.scss @@ -0,0 +1,53 @@ +@import '@elastic/eui/src/components/flyout/variables'; +@import '@elastic/eui/src/components/flyout/mixins'; + +.lnsPalettePanelContainer { + // Use the EuiFlyout style + @include euiFlyout; + // But with custom positioning to keep it within the sidebar contents + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; + animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; + // making just a bit higher than the dimension flyout to stack on top of it + z-index: $euiZLevel3 + 1 +} + +.lnsPalettePanelContainer__footer { + padding: $euiSizeS; +} + +.lnsPalettePanelContainer__header { + padding: $euiSizeS $euiSizeXS; +} + +.lnsPalettePanelContainer__headerTitle { + padding: $euiSizeS $euiSizeXS; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +.lnsPalettePanelContainer__headerLink { + &:focus-within { + background-color: transparentize($euiColorVis1, .9); + + .lnsPalettePanelContainer__headerTitle { + text-decoration: underline; + } + } +} + +.lnsPalettePanelContainer__backIcon { + &:hover { + transform: none !important; // sass-lint:disable-line no-important + } + + &:focus { + background-color: transparent; + } +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.tsx new file mode 100644 index 0000000000000..1371fbe73ef84 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/palette_panel_container.tsx @@ -0,0 +1,112 @@ +/* + * 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 './palette_panel_container.scss'; + +import React, { useState, useEffect, MutableRefObject } from 'react'; +import { + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiTitle, + EuiButtonIcon, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFocusTrap, + EuiOutsideClickDetector, + EuiPortal, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +export function PalettePanelContainer({ + isOpen, + handleClose, + children, + siblingRef, +}: { + isOpen: boolean; + handleClose: () => void; + children: React.ReactElement | React.ReactElement[]; + siblingRef: MutableRefObject; +}) { + const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); + + const closeFlyout = () => { + handleClose(); + setFocusTrapIsEnabled(false); + }; + + useEffect(() => { + if (isOpen) { + // without setTimeout here the flyout pushes content when animating + setTimeout(() => { + setFocusTrapIsEnabled(true); + }, 255); + } + }, [isOpen]); + + return isOpen && siblingRef.current ? ( + + + +
+ + + + + + + +

+ + {i18n.translate('xpack.lens.table.palettePanelTitle', { + defaultMessage: 'Edit color', + })} + +

+
+
+
+
+ + {children} + + + + {i18n.translate('xpack.lens.table.palettePanelContainer.back', { + defaultMessage: 'Back', + })} + + +
+
+
+
+ ) : null; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/shared_utils.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/shared_utils.tsx new file mode 100644 index 0000000000000..92a949e65c67e --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/shared_utils.tsx @@ -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 { Datatable } from 'src/plugins/expressions'; +import { getOriginalId } from '../transpose_helpers'; + +export const findMinMaxByColumnId = (columnIds: string[], table: Datatable | undefined) => { + const minMax: Record = {}; + + if (table != null) { + for (const columnId of columnIds) { + const originalId = getOriginalId(columnId); + minMax[originalId] = minMax[originalId] || { max: -Infinity, min: Infinity }; + table.rows.forEach((row) => { + const rowValue = row[columnId]; + if (rowValue != null) { + if (minMax[originalId].min > rowValue) { + minMax[originalId].min = rowValue; + } + if (minMax[originalId].max < rowValue) { + minMax[originalId].max = rowValue; + } + } + }); + // what happens when there's no data in the table? Fallback to a percent range + if (minMax[originalId].max === -Infinity) { + minMax[originalId] = { max: 100, min: 0, fallback: true }; + } + } + } + return minMax; +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 22577e8ef5fd3..509969c2b71ec 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -15,6 +15,8 @@ import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { DataContext, DatatableComponent } from './table_basic'; import { LensMultiTable } from '../../types'; import { DatatableProps } from '../expression'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { IUiSettingsClient } from 'kibana/public'; function sampleArgs() { const indexPatternId = 'indexPatternId'; @@ -99,6 +101,8 @@ describe('DatatableComponent', () => { formatFactory={(x) => x as IFieldFormat} dispatchEvent={onDispatchEvent} getType={jest.fn()} + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} renderMode="edit" /> ) @@ -118,6 +122,8 @@ describe('DatatableComponent', () => { getType={jest.fn()} rowHasRowClickTriggerActions={[true, true, true]} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ) ).toMatchSnapshot(); @@ -136,6 +142,8 @@ describe('DatatableComponent', () => { getType={jest.fn()} rowHasRowClickTriggerActions={[false, false, false]} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ) ).toMatchSnapshot(); @@ -158,6 +166,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -199,6 +209,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -279,6 +291,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -325,6 +339,8 @@ describe('DatatableComponent', () => { type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) )} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); @@ -345,6 +361,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -393,6 +411,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -421,6 +441,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -447,6 +469,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); @@ -471,6 +495,8 @@ describe('DatatableComponent', () => { dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="edit" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); // mnake a copy of the data, changing only the name of the first column @@ -483,4 +509,34 @@ describe('DatatableComponent', () => { 'new a' ); }); + + test('it does compute minMax for each numeric column', () => { + const { data, args } = sampleArgs(); + + const wrapper = shallow( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + paletteService={chartPluginMock.createPaletteRegistry()} + uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} + /> + ); + + expect(wrapper.find(DataContext.Provider).prop('value').minMaxByColumnId).toEqual({ + c: { min: 3, max: 3 }, + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index 24cde07cebaa0..e6fcf3f321f7f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -18,6 +18,7 @@ import { EuiDataGridSorting, EuiDataGridStyle, } from '@elastic/eui'; +import { CustomPaletteState, PaletteOutput } from 'src/plugins/charts/common'; import { FormatFactory, LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; import { VisualizationContainer } from '../../visualization_container'; import { EmptyPlaceholder } from '../../shared_components'; @@ -40,6 +41,8 @@ import { createGridSortingConfig, createTransposeColumnFilterHandler, } from './table_actions'; +import { findMinMaxByColumnId } from './shared_utils'; +import { CUSTOM_PALETTE } from '../../shared_components/coloring/constants'; export const DataContext = React.createContext({}); @@ -50,8 +53,9 @@ const gridStyle: EuiDataGridStyle = { export interface ColumnConfig { columns: Array< - ColumnState & { + Omit & { type: 'lens_datatable_column'; + palette?: PaletteOutput; } >; sortingColumnId: string | undefined; @@ -203,20 +207,34 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ] ); + const isNumericMap: Record = useMemo(() => { + const numericMap: Record = {}; + for (const column of firstLocalTable.columns) { + numericMap[column.id] = column.meta.type === 'number'; + } + return numericMap; + }, [firstLocalTable]); + const alignments: Record = useMemo(() => { const alignmentMap: Record = {}; columnConfig.columns.forEach((column) => { if (column.alignment) { alignmentMap[column.columnId] = column.alignment; } else { - const isNumeric = - firstLocalTable.columns.find((dataColumn) => dataColumn.id === column.columnId)?.meta - .type === 'number'; - alignmentMap[column.columnId] = isNumeric ? 'right' : 'left'; + alignmentMap[column.columnId] = isNumericMap[column.columnId] ? 'right' : 'left'; } }); return alignmentMap; - }, [firstLocalTable, columnConfig]); + }, [columnConfig, isNumericMap]); + + const minMaxByColumnId: Record = useMemo(() => { + return findMinMaxByColumnId( + columnConfig.columns + .filter(({ columnId }) => isNumericMap[columnId]) + .map(({ columnId }) => columnId), + firstTable + ); + }, [firstTable, isNumericMap, columnConfig]); const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { @@ -254,7 +272,10 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ]; }, [firstTableRef, onRowContextMenuClick, columnConfig, hasAtLeastOneRowClickAction]); - const renderCellValue = useMemo(() => createGridCell(formatters, DataContext), [formatters]); + const renderCellValue = useMemo( + () => createGridCell(formatters, columnConfig, DataContext, props.uiSettings), + [formatters, columnConfig, props.uiSettings] + ); const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ visibleColumns, @@ -286,6 +307,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { table: firstLocalTable, rowHasRowClickTriggerActions: props.rowHasRowClickTriggerActions, alignments, + minMaxByColumnId, + getColorForValue: props.paletteService.get(CUSTOM_PALETTE).getColorForValue!, }} > IAggType; renderMode: RenderMode; + paletteService: PaletteRegistry; + uiSettings: IUiSettingsClient; /** * A boolean for each table row, which is true if the row active @@ -55,4 +59,10 @@ export interface DataContextType { table?: Datatable; rowHasRowClickTriggerActions?: boolean[]; alignments?: Record; + minMaxByColumnId?: Record; + getColorForValue?: ( + value: number | undefined, + state: CustomPaletteState, + minMax: { min: number; max: number } + ) => string | undefined; } diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 7d879217abf8b..2d5f4aea98856 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -17,6 +17,9 @@ import { ExpressionFunctionDefinition, ExpressionRenderDefinition, } from 'src/plugins/expressions'; +import { CustomPaletteState, PaletteOutput } from 'src/plugins/charts/common'; +import { PaletteRegistry } from 'src/plugins/charts/public'; +import { IUiSettingsClient } from 'kibana/public'; import { getSortingCriteria } from './sorting'; import { DatatableComponent } from './components/table_basic'; @@ -26,10 +29,15 @@ import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } fr import type { DatatableRender } from './components/types'; import { transposeTable } from './transpose_helpers'; +export type ColumnConfigArg = Omit & { + type: 'lens_datatable_column'; + palette?: PaletteOutput; +}; + export interface Args { title: string; description?: string; - columns: Array; + columns: ColumnConfigArg[]; sortingColumnId: string | undefined; sortingDirection: 'asc' | 'desc' | 'none'; } @@ -160,6 +168,11 @@ export const datatableColumn: ExpressionFunctionDefinition< width: { types: ['number'], help: '' }, isTransposed: { types: ['boolean'], help: '' }, transposable: { types: ['boolean'], help: '' }, + colorMode: { types: ['string'], help: '' }, + palette: { + types: ['palette'], + help: '', + }, }, fn: function fn(input: unknown, args: ColumnState) { return { @@ -172,6 +185,8 @@ export const datatableColumn: ExpressionFunctionDefinition< export const getDatatableRenderer = (dependencies: { formatFactory: FormatFactory; getType: Promise<(name: string) => IAggType>; + paletteService: PaletteRegistry; + uiSettings: IUiSettingsClient; }): ExpressionRenderDefinition => ({ name: 'lens_datatable_renderer', displayName: i18n.translate('xpack.lens.datatable.visualizationName', { @@ -222,8 +237,10 @@ export const getDatatableRenderer = (dependencies: { formatFactory={dependencies.formatFactory} dispatchEvent={handlers.event} renderMode={handlers.getRenderMode()} + paletteService={dependencies.paletteService} getType={resolvedGetType} rowHasRowClickTriggerActions={rowHasRowClickTriggerActions} + uiSettings={dependencies.uiSettings} /> , domNode, diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index f0939f6195229..7f48d00d00f7f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -6,6 +6,7 @@ */ import { CoreSetup } from 'kibana/public'; +import { ChartsPluginSetup } from 'src/plugins/charts/public'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { EditorFrameSetup, FormatFactory } from '../types'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -17,6 +18,7 @@ export interface DatatableVisualizationPluginSetupPlugins { expressions: ExpressionsSetup; formatFactory: Promise; editorFrame: EditorFrameSetup; + charts: ChartsPluginSetup; } export class DatatableVisualization { @@ -24,15 +26,16 @@ export class DatatableVisualization { setup( core: CoreSetup, - { expressions, formatFactory, editorFrame }: DatatableVisualizationPluginSetupPlugins + { expressions, formatFactory, editorFrame, charts }: DatatableVisualizationPluginSetupPlugins ) { editorFrame.registerVisualization(async () => { const { getDatatable, datatableColumn, getDatatableRenderer, - datatableVisualization, + getDatatableVisualization, } = await import('../async_services'); + const palettes = await charts.palettes.getPalettes(); const resolvedFormatFactory = await formatFactory; expressions.registerFunction(() => datatableColumn); @@ -43,9 +46,11 @@ export class DatatableVisualization { getType: core .getStartServices() .then(([_, { data: dataStart }]) => dataStart.search.aggs.types.get), + paletteService: palettes, + uiSettings: core.uiSettings, }) ); - return datatableVisualization; + return getDatatableVisualization({ paletteService: palettes }); }); } } diff --git a/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts b/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts index 6e29e018b481e..a35edf7499073 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/transpose_helpers.ts @@ -7,9 +7,9 @@ import type { FieldFormat } from 'src/plugins/data/public'; import type { Datatable, DatatableColumn, DatatableRow } from 'src/plugins/expressions'; +import { ColumnConfig } from './components/table_basic'; -import { Args } from './expression'; -import { ColumnState } from './visualization'; +import { Args, ColumnConfigArg } from './expression'; const TRANSPOSE_SEPARATOR = '---'; @@ -87,11 +87,11 @@ export function transposeTable( function transposeRows( firstTable: Datatable, - bucketsColumnArgs: Array, + bucketsColumnArgs: ColumnConfigArg[], formatters: Record, transposedColumnFormatter: FieldFormat, transposedColumnId: string, - metricsColumnArgs: Array + metricsColumnArgs: ColumnConfigArg[] ) { const rowsByBucketColumns: Record = groupRowsByBucketColumns( firstTable, @@ -113,8 +113,8 @@ function transposeRows( */ function updateColumnArgs( args: Args, - bucketsColumnArgs: Array, - transposedColumnGroups: Array> + bucketsColumnArgs: ColumnConfig['columns'], + transposedColumnGroups: Array ) { args.columns = [...bucketsColumnArgs]; // add first column from each group, then add second column for each group, ... @@ -151,8 +151,8 @@ function getUniqueValues(table: Datatable, formatter: FieldFormat, columnId: str */ function transposeColumns( args: Args, - bucketsColumnArgs: Array, - metricColumns: Array, + bucketsColumnArgs: ColumnConfig['columns'], + metricColumns: ColumnConfig['columns'], firstTable: Datatable, uniqueValues: string[], uniqueRawValues: unknown[], @@ -196,10 +196,10 @@ function transposeColumns( */ function mergeRowGroups( rowsByBucketColumns: Record, - bucketColumns: ColumnState[], + bucketColumns: ColumnConfigArg[], formatter: FieldFormat, transposedColumnId: string, - metricColumns: ColumnState[] + metricColumns: ColumnConfigArg[] ) { return Object.values(rowsByBucketColumns).map((rows) => { const mergedRow: DatatableRow = {}; @@ -222,7 +222,7 @@ function mergeRowGroups( */ function groupRowsByBucketColumns( firstTable: Datatable, - bucketColumns: ColumnState[], + bucketColumns: ColumnConfigArg[], formatters: Record ) { const rowsByBucketColumns: Record = {}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 1848565114dea..ea8237defc291 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -8,7 +8,7 @@ import { Ast } from '@kbn/interpreter/common'; import { buildExpression } from '../../../../../src/plugins/expressions/public'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; -import { DatatableVisualizationState, datatableVisualization } from './visualization'; +import { DatatableVisualizationState, getDatatableVisualization } from './visualization'; import { Operation, DataType, @@ -16,6 +16,7 @@ import { TableSuggestionColumn, VisualizationDimensionGroupConfig, } from '../types'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; function mockFrame(): FramePublicAPI { return { @@ -32,6 +33,10 @@ function mockFrame(): FramePublicAPI { }; } +const datatableVisualization = getDatatableVisualization({ + paletteService: chartPluginMock.createPaletteRegistry(), +}); + describe('Datatable Visualization', () => { describe('#initialize', () => { it('should initialize from the empty state', () => { @@ -427,22 +432,28 @@ describe('Datatable Visualization', () => { ); const columnArgs = buildExpression(expression).findFunction('lens_datatable_column'); expect(columnArgs).toHaveLength(2); - expect(columnArgs[0].arguments).toEqual({ - columnId: ['c'], - hidden: [], - width: [], - isTransposed: [], - transposable: [true], - alignment: [], - }); - expect(columnArgs[1].arguments).toEqual({ - columnId: ['b'], - hidden: [], - width: [], - isTransposed: [], - transposable: [true], - alignment: [], - }); + expect(columnArgs[0].arguments).toEqual( + expect.objectContaining({ + columnId: ['c'], + hidden: [], + width: [], + isTransposed: [], + transposable: [true], + alignment: [], + colorMode: ['none'], + }) + ); + expect(columnArgs[1].arguments).toEqual( + expect.objectContaining({ + columnId: ['b'], + hidden: [], + width: [], + isTransposed: [], + transposable: [true], + alignment: [], + colorMode: ['none'], + }) + ); }); it('returns no expression if the metric dimension is not defined', () => { diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 9bd482c73bff5..efde4160019e7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -11,6 +11,7 @@ import { Ast } from '@kbn/interpreter/common'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { DatatableColumn } from 'src/plugins/expressions/public'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { SuggestionRequest, Visualization, @@ -19,6 +20,9 @@ import { } from '../types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; import { TableDimensionEditor } from './components/dimension_editor'; +import { CUSTOM_PALETTE } from '../shared_components/coloring/constants'; +import { CustomPaletteParams } from '../shared_components/coloring/types'; +import { getStopsForFixedMode } from '../shared_components'; export interface ColumnState { columnId: string; @@ -32,6 +36,8 @@ export interface ColumnState { originalName?: string; bucketValues?: Array<{ originalBucketColumn: DatatableColumn; value: unknown }>; alignment?: 'left' | 'right' | 'center'; + palette?: PaletteOutput; + colorMode?: 'none' | 'cell' | 'text'; } export interface SortingState { @@ -49,7 +55,11 @@ const visualizationLabel = i18n.translate('xpack.lens.datatable.label', { defaultMessage: 'Table', }); -export const datatableVisualization: Visualization = { +export const getDatatableVisualization = ({ + paletteService, +}: { + paletteService: PaletteRegistry; +}): Visualization => ({ id: 'lnsDatatable', visualizationTypes: [ @@ -239,10 +249,26 @@ export const datatableVisualization: Visualization layerId: state.layerId, accessors: sortedColumns .filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed) - .map((accessor) => ({ - columnId: accessor, - triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined, - })), + .map((accessor) => { + const columnConfig = columnMap[accessor]; + const hasColoring = Boolean( + columnConfig.colorMode !== 'none' && columnConfig.palette?.params?.stops + ); + return { + columnId: accessor, + triggerIcon: columnConfig.hidden + ? 'invisible' + : hasColoring + ? 'colorBy' + : undefined, + palette: hasColoring + ? getStopsForFixedMode( + columnConfig.palette?.params?.stops || [], + columnConfig.palette?.params?.colorStops + ) + : undefined, + }; + }), supportsMoreColumns: true, filterOperations: (op) => !op.isBucketed, required: true, @@ -285,7 +311,7 @@ export const datatableVisualization: Visualization renderDimensionEditor(domElement, props) { render( - + , domElement ); @@ -320,26 +346,41 @@ export const datatableVisualization: Visualization arguments: { title: [title || ''], description: [description || ''], - columns: columns.map((column) => ({ - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_column', - arguments: { - columnId: [column.columnId], - hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], - width: typeof column.width === 'undefined' ? [] : [column.width], - isTransposed: - typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed], - transposable: [ - !datasource!.getOperationForColumnId(column.columnId)?.isBucketed, - ], - alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment], + columns: columns.map((column) => { + const paletteParams = { + ...column.palette?.params, + // rewrite colors and stops as two distinct arguments + colors: (column.palette?.params?.stops || []).map(({ color }) => color), + stops: + column.palette?.params?.name === 'custom' + ? (column.palette?.params?.stops || []).map(({ stop }) => stop) + : [], + reverse: false, // managed at UI level + }; + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_column', + arguments: { + columnId: [column.columnId], + hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], + width: typeof column.width === 'undefined' ? [] : [column.width], + isTransposed: + typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed], + transposable: [ + !datasource!.getOperationForColumnId(column.columnId)?.isBucketed, + ], + alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment], + colorMode: [column.colorMode ?? 'none'], + palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)], + }, }, - }, - ], - })), + ], + }; + }), sortingColumnId: [state.sorting?.columnId || ''], sortingDirection: [state.sorting?.direction || 'none'], }, @@ -395,7 +436,7 @@ export const datatableVisualization: Visualization return state; } }, -}; +}); function getDataSourceAndSortedColumns( state: DatatableVisualizationState, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index b8d3170b3e165..a8d610f2740de 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -29,11 +29,13 @@ export function DimensionContainer({ groupLabel, handleClose, panel, + panelRef, }: { isOpen: boolean; handleClose: () => void; panel: React.ReactElement; groupLabel: string; + panelRef: (el: HTMLDivElement) => void; }) { const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); @@ -73,65 +75,67 @@ export function DimensionContainer({ }); return isOpen ? ( - - - -
- - - - - - - -

- - {i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel, - }, - })} - -

-
-
-
-
- - {panel} - - - - {i18n.translate('xpack.lens.dimensionContainer.close', { - defaultMessage: 'Close', - })} - - -
-
-
+
+ + + +
+ + + + +

+ + {i18n.translate('xpack.lens.configure.configurePanelTitle', { + defaultMessage: '{groupLabel} configuration', + values: { + groupLabel, + }, + })} + +

+
+
+ + + +
+
+ + {panel} + + + + {i18n.translate('xpack.lens.dimensionContainer.close', { + defaultMessage: 'Close', + })} + + +
+
+
+
) : null; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index cf3c9099d4b0d..a605a94a34646 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -7,7 +7,7 @@ import './layer_panel.scss'; -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { EuiPanel, EuiSpacer, @@ -72,6 +72,7 @@ export function LayerPanel( setActiveDimension(initialActiveDimensionState); }, [activeVisualization.id]); + const panelRef = useRef(null); const registerLayerRef = useCallback((el) => registerNewLayerRef(layerId, el), [ layerId, registerNewLayerRef, @@ -405,6 +406,7 @@ export function LayerPanel( (panelRef.current = el)} isOpen={!!activeId} groupLabel={activeGroup?.groupLabel || ''} handleClose={() => { @@ -484,6 +486,7 @@ export function LayerPanel( groupId: activeGroup.groupId, accessor: activeId, setState: props.updateVisualization, + panelRef, }} />
diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index a56b3ccaa5bde..38669d72474df 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -105,10 +105,9 @@ export type FrameMock = jest.Mocked; export function createMockPaletteDefinition(): jest.Mocked { return { - getColors: jest.fn((_) => ['#ff0000', '#00ff00']), + getCategoricalColors: jest.fn((_) => ['#ff0000', '#00ff00']), title: 'Mock Palette', id: 'default', - renderEditor: jest.fn(), toExpression: jest.fn(() => ({ type: 'expression', chain: [ @@ -119,7 +118,7 @@ export function createMockPaletteDefinition(): jest.Mocked { }, ], })), - getColor: jest.fn().mockReturnValue('#ff0000'), + getCategoricalColor: jest.fn().mockReturnValue('#ff0000'), }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 8d18a2752fd7e..0e74ef6b85c80 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useState } from 'react'; import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import { useDebounceWithOptions } from '../../../../shared_components'; import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; import { IndexPatternLayer } from '../../../types'; import { @@ -19,12 +20,7 @@ import { hasDateField, } from './utils'; import { updateColumnParam } from '../../layer_helpers'; -import { - getFormatFromPreviousColumn, - isValidNumber, - useDebounceWithOptions, - getFilter, -} from '../helpers'; +import { getFormatFromPreviousColumn, isValidNumber, getFilter } from '../helpers'; import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils'; import { HelpPopover, HelpPopoverButton } from '../../../help_popover'; import type { OperationDefinition, ParamEditorProps } from '..'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx index f719ac4250912..45abbcd3d9cf9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx @@ -5,35 +5,11 @@ * 2.0. */ -import { useRef } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; import { i18n } from '@kbn/i18n'; import { IndexPatternColumn, operationDefinitionMap } from '.'; import { FieldBasedIndexPatternColumn } from './column_types'; import { IndexPattern } from '../../types'; -export const useDebounceWithOptions = ( - fn: Function, - { skipFirstRender }: { skipFirstRender: boolean } = { skipFirstRender: false }, - ms?: number | undefined, - deps?: React.DependencyList | undefined -) => { - const isFirstRender = useRef(true); - const newDeps = [...(deps || []), isFirstRender]; - - return useDebounce( - () => { - if (skipFirstRender && isFirstRender.current) { - isFirstRender.current = false; - return; - } - return fn(); - }, - ms, - newDeps - ); -}; - export function getInvalidFieldMessage( column: FieldBasedIndexPatternColumn, indexPattern?: IndexPattern diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index 705a1f7172fff..4c09ae4ed8c47 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -16,10 +16,10 @@ import { getInvalidFieldMessage, getSafeName, isValidNumber, - useDebounceWithOptions, getFilter, } from './helpers'; import { FieldBasedIndexPatternColumn } from './column_types'; +import { useDebounceWithOptions } from '../../../shared_components'; export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColumn { operationType: 'percentile'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx index b3ffb58df00d3..43f5527e42d4b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/advanced_editor.tsx @@ -22,6 +22,7 @@ import { htmlIdGenerator, keys, } from '@elastic/eui'; +import { useDebounceWithOptions } from '../../../../shared_components'; import { IFieldFormat } from '../../../../../../../../src/plugins/data/common'; import { RangeTypeLens, isValidRange } from './ranges'; import { FROM_PLACEHOLDER, TO_PLACEHOLDER, TYPING_DEBOUNCE_TIME } from './constants'; @@ -31,7 +32,7 @@ import { DraggableBucketContainer, LabelInput, } from '../shared_components'; -import { isValidNumber, useDebounceWithOptions } from '../helpers'; +import { isValidNumber } from '../helpers'; const generateId = htmlIdGenerator(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx index 4851b6ff3ec97..3389c723b4daf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx @@ -23,7 +23,7 @@ import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/public'; import { RangeColumnParams, UpdateParamsFnType, MODES_TYPES } from './ranges'; import { AdvancedRangeEditor } from './advanced_editor'; import { TYPING_DEBOUNCE_TIME, MODES, MIN_HISTOGRAM_BARS } from './constants'; -import { useDebounceWithOptions } from '../helpers'; +import { useDebounceWithOptions } from '../../../../shared_components'; import { HelpPopover, HelpPopoverButton } from '../../../help_popover'; const GranularityHelpPopover = () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx index 915e67c4eba0b..a4c0f8f1c50e0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx @@ -8,7 +8,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFieldNumber } from '@elastic/eui'; -import { useDebounceWithOptions } from '../helpers'; +import { useDebounceWithOptions } from '../../../../shared_components'; export const ValuesInput = ({ value, diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index 7191da0af6bfe..a9e7e4adb9ca7 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -161,7 +161,7 @@ describe('PieVisualization component', () => { [] as HierarchyOfArrays ); - expect(defaultArgs.paletteService.get('mock').getColor).toHaveBeenCalledWith( + expect(defaultArgs.paletteService.get('mock').getCategoricalColor).toHaveBeenCalledWith( [ { name: 'css', diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index cc31222f6b9ab..6c1cbe63a5a3e 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -150,7 +150,7 @@ export function PieComponent( } } - const outputColor = paletteService.get(palette.name).getColor( + const outputColor = paletteService.get(palette.name).getCategoricalColor( seriesLayers, { behindText: categoryDisplay !== 'hide', diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index ad8d87292b1d8..f413b122d913c 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -126,7 +126,7 @@ export const getPieVisualization = ({ triggerIcon: 'colorBy', palette: paletteService .get(state.palette?.name || 'default') - .getColors(10, state.palette?.params), + .getCategoricalColors(10, state.palette?.params), }; } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx new file mode 100644 index 0000000000000..54c7f3cef90fe --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.test.tsx @@ -0,0 +1,156 @@ +/* + * 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 { EuiColorPicker } from '@elastic/eui'; +import { mount } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { CustomStops, CustomStopsProps } from './color_stops'; + +describe('Color Stops component', () => { + let props: CustomStopsProps; + beforeEach(() => { + props = { + colorStops: [ + { color: '#aaa', stop: 20 }, + { color: '#bbb', stop: 40 }, + { color: '#ccc', stop: 60 }, + ], + paletteConfiguration: {}, + dataBounds: { min: 0, max: 200 }, + onChange: jest.fn(), + 'data-test-prefix': 'my-test', + }; + }); + it('should display all the color stops passed', () => { + const component = mount(); + expect( + component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]') + ).toHaveLength(3); + }); + + it('should disable the delete buttons when there are 2 stops or less', () => { + // reduce to 2 stops + props.colorStops = props.colorStops.slice(0, 2); + const component = mount(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_removeStop_0"]') + .first() + .prop('isDisabled') + ).toBe(true); + }); + + it('should add a new stop with default color and reasonable distance from last one', () => { + let component = mount(); + const addStopButton = component + .find('[data-test-subj="my-test_dynamicColoring_addStop"]') + .first(); + act(() => { + addStopButton.prop('onClick')!({} as React.MouseEvent); + }); + component = component.update(); + + expect( + component.find('input[data-test-subj^="my-test_dynamicColoring_stop_value_"]') + ).toHaveLength(4); + expect( + component.find('input[data-test-subj="my-test_dynamicColoring_stop_value_3"]').prop('value') + ).toBe('80'); // 60-40 + 60 + expect( + component + // workaround for https://github.com/elastic/eui/issues/4792 + .find('[data-test-subj="my-test_dynamicColoring_stop_color_3"]') + .last() // pick the inner element + .childAt(0) + .prop('color') + ).toBe('#ccc'); // pick previous color + }); + + it('should restore previous color when abandoning the field with an empty color', () => { + let component = mount(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('color') + ).toBe('#aaa'); + act(() => { + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('onChange')!('', { + rgba: [NaN, NaN, NaN, NaN], + hex: '', + isValid: false, + }); + }); + component = component.update(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('color') + ).toBe(''); + act(() => { + component + .find('[data-test-subj="my-test_dynamicColoring_stop_color_0"]') + .first() + .prop('onBlur')!({} as React.FocusEvent); + }); + component = component.update(); + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .find(EuiColorPicker) + .first() + .prop('color') + ).toBe('#aaa'); + }); + + it('should sort stops value on whole component blur', () => { + let component = mount(); + let firstStopValueInput = component + .find('[data-test-subj="my-test_dynamicColoring_stop_value_0"]') + .first(); + act(() => { + firstStopValueInput.prop('onChange')!(({ + target: { value: ' 90' }, + } as unknown) as React.ChangeEvent); + }); + + component = component.update(); + + act(() => { + component + .find('[data-test-subj="my-test_dynamicColoring_stop_row_0"]') + .first() + .prop('onBlur')!({} as React.FocusEvent); + }); + component = component.update(); + + // retrieve again the input + firstStopValueInput = component + .find('[data-test-subj="my-test_dynamicColoring_stop_value_0"]') + .first(); + expect(firstStopValueInput.prop('value')).toBe('40'); + // the previous one move at the bottom + expect( + component + .find('[data-test-subj="my-test_dynamicColoring_stop_value_2"]') + .first() + .prop('value') + ).toBe('90'); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx new file mode 100644 index 0000000000000..37197b232ddf5 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_stops.tsx @@ -0,0 +1,294 @@ +/* + * 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, { useState, useCallback, useMemo } from 'react'; +import type { FocusEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldNumber, + EuiColorPicker, + EuiButtonIcon, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmpty, + EuiSpacer, + EuiScreenReaderOnly, + htmlIdGenerator, +} from '@elastic/eui'; +import useUnmount from 'react-use/lib/useUnmount'; +import { DEFAULT_COLOR } from './constants'; +import { getDataMinMax, getStepValue, isValidColor } from './utils'; +import { TooltipWrapper, useDebouncedValue } from '../index'; +import { ColorStop, CustomPaletteParams } from './types'; + +const idGeneratorFn = htmlIdGenerator(); + +function areStopsValid(colorStops: Array<{ color: string; stop: string }>) { + return colorStops.every( + ({ color, stop }) => isValidColor(color) && !Number.isNaN(parseFloat(stop)) + ); +} + +function shouldSortStops(colorStops: Array<{ color: string; stop: string | number }>) { + return colorStops.some(({ stop }, i) => { + const numberStop = Number(stop); + const prevNumberStop = Number(colorStops[i - 1]?.stop ?? -Infinity); + return numberStop < prevNumberStop; + }); +} + +export interface CustomStopsProps { + colorStops: ColorStop[]; + onChange: (colorStops: ColorStop[]) => void; + dataBounds: { min: number; max: number }; + paletteConfiguration: CustomPaletteParams | undefined; + 'data-test-prefix': string; +} +export const CustomStops = ({ + colorStops, + onChange, + paletteConfiguration, + dataBounds, + ['data-test-prefix']: dataTestPrefix, +}: CustomStopsProps) => { + const onChangeWithValidation = useCallback( + (newColorStops: Array<{ color: string; stop: string }>) => { + const areStopsValuesValid = areStopsValid(newColorStops); + const shouldSort = shouldSortStops(newColorStops); + if (areStopsValuesValid && !shouldSort) { + onChange(newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) }))); + } + }, + [onChange] + ); + + const memoizedValues = useMemo(() => { + return colorStops.map(({ color, stop }, i) => ({ + color, + stop: String(stop), + id: idGeneratorFn(), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paletteConfiguration?.name, paletteConfiguration?.reverse, paletteConfiguration?.rangeType]); + + const { inputValue: localColorStops, handleInputChange: setLocalColorStops } = useDebouncedValue({ + onChange: onChangeWithValidation, + value: memoizedValues, + }); + const [sortedReason, setSortReason] = useState(''); + const shouldEnableDelete = localColorStops.length > 2; + + const [popoverInFocus, setPopoverInFocus] = useState(false); + + // refresh on unmount: + // the onChange logic here is a bit different than the one above as it has to actively sort if required + useUnmount(() => { + const areStopsValuesValid = areStopsValid(localColorStops); + const shouldSort = shouldSortStops(localColorStops); + if (areStopsValuesValid && shouldSort) { + onChange( + localColorStops + .map(({ color, stop }) => ({ color, stop: Number(stop) })) + .sort(({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB)) + ); + } + }); + + const rangeType = paletteConfiguration?.rangeType || 'percent'; + + return ( + <> + {sortedReason ? ( + +

+ {i18n.translate('xpack.lens.dynamicColoring.customPalette.sortReason', { + defaultMessage: 'Color stops have been sorted due to new stop value {value}', + values: { + value: sortedReason, + }, + })} +

+
+ ) : null} + + + {localColorStops.map(({ color, stop, id }, index) => { + const prevStopValue = Number(localColorStops[index - 1]?.stop ?? -Infinity); + const nextStopValue = Number(localColorStops[index + 1]?.stop ?? Infinity); + + return ( + ) => { + // sort the stops when the focus leaves the row container + const shouldSort = Number(stop) > nextStopValue || prevStopValue > Number(stop); + const isFocusStillInContent = + (e.currentTarget as Node)?.contains(e.relatedTarget as Node) || popoverInFocus; + const hasInvalidColor = !isValidColor(color); + if ((shouldSort && !isFocusStillInContent) || hasInvalidColor) { + // replace invalid color with previous valid one + const lastValidColor = hasInvalidColor ? colorStops[index].color : color; + const localColorStopsCopy = localColorStops.map((item, i) => + i === index ? { color: lastValidColor, stop, id } : item + ); + setLocalColorStops( + localColorStopsCopy.sort( + ({ stop: stopA }, { stop: stopB }) => Number(stopA) - Number(stopB) + ) + ); + setSortReason(stop); + } + }} + > + + + { + const newStopString = target.value.trim(); + const newColorStops = [...localColorStops]; + newColorStops[index] = { + color, + stop: newStopString, + id, + }; + setLocalColorStops(newColorStops); + }} + append={rangeType === 'percent' ? '%' : undefined} + aria-label={i18n.translate( + 'xpack.lens.dynamicColoring.customPalette.stopAriaLabel', + { + defaultMessage: 'Stop {index}', + values: { + index: index + 1, + }, + } + )} + /> + + + { + // make sure that the popover is closed + if (color === '' && !popoverInFocus) { + const newColorStops = [...localColorStops]; + newColorStops[index] = { color: colorStops[index].color, stop, id }; + setLocalColorStops(newColorStops); + } + }} + > + { + const newColorStops = [...localColorStops]; + newColorStops[index] = { color: newColor, stop, id }; + setLocalColorStops(newColorStops); + }} + secondaryInputDisplay="top" + color={color} + isInvalid={!isValidColor(color)} + showAlpha + compressed + onFocus={() => setPopoverInFocus(true)} + onBlur={() => { + setPopoverInFocus(false); + if (color === '') { + const newColorStops = [...localColorStops]; + newColorStops[index] = { color: colorStops[index].color, stop, id }; + setLocalColorStops(newColorStops); + } + }} + placeholder=" " + /> + + + + + { + const newColorStops = localColorStops.filter((_, i) => i !== index); + setLocalColorStops(newColorStops); + }} + data-test-subj={`${dataTestPrefix}_dynamicColoring_removeStop_${index}`} + isDisabled={!shouldEnableDelete} + /> + + + + + ); + })} + + + + + { + const newColorStops = [...localColorStops]; + const length = newColorStops.length; + const { max } = getDataMinMax(rangeType, dataBounds); + const step = getStepValue( + colorStops, + newColorStops.map(({ color, stop }) => ({ color, stop: Number(stop) })), + max + ); + const prevColor = localColorStops[length - 1].color || DEFAULT_COLOR; + const newStop = step + Number(localColorStops[length - 1].stop); + newColorStops.push({ + color: prevColor, + stop: String(newStop), + id: idGeneratorFn(), + }); + setLocalColorStops(newColorStops); + }} + > + {i18n.translate('xpack.lens.dynamicColoring.customPalette.addColorStop', { + defaultMessage: 'Add color stop', + })} + + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/constants.ts b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts new file mode 100644 index 0000000000000..5e6fc207656ac --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/constants.ts @@ -0,0 +1,29 @@ +/* + * 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 { RequiredPaletteParamTypes } from './types'; + +export const DEFAULT_PALETTE_NAME = 'positive'; +export const FIXED_PROGRESSION = 'fixed' as const; +export const CUSTOM_PALETTE = 'custom'; +export const DEFAULT_CONTINUITY = 'above'; +export const DEFAULT_MIN_STOP = 0; +export const DEFAULT_MAX_STOP = 100; +export const DEFAULT_COLOR_STEPS = 5; +export const DEFAULT_COLOR = '#6092C0'; // Same as EUI ColorStops default for new stops +export const defaultPaletteParams: RequiredPaletteParamTypes = { + name: DEFAULT_PALETTE_NAME, + reverse: false, + rangeType: 'percent', + rangeMin: DEFAULT_MIN_STOP, + rangeMax: DEFAULT_MAX_STOP, + progression: FIXED_PROGRESSION, + stops: [], + steps: DEFAULT_COLOR_STEPS, + colorStops: [], + continuity: DEFAULT_CONTINUITY, +}; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/index.ts b/x-pack/plugins/lens/public/shared_components/coloring/index.ts new file mode 100644 index 0000000000000..3b34c6662c681 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { CustomizablePalette } from './palette_configuration'; +export { CustomStops } from './color_stops'; +export * from './types'; +export * from './utils'; +export * from './constants'; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss new file mode 100644 index 0000000000000..c6b14c5c5f9a3 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.scss @@ -0,0 +1,7 @@ +.lnsPalettePanel__section--shaded { + background-color: $euiColorLightestShade; +} + +.lnsPalettePanel__section { + padding: $euiSizeS; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx new file mode 100644 index 0000000000000..28ba28a5801e4 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.test.tsx @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiColorPalettePickerPaletteProps } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { ReactWrapper } from 'enzyme'; +import { CustomPaletteParams } from './types'; +import { applyPaletteParams } from './utils'; +import { CustomizablePalette } from './palette_configuration'; + +describe('palette utilities', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + describe('applyPaletteParams', () => { + it('should return a set of colors for a basic configuration', () => { + expect( + applyPaletteParams( + paletteRegistry, + { type: 'palette', name: 'positive' }, + { min: 0, max: 100 } + ) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should reverse the palette color stops correctly', () => { + expect( + applyPaletteParams( + paletteRegistry, + { + type: 'palette', + name: 'positive', + params: { reverse: true }, + }, + { min: 0, max: 100 } + ) + ).toEqual([ + { color: 'yellow', stop: 20 }, + { color: 'blue', stop: 70 }, + ]); + }); + }); +}); + +describe('palette panel', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + let props: { + palettes: PaletteRegistry; + activePalette: PaletteOutput; + setPalette: (palette: PaletteOutput) => void; + dataBounds: { min: number; max: number }; + }; + + describe('palette picker', () => { + beforeEach(() => { + props = { + activePalette: { type: 'palette', name: 'positive' }, + palettes: paletteRegistry, + setPalette: jest.fn(), + dataBounds: { min: 0, max: 100 }, + }; + }); + + function changePaletteIn(instance: ReactWrapper, newPaletteName: string) { + return ((instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_palette_picker"]') + .at(1) + .prop('onChange') as unknown) as (value: string) => void)?.(newPaletteName); + } + + it('should show only dynamic coloring enabled palette + custom option', () => { + const instance = mountWithIntl(); + const paletteOptions = instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_palette_picker"]') + .at(1) + .prop('palettes') as EuiColorPalettePickerPaletteProps[]; + expect(paletteOptions.length).toEqual(2); + + expect(paletteOptions[paletteOptions.length - 1]).toEqual({ + title: 'Custom Mocked Palette', // <- picks the title of the custom palette + type: 'fixed', + value: 'custom', + palette: ['blue', 'yellow'], + 'data-test-subj': 'custom-palette', + }); + }); + + it('should set the colorStops and stops when selecting the Custom palette from the list', () => { + const instance = mountWithIntl(); + + changePaletteIn(instance, 'custom'); + + expect(props.setPalette).toHaveBeenCalledWith({ + type: 'palette', + name: 'custom', + params: expect.objectContaining({ + colorStops: [ + { color: 'blue', stop: 0 }, + { color: 'yellow', stop: 50 }, + ], + stops: [ + { color: 'blue', stop: 50 }, + { color: 'yellow', stop: 100 }, + ], + name: 'custom', + }), + }); + }); + + describe('reverse option', () => { + beforeEach(() => { + props = { + activePalette: { type: 'palette', name: 'positive' }, + palettes: paletteRegistry, + setPalette: jest.fn(), + dataBounds: { min: 0, max: 100 }, + }; + }); + + function toggleReverse(instance: ReactWrapper, checked: boolean) { + return instance + .find('[data-test-subj="lnsDatatable_dynamicColoring_reverse"]') + .first() + .prop('onClick')!({} as React.MouseEvent); + } + + it('should reverse the colorStops on click', () => { + const instance = mountWithIntl(); + + toggleReverse(instance, true); + + expect(props.setPalette).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + reverse: true, + }), + }) + ); + }); + }); + + describe('custom stops', () => { + beforeEach(() => { + props = { + activePalette: { type: 'palette', name: 'positive' }, + palettes: paletteRegistry, + setPalette: jest.fn(), + dataBounds: { min: 0, max: 100 }, + }; + }); + it('should be visible for predefined palettes', () => { + const instance = mountWithIntl(); + expect( + instance.find('[data-test-subj="lnsDatatable_dynamicColoring_custom_stops"]').exists() + ).toEqual(true); + }); + + it('should be visible for custom palettes', () => { + const instance = mountWithIntl( + + ); + expect( + instance.find('[data-test-subj="lnsDatatable_dynamicColoring_custom_stops"]').exists() + ).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx new file mode 100644 index 0000000000000..df01b3e57cd7d --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_configuration.tsx @@ -0,0 +1,340 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { + EuiFormRow, + htmlIdGenerator, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiSuperSelect, + EuiIcon, + EuiIconTip, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { PalettePicker } from './palette_picker'; + +import './palette_configuration.scss'; + +import { CustomStops } from './color_stops'; +import { defaultPaletteParams, CUSTOM_PALETTE, DEFAULT_COLOR_STEPS } from './constants'; +import { CustomPaletteParams, RequiredPaletteParamTypes } from './types'; +import { + getColorStops, + getPaletteStops, + mergePaletteParams, + getDataMinMax, + remapStopsByNewInterval, + getSwitchToCustomParams, + reversePalette, + roundStopValues, +} from './utils'; +const idPrefix = htmlIdGenerator()(); + +/** + * Some name conventions here: + * * `displayStops` => It's an additional transformation of `stops` into a [0, N] domain for the EUIPaletteDisplay component. + * * `stops` => final steps used to table coloring. It is a rightShift of the colorStops + * * `colorStops` => user's color stop inputs. Used to compute range min. + * + * When the user inputs the colorStops, they are designed to be the initial part of the color segment, + * so the next stops indicate where the previous stop ends. + * Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`, + * by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`. + * EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with + * some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok. + * + * These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening + * for a single change. + */ + +export function CustomizablePalette({ + palettes, + activePalette, + setPalette, + dataBounds, +}: { + palettes: PaletteRegistry; + activePalette: PaletteOutput; + setPalette: (palette: PaletteOutput) => void; + dataBounds: { min: number; max: number }; +}) { + const isCurrentPaletteCustom = activePalette.params?.name === CUSTOM_PALETTE; + + const colorStopsToShow = roundStopValues( + getColorStops(palettes, activePalette?.params?.colorStops || [], activePalette, dataBounds) + ); + + return ( + <> +
+ + { + const isNewPaletteCustom = newPalette.name === CUSTOM_PALETTE; + const newParams: CustomPaletteParams = { + ...activePalette.params, + name: newPalette.name, + colorStops: undefined, + }; + + if (isNewPaletteCustom) { + newParams.colorStops = getColorStops(palettes, [], activePalette, dataBounds); + } + + newParams.stops = getPaletteStops(palettes, newParams, { + prevPalette: + isNewPaletteCustom || isCurrentPaletteCustom ? undefined : newPalette.name, + dataBounds, + }); + + setPalette({ + ...newPalette, + params: newParams, + }); + }} + showCustomPalette + showDynamicColorOnly + /> + + + ['continuity']) => + setPalette( + mergePaletteParams(activePalette, { + continuity, + }) + ) + } + /> + + + {i18n.translate('xpack.lens.table.dynamicColoring.rangeType.label', { + defaultMessage: 'Value type', + })}{' '} + + + } + display="rowCompressed" + > + { + const newRangeType = id.replace( + idPrefix, + '' + ) as RequiredPaletteParamTypes['rangeType']; + + const params: CustomPaletteParams = { rangeType: newRangeType }; + if (isCurrentPaletteCustom) { + const { min: newMin, max: newMax } = getDataMinMax(newRangeType, dataBounds); + const { min: oldMin, max: oldMax } = getDataMinMax( + activePalette.params?.rangeType, + dataBounds + ); + const newColorStops = remapStopsByNewInterval(colorStopsToShow, { + oldInterval: oldMax - oldMin, + newInterval: newMax - newMin, + newMin, + oldMin, + }); + const stops = getPaletteStops( + palettes, + { ...activePalette.params, colorStops: newColorStops, ...params }, + { dataBounds } + ); + params.colorStops = newColorStops; + params.stops = stops; + params.rangeMin = newColorStops[0].stop; + params.rangeMax = newColorStops[newColorStops.length - 1].stop; + } else { + params.stops = getPaletteStops( + palettes, + { ...activePalette.params, ...params }, + { prevPalette: activePalette.name, dataBounds } + ); + } + setPalette(mergePaletteParams(activePalette, params)); + }} + /> + + + { + const params: CustomPaletteParams = { reverse: !activePalette.params?.reverse }; + if (isCurrentPaletteCustom) { + params.colorStops = reversePalette(colorStopsToShow); + params.stops = getPaletteStops( + palettes, + { + ...(activePalette?.params || {}), + colorStops: params.colorStops, + }, + { dataBounds } + ); + } else { + params.stops = reversePalette( + activePalette?.params?.stops || + getPaletteStops( + palettes, + { ...activePalette.params, ...params }, + { prevPalette: activePalette.name, dataBounds } + ) + ); + } + setPalette(mergePaletteParams(activePalette, params)); + }} + > + + + + + + {i18n.translate('xpack.lens.table.dynamicColoring.reverse.label', { + defaultMessage: 'Reverse colors', + })} + + + + + } + > + { + const newParams = getSwitchToCustomParams( + palettes, + activePalette, + { + colorStops, + steps: activePalette.params!.steps || DEFAULT_COLOR_STEPS, + rangeMin: colorStops[0]?.stop, + rangeMax: colorStops[colorStops.length - 1]?.stop, + }, + dataBounds + ); + return setPalette(newParams); + }} + /> + +
+ + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx new file mode 100644 index 0000000000000..164ed9bf067a6 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/palette_picker.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiColorPalettePicker, EuiColorPalettePickerPaletteProps } from '@elastic/eui'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { + CUSTOM_PALETTE, + DEFAULT_COLOR_STEPS, + FIXED_PROGRESSION, + defaultPaletteParams, +} from '../../shared_components/coloring/constants'; +import { CustomPaletteParams } from '../../shared_components/coloring/types'; +import { getStopsForFixedMode } from '../../shared_components/coloring/utils'; + +function getCustomPaletteConfig( + palettes: PaletteRegistry, + activePalette: PaletteOutput | undefined +) { + const { id, title } = palettes.get(CUSTOM_PALETTE); + + // Try to generate a palette from the current one + if (activePalette && activePalette.name !== CUSTOM_PALETTE) { + const currentPalette = palettes.get(activePalette.name); + if (currentPalette) { + const stops = currentPalette.getCategoricalColors(DEFAULT_COLOR_STEPS, activePalette?.params); + const palette = activePalette.params?.reverse ? stops.reverse() : stops; + return { + value: id, + title, + type: FIXED_PROGRESSION, + palette, + 'data-test-subj': `custom-palette`, + }; + } + } + // if not possible just show some text + if (!activePalette?.params?.stops) { + return { value: id, title, type: 'text' as const, 'data-test-subj': `custom-palette` }; + } + + // full custom palette + return { + value: id, + title, + type: FIXED_PROGRESSION, + 'data-test-subj': `custom-palette`, + palette: getStopsForFixedMode(activePalette.params.stops, activePalette.params.colorStops), + }; +} + +// Note: this is a special palette picker different from the one in the root shared folder +// ideally these should be merged together, but as for now this holds some custom logic hard to remove +export function PalettePicker({ + palettes, + activePalette, + setPalette, + showCustomPalette, + showDynamicColorOnly, + ...rest +}: { + palettes: PaletteRegistry; + activePalette?: PaletteOutput; + setPalette: (palette: PaletteOutput) => void; + showCustomPalette?: boolean; + showDynamicColorOnly?: boolean; +}) { + const palettesToShow: EuiColorPalettePickerPaletteProps[] = palettes + .getAll() + .filter(({ internal, canDynamicColoring }) => + showDynamicColorOnly ? canDynamicColoring : !internal + ) + .map(({ id, title, getCategoricalColors }) => { + const colors = getCategoricalColors( + DEFAULT_COLOR_STEPS, + id === activePalette?.name ? activePalette?.params : undefined + ); + return { + value: id, + title, + type: FIXED_PROGRESSION, + palette: activePalette?.params?.reverse ? colors.reverse() : colors, + 'data-test-subj': `${id}-palette`, + }; + }); + if (showCustomPalette) { + palettesToShow.push(getCustomPaletteConfig(palettes, activePalette)); + } + return ( + { + setPalette({ + type: 'palette', + name: newPalette, + }); + }} + valueOfSelected={activePalette?.name || defaultPaletteParams.name} + selectionDisplay="palette" + {...rest} + /> + ); +} diff --git a/x-pack/plugins/lens/public/shared_components/coloring/types.ts b/x-pack/plugins/lens/public/shared_components/coloring/types.ts new file mode 100644 index 0000000000000..d9a8edf0ccb62 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/types.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ +export interface ColorStop { + color: string; + stop: number; +} + +export interface CustomPaletteParams { + name?: string; + reverse?: boolean; + rangeType?: 'number' | 'percent'; + continuity?: 'above' | 'below' | 'all' | 'none'; + progression?: 'fixed'; + rangeMin?: number; + rangeMax?: number; + stops?: ColorStop[]; + colorStops?: ColorStop[]; + steps?: number; +} + +export type RequiredPaletteParamTypes = Required; diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts new file mode 100644 index 0000000000000..8aaab0923584d --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts @@ -0,0 +1,399 @@ +/* + * 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 { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { + applyPaletteParams, + getContrastColor, + getDataMinMax, + getPaletteStops, + getStepValue, + isValidColor, + mergePaletteParams, + remapStopsByNewInterval, + reversePalette, + roundStopValues, +} from './utils'; + +describe('applyPaletteParams', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + it('should return a palette stops array only by the name', () => { + expect( + applyPaletteParams( + paletteRegistry, + { name: 'default', type: 'palette', params: { name: 'default' } }, + { min: 0, max: 100 } + ) + ).toEqual([ + // stops are 0 and 50 by with a 20 offset (100 divided by 5 steps) for display + // the mock palette service has only 2 colors so tests are a bit off by that + { color: 'red', stop: 20 }, + { color: 'black', stop: 70 }, + ]); + }); + + it('should return a palette stops array reversed', () => { + expect( + applyPaletteParams( + paletteRegistry, + { name: 'default', type: 'palette', params: { name: 'default', reverse: true } }, + { min: 0, max: 100 } + ) + ).toEqual([ + { color: 'black', stop: 20 }, + { color: 'red', stop: 70 }, + ]); + }); +}); + +describe('remapStopsByNewInterval', () => { + it('should correctly remap the current palette from 0..1 to 0...100', () => { + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ], + { newInterval: 100, oldInterval: 1, newMin: 0, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 90 }, + ]); + + // now test the other way around + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 90 }, + ], + { newInterval: 1, oldInterval: 100, newMin: 0, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ]); + }); + + it('should correctly handle negative numbers to/from', () => { + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: -100 }, + { color: 'green', stop: -50 }, + { color: 'red', stop: -1 }, + ], + { newInterval: 100, oldInterval: 100, newMin: 0, oldMin: -100 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 99 }, + ]); + + // now map the other way around + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 99 }, + ], + { newInterval: 100, oldInterval: 100, newMin: -100, oldMin: 0 } + ) + ).toEqual([ + { color: 'black', stop: -100 }, + { color: 'green', stop: -50 }, + { color: 'red', stop: -1 }, + ]); + + // and test also palettes that also contains negative values + expect( + remapStopsByNewInterval( + [ + { color: 'black', stop: -50 }, + { color: 'green', stop: 0 }, + { color: 'red', stop: 50 }, + ], + { newInterval: 100, oldInterval: 100, newMin: 0, oldMin: -50 } + ) + ).toEqual([ + { color: 'black', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'red', stop: 100 }, + ]); + }); +}); + +describe('getDataMinMax', () => { + it('should pick the correct min/max based on the current range type', () => { + expect(getDataMinMax('percent', { min: -100, max: 0 })).toEqual({ min: 0, max: 100 }); + }); + + it('should pick the correct min/max apply percent by default', () => { + expect(getDataMinMax(undefined, { min: -100, max: 0 })).toEqual({ min: 0, max: 100 }); + }); +}); + +describe('getPaletteStops', () => { + const paletteRegistry = chartPluginMock.createPaletteRegistry(); + it('should correctly compute a predefined palette stops definition from only the name', () => { + expect( + getPaletteStops(paletteRegistry, { name: 'mock' }, { dataBounds: { min: 0, max: 100 } }) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should correctly compute a predefined palette stops definition from explicit prevPalette (override)', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default' }, + { dataBounds: { min: 0, max: 100 }, prevPalette: 'mock' } + ) + ).toEqual([ + { color: 'blue', stop: 20 }, + { color: 'yellow', stop: 70 }, + ]); + }); + + it('should infer the domain from dataBounds but start from 0', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default', rangeType: 'number' }, + { dataBounds: { min: 1, max: 11 }, prevPalette: 'mock' } + ) + ).toEqual([ + { color: 'blue', stop: 2 }, + { color: 'yellow', stop: 7 }, + ]); + }); + + it('should override the minStop when requested', () => { + expect( + getPaletteStops( + paletteRegistry, + { name: 'default', rangeType: 'number' }, + { dataBounds: { min: 1, max: 11 }, mapFromMinValue: true } + ) + ).toEqual([ + { color: 'red', stop: 1 }, + { color: 'black', stop: 6 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 80 }, + ], + }, + { dataBounds: { min: 0, max: 100 } } + ) + ).toEqual([ + { color: 'green', stop: 40 }, + { color: 'blue', stop: 80 }, + { color: 'red', stop: 100 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - handle stop at the end', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 40 }, + { color: 'red', stop: 100 }, + ], + }, + { dataBounds: { min: 0, max: 100 } } + ) + ).toEqual([ + { color: 'green', stop: 40 }, + { color: 'blue', stop: 100 }, + { color: 'red', stop: 101 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - handle stop at the end (fractional)', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + rangeType: 'number', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 0.4 }, + { color: 'red', stop: 1 }, + ], + }, + { dataBounds: { min: 0, max: 1 } } + ) + ).toEqual([ + { color: 'green', stop: 0.4 }, + { color: 'blue', stop: 1 }, + { color: 'red', stop: 2 }, + ]); + }); + + it('should compute a display stop palette from custom colorStops defined by the user - stretch the stops to 100% percent', () => { + expect( + getPaletteStops( + paletteRegistry, + { + name: 'custom', + colorStops: [ + { color: 'green', stop: 0 }, + { color: 'blue', stop: 0.4 }, + { color: 'red', stop: 1 }, + ], + }, + { dataBounds: { min: 0, max: 1 } } + ) + ).toEqual([ + { color: 'green', stop: 0.4 }, + { color: 'blue', stop: 1 }, + { color: 'red', stop: 100 }, // default rangeType is percent, hence stretch to 100% + ]); + }); +}); + +describe('reversePalette', () => { + it('should correctly reverse color and stops', () => { + expect( + reversePalette([ + { color: 'red', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'blue', stop: 0.9 }, + ]) + ).toEqual([ + { color: 'blue', stop: 0 }, + { color: 'green', stop: 0.5 }, + { color: 'red', stop: 0.9 }, + ]); + }); +}); + +describe('mergePaletteParams', () => { + it('should return a full palette', () => { + expect(mergePaletteParams({ type: 'palette', name: 'myPalette' }, { reverse: true })).toEqual({ + type: 'palette', + name: 'myPalette', + params: { reverse: true }, + }); + }); +}); + +describe('isValidColor', () => { + it('should return ok for valid hex color notation', () => { + expect(isValidColor('#fff')).toBe(true); + expect(isValidColor('#ffffff')).toBe(true); + expect(isValidColor('#ffffffaa')).toBe(true); + }); + + it('should return false for non valid strings', () => { + expect(isValidColor('')).toBe(false); + expect(isValidColor('#')).toBe(false); + expect(isValidColor('#ff')).toBe(false); + expect(isValidColor('123')).toBe(false); + expect(isValidColor('rgb(1, 1, 1)')).toBe(false); + expect(isValidColor('rgba(1, 1, 1, 0)')).toBe(false); + expect(isValidColor('#ffffffgg')).toBe(false); + expect(isValidColor('#fff00')).toBe(false); + // this version of chroma does not support hex4 format + expect(isValidColor('#fffa')).toBe(false); + }); +}); + +describe('roundStopValues', () => { + it('should round very long values', () => { + expect(roundStopValues([{ color: 'red', stop: 0.1515 }])).toEqual([ + { color: 'red', stop: 0.15 }, + ]); + }); +}); + +describe('getStepValue', () => { + it('should compute the next step based on the last 2 stops', () => { + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 100 + ) + ).toBe(50); + + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 80 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 90 + ) + ).toBe(10); // 90 - 80 + + expect( + getStepValue( + // first arg is taken as max reference + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 100 }, + ], + [ + { color: 'red', stop: 0 }, + { color: 'red', stop: 50 }, + ], + 100 + ) + ).toBe(1); + }); +}); + +describe('getContrastColor', () => { + it('should pick the light color when the passed one is dark', () => { + expect(getContrastColor('#000', true)).toBe('#ffffff'); + expect(getContrastColor('#000', false)).toBe('#ffffff'); + }); + + it('should pick the dark color when the passed one is light', () => { + expect(getContrastColor('#fff', true)).toBe('#000000'); + expect(getContrastColor('#fff', false)).toBe('#000000'); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts new file mode 100644 index 0000000000000..89fceec533493 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts @@ -0,0 +1,308 @@ +/* + * 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 chroma from 'chroma-js'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { euiLightVars, euiDarkVars } from '@kbn/ui-shared-deps/theme'; +import { isColorDark } from '@elastic/eui'; +import { + CUSTOM_PALETTE, + defaultPaletteParams, + DEFAULT_COLOR_STEPS, + DEFAULT_MAX_STOP, + DEFAULT_MIN_STOP, +} from './constants'; +import { CustomPaletteParams, ColorStop } from './types'; + +/** + * Some name conventions here: + * * `displayStops` => It's an additional transformation of `stops` into a [0, N] domain for the EUIPaletteDisplay component. + * * `stops` => final steps used to table coloring. It is a rightShift of the colorStops + * * `colorStops` => user's color stop inputs. Used to compute range min. + * + * When the user inputs the colorStops, they are designed to be the initial part of the color segment, + * so the next stops indicate where the previous stop ends. + * Both table coloring logic and EuiPaletteDisplay format implementation works differently than our current `colorStops`, + * by having the stop values at the end of each color segment rather than at the beginning: `stops` values are computed by a rightShift of `colorStops`. + * EuiPaletteDisplay has an additional requirement as it is always mapped against a domain [0, N]: from `stops` the `displayStops` are computed with + * some continuity enrichment and a remap against a [0, 100] domain to make the palette component work ok. + * + * These naming conventions would be useful to track the code flow in this feature as multiple transformations are happening + * for a single change. + */ + +export function applyPaletteParams( + palettes: PaletteRegistry, + activePalette: PaletteOutput, + dataBounds: { min: number; max: number } +) { + // make a copy of it as they have to be manipulated later on + let displayStops = getPaletteStops(palettes, activePalette?.params || {}, { + dataBounds, + }); + + if (activePalette?.params?.reverse && activePalette?.params?.name !== CUSTOM_PALETTE) { + displayStops = reversePalette(displayStops); + } + return displayStops; +} + +// Need to shift the Custom palette in order to correctly visualize it when in display mode +function shiftPalette(stops: ColorStop[], max: number) { + // shift everything right and add an additional stop at the end + const result = stops.map((entry, i, array) => ({ + ...entry, + stop: i + 1 < array.length ? array[i + 1].stop : max, + })); + if (stops[stops.length - 1].stop === max) { + // extends the range by a fair amount to make it work the extra case for the last stop === max + const computedStep = getStepValue(stops, result, max) || 1; + // do not go beyond the unit step in this case + const step = Math.min(1, computedStep); + result[stops.length - 1].stop = max + step; + } + return result; +} + +// Utility to remap color stops within new domain +export function remapStopsByNewInterval( + controlStops: ColorStop[], + { + newInterval, + oldInterval, + newMin, + oldMin, + }: { newInterval: number; oldInterval: number; newMin: number; oldMin: number } +) { + return (controlStops || []).map(({ color, stop }) => { + return { + color, + stop: newMin + ((stop - oldMin) * newInterval) / oldInterval, + }; + }); +} + +function getOverallMinMax( + params: CustomPaletteParams | undefined, + dataBounds: { min: number; max: number } +) { + const { min: dataMin, max: dataMax } = getDataMinMax(params?.rangeType, dataBounds); + const minStopValue = params?.colorStops?.[0]?.stop ?? Infinity; + const maxStopValue = params?.colorStops?.[params.colorStops.length - 1]?.stop ?? -Infinity; + const overallMin = Math.min(dataMin, minStopValue); + const overallMax = Math.max(dataMax, maxStopValue); + return { min: overallMin, max: overallMax }; +} + +export function getDataMinMax( + rangeType: CustomPaletteParams['rangeType'] | undefined, + dataBounds: { min: number; max: number } +) { + const dataMin = rangeType === 'number' ? dataBounds.min : DEFAULT_MIN_STOP; + const dataMax = rangeType === 'number' ? dataBounds.max : DEFAULT_MAX_STOP; + return { min: dataMin, max: dataMax }; +} + +/** + * This is a generic function to compute stops from the current parameters. + */ +export function getPaletteStops( + palettes: PaletteRegistry, + activePaletteParams: CustomPaletteParams, + // used to customize color resolution + { + prevPalette, + dataBounds, + mapFromMinValue, + }: { prevPalette?: string; dataBounds: { min: number; max: number }; mapFromMinValue?: boolean } +) { + const { min: minValue, max: maxValue } = getOverallMinMax(activePaletteParams, dataBounds); + const interval = maxValue - minValue; + const { stops: currentStops, ...otherParams } = activePaletteParams || {}; + + if (activePaletteParams.name === 'custom' && activePaletteParams?.colorStops) { + // need to generate the palette from the existing controlStops + return shiftPalette(activePaletteParams.colorStops, maxValue); + } + // generate a palette from predefined ones and customize the domain + const colorStopsFromPredefined = palettes + .get(prevPalette || activePaletteParams?.name || defaultPaletteParams.name) + .getCategoricalColors(defaultPaletteParams.steps, otherParams); + + const newStopsMin = mapFromMinValue ? minValue : interval / defaultPaletteParams.steps; + + const stops = remapStopsByNewInterval( + colorStopsFromPredefined.map((color, index) => ({ color, stop: index })), + { + newInterval: interval, + oldInterval: colorStopsFromPredefined.length, + newMin: newStopsMin, + oldMin: 0, + } + ); + return stops; +} + +export function reversePalette(paletteColorRepresentation: ColorStop[] = []) { + const stops = paletteColorRepresentation.map(({ stop }) => stop); + return paletteColorRepresentation + .map(({ color }, i) => ({ + color, + stop: stops[paletteColorRepresentation.length - i - 1], + })) + .reverse(); +} + +export function mergePaletteParams( + activePalette: PaletteOutput, + newParams: CustomPaletteParams +): PaletteOutput { + return { + ...activePalette, + params: { + ...activePalette.params, + ...newParams, + }, + }; +} + +function isValidPonyfill(colorString: string) { + // we're using an old version of chroma without the valid function + try { + chroma(colorString); + return true; + } catch (e) { + return false; + } +} + +export function isValidColor(colorString: string) { + // chroma can handle also hex values with alpha channel/transparency + // chroma accepts also hex without #, so test for it + return colorString !== '' && /^#/.test(colorString) && isValidPonyfill(colorString); +} + +export function roundStopValues(colorStops: ColorStop[]) { + return colorStops.map(({ color, stop }) => { + const roundedStop = Number(stop.toFixed(2)); + return { color, stop: roundedStop }; + }); +} + +// very simple heuristic: pick last two stops and compute a new stop based on the same distance +// if the new stop is above max, then reduce the step to reach max, or if zero then just 1. +// +// it accepts two series of stops as the function is used also when computing stops from colorStops +export function getStepValue(colorStops: ColorStop[], newColorStops: ColorStop[], max: number) { + const length = newColorStops.length; + // workout the steps from the last 2 items + const dataStep = newColorStops[length - 1].stop - newColorStops[length - 2].stop || 1; + let step = Number(dataStep.toFixed(2)); + if (max < colorStops[length - 1].stop + step) { + const diffToMax = max - colorStops[length - 1].stop; + // if the computed step goes way out of bound, fallback to 1, otherwise reach max + step = diffToMax > 0 ? diffToMax : 1; + } + return step; +} + +export function getSwitchToCustomParams( + palettes: PaletteRegistry, + activePalette: PaletteOutput, + newParams: CustomPaletteParams, + dataBounds: { min: number; max: number } +) { + // if it's already a custom palette just return the params + if (activePalette?.params?.name === CUSTOM_PALETTE) { + const stops = getPaletteStops( + palettes, + { + steps: DEFAULT_COLOR_STEPS, + ...activePalette.params, + ...newParams, + }, + { + dataBounds, + } + ); + return mergePaletteParams(activePalette, { + ...newParams, + stops, + }); + } + // prepare everything to switch to custom palette + const newPaletteParams = { + steps: DEFAULT_COLOR_STEPS, + ...activePalette.params, + ...newParams, + name: CUSTOM_PALETTE, + }; + + const stops = getPaletteStops(palettes, newPaletteParams, { + prevPalette: newPaletteParams.colorStops ? undefined : activePalette.name, + dataBounds, + }); + return mergePaletteParams( + { name: CUSTOM_PALETTE, type: 'palette' }, + { + ...newPaletteParams, + stops, + } + ); +} + +export function getColorStops( + palettes: PaletteRegistry, + colorStops: Required['stops'], + activePalette: PaletteOutput, + dataBounds: { min: number; max: number } +) { + // just forward the current stops if custom + if (activePalette?.name === CUSTOM_PALETTE) { + return colorStops; + } + // for predefined palettes create some stops, then drop the last one. + // we're using these as starting point for the user + let freshColorStops = getPaletteStops( + palettes, + { ...activePalette?.params }, + // mapFromMinValue is a special flag to offset the stops values + // used here to avoid a new remap/left shift + { dataBounds, mapFromMinValue: true } + ); + if (activePalette?.params?.reverse) { + freshColorStops = reversePalette(freshColorStops); + } + return freshColorStops; +} + +export function getContrastColor(color: string, isDarkTheme: boolean) { + const darkColor = isDarkTheme ? euiDarkVars.euiColorInk : euiLightVars.euiColorInk; + const lightColor = isDarkTheme ? euiDarkVars.euiColorGhost : euiLightVars.euiColorGhost; + return isColorDark(...chroma(color).rgb()) ? lightColor : darkColor; +} + +/** + * Same as stops, but remapped against a range 0-100 + */ +export function getStopsForFixedMode(stops: ColorStop[], colorStops?: ColorStop[]) { + const referenceStops = + colorStops || stops.map(({ color }, index) => ({ color, stop: 20 * index })); + const fallbackStops = stops; + + // what happens when user set two stops with the same value? we'll fallback to the display interval + const oldInterval = + referenceStops[referenceStops.length - 1].stop - referenceStops[0].stop || + fallbackStops[fallbackStops.length - 1].stop - fallbackStops[0].stop; + + return remapStopsByNewInterval(stops, { + newInterval: 100, + oldInterval, + newMin: 0, + oldMin: referenceStops[0].stop, + }); +} diff --git a/x-pack/plugins/lens/public/shared_components/helpers.ts b/x-pack/plugins/lens/public/shared_components/helpers.ts new file mode 100644 index 0000000000000..a9f35757c4cbf --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/helpers.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRef } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +export const useDebounceWithOptions = ( + fn: Function, + { skipFirstRender }: { skipFirstRender: boolean } = { skipFirstRender: false }, + ms?: number | undefined, + deps?: React.DependencyList | undefined +) => { + const isFirstRender = useRef(true); + const newDeps = [...(deps || []), isFirstRender]; + + return useDebounce( + () => { + if (skipFirstRender && isFirstRender.current) { + isFirstRender.current = false; + return; + } + return fn(); + }, + ms, + newDeps + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index ae57da976a881..cf8536884acdf 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -9,4 +9,7 @@ export * from './empty_placeholder'; export { ToolbarPopoverProps, ToolbarPopover } from './toolbar_popover'; export { LegendSettingsPopover } from './legend_settings_popover'; export { PalettePicker } from './palette_picker'; +export { TooltipWrapper } from './tooltip_wrapper'; +export * from './coloring'; export { useDebouncedValue } from './debounced_value'; +export * from './helpers'; diff --git a/x-pack/plugins/lens/public/shared_components/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx index b15a6749d4c2d..6424dc8143f95 100644 --- a/x-pack/plugins/lens/public/shared_components/palette_picker.tsx +++ b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx @@ -7,10 +7,9 @@ import React from 'react'; import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; -import { EuiColorPalettePicker } from '@elastic/eui'; +import { EuiColorPalettePicker, EuiColorPalettePickerPaletteProps } from '@elastic/eui'; import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { NativeRenderer } from '../native_renderer'; export function PalettePicker({ palettes, @@ -21,6 +20,20 @@ export function PalettePicker({ activePalette?: PaletteOutput; setPalette: (palette: PaletteOutput) => void; }) { + const palettesToShow: EuiColorPalettePickerPaletteProps[] = palettes + .getAll() + .filter(({ internal }) => !internal) + .map(({ id, title, getCategoricalColors }) => { + return { + value: id, + title, + type: 'fixed', + palette: getCategoricalColors( + 10, + id === activePalette?.name ? activePalette?.params : undefined + ), + }; + }); return ( !internal) - .map(({ id, title, getColors }) => { - return { - value: id, - title, - type: 'fixed', - palette: getColors( - 10, - id === activePalette?.name ? activePalette?.params : undefined - ), - }; - })} + palettes={palettesToShow} onChange={(newPalette) => { setPalette({ type: 'palette', @@ -56,21 +56,6 @@ export function PalettePicker({ valueOfSelected={activePalette?.name || 'default'} selectionDisplay={'palette'} /> - {activePalette && palettes.get(activePalette.name).renderEditor && ( - { - setPalette({ - type: 'palette', - name: activePalette.name, - params: updater(activePalette.params), - }); - }, - }} - /> - )} ); diff --git a/x-pack/plugins/lens/public/xy_visualization/tooltip_wrapper.tsx b/x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/tooltip_wrapper.tsx rename to x-pack/plugins/lens/public/shared_components/tooltip_wrapper.tsx diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 5a632e03f8f36..984fbf5555949 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -9,6 +9,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { CoreSetup } from 'kibana/public'; import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { SavedObjectReference } from 'kibana/public'; +import { MutableRefObject } from 'react'; import { RowClickContext } from '../../../../src/plugins/ui_actions/public'; import { ExpressionAstExpression, @@ -391,13 +392,14 @@ export type VisualizationDimensionEditorProps = VisualizationConfig groupId: string; accessor: string; setState: (newState: T) => void; + panelRef: MutableRefObject; }; export interface AccessorConfig { columnId: string; triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible'; color?: string; - palette?: string[]; + palette?: string[] | Array<{ color: string; stop: number }>; } export type VisualizationDimensionGroupConfig = SharedDimensionProps & { diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index d2e87ece5b5ec..ef0c350f20961 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -118,7 +118,7 @@ export function getAccessorColorConfig( ); const customColor = currentYConfig?.color || - paletteService.get(currentPalette.name).getColor( + paletteService.get(currentPalette.name).getCategoricalColor( [ { name: columnToLabel[accessor] || accessor, diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index e3b4565913ad8..608971d281981 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -798,7 +798,7 @@ export function XYChart({ ), }, ]; - return paletteService.get(palette.name).getColor( + return paletteService.get(palette.name).getCategoricalColor( seriesLayers, { maxDepth: 1, diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx index b07feb85892e5..843680e3f28ac 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx @@ -7,14 +7,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ToolbarPopover } from '../../shared_components'; +import { ToolbarPopover, TooltipWrapper } from '../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { LineCurveOption } from './line_curve_option'; import { FillOpacityOption } from './fill_opacity_option'; import { XYState } from '../types'; import { hasHistogramSeries } from '../state_helpers'; import { ValidLayer } from '../types'; -import { TooltipWrapper } from '../tooltip_wrapper'; import { FramePublicAPI } from '../../types'; function getValueLabelDisableReason({ diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index aa4b91b840db3..8fbc8e8b2ef7a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -481,12 +481,12 @@ describe('xy_visualization', () => { it('should query palette to fill in colors for other dimensions', () => { const palette = paletteServiceMock.get('default'); - (palette.getColor as jest.Mock).mockClear(); + (palette.getCategoricalColor as jest.Mock).mockClear(); const accessorConfig = callConfigAndFindYConfig({}, 'c'); expect(accessorConfig.triggerIcon).toEqual('color'); // black is the color returned from the palette mock expect(accessorConfig.color).toEqual('black'); - expect(palette.getColor).toHaveBeenCalledWith( + expect(palette.getCategoricalColor).toHaveBeenCalledWith( [ { name: 'c', @@ -505,9 +505,9 @@ describe('xy_visualization', () => { label: 'Overwritten label', }); const palette = paletteServiceMock.get('default'); - (palette.getColor as jest.Mock).mockClear(); + (palette.getCategoricalColor as jest.Mock).mockClear(); callConfigAndFindYConfig({}, 'c'); - expect(palette.getColor).toHaveBeenCalledWith( + expect(palette.getCategoricalColor).toHaveBeenCalledWith( [ expect.objectContaining({ name: 'Overwritten label', @@ -526,7 +526,7 @@ describe('xy_visualization', () => { }, 'c' ); - expect(palette.getColor).toHaveBeenCalled(); + expect(palette.getCategoricalColor).toHaveBeenCalled(); }); it('should not show any indicator as long as there is no data', () => { @@ -551,7 +551,7 @@ describe('xy_visualization', () => { it('should show current palette for break down by dimension', () => { const palette = paletteServiceMock.get('mock'); const customColors = ['yellow', 'green']; - (palette.getColors as jest.Mock).mockReturnValue(customColors); + (palette.getCategoricalColors as jest.Mock).mockReturnValue(customColors); const breakdownConfig = callConfigForBreakdownConfigs({ palette: { type: 'palette', name: 'mock', params: {} }, splitAccessor: 'd', @@ -570,9 +570,9 @@ describe('xy_visualization', () => { paletteGetter.mockReturnValue({ id: 'default', title: '', - getColors: jest.fn(), + getCategoricalColors: jest.fn(), toExpression: jest.fn(), - getColor: jest.fn().mockReturnValueOnce('blue').mockReturnValueOnce('green'), + getCategoricalColor: jest.fn().mockReturnValueOnce('blue').mockReturnValueOnce('green'), }); const yConfigs = callConfigForYConfigs({}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 19cfcb1a60cc7..fa9d46be11d68 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -235,7 +235,7 @@ export const getXyVisualization = ({ triggerIcon: 'colorBy', palette: paletteService .get(layer.palette?.name || 'default') - .getColors(10, layer.palette?.params), + .getCategoricalColors(10, layer.palette?.params), }, ] : [], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 0bafbead7d543..bc10236cf1977 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -260,6 +260,7 @@ describe('XY Config panels', () => { state={{ ...state, layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }] }} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); @@ -283,6 +284,7 @@ describe('XY Config panels', () => { state={state} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); @@ -326,6 +328,7 @@ describe('XY Config panels', () => { }} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); @@ -365,6 +368,7 @@ describe('XY Config panels', () => { }} formatFactory={jest.fn()} paletteService={chartPluginMock.createPaletteRegistry()} + panelRef={React.createRef()} /> ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index a6517894654ed..48f0cacf75938 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -41,9 +41,8 @@ import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_h import { trackUiEvent } from '../lens_ui_telemetry'; import { LegendSettingsPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; -import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration, GroupsConfiguration } from './axes_configuration'; -import { PalettePicker } from '../shared_components'; +import { PalettePicker, TooltipWrapper } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 1652e78d3d2cb..4fce4c276c336 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -112,7 +112,7 @@ export async function getChartsPaletteServiceGetColor(): Promise< const chartConfiguration = { syncColors: true }; return (value: string) => { const series = [{ name: value, rankAtDepth: 0, totalSeriesAtDepth: 1 }]; - const color = paletteDefinition.getColor(series, chartConfiguration); + const color = paletteDefinition.getCategoricalColor(series, chartConfiguration); return color ? color : '#3d3d3d'; }; } diff --git a/x-pack/test/accessibility/apps/lens.ts b/x-pack/test/accessibility/apps/lens.ts index a8d20ff56de08..682aa5a576f9e 100644 --- a/x-pack/test/accessibility/apps/lens.ts +++ b/x-pack/test/accessibility/apps/lens.ts @@ -69,6 +69,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); + it('lens datatable with dynamic cell colouring', async () => { + await PageObjects.lens.openDimensionEditor('lnsDatatable_metrics > lns-dimensionTrigger'); + await PageObjects.lens.setTableDynamicColoring('cell'); + await a11y.testAppSnapshot(); + }); + + it('lens datatable with dynamic text colouring', async () => { + await PageObjects.lens.setTableDynamicColoring('text'); + await a11y.testAppSnapshot(); + }); + + it('lens datatable with palette panel open', async () => { + await PageObjects.lens.openTablePalettePanel(); + await a11y.testAppSnapshot(); + }); + + it('lens datatable with custom palette stops', async () => { + await PageObjects.lens.changePaletteTo('custom'); + await a11y.testAppSnapshot(); + await PageObjects.lens.closePaletteEditor(); + await PageObjects.lens.closeDimensionEditor(); + }); + it('lens metric chart', async () => { await PageObjects.lens.switchToVisualization('lnsMetric'); await a11y.testAppSnapshot(); diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts index f0f3ce27f4c31..f048bf47991f2 100644 --- a/x-pack/test/functional/apps/lens/table.ts +++ b/x-pack/test/functional/apps/lens/table.ts @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const find = getService('find'); const retry = getService('retry'); + const testSubjects = getService('testSubjects'); describe('lens datatable', () => { it('should able to sort a table by a column', async () => { @@ -93,5 +94,55 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); }); + + it('should show dynamic coloring feature for numeric columns', async () => { + await PageObjects.lens.openDimensionEditor('lnsDatatable_metrics > lns-dimensionTrigger'); + await PageObjects.lens.setTableDynamicColoring('text'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(0, 2); + expect(styleObj['background-color']).to.be(undefined); + expect(styleObj.color).to.be('rgb(133, 189, 177)'); + }); + + it('should allow to color cell background rather than text', async () => { + await PageObjects.lens.setTableDynamicColoring('cell'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(0, 2); + expect(styleObj['background-color']).to.be('rgb(133, 189, 177)'); + // should also set text color when in cell mode + expect(styleObj.color).to.be('rgb(0, 0, 0)'); + }); + + it('should open the palette panel to customize the palette look', async () => { + await PageObjects.lens.openTablePalettePanel(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.changePaletteTo('temperature'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(0, 2); + expect(styleObj['background-color']).to.be('rgb(235, 239, 245)'); + }); + + it('tweak the color stops numeric value', async () => { + await testSubjects.setValue('lnsDatatable_dynamicColoring_stop_value_0', '30', { + clearWithKeyboard: true, + }); + // when clicking on another row will trigger a sorting + update + await testSubjects.click('lnsDatatable_dynamicColoring_stop_value_1'); + await PageObjects.header.waitUntilLoadingHasFinished(); + // pick a cell without color as is below the range + const styleObj = await PageObjects.lens.getDatatableCellStyle(3, 3); + expect(styleObj['background-color']).to.be(undefined); + // should also set text color when in cell mode + expect(styleObj.color).to.be(undefined); + }); + + it('should allow the user to reverse the palette', async () => { + await testSubjects.click('lnsDatatable_dynamicColoring_reverse'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const styleObj = await PageObjects.lens.getDatatableCellStyle(1, 1); + expect(styleObj['background-color']).to.be('rgb(168, 191, 218)'); + // should also set text color when in cell mode + expect(styleObj.color).to.be('rgb(0, 0, 0)'); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index f73440e331466..b16944cd73060 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -126,8 +126,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont } if (opts.palette) { - await testSubjects.click('lns-palettePicker'); - await find.clickByCssSelector(`#${opts.palette}`); + await this.setPalette(opts.palette); } if (!opts.keepOpen) { @@ -671,6 +670,18 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return el.getVisibleText(); }, + async getDatatableCellStyle(rowIndex = 0, colIndex = 0) { + const el = await this.getDatatableCell(rowIndex, colIndex); + const styleString = await el.getAttribute('style'); + return styleString.split(';').reduce>((memo, cssLine) => { + const [prop, value] = cssLine.split(':'); + if (prop && value) { + memo[prop.trim()] = value.trim(); + } + return memo; + }, {}); + }, + async getDatatableHeader(index = 0) { return find.byCssSelector( `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${ @@ -714,6 +725,46 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return buttonEl.click(); }, + async setTableDynamicColoring(coloringType: 'none' | 'cell' | 'text') { + await testSubjects.click('lnsDatatable_dynamicColoring_groups_' + coloringType); + }, + + async openTablePalettePanel() { + await testSubjects.click('lnsDatatable_dynamicColoring_trigger'); + }, + + // different picker from the next one + async changePaletteTo(paletteName: string) { + await testSubjects.click('lnsDatatable_dynamicColoring_palette_picker'); + await testSubjects.click(`${paletteName}-palette`); + }, + + async setPalette(paletteName: string) { + await testSubjects.click('lns-palettePicker'); + await find.clickByCssSelector(`#${paletteName}`); + }, + + async closePaletteEditor() { + await retry.try(async () => { + await testSubjects.click('lns-indexPattern-PalettePanelContainerBack'); + await testSubjects.missingOrFail('lns-indexPattern-PalettePanelContainerBack'); + }); + }, + + async openColorStopPopup(index = 0) { + const stopEls = await testSubjects.findAll('euiColorStopThumb'); + if (stopEls[index]) { + await stopEls[index].click(); + } + }, + + async setColorStopValue(value: number | string) { + await testSubjects.setValue( + 'lnsDatatable_dynamicColoring_progression_custom_stops_value', + String(value) + ); + }, + async toggleColumnVisibility(dimension: string) { await this.openDimensionEditor(dimension); const id = 'lns-table-column-hidden';