From f4312d742f720bf4107c49cbe21f72877ffb1f20 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 3 Dec 2025 12:45:44 -0700 Subject: [PATCH 1/6] refactor: colorByValue types - remove `to`, `from` and `exact` step types - merge `colorByValueSchema` into single schema - remove top-level `min` and `max` properties - add single `step` type with optional `to` and `from` - limit min size of steps to `1` - add validation for step configurations - update color schema tests --- .../config_builder/schema/color.test.ts | 312 +++++++++--------- .../config_builder/schema/color.ts | 182 ++++------ 2 files changed, 227 insertions(+), 267 deletions(-) diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts index f1c2c563cd993..eada5fa6676ff 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts @@ -7,103 +7,128 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { freeze, produce } from 'immer'; + import { allColoringTypeSchema, type ColorByValueType, type ColorMappingType } from './color'; describe('Color Schema', () => { describe('colorByValue schema', () => { - it('validates a valid dynamic absolute color configuration', () => { - const input: ColorByValueType = { - type: 'dynamic', - range: 'absolute', - steps: [ - { - type: 'from', - from: 0, - color: '#ff0000', - }, - { - type: 'exact', - value: 50, - color: '#0000ff', - }, - { - type: 'to', - to: 75, - color: '#00ff00', - }, - ], - }; - - const validated = allColoringTypeSchema.validate(input); - expect(validated).toEqual(input); - }); - - it('validates percentage range type', () => { - const input: ColorByValueType = { - type: 'dynamic', - min: 0, - max: 100, - range: 'percentage', - steps: [ - { - type: 'from', - from: 0, - color: '#ff0000', - }, - { - type: 'exact', - value: 50, - color: '#0000ff', - }, - { - type: 'to', - to: 75, - color: '#00ff00', - }, - ], - }; - - const validated = allColoringTypeSchema.validate(input); - expect(validated).toEqual(input); - }); - - it('throw on invalid steps sorting order', () => { - const input: ColorByValueType = { - type: 'dynamic', - range: 'absolute', - steps: [ - { - type: 'from', - from: 0, - color: '#ff0000', - }, - { - type: 'to', - to: 50, - color: '#00ff00', - }, - { - type: 'exact', - value: 75, - color: '#0000ff', - }, - ], - }; - - expect(() => allColoringTypeSchema.validate(input)).toThrow(); - }); - - it('throws on invalid range type', () => { - const input = { - type: 'dynamic', - min: 0, - max: 100, - range: 'invalid', - steps: [], - }; - - expect(() => allColoringTypeSchema.validate(input)).toThrow(); - }); + describe.each(['absolute', 'percentage'])( + 'range type - %s', + (range) => { + const baseConfig = freeze({ + type: 'dynamic', + range, + steps: [ + { + from: 0, + to: 50, + color: '#ff0000', + }, + { + from: 50, + to: 75, + color: '#00ff00', + }, + { + from: 75, + to: 100, + color: '#0000ff', + }, + ], + }); + + it('should validate complete step ranges', () => { + const validated = allColoringTypeSchema.validate(baseConfig); + expect(validated).toEqual(baseConfig); + }); + + it('should validate with implicit lower and upper bounds', () => { + const config = produce(baseConfig, (base) => { + base.steps[0].from = undefined; + base.steps[2].to = undefined; + }); + const validated = allColoringTypeSchema.validate(config); + expect(validated).toEqual(config); + }); + + it('should validate with implicit lower bound', () => { + const config = produce(baseConfig, (base) => { + base.steps[0].from = undefined; + }); + const validated = allColoringTypeSchema.validate(config); + expect(validated).toEqual(config); + }); + + it('should validate with implicit upper bound', () => { + const config = produce(baseConfig, (base) => { + base.steps[2].to = undefined; + }); + const validated = allColoringTypeSchema.validate(config); + expect(validated).toEqual(config); + }); + + describe('validation errors', () => { + it('should invalidate empty steps', () => { + const config = produce(baseConfig, (base) => { + base.steps = []; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + + it('should invalidate implicit from on middle step', () => { + const config = produce(baseConfig, (base) => { + base.steps[1].from = undefined; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + + it('should invalidate implicit from on last step', () => { + const config = produce(baseConfig, (base) => { + base.steps[2].from = undefined; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + + it('should invalidate implicit to on middle step', () => { + const config = produce(baseConfig, (base) => { + base.steps[1].to = undefined; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + + it('should invalidate implicit from on first step', () => { + const config = produce(baseConfig, (base) => { + base.steps[0].to = undefined; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + + it('should invalidate discontinuous step ranges', () => { + const config = produce(baseConfig, (base) => { + base.steps[1].from = base.steps[1].from! + 1; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + + it('should invalidate overlapping step ranges', () => { + const config = produce(baseConfig, (base) => { + base.steps[0].to = base.steps[1].from! + 1; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + + it('should invalidate inverted range', () => { + const config = produce(baseConfig, (base) => { + const { from, to } = base.steps[1]; + base.steps[1].to = from; + base.steps[1].from = to; + }); + expect(() => allColoringTypeSchema.validate(config)).toThrow(); + }); + }); + } + ); }); describe('staticColor schema', () => { @@ -116,6 +141,17 @@ describe('Color Schema', () => { const validated = allColoringTypeSchema.validate(input); expect(validated).toEqual(input); }); + + describe('validation errors', () => { + it('throws on invalid color format in static configuration', () => { + const input = { + type: 'static', + palette: 'not-a-color', + }; + + expect(() => allColoringTypeSchema.validate(input)).toThrow(); + }); + }); }); describe('colorMapping schema', () => { @@ -251,68 +287,6 @@ describe('Color Schema', () => { const validated = allColoringTypeSchema.validate(input); expect(validated).toEqual(input); }); - }); - - describe('validation errors', () => { - it('throws on missing required fields in dynamic configuration', () => { - const input = { - type: 'dynamic', - min: 0, - // missing max - range: 'percentage', - steps: [], - }; - - expect(() => allColoringTypeSchema.validate(input)).toThrow(); - }); - - it('throws on invalid color format in static configuration', () => { - const input = { - type: 'static', - palette: 'not-a-color', - }; - - expect(() => allColoringTypeSchema.validate(input)).toThrow(); - }); - - it('throws on invalid mode in color mapping', () => { - const input = { - palette: 'kibana_palette', - mode: 'invalid', - colorMapping: { - values: ['value1'], - }, - otherColors: {}, - }; - - expect(() => allColoringTypeSchema.validate(input)).toThrow(); - }); - - it('throws on empty values array in categorical mapping', () => { - const input = { - palette: 'kibana_palette', - mode: 'categorical', - colorMapping: { - values: [], - }, - otherColors: {}, - }; - - expect(() => allColoringTypeSchema.validate(input)).toThrow(); - }); - }); - - describe('edge cases', () => { - it('validates dynamic configuration with minimum required fields', () => { - const input = { - type: 'dynamic', - range: 'absolute', - steps: [], - }; - - const validated = allColoringTypeSchema.validate(input); - expect(validated).toEqual(input); - }); it('validates color mapping with minimal otherColors', () => { const input: ColorMappingType = { @@ -324,5 +298,33 @@ describe('Color Schema', () => { const validated = allColoringTypeSchema.validate(input); expect(validated).toEqual(input); }); + + describe('validation errors', () => { + it('throws on invalid mode in color mapping', () => { + const input = { + palette: 'kibana_palette', + mode: 'invalid', + colorMapping: { + values: ['value1'], + }, + otherColors: {}, + }; + + expect(() => allColoringTypeSchema.validate(input)).toThrow(); + }); + + it('throws on empty values array in categorical mapping', () => { + const input = { + palette: 'kibana_palette', + mode: 'categorical', + colorMapping: { + values: [], + }, + otherColors: {}, + }; + + expect(() => allColoringTypeSchema.validate(input)).toThrow(); + }); + }); }); }); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts index e17ad2296c3f6..8fe3f8dd31ba3 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts @@ -11,122 +11,81 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import { serializedValueSchema } from './serializedValue'; -const colorByValueBase = schema.object({ - type: schema.literal('dynamic'), // Specifies that the color assignment is dynamic (by value). Possible value: 'dynamic' +const colorByValueStepsSchema = schema.arrayOf( + schema.object({ + /** + * The value from which this color applies (inclusive). + */ + from: schema.maybe( + schema.number({ + meta: { description: 'The value from which this color applies (inclusive).' }, + }) + ), + /** + * The value up to which this color applies (inclusive). + */ + to: schema.maybe( + schema.number({ + meta: { description: 'The value up to which this color applies (inclusive).' }, + }) + ), + /** + * The color to use for this step. + */ + color: schema.string({ meta: { description: 'The color to use for this step.' } }), + }), + { + meta: { + description: 'Array of ordered color steps defining the range each color is applied.', + }, + minSize: 1, + validate(steps) { + let trackingValue = steps[0].from ?? steps[0].to ?? -Infinity; + for (const [i, step] of steps.entries()) { + if (step.from === undefined) { + if (i === 0) continue; + return 'The "from" value is required for all steps except the first.'; + } - /** - * Array of color steps defining the mapping from values to colors. - * Each step can be: - * - 'from': Color applies from a specified value upwards. - * - 'to': Color applies up to a specified value. - * - 'exact': Color applies to an exact value. - */ - steps: schema.arrayOf( - schema.oneOf([ - schema.object({ - /** - * Step type indicating the color applies from a specific value upwards. - * Possible value: 'from' - */ - type: schema.literal('from'), - /** - * The value from which this color applies (inclusive). - */ - from: schema.number({ - meta: { description: 'The value from which this color applies (inclusive).' }, - }), - /** - * The color to use for this step. - */ - color: schema.string({ meta: { description: 'The color to use for this step.' } }), - }), - schema.object({ - /** - * Step type indicating the color applies up to a specific value. - * Possible value: 'to' - */ - type: schema.literal('to'), - /** - * The value up to which this color applies (inclusive). - */ - to: schema.number({ - meta: { description: 'The value up to which this color applies (inclusive).' }, - }), - /** - * The color to use for this step. - */ - color: schema.string({ meta: { description: 'The color to use for this step.' } }), - }), - schema.object({ - type: schema.literal('exact'), // Step type indicating the color applies to an exact value. Possible value: 'exact' - /** - * The exact value to which this color applies. - */ - value: schema.number({ - meta: { description: 'The exact value to which this color applies.' }, - }), - /** - * The color to use for this exact value. - */ - color: schema.string({ meta: { description: 'The color to use for this exact value.' } }), - }), - ]), - { - validate(steps) { - if ( - steps.some((step) => step.type === 'from') && - steps.findIndex((step) => step.type === 'from') !== 0 - ) { - return 'The "from" step must be the first step in the array.'; + if (step.to === undefined) { + if (i === steps.length - 1) continue; + return 'The "to" value is required for all steps except the last.'; } - if ( - steps.some((step) => step.type === 'to') && - steps.findIndex((step) => step.type === 'to') !== steps.length - 1 - ) { - return 'The "to" step must be the last step in the array.'; + + if (step.from > step.to) { + return `"step[${i}].from" must be less than the "step[${i}].to".`; } - return undefined; - }, - } - ), -}); -export const colorByValueAbsolute = schema.allOf([ - colorByValueBase, - schema.object({ range: schema.literal('absolute') }), -]); + if (step.from !== trackingValue && i !== 0) { + return `Step ranges must be continuous. "step[${i}].from" and "step[${ + i - 1 + }].to" must be equal.`; + } -export const colorByValueSchema = schema.oneOf([ - colorByValueAbsolute, - schema.allOf([ - colorByValueBase, - schema.object({ - /** - * The minimum value for the color range. Used as the lower bound for value-based color assignment. - */ - min: schema.number({ - meta: { - description: - 'The minimum value for the color range. Used as the lower bound for value-based color assignment.', - }, - }), - /** - * The maximum value for the color range. Used as the upper bound for value-based color assignment. - */ - max: schema.number({ - meta: { - description: - 'The maximum value for the color range. Used as the upper bound for value-based color assignment.', - }, - }), - /** - * Determines whether the range is interpreted as absolute or as a percentage of the data. - * Possible values: 'absolute', 'percentage' - */ - range: schema.literal('percentage'), // Range is interpreted as percentage values. Possible value: 'percentage' - }), - ]), -]); + trackingValue = step.to; + } + }, + } +); + +const colorByValueSchema = schema.object({ + type: schema.literal('dynamic'), + + /** + * Determines whether the range is interpreted as absolute or as a percentage of the data. + */ + range: schema.oneOf([schema.literal('absolute'), schema.literal('percentage')], { + meta: { + description: + 'Determines whether the range is interpreted as absolute or as a percentage of the data.', + }, + }), + + /** + * Array of color steps defining the mapping from values to colors. + */ + steps: colorByValueStepsSchema, +}); export const staticColorSchema = schema.object({ type: schema.literal('static'), // Specifies that the color assignment is static (single color for all values). Possible value: 'static' @@ -198,7 +157,6 @@ export const allColoringTypeSchema = schema.oneOf([ export type StaticColorType = TypeOf; export type ColorByValueType = TypeOf; -export type ColorByValueAbsoluteType = TypeOf; export type ColorMappingType = TypeOf; export type ColorMappingCategoricalType = TypeOf; export type ColorMappingGradientType = TypeOf; From 4615e25d73a3b37919b25cd87a6d28ecd51dfaf0 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 3 Dec 2025 14:41:21 -0700 Subject: [PATCH 2/6] update color transform logic with `to`, `from` steps --- .../config_builder/schema/color.test.ts | 18 +-- .../config_builder/schema/color.ts | 21 ++- .../transforms/{ => coloring}/coloring.ts | 140 ++++++++++-------- .../transforms/coloring/index.ts | 10 ++ 4 files changed, 103 insertions(+), 86 deletions(-) rename src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/{ => coloring}/coloring.ts (75%) create mode 100644 src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/index.ts diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts index eada5fa6676ff..5270f3d1eec8d 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.test.ts @@ -20,21 +20,9 @@ describe('Color Schema', () => { type: 'dynamic', range, steps: [ - { - from: 0, - to: 50, - color: '#ff0000', - }, - { - from: 50, - to: 75, - color: '#00ff00', - }, - { - from: 75, - to: 100, - color: '#0000ff', - }, + { from: 0, to: 50, color: '#ff0000' }, + { from: 50, to: 75, color: '#00ff00' }, + { from: 75, to: 100, color: '#0000ff' }, ], }); diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts index 8fe3f8dd31ba3..bded1dc0bf98a 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts @@ -16,18 +16,22 @@ const colorByValueStepsSchema = schema.arrayOf( /** * The value from which this color applies (inclusive). */ - from: schema.maybe( - schema.number({ - meta: { description: 'The value from which this color applies (inclusive).' }, - }) + from: schema.nullable( + schema.maybe( + schema.number({ + meta: { description: 'The value from which this color applies (inclusive).' }, + }) + ) ), /** * The value up to which this color applies (inclusive). */ - to: schema.maybe( - schema.number({ - meta: { description: 'The value up to which this color applies (inclusive).' }, - }) + to: schema.nullable( + schema.maybe( + schema.number({ + meta: { description: 'The value up to which this color applies (exclusive).' }, + }) + ) ), /** * The color to use for this step. @@ -157,6 +161,7 @@ export const allColoringTypeSchema = schema.oneOf([ export type StaticColorType = TypeOf; export type ColorByValueType = TypeOf; +export type ColorByValueStep = TypeOf[number]; export type ColorMappingType = TypeOf; export type ColorMappingCategoricalType = TypeOf; export type ColorMappingGradientType = TypeOf; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts similarity index 75% rename from src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring.ts rename to src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts index a76ac19b2367c..328ba1ce8341f 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts @@ -7,14 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ColorMapping, CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; +import type { ColorMapping, ColorStop, CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; + import type { + ColorByValueStep, ColorByValueType, ColorMappingColorDefType, ColorMappingType, StaticColorType, -} from '../schema/color'; -import type { SerializableValueType } from '../schema/serializedValue'; +} from '../../schema/color'; +import type { SerializableValueType } from '../../schema/serializedValue'; const LENS_COLOR_BY_VALUE_RANGE_TYPE = 'absolute'; const LENS_DEFAULT_COLOR_MAPPING_PALETTE = 'default'; @@ -30,82 +32,94 @@ const API_TO_LEGACY_RANGE_NAMES: Record<'percentage' | 'absolute', 'percent' | ' }; export function fromColorByValueAPIToLensState( - color: ColorByValueType | undefined + config?: ColorByValueType | undefined ): PaletteOutput | undefined { - if (!color) { - return; - } - const stops = color.steps.map((step) => { - if (step.type === 'from') { - return { color: step.color, stop: step.from }; - } - if (step.type === 'to') { - return { color: step.color, stop: step.to }; - } - return { - color: step.color, - stop: step.value, - }; - }); + if (!config) return; + + const stops = config.steps.map( + ({ to, color }): ColorStop => ({ + color, + stop: to ?? null, // we need to implicitly set this value to the domain max in client code + }) + ); + const colorStops = config.steps.map( + ({ from, color }): ColorStop => ({ + color, + stop: from ?? null, + }) + ); + + const rangeMin = colorStops.at(0)?.stop ?? null; + const rangeMax = stops.at(-1)?.stop ?? null; + return { type: 'palette', name: 'custom', params: { name: 'custom', - ...(color.range === 'percentage' ? { rangeMin: color.min, rangeMax: color.max } : {}), - rangeType: color.range - ? API_TO_LEGACY_RANGE_NAMES[color.range] + reverse: false, // always applied to steps during transform + rangeMin, + rangeMax, + rangeType: config.range + ? API_TO_LEGACY_RANGE_NAMES[config.range] : API_TO_LEGACY_RANGE_NAMES.absolute, stops, - colorStops: stops, + colorStops, + continuity: + rangeMin === null && rangeMax === null + ? 'all' + : rangeMax === null + ? 'above' + : rangeMin === null + ? 'below' + : 'none', + steps: stops.length, + maxSteps: Math.max(5, stops.length), // TODO: point this to a constant or a common default }, }; } export function fromColorByValueLensStateToAPI( - color: PaletteOutput | undefined + config: PaletteOutput | undefined ): ColorByValueType | undefined { - if (!color || !color.params) { - return; - } - const rangeType = color.params.rangeType - ? LEGACY_TO_API_RANGE_NAMES[color.params.rangeType] - : LENS_COLOR_BY_VALUE_RANGE_TYPE; - if (rangeType === 'absolute') { - return { - type: 'dynamic', - range: rangeType, - steps: - color.params.stops?.map((step, index) => { - const isFirst = index === 0; - if (isFirst) { - return { type: 'from', color: step.color, from: step.stop }; - } - const isLast = index === (color.params?.stops?.length ?? 0) - 1; - if (isLast) { - return { type: 'to', color: step.color, to: step.stop }; - } - return { type: 'exact', color: step.color, value: step.stop }; - }) ?? [], - }; - } + const colorParams = config?.params; + + if (!colorParams) return; + + const { stops = [], rangeType } = colorParams; + + // TODO: handle reverse + return { type: 'dynamic', - min: color.params.rangeMin!, - max: color.params.rangeMax!, - range: rangeType, - steps: - color.params.stops?.map((step, index) => { - const isFirst = index === 0; - if (isFirst) { - return { type: 'from', color: step.color, from: step.stop }; - } - const isLast = index === (color.params?.stops?.length ?? 0) - 1; - if (isLast) { - return { type: 'to', color: step.color, to: step.stop }; - } - return { type: 'exact', color: step.color, value: step.stop }; - }) ?? [], + range: rangeType ? LEGACY_TO_API_RANGE_NAMES[rangeType] : LENS_COLOR_BY_VALUE_RANGE_TYPE, + steps: stops.map((step, i): ColorByValueStep => { + const { stop: currentStop, color } = step; + if (i === 0) { + return { + ...(colorParams.rangeMin ? { from: colorParams.rangeMin } : {}), + to: currentStop, + color, + }; + } + + const prevStop = stops[i - 1].stop ?? undefined; + + if (i === stops.length - 1) { + return { + from: prevStop, + // ignores stop value, current logic sets last stop to max domain not user defined rangeMax + ...(colorParams.rangeMax ? { to: colorParams.rangeMax } : {}), + color, + }; + } + + return { + from: prevStop, + to: currentStop, + color, + }; + }), }; } diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/index.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/index.ts new file mode 100644 index 0000000000000..19556d9a16ad4 --- /dev/null +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/index.ts @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export * from './coloring'; From 49a447612438b6898df8fa2ff5df190845b87d70 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 3 Dec 2025 17:50:51 -0700 Subject: [PATCH 3/6] chore: cleanup color types and fix type errors --- .../shared/kbn-coloring/src/palettes/types.ts | 15 +++++++++------ .../schema/charts/legacy_metric.ts | 4 ++-- .../config_builder/schema/charts/metric.ts | 6 +++--- .../config_builder/schema/color.ts | 19 ++++++++++++------- .../config_builder/tests/validate.ts | 4 +++- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/platform/packages/shared/kbn-coloring/src/palettes/types.ts b/src/platform/packages/shared/kbn-coloring/src/palettes/types.ts index cb5715793c9bd..713806a850be9 100644 --- a/src/platform/packages/shared/kbn-coloring/src/palettes/types.ts +++ b/src/platform/packages/shared/kbn-coloring/src/palettes/types.ts @@ -141,15 +141,18 @@ export interface CustomPaletteParams { reverse?: boolean; rangeType?: 'number' | 'percent'; continuity?: PaletteContinuity; + /** + * @deprecated - appears to be unused + */ progression?: 'fixed'; - rangeMin?: number; - rangeMax?: number; - /** lower color stops */ + rangeMin?: number | null; + rangeMax?: number | null; + /** upper bounds of color stops ranges */ stops?: ColorStop[]; - /** upper color stops */ + /** lower bounds of color stops ranges */ colorStops?: ColorStop[]; steps?: number; - maxSteps?: number | undefined; + maxSteps?: number; } export type RequiredPaletteParamTypes = Assign< @@ -159,5 +162,5 @@ export type RequiredPaletteParamTypes = Assign< export interface ColorStop { color: string; - stop: number; + stop: number | null; } diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/legacy_metric.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/legacy_metric.ts index 5de15380fb629..8cc051a989ac8 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/legacy_metric.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/legacy_metric.ts @@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema'; import { esqlColumnSchema, genericOperationOptionsSchema } from '../metric_ops'; import { datasetSchema, datasetEsqlTableSchema } from '../dataset'; import { layerSettingsSchema, sharedPanelInfoSchema, dslOnlyPanelInfoSchema } from '../shared'; -import { applyColorToSchema, colorByValueAbsolute } from '../color'; +import { applyColorToSchema, colorByValueAbsoluteSchema } from '../color'; import { horizontalAlignmentSchema, verticalAlignmentSchema } from '../alignments'; import { mergeAllMetricsWithChartDimensionSchema } from './shared'; @@ -73,7 +73,7 @@ const legacyMetricStateMetricOptionsSchema = schema.object({ /** * Color configuration */ - color: schema.maybe(colorByValueAbsolute), + color: schema.maybe(colorByValueAbsoluteSchema), }); export const legacyMetricStateSchemaNoESQL = schema.object({ diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts index e18269745819a..c38bb42a68edf 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/charts/metric.ts @@ -14,7 +14,7 @@ import { esqlColumnSchema, genericOperationOptionsSchema, } from '../metric_ops'; -import { colorByValueAbsolute, staticColorSchema, applyColorToSchema } from '../color'; +import { colorByValueAbsoluteSchema, staticColorSchema, applyColorToSchema } from '../color'; import { datasetSchema, datasetEsqlTableSchema } from '../dataset'; import { collapseBySchema, @@ -116,7 +116,7 @@ const metricStatePrimaryMetricOptionsSchema = schema.object({ /** * Color configuration */ - color: schema.maybe(schema.oneOf([colorByValueAbsolute, staticColorSchema])), + color: schema.maybe(schema.oneOf([colorByValueAbsoluteSchema, staticColorSchema])), /** * Where to apply the color (background or value) */ @@ -155,7 +155,7 @@ const metricStateSecondaryMetricOptionsSchema = schema.object({ /** * Color configuration */ - color: schema.maybe(schema.oneOf([colorByValueAbsolute, staticColorSchema])), + color: schema.maybe(schema.oneOf([colorByValueAbsoluteSchema, staticColorSchema])), }); const metricStateBreakdownByOptionsSchema = schema.object({ diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts index bded1dc0bf98a..548e0c3fc1a91 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/schema/color.ts @@ -9,6 +9,7 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; +import { isNil } from 'lodash'; import { serializedValueSchema } from './serializedValue'; const colorByValueStepsSchema = schema.arrayOf( @@ -16,8 +17,8 @@ const colorByValueStepsSchema = schema.arrayOf( /** * The value from which this color applies (inclusive). */ - from: schema.nullable( - schema.maybe( + from: schema.maybe( + schema.nullable( schema.number({ meta: { description: 'The value from which this color applies (inclusive).' }, }) @@ -26,8 +27,8 @@ const colorByValueStepsSchema = schema.arrayOf( /** * The value up to which this color applies (inclusive). */ - to: schema.nullable( - schema.maybe( + to: schema.maybe( + schema.nullable( schema.number({ meta: { description: 'The value up to which this color applies (exclusive).' }, }) @@ -46,12 +47,12 @@ const colorByValueStepsSchema = schema.arrayOf( validate(steps) { let trackingValue = steps[0].from ?? steps[0].to ?? -Infinity; for (const [i, step] of steps.entries()) { - if (step.from === undefined) { + if (isNil(step.from)) { if (i === 0) continue; return 'The "from" value is required for all steps except the first.'; } - if (step.to === undefined) { + if (isNil(step.to)) { if (i === steps.length - 1) continue; return 'The "to" value is required for all steps except the last.'; } @@ -72,7 +73,7 @@ const colorByValueStepsSchema = schema.arrayOf( } ); -const colorByValueSchema = schema.object({ +export const colorByValueSchema = schema.object({ type: schema.literal('dynamic'), /** @@ -91,6 +92,10 @@ const colorByValueSchema = schema.object({ steps: colorByValueStepsSchema, }); +export const colorByValueAbsoluteSchema = colorByValueSchema.extends({ + range: schema.literal('absolute'), +}); + export const staticColorSchema = schema.object({ type: schema.literal('static'), // Specifies that the color assignment is static (single color for all values). Possible value: 'static' /** diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/validate.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/validate.ts index 21fb30cd50e48..5b4f6a4efc202 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/validate.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/validate.ts @@ -7,8 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Type } from '@kbn/config-schema'; import { unset } from 'lodash'; + +import type { Type } from '@kbn/config-schema'; + import { LensConfigBuilder } from '../config_builder'; import type { LensAttributes } from '../types'; import type { LensApiState } from '../schema'; From 8222a1dbbe43e40f1f558431fb8c4f04eb3eaa75 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 3 Dec 2025 17:55:53 -0700 Subject: [PATCH 4/6] test: add dynamic color metric example mock --- .../tests/metric/dynamic_colors.mock.ts | 145 ++++++++++++++++++ .../tests/metric/metric.test.ts | 5 + 2 files changed, 150 insertions(+) create mode 100644 src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/dynamic_colors.mock.ts diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/dynamic_colors.mock.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/dynamic_colors.mock.ts new file mode 100644 index 0000000000000..1faf5d05cdd25 --- /dev/null +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/dynamic_colors.mock.ts @@ -0,0 +1,145 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { LensAttributes } from '../../types'; + +export const dynamicColorsMetricAttributes: LensAttributes = { + description: 'Metric - dynamic colors', + state: { + visualization: { + layerId: 'e016676a-c659-4af1-bd71-52a1e5fb37f7', + layerType: 'data', + metricAccessor: 'd8ef3452-490c-45e3-9505-e44b562b9f1d', + breakdownByAccessor: 'd3c6a135-31a8-4dc0-b7a2-027ac433333c', + palette: { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: null, + rangeMax: null, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 414.66, + }, + { + color: '#fcd883', + stop: 537.31, + }, + { + color: '#f6726a', + stop: 660, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: null, + }, + { + color: '#fcd883', + stop: 414.66, + }, + { + color: '#f6726a', + stop: 537.31, + }, + ], + continuity: 'all', + maxSteps: 5, + }, + }, + secondaryTrend: { + type: 'none', + }, + secondaryLabelPosition: 'before', + }, + query: { + query: '', + language: 'kuery', + }, + filters: [], + datasourceStates: { + formBased: { + layers: { + 'e016676a-c659-4af1-bd71-52a1e5fb37f7': { + columns: { + 'd3c6a135-31a8-4dc0-b7a2-027ac433333c': { + label: 'Top 3 values of extension.keyword', + dataType: 'string', + operationType: 'terms', + sourceField: 'extension.keyword', + isBucketed: true, + params: { + // @ts-expect-error + size: 3, + orderBy: { + type: 'column', + columnId: 'd8ef3452-490c-45e3-9505-e44b562b9f1d', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + include: [], + exclude: [], + includeIsRegex: false, + excludeIsRegex: false, + }, + }, + 'd8ef3452-490c-45e3-9505-e44b562b9f1d': { + label: 'Count of records', + dataType: 'number', + operationType: 'count', + isBucketed: false, + sourceField: '___records___', + params: { + // @ts-expect-error + emptyAsNull: true, + }, + }, + }, + columnOrder: [ + 'd3c6a135-31a8-4dc0-b7a2-027ac433333c', + 'd8ef3452-490c-45e3-9505-e44b562b9f1d', + ], + incompleteColumns: {}, + sampling: 1, + }, + }, + }, + // @ts-expect-error + indexpattern: { + layers: {}, + }, + textBased: { + layers: {}, + }, + }, + internalReferences: [], + adHocDataViews: {}, + }, + title: 'Metric - dynamic colors', + version: 1, + visualizationType: 'lnsMetric', + references: [ + { + type: 'index-pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'indexpattern-datasource-layer-e016676a-c659-4af1-bd71-52a1e5fb37f7', + }, + ], +} satisfies LensAttributes; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts index 1c9de0b62b4d3..1872b8864cbd5 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/tests/metric/metric.test.ts @@ -12,6 +12,7 @@ import { validateConverter } from '../validate'; import { simpleMetricAttributes } from './simple.mock'; import { breakdownMetricAttributes } from './breakdown.mock'; import { complexMetricAttributes } from './complex.mock'; +import { dynamicColorsMetricAttributes } from './dynamic_colors.mock'; describe('Metric', () => { it('should convert a simple metric', () => { @@ -25,4 +26,8 @@ describe('Metric', () => { it('should convert a breakdown-by metric', () => { validateConverter(breakdownMetricAttributes, metricStateSchema); }); + + it('should convert a dynamic colors metric', () => { + validateConverter(dynamicColorsMetricAttributes, metricStateSchema); + }); }); From fcf216bbcbc8692e63e2568858c8d2a37cb70042 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 3 Dec 2025 20:58:08 -0700 Subject: [PATCH 5/6] fix: last step issues and update coloring tests --- .../transforms/coloring/absolute.mocks.ts | 186 +++++++++++++++ .../transforms/coloring/bad_max_step.mocks.ts | 144 ++++++++++++ .../{ => coloring}/coloring.test.ts | 217 +++++++++++++----- .../transforms/coloring/coloring.ts | 7 +- .../transforms/coloring/percentage.mocks.ts | 186 +++++++++++++++ 5 files changed, 679 insertions(+), 61 deletions(-) create mode 100644 src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/absolute.mocks.ts create mode 100644 src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/bad_max_step.mocks.ts rename src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/{ => coloring}/coloring.test.ts (69%) create mode 100644 src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/percentage.mocks.ts diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/absolute.mocks.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/absolute.mocks.ts new file mode 100644 index 0000000000000..00f5504ee621e --- /dev/null +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/absolute.mocks.ts @@ -0,0 +1,186 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; + +export const noLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: null, + rangeMax: null, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 424.65, + }, + { + color: '#fcd883', + stop: 541.29, + }, + { + color: '#f6726a', + stop: null, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: null, + }, + { + color: '#fcd883', + stop: 424.65, + }, + { + color: '#f6726a', + stop: 541.29, + }, + ], + continuity: 'all', + maxSteps: 5, + }, +}; + +export const lowerLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: 300, + rangeMax: null, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 424.65, + }, + { + color: '#fcd883', + stop: 541.29, + }, + { + color: '#f6726a', + stop: null, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: 300, + }, + { + color: '#fcd883', + stop: 424.65, + }, + { + color: '#f6726a', + stop: 541.29, + }, + ], + continuity: 'above', + maxSteps: 5, + }, +}; + +export const upperLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: null, + rangeMax: 700, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 424.65, + }, + { + color: '#fcd883', + stop: 541.29, + }, + { + color: '#f6726a', + stop: 700, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: null, + }, + { + color: '#fcd883', + stop: 424.65, + }, + { + color: '#f6726a', + stop: 541.29, + }, + ], + continuity: 'below', + maxSteps: 5, + }, +}; + +export const upperAndLowerLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: 300, + rangeMax: 700, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 424.65, + }, + { + color: '#fcd883', + stop: 541.29, + }, + { + color: '#f6726a', + stop: 700, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: 300, + }, + { + color: '#fcd883', + stop: 424.65, + }, + { + color: '#f6726a', + stop: 541.29, + }, + ], + continuity: 'none', + maxSteps: 5, + }, +}; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/bad_max_step.mocks.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/bad_max_step.mocks.ts new file mode 100644 index 0000000000000..f014c451d97b0 --- /dev/null +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/bad_max_step.mocks.ts @@ -0,0 +1,144 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; + +// These mocks are to test that existing SO state last stop is transformed correctly to the rangeMax. + +export const noLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'percent', + rangeMin: null, + rangeMax: null, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 33.32, + }, + { + color: '#fcd883', + stop: 66.65, + }, + { + color: '#f6726a', + stop: 100, // this should be null + }, + ], + colorStops: [ + { + color: '#24c292', + stop: null, + }, + { + color: '#fcd883', + stop: 33.32, + }, + { + color: '#f6726a', + stop: 66.65, + }, + ], + continuity: 'all', + maxSteps: 5, + }, +}; + +export const lowerLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'percent', + rangeMin: 0, + rangeMax: null, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 33.32, + }, + { + color: '#fcd883', + stop: 66.65, + }, + { + color: '#f6726a', + stop: 100, // this should be null + }, + ], + colorStops: [ + { + color: '#24c292', + stop: 0, + }, + { + color: '#fcd883', + stop: 33.32, + }, + { + color: '#f6726a', + stop: 66.65, + }, + ], + continuity: 'above', + maxSteps: 5, + }, +}; + +export const upperAndLowerLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'number', + rangeMin: 300, + rangeMax: 700, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 424.65, + }, + { + color: '#fcd883', + stop: 541.29, + }, + { + color: '#f6726a', + stop: 658, // this should be 700 + }, + ], + colorStops: [ + { + color: '#24c292', + stop: 300, + }, + { + color: '#fcd883', + stop: 424.65, + }, + { + color: '#f6726a', + stop: 541.29, + }, + ], + continuity: 'none', + maxSteps: 5, + }, +}; diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.test.ts similarity index 69% rename from src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring.test.ts rename to src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.test.ts index bad13fdf8f1e3..3e3be48109e29 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.test.ts @@ -8,7 +8,8 @@ */ import type { ColorMapping, CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; -import type { ColorByValueType, ColorMappingType, StaticColorType } from '../schema/color'; + +import type { ColorByValueType, ColorMappingType, StaticColorType } from '../../schema/color'; import { fromColorByValueAPIToLensState, fromColorByValueLensStateToAPI, @@ -18,20 +19,24 @@ import { fromColorMappingLensStateToAPI, } from './coloring'; +import * as percentageMocks from './percentage.mocks'; +import * as absoluteMocks from './absolute.mocks'; +import * as badMaxStepsMocks from './bad_max_step.mocks'; + describe('Color util transforms', () => { describe('fromColorByValueAPIToLensState', () => { it('should return undefined when color is undefined', () => { expect(fromColorByValueAPIToLensState(undefined)).toBeUndefined(); }); - it('should convert absolute range color with from/to/exact steps', () => { + it('should convert absolute range color steps', () => { const colorByValue: ColorByValueType = { type: 'dynamic', range: 'absolute', steps: [ - { type: 'from', color: '#ff0000', from: 0 }, - { type: 'exact', color: '#ffff00', value: 50 }, - { type: 'to', color: '#00ff00', to: 100 }, + { color: 'red', to: 0 }, + { color: 'green', from: 0, to: 100 }, + { color: 'blue', from: 100 }, ], }; @@ -43,29 +48,34 @@ describe('Color util transforms', () => { params: { name: 'custom', rangeType: 'number', + progression: 'fixed', + continuity: 'all', + reverse: false, + steps: 3, + maxSteps: 5, + rangeMax: null, + rangeMin: null, stops: [ - { color: '#ff0000', stop: 0 }, - { color: '#ffff00', stop: 50 }, - { color: '#00ff00', stop: 100 }, + { color: 'red', stop: 0 }, + { color: 'green', stop: 100 }, + { color: 'blue', stop: null }, ], colorStops: [ - { color: '#ff0000', stop: 0 }, - { color: '#ffff00', stop: 50 }, - { color: '#00ff00', stop: 100 }, + { color: 'red', stop: null }, + { color: 'green', stop: 0 }, + { color: 'blue', stop: 100 }, ], }, - }); + } satisfies PaletteOutput); }); it('should convert percentage range color with min/max values', () => { const colorByValue: ColorByValueType = { type: 'dynamic', range: 'percentage', - min: 10, - max: 90, steps: [ - { type: 'from', color: '#ff0000', from: 10 }, - { type: 'to', color: '#00ff00', to: 90 }, + { color: 'red', from: 10, to: 50 }, + { color: 'green', from: 50, to: 90 }, ], }; @@ -76,26 +86,31 @@ describe('Color util transforms', () => { name: 'custom', params: { name: 'custom', + rangeType: 'percent', + continuity: 'none', + progression: 'fixed', + reverse: false, + steps: 2, + maxSteps: 5, rangeMin: 10, rangeMax: 90, - rangeType: 'percent', stops: [ - { color: '#ff0000', stop: 10 }, - { color: '#00ff00', stop: 90 }, + { color: 'red', stop: 50 }, + { color: 'green', stop: 90 }, ], colorStops: [ - { color: '#ff0000', stop: 10 }, - { color: '#00ff00', stop: 90 }, + { color: 'red', stop: 10 }, + { color: 'green', stop: 50 }, ], }, - }); + } satisfies PaletteOutput); }); it('should default to absolute range when range is not specified', () => { - const colorByValue: ColorByValueType = { + const colorByValue = { type: 'dynamic', - steps: [{ type: 'exact', color: '#ff0000', value: 50 }], - } as ColorByValueType; + steps: [{ color: 'red', from: 0, to: 50 }], + } satisfies Partial as ColorByValueType; const result = fromColorByValueAPIToLensState(colorByValue); @@ -125,22 +140,27 @@ describe('Color util transforms', () => { name: 'custom', rangeType: 'number', stops: [ - { color: '#ff0000', stop: 0 }, - { color: '#ffff00', stop: 50 }, - { color: '#00ff00', stop: 100 }, + { color: 'red', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'blue', stop: 100 }, + ], + colorStops: [ + { color: 'red', stop: null }, + { color: 'green', stop: 0 }, + { color: 'blue', stop: 50 }, ], }, }; const result = fromColorByValueLensStateToAPI(palette); - expect(result).toEqual({ + expect(result).toMatchObject({ type: 'dynamic', range: 'absolute', steps: [ - { type: 'from', color: '#ff0000', from: 0 }, - { type: 'exact', color: '#ffff00', value: 50 }, - { type: 'to', color: '#00ff00', to: 100 }, + { color: 'red', to: 0 }, + { color: 'green', from: 0, to: 50 }, + { color: 'blue', from: 50 }, ], }); }); @@ -152,12 +172,17 @@ describe('Color util transforms', () => { params: { name: 'custom', rangeType: 'percent', - rangeMin: 10, - rangeMax: 90, + rangeMin: 5, + rangeMax: 95, stops: [ - { color: '#ff0000', stop: 10 }, - { color: '#0000aa', stop: 50 }, - { color: '#00ff00', stop: 90 }, + { color: 'red', stop: 10 }, + { color: 'green', stop: 50 }, + { color: 'blue', stop: 90 }, + ], + colorStops: [ + { color: 'red', stop: 5 }, + { color: 'green', stop: 10 }, + { color: 'blue', stop: 50 }, ], }, }; @@ -166,13 +191,11 @@ describe('Color util transforms', () => { expect(result).toEqual({ type: 'dynamic', - min: 10, - max: 90, range: 'percentage', steps: [ - { type: 'from', color: '#ff0000', from: 10 }, - { type: 'exact', color: '#0000aa', value: 50 }, - { type: 'to', color: '#00ff00', to: 90 }, + { color: 'red', from: 5, to: 10 }, + { color: 'green', from: 10, to: 50 }, + { color: 'blue', from: 50, to: 95 }, ], }); }); @@ -184,7 +207,7 @@ describe('Color util transforms', () => { params: { name: 'custom', rangeType: 'number', - stops: [{ color: '#ff0000', stop: 50 }], + stops: [{ color: 'red', stop: 50 }], }, }; @@ -193,7 +216,7 @@ describe('Color util transforms', () => { expect(result).toEqual({ type: 'dynamic', range: 'absolute', - steps: [{ type: 'from', color: '#ff0000', from: 50 }], + steps: [{ color: 'red', to: 50 }], }); }); @@ -205,8 +228,8 @@ describe('Color util transforms', () => { name: 'custom', rangeType: 'number', stops: [ - { color: '#ff0000', stop: 0 }, - { color: '#00ff00', stop: 100 }, + { color: 'red', stop: 0 }, + { color: 'green', stop: 100 }, ], }, }; @@ -217,8 +240,8 @@ describe('Color util transforms', () => { type: 'dynamic', range: 'absolute', steps: [ - { type: 'from', color: '#ff0000', from: 0 }, - { type: 'to', color: '#00ff00', to: 100 }, + { color: 'red', to: 0 }, + { color: 'green', from: 0 }, ], }); }); @@ -229,7 +252,7 @@ describe('Color util transforms', () => { name: 'custom', params: { name: 'custom', - stops: [{ color: '#ff0000', stop: 50 }], + stops: [{ color: 'red', stop: 50 }], }, }; @@ -429,6 +452,52 @@ describe('Color util transforms', () => { }); describe('round-trip conversions', () => { + describe('percentage range', () => { + it.each([ + ['no limit', percentageMocks.noLimitPalette], + ['lower limit', percentageMocks.lowerLimitPalette], + ['upper limit', percentageMocks.upperLimitPalette], + ['upper and lower limit', percentageMocks.upperAndLowerLimitPalette], + ])('should convert lens palette state to API and back - %s', (_, palette) => { + const apiColorByValue = fromColorByValueLensStateToAPI(palette); + const returnedPaletteState = fromColorByValueAPIToLensState(apiColorByValue); + + expect(returnedPaletteState).toEqual(palette); + }); + }); + + describe('absolute range', () => { + it.each([ + ['no limit', absoluteMocks.noLimitPalette], + ['lower limit', absoluteMocks.lowerLimitPalette], + ['upper limit', absoluteMocks.upperLimitPalette], + ['upper and lower limit', absoluteMocks.upperAndLowerLimitPalette], + ])('should convert lens palette state to API and back - %s', (_, palette) => { + const apiColorByValue = fromColorByValueLensStateToAPI(palette); + const returnedPaletteState = fromColorByValueAPIToLensState(apiColorByValue); + + expect(returnedPaletteState).toEqual(palette); + }); + }); + + describe('bad max steps', () => { + it.each([ + ['no limit', badMaxStepsMocks.noLimitPalette], + ['lower limit', badMaxStepsMocks.lowerLimitPalette], + ['upper and lower limit', badMaxStepsMocks.upperAndLowerLimitPalette], + ])('should convert lens palette state to API and back - %s', (_, palette) => { + const apiColorByValue = fromColorByValueLensStateToAPI(palette); + const returnedPaletteState = fromColorByValueAPIToLensState(apiColorByValue); + + // Currently the final stop value is set to the domain max or the implicit max value + // instead of the more accurate rangeMax value. We need to override the final stop + // value to the rangeMax value, to match that of the transformed state. + palette.params!.stops!.at(-1)!.stop = palette.params!.rangeMax ?? null; + + expect(returnedPaletteState).toEqual(palette); + }); + }); + it('should maintain data integrity for static colors', () => { const originalColor = '#ff0000'; const apiFormat = fromStaticColorLensStateToAPI(originalColor); @@ -442,9 +511,41 @@ describe('Color util transforms', () => { type: 'dynamic', range: 'absolute', steps: [ - { type: 'from', color: '#ff0000', from: 0 }, - { type: 'exact', color: '#ffff00', value: 50 }, - { type: 'to', color: '#00ff00', to: 100 }, + { color: 'red', to: 50 }, + { color: 'green', from: 50, to: 100 }, + { color: 'blue', from: 100 }, + ], + }; + + const lensState = fromColorByValueAPIToLensState(originalColorByValue); + const backToAPI = fromColorByValueLensStateToAPI(lensState); + + expect(backToAPI).toEqual(originalColorByValue); + }); + + it('should maintain data integrity with falsy min', () => { + const originalColorByValue: ColorByValueType = { + type: 'dynamic', + range: 'absolute', + steps: [ + { color: 'red', from: 0, to: 50 }, + { color: 'blue', from: 50 }, + ], + }; + + const lensState = fromColorByValueAPIToLensState(originalColorByValue); + const backToAPI = fromColorByValueLensStateToAPI(lensState); + + expect(backToAPI).toEqual(originalColorByValue); + }); + + it('should maintain data integrity with falsy max', () => { + const originalColorByValue: ColorByValueType = { + type: 'dynamic', + range: 'absolute', + steps: [ + { color: 'red', to: -50 }, + { color: 'blue', from: -50, to: 0 }, ], }; @@ -458,11 +559,9 @@ describe('Color util transforms', () => { const originalColorByValue: ColorByValueType = { type: 'dynamic', range: 'percentage', - min: 10, - max: 90, steps: [ - { type: 'from', color: '#ff0000', from: 10 }, - { type: 'to', color: '#00ff00', to: 90 }, + { color: 'red', from: 5, to: 90 }, + { color: 'green', from: 90, to: 95 }, ], }; @@ -472,7 +571,7 @@ describe('Color util transforms', () => { expect(backToAPI).toEqual(originalColorByValue); }); - it('should mantain data integrity for categorical color mapping with specific color codes', () => { + it('should maintain data integrity for categorical color mapping with specific color codes', () => { const originalColorMapping: ColorMappingType = { palette: 'kibana_palette', mode: 'categorical', @@ -491,7 +590,7 @@ describe('Color util transforms', () => { expect(backToAPI).toEqual(originalColorMapping); }); - it('should mantain data integrity for categorical color mapping with mixed assignments', () => { + it('should maintain data integrity for categorical color mapping with mixed assignments', () => { const originalColorMapping: ColorMappingType = { palette: 'kibana_palette', mode: 'categorical', @@ -513,7 +612,7 @@ describe('Color util transforms', () => { expect(backToAPI).toEqual(originalColorMapping); }); - it('should mantain data integrity for gradient color mapping with mixed assignments', () => { + it('should maintain data integrity for gradient color mapping with mixed assignments', () => { const originalColorMapping: ColorMappingType = { palette: 'kibana_palette', mode: 'gradient', diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts index 328ba1ce8341f..250148a4a1ce3 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { isNil } from 'lodash'; + import type { ColorMapping, ColorStop, CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; import type { @@ -57,6 +59,7 @@ export function fromColorByValueAPIToLensState( name: 'custom', params: { name: 'custom', + progression: 'fixed', // to be removed reverse: false, // always applied to steps during transform rangeMin, rangeMax, @@ -97,7 +100,7 @@ export function fromColorByValueLensStateToAPI( const { stop: currentStop, color } = step; if (i === 0) { return { - ...(colorParams.rangeMin ? { from: colorParams.rangeMin } : {}), + ...(!isNil(colorParams.rangeMin) && { from: colorParams.rangeMin }), to: currentStop, color, }; @@ -109,7 +112,7 @@ export function fromColorByValueLensStateToAPI( return { from: prevStop, // ignores stop value, current logic sets last stop to max domain not user defined rangeMax - ...(colorParams.rangeMax ? { to: colorParams.rangeMax } : {}), + ...(!isNil(colorParams.rangeMax) && { to: colorParams.rangeMax }), color, }; } diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/percentage.mocks.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/percentage.mocks.ts new file mode 100644 index 0000000000000..a023765b96b41 --- /dev/null +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/percentage.mocks.ts @@ -0,0 +1,186 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; + +export const noLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'percent', + rangeMin: null, + rangeMax: null, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 33.32, + }, + { + color: '#fcd883', + stop: 66.65, + }, + { + color: '#f6726a', + stop: null, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: null, + }, + { + color: '#fcd883', + stop: 33.32, + }, + { + color: '#f6726a', + stop: 66.65, + }, + ], + continuity: 'all', + maxSteps: 5, + }, +}; + +export const lowerLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'percent', + rangeMin: 0, + rangeMax: null, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 33.32, + }, + { + color: '#fcd883', + stop: 66.65, + }, + { + color: '#f6726a', + stop: null, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: 0, + }, + { + color: '#fcd883', + stop: 33.32, + }, + { + color: '#f6726a', + stop: 66.65, + }, + ], + continuity: 'above', + maxSteps: 5, + }, +}; + +export const upperLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'percent', + rangeMin: null, + rangeMax: 100, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 33.32, + }, + { + color: '#fcd883', + stop: 66.65, + }, + { + color: '#f6726a', + stop: 100, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: null, + }, + { + color: '#fcd883', + stop: 33.32, + }, + { + color: '#f6726a', + stop: 66.65, + }, + ], + continuity: 'below', + maxSteps: 5, + }, +}; + +export const upperAndLowerLimitPalette: PaletteOutput = { + name: 'custom', + type: 'palette', + params: { + steps: 3, + name: 'custom', + reverse: false, + rangeType: 'percent', + rangeMin: 0, + rangeMax: 100, + progression: 'fixed', + stops: [ + { + color: '#24c292', + stop: 33.32, + }, + { + color: '#fcd883', + stop: 66.65, + }, + { + color: '#f6726a', + stop: 100, + }, + ], + colorStops: [ + { + color: '#24c292', + stop: 0, + }, + { + color: '#fcd883', + stop: 33.32, + }, + { + color: '#f6726a', + stop: 66.65, + }, + ], + continuity: 'none', + maxSteps: 5, + }, +}; From c353787471b1c6f32c143dfe34988ed57930cbc5 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 3 Dec 2025 21:15:16 -0700 Subject: [PATCH 6/6] chore: add reverse logic converting state to api --- .../transforms/coloring/coloring.test.ts | 34 +++++++++++++++++++ .../transforms/coloring/coloring.ts | 13 +++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.test.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.test.ts index 3e3be48109e29..c596e90aefe0d 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.test.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.test.ts @@ -280,6 +280,40 @@ describe('Color util transforms', () => { steps: [], }); }); + + it('should reverse palette stops to API format', () => { + const palette: PaletteOutput = { + type: 'palette', + name: 'custom', + params: { + name: 'custom', + reverse: true, + rangeType: 'number', + stops: [ + { color: 'red', stop: 0 }, + { color: 'green', stop: 50 }, + { color: 'blue', stop: 100 }, + ], + colorStops: [ + { color: 'red', stop: null }, + { color: 'green', stop: 0 }, + { color: 'blue', stop: 50 }, + ], + }, + }; + + const result = fromColorByValueLensStateToAPI(palette); + + expect(result).toMatchObject({ + type: 'dynamic', + range: 'absolute', + steps: [ + { color: 'blue', to: 0 }, + { color: 'green', from: 0, to: 50 }, + { color: 'red', from: 50 }, + ], + }); + }); }); describe('fromStaticColorLensStateToAPI', () => { diff --git a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts index 250148a4a1ce3..d6b7b7e9fe39d 100644 --- a/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts +++ b/src/platform/packages/shared/kbn-lens-embeddable-utils/config_builder/transforms/coloring/coloring.ts @@ -89,9 +89,16 @@ export function fromColorByValueLensStateToAPI( if (!colorParams) return; - const { stops = [], rangeType } = colorParams; - - // TODO: handle reverse + const { stops: originalStops = [], rangeType, reverse } = colorParams; + const stops = !reverse + ? originalStops + : originalStops + .slice() + .reverse() + .map(({ color }, i) => ({ + ...originalStops[i], + color, + })); return { type: 'dynamic',