diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 80531dc74c2a1..a0b4facb984a7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -9,7 +9,7 @@ pageLoadAssetSize: bfetch: 22837 canvas: 29355 cases: 180037 - charts: 55000 + charts: 60000 cloud: 21076 cloudDataMigration: 19170 cloudDefend: 18697 diff --git a/src/platform/packages/shared/chart-expressions-common/color_categories.test.ts b/src/platform/packages/shared/chart-expressions-common/color_categories.test.ts index 19baacfc3fcf4..89b05792f9dda 100644 --- a/src/platform/packages/shared/chart-expressions-common/color_categories.test.ts +++ b/src/platform/packages/shared/chart-expressions-common/color_categories.test.ts @@ -7,55 +7,148 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { DatatableColumn, DatatableRow } from '@kbn/expressions-plugin/common'; -import { getColorCategories } from './color_categories'; - -const getNextExtension = (() => { - let i = 0; - const extensions = ['gz', 'css', '', 'rpm', 'deb', 'zip', null]; - return () => extensions[i++ % extensions.length]; -})(); - -const basicDatatable = { - columns: ['count', 'extension'].map((id) => ({ id } as DatatableColumn)), - rows: Array.from({ length: 10 }).map((_, i) => ({ - count: i, - extension: getNextExtension(), - })) as DatatableRow[], -}; - -describe('getColorCategories', () => { - it('should return no categories when accessor is undefined', () => { - expect(getColorCategories(basicDatatable.rows)).toEqual([]); - }); +import { DatatableRow } from '@kbn/expressions-plugin/common'; +import { getColorCategories, getLegacyColorCategories } from './color_categories'; +import { MultiFieldKey, RangeKey } from '@kbn/data-plugin/common'; - it('should return no categories when accessor is not found', () => { - expect(getColorCategories(basicDatatable.rows, 'N/A')).toEqual([]); - }); +class FakeClass {} - it('should return no categories when no rows are defined', () => { - expect(getColorCategories(undefined, 'extension')).toEqual([]); - }); +const values = [ + 1, + false, + true, + 0, + NaN, + null, + undefined, + '', + 'test-string', + { test: 'obj' }, + ['array'], +]; +const mockRange = new RangeKey({ from: 0, to: 100 }); +const mockMultiField = new MultiFieldKey({ key: ['one', 'two'] }); +const fakeClass = new FakeClass(); +const complex = [mockRange, mockMultiField, fakeClass]; + +const mockDatatableRows = Array.from({ length: 20 }).map((_, i) => ({ + count: i, + value: values[i % values.length], + complex: complex[i % complex.length], +})); + +describe('Color Categories', () => { + describe('getColorCategories', () => { + it('should return no categories when accessor is undefined', () => { + expect(getColorCategories(mockDatatableRows)).toEqual([]); + }); + + it('should return no categories when accessor is not found', () => { + expect(getColorCategories(mockDatatableRows, 'N/A')).toEqual([]); + }); + + it('should return no categories when no rows are defined', () => { + expect(getColorCategories(undefined, 'extension')).toEqual([]); + }); - it('should return all categories from non-transpose datatable', () => { - expect(getColorCategories(basicDatatable.rows, 'extension')).toEqual([ - 'gz', - 'css', - '', - 'rpm', - 'deb', - 'zip', - 'null', - ]); + it('should return all categories from mixed value datatable', () => { + expect(getColorCategories(mockDatatableRows, 'value')).toEqual([ + 1, + false, + true, + 0, + NaN, + null, + undefined, + '', + 'test-string', + { + test: 'obj', + }, + ['array'], + ]); + }); + + it('should exclude selected categories from datatable', () => { + expect( + getColorCategories(mockDatatableRows, 'value', [ + 1, + false, + true, + 0, + NaN, + null, + undefined, + '', + ]) + ).toEqual([ + 'test-string', + { + test: 'obj', + }, + ['array'], + ]); + }); + + it('should return known serialized categories from datatable', () => { + expect(getColorCategories(mockDatatableRows, 'complex', [])).toEqual([ + mockRange.serialize(), + mockMultiField.serialize(), + fakeClass, + ]); + }); }); - it('should exclude selected categories from non-transpose datatable', () => { - expect(getColorCategories(basicDatatable.rows, 'extension', ['', null])).toEqual([ - 'gz', - 'css', - 'rpm', - 'deb', - 'zip', - ]); + describe('getLegacyColorCategories', () => { + it('should return no categories when accessor is undefined', () => { + expect(getLegacyColorCategories(mockDatatableRows)).toEqual([]); + }); + + it('should return no categories when accessor is not found', () => { + expect(getLegacyColorCategories(mockDatatableRows, 'N/A')).toEqual([]); + }); + + it('should return no categories when no rows are defined', () => { + expect(getLegacyColorCategories(undefined, 'extension')).toEqual([]); + }); + + it('should return all categories from mixed value datatable', () => { + expect(getLegacyColorCategories(mockDatatableRows, 'value')).toEqual([ + '1', + 'false', + 'true', + '0', + 'NaN', + 'null', + 'undefined', + '', + 'test-string', + '{"test":"obj"}', + 'array', + ]); + }); + + it('should exclude selected categories from datatable', () => { + expect( + getLegacyColorCategories(mockDatatableRows, 'value', [ + 1, + false, + true, + 0, + NaN, + null, + undefined, + '', + ]) + ).toEqual(['test-string', '{"test":"obj"}', 'array']); + }); + + it('should return known serialized categories from datatable', () => { + expect(getLegacyColorCategories(mockDatatableRows, 'complex', [])).toEqual([ + String(mockRange), + String(mockMultiField), + JSON.stringify(fakeClass), + ]); + }); }); }); diff --git a/src/platform/packages/shared/chart-expressions-common/color_categories.ts b/src/platform/packages/shared/chart-expressions-common/color_categories.ts index d1ee8a2514789..3f573ab6de570 100644 --- a/src/platform/packages/shared/chart-expressions-common/color_categories.ts +++ b/src/platform/packages/shared/chart-expressions-common/color_categories.ts @@ -8,40 +8,46 @@ */ import { DatatableRow } from '@kbn/expressions-plugin/common'; -import { isMultiFieldKey } from '@kbn/data-plugin/common'; +import { RawValue, SerializedValue, serializeField } from '@kbn/data-plugin/common'; +import { getValueKey } from '@kbn/coloring'; /** - * Get the stringified version of all the categories that needs to be colored in the chart. - * Multifield keys will return as array of string and simple fields (numeric, string) will be returned as a plain unformatted string. + * Returns all serialized categories of the dataset for color matching. + * All non-serializable fields will be as a plain unformatted string. * * Note: This does **NOT** support transposed columns */ export function getColorCategories( rows: DatatableRow[] = [], accessor?: string, - exclude?: any[] -): Array { + exclude?: RawValue[], + legacyMode: boolean = false // stringifies raw values +): SerializedValue[] { if (!accessor) return []; - return rows - .filter(({ [accessor]: v }) => !(v === undefined || exclude?.includes(v))) - .map((r) => { - const v = r[accessor]; - // The categories needs to be stringified in their unformatted version. - // We can't distinguish between a number and a string from a text input and the match should - // work with both numeric field values and string values. - const key = (isMultiFieldKey(v) ? v.keys : [v]).map(String); - const stringifiedKeys = key.join(','); - return { key, stringifiedKeys }; - }) - .reduce<{ keys: Set; categories: Array }>( - (acc, { key, stringifiedKeys }) => { - if (!acc.keys.has(stringifiedKeys)) { - acc.keys.add(stringifiedKeys); - acc.categories.push(key.length === 1 ? key[0] : key); - } - return acc; - }, - { keys: new Set(), categories: [] } - ).categories; + const seen = new Set(); + return rows.reduce((acc, row) => { + const hasValue = Object.hasOwn(row, accessor); + const rawValue: RawValue = row[accessor]; + const key = getValueKey(rawValue); + if (hasValue && !exclude?.includes(rawValue) && !seen.has(key)) { + const value = serializeField(rawValue); + seen.add(key); + acc.push(legacyMode ? key : value); + } + return acc; + }, []); +} + +/** + * Returns all *stringified* categories of the dataset for color matching. + * + * Should **only** be used with legacy `palettes` + */ +export function getLegacyColorCategories( + rows?: DatatableRow[], + accessor?: string, + exclude?: RawValue[] +): string[] { + return getColorCategories(rows, accessor, exclude, true).map(String); } diff --git a/src/platform/packages/shared/chart-expressions-common/index.ts b/src/platform/packages/shared/chart-expressions-common/index.ts index 0781e825bee58..865bf5ad90472 100644 --- a/src/platform/packages/shared/chart-expressions-common/index.ts +++ b/src/platform/packages/shared/chart-expressions-common/index.ts @@ -15,4 +15,4 @@ export { } from './utils'; export type { Simplify, MakeOverridesSerializable, ChartSizeSpec, ChartSizeEvent } from './types'; export { isChartSizeEvent } from './types'; -export { getColorCategories } from './color_categories'; +export { getColorCategories, getLegacyColorCategories } from './color_categories'; diff --git a/src/platform/packages/shared/chart-expressions-common/tsconfig.json b/src/platform/packages/shared/chart-expressions-common/tsconfig.json index 23da1315eccdb..da528bed5d5d1 100644 --- a/src/platform/packages/shared/chart-expressions-common/tsconfig.json +++ b/src/platform/packages/shared/chart-expressions-common/tsconfig.json @@ -19,5 +19,6 @@ "@kbn/core-execution-context-common", "@kbn/expressions-plugin", "@kbn/data-plugin", + "@kbn/coloring", ] } diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/README.md b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/README.md index 220824ca47820..04661e62e50fb 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/README.md +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/README.md @@ -3,9 +3,9 @@ This shared component can be used to define a color mapping as an association of one or multiple string values to a color definition. This package provides: -- a React component, called `CategoricalColorMapping` that provides a simplified UI (that in general can be hosted in a flyout), that helps the user generate a `ColorMapping.Config` object that descibes the mappings configuration -- a function `getColorFactory` that given a color mapping configuration returns a function that maps a passed category to the corresponding color -- a definition scheme for the color mapping, based on the type `ColorMapping.Config`, that provides an extensible way of describing the link between colors and rules. Collects the minimal information required apply colors based on categories. Together with the `ColorMappingInputData` can be used to get colors in a deterministic way. +- A React component, called `CategoricalColorMapping` that provides a simplified UI (that in general can be hosted in a flyout), that helps the user generate a `ColorMapping.Config` object that describes the mappings configuration +- A function `getColorFactory` that given a color mapping configuration returns a function that maps a passed category to the corresponding color +- A definition scheme for the color mapping, based on the type `ColorMapping.Config`, that provides an extensible way of describing the link between colors and rules. Collects the minimal information required apply colors based on categories. Together with the `ColorMappingInputData` can be used to get colors in a deterministic way. An example of the configuration is the following: @@ -14,10 +14,10 @@ const DEFAULT_COLOR_MAPPING_CONFIG: ColorMapping.Config = { assignmentMode: 'auto', assignments: [ { - rule: { - type: 'matchExactly', - values: ['']; - }, + rules: [{ + type: 'match', + pattern: ''; + }], color: { type: 'categorical', paletteId: 'eui', @@ -27,9 +27,9 @@ const DEFAULT_COLOR_MAPPING_CONFIG: ColorMapping.Config = { ], specialAssignments: [ { - rule: { + rules: [{ type: 'other', - }, + }], color: { type: 'categorical', paletteId: 'neutral', @@ -45,7 +45,7 @@ const DEFAULT_COLOR_MAPPING_CONFIG: ColorMapping.Config = { }; ``` -The function `getColorFactory` is a curry function where, given the model, a palette getter, the theme mode (dark/light) and a list of categories, returns a function that can be used to pick the right color based on a given category. +The function `getColorFactory` is a curry function where, given the model, a palette getter, the theme mode (dark/light) and a list of categories, returns a `ColorHandlingFn` that can be used to pick the right color based on a given category. ```ts function getColorFactory( @@ -56,22 +56,19 @@ function getColorFactory( type: 'categories'; categories: Array; } -): (category: string | string[]) => Color +): ColorHandlingFn ``` - - A `category` can be in the shape of a plain string or an array of strings. Numbers, MultiFieldKey, IP etc needs to be stringified. - The `CategoricalColorMapping` React component has the following props: ```tsx function CategoricalColorMapping(props: { /** The initial color mapping model, usually coming from a the visualization saved object */ model: ColorMapping.Config; - /** A map of paletteId and palette configuration */ - palettes: Map; + /** A collection of palette configurations */ + palettes: KbnPalettes; /** A data description of what needs to be colored */ data: ColorMappingInputData; /** Theme dark mode */ @@ -80,8 +77,11 @@ function CategoricalColorMapping(props: { specialTokens: Map; /** A function called at every change in the model */ onModelUpdate: (model: ColorMapping.Config) => void; + /** Formatter for raw value assignments */ + formatter?: IFieldFormat; + /** Allow custom match rule when no other option is found */ + allowCustomMatch?: boolean; }) - ``` -the `onModelUpdate` callback is called everytime a change in the model is applied from within the component. Is not called when the `model` prop is updated. \ No newline at end of file +the `onModelUpdate` callback is called every time a change in the model is applied from within the component. Is not called when the `model` prop is updated. \ No newline at end of file diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/__stories__/color_mapping.stories.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/__stories__/color_mapping.stories.tsx index 981fb12bb2966..a2fd03cc22f00 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/__stories__/color_mapping.stories.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/__stories__/color_mapping.stories.tsx @@ -12,11 +12,11 @@ import { getKbnPalettes } from '@kbn/palettes'; import { EuiFlyout, EuiForm, EuiPage, isColorDark } from '@elastic/eui'; import type { StoryFn, StoryObj } from '@storybook/react'; import { css } from '@emotion/react'; +import { RawValue, deserializeField } from '@kbn/data-plugin/common'; import { CategoricalColorMapping, ColorMappingProps } from '../categorical_color_mapping'; import { DEFAULT_COLOR_MAPPING_CONFIG } from '../config/default_color_mapping'; import { ColorMapping } from '../config'; import { getColorFactory } from '../color/color_handling'; -import { ruleMatch } from '../color/rule_matching'; import { getValidColor } from '../color/color_math'; export default { @@ -25,6 +25,8 @@ export default { decorators: [(story: Function) => story()], }; +const formatter = (value: unknown) => String(value); + const Template: StoryFn> = (args) => { const [updatedModel, setUpdateModel] = useState( DEFAULT_COLOR_MAPPING_CONFIG @@ -37,11 +39,12 @@ const Template: StoryFn> = (args) => {
    {args.data.type === 'categories' && - args.data.categories.map((c, i) => { - const match = updatedModel.assignments.some(({ rule }) => { - return ruleMatch(rule, c); - }); - const color = colorFactory(c); + args.data.categories.map((category, i) => { + const value: RawValue = deserializeField(category); + const match = updatedModel.assignments.some(({ rules }) => + rules.some((r) => (r.type === 'raw' ? r.value === value : false)) + ); + const color = colorFactory(value); const isDark = isColorDark(...getValidColor(color).rgb()); return ( @@ -58,7 +61,7 @@ const Template: StoryFn> = (args) => { font-weight: ${match ? 'bold' : 'normal'}; `} > - {c} + {formatter(value)} ); })} @@ -89,9 +92,11 @@ export const Default: StoryObj = { }, specialAssignments: [ { - rule: { - type: 'other', - }, + rules: [ + { + type: 'other', + }, + ], color: { type: 'loop', }, diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/__stories__/raw_color_mapping.stories.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/__stories__/raw_color_mapping.stories.tsx new file mode 100644 index 0000000000000..1d413b4b32e72 --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/__stories__/raw_color_mapping.stories.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 React, { FC, useState } from 'react'; +import { getKbnPalettes } from '@kbn/palettes'; +import { EuiFlyout, EuiForm, EuiPage, isColorDark } from '@elastic/eui'; +import type { StoryFn } from '@storybook/react'; +import { css } from '@emotion/react'; +import { + MultiFieldKey, + RawValue, + SerializedValue, + deserializeField, +} from '@kbn/data-plugin/common'; +import { IFieldFormat } from '@kbn/field-formats-plugin/common'; +import { CategoricalColorMapping, ColorMappingProps } from '../categorical_color_mapping'; +import { DEFAULT_COLOR_MAPPING_CONFIG } from '../config/default_color_mapping'; +import { ColorMapping } from '../config'; +import { getColorFactory } from '../color/color_handling'; +import { getValidColor } from '../color/color_math'; + +export default { + title: 'Raw Color Mapping', + component: CategoricalColorMapping, + decorators: [(story: Function) => story()], +}; + +const formatter = { + convert: (value: MultiFieldKey) => { + return value.keys.join(' - '); + }, +} as IFieldFormat; + +const Template: StoryFn> = (args) => { + const [updatedModel, setUpdateModel] = useState( + DEFAULT_COLOR_MAPPING_CONFIG + ); + + const palettes = getKbnPalettes({ darkMode: false }); + const colorFactory = getColorFactory(updatedModel, palettes, false, args.data); + + return ( + +
      + {args.data.type === 'categories' && + args.data.categories.map((category: SerializedValue, i) => { + const value: RawValue = deserializeField(category); + const match = updatedModel.assignments.some(({ rules }) => + rules.some((r) => (r.type === 'raw' ? String(r.value) === String(category) : false)) + ); + const color = colorFactory(value); + const isDark = isColorDark(...getValidColor(color).rgb()); + + return ( +
    1. + {formatter.convert(value)} +
    2. + ); + })} +
    + {}} + hideCloseButton + ownFocus={false} + > + + + + +
    + ); +}; + +export const Default = { + render: Template, + args: { + model: { + ...DEFAULT_COLOR_MAPPING_CONFIG, + paletteId: 'eui_amsterdam', + + colorMode: { + type: 'categorical', + }, + specialAssignments: [ + { + rules: [ + { + type: 'other', + }, + ], + color: { + type: 'loop', + }, + touched: false, + }, + ], + assignments: [], + }, + isDarkMode: false, + formatter, + data: { + type: 'categories', + categories: [ + { type: 'multiFieldKey', keys: ['US', 'Canada'] }, + { type: 'multiFieldKey', keys: ['Mexico'] }, + { type: 'multiFieldKey', keys: ['Brasil'] }, + { type: 'multiFieldKey', keys: ['Canada'] }, + { type: 'multiFieldKey', keys: ['Canada', 'US'] }, + { type: 'multiFieldKey', keys: ['Italy', 'Germany'] }, + { type: 'multiFieldKey', keys: ['France'] }, + { type: 'multiFieldKey', keys: ['Spain', 'Portugal'] }, + { type: 'multiFieldKey', keys: ['UK'] }, + { type: 'multiFieldKey', keys: ['Sweden'] }, + { type: 'multiFieldKey', keys: ['Sweden', 'Finland'] }, + ], + }, + + specialTokens: new Map(), + // eslint-disable-next-line no-console + onModelUpdate: (model: any) => console.log(model), + }, +}; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.test.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.test.tsx index 0a9966e397fe4..5f603620c7c68 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.test.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.test.tsx @@ -8,105 +8,113 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { CategoricalColorMapping, ColorMappingInputData } from './categorical_color_mapping'; -import { DEFAULT_COLOR_MAPPING_CONFIG } from './config/default_color_mapping'; -import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; import { getKbnPalettes } from '@kbn/palettes'; -const ASSIGNMENTS_LIST = '[data-test-subj="lns-colorMapping-assignmentsList"]'; -const ASSIGNMENTS_PROMPT = '[data-test-subj="lns-colorMapping-assignmentsPrompt"]'; -const ASSIGNMENTS_PROMPT_ADD_ALL = '[data-test-subj="lns-colorMapping-assignmentsPromptAddAll"]'; -const ASSIGNMENT_ITEM = (i: number) => `[data-test-subj="lns-colorMapping-assignmentsItem${i}"]`; +import { + CategoricalColorMapping, + ColorMappingInputCategoricalData, + ColorMappingInputData, + ColorMappingProps, +} from './categorical_color_mapping'; +import { DEFAULT_COLOR_MAPPING_CONFIG } from './config/default_color_mapping'; + +const ASSIGNMENTS_LIST = 'lns-colorMapping-assignmentsList'; +const ASSIGNMENTS_PROMPT = 'lns-colorMapping-assignmentsPrompt'; +const ASSIGNMENTS_PROMPT_ADD_ALL = 'lns-colorMapping-assignmentsPromptAddAll'; +const ASSIGNMENT_ITEM = (i: number) => `lns-colorMapping-assignmentsItem${i}`; + +const palettes = getKbnPalettes({ darkMode: false }); +const specialTokens = new Map([ + ['__other__', 'Other'], + ['__empty__', '(Empty)'], + ['', '(Empty)'], +]); +const categoryData: ColorMappingInputCategoricalData = { + type: 'categories', + categories: ['categoryA', 'categoryB'], +}; +const mockFormatter = fieldFormatsServiceMock.createStartContract().deserialize(); describe('color mapping', () => { - const palettes = getKbnPalettes({ darkMode: false }); + let defaultProps: ColorMappingProps; - it('load a default color mapping', () => { - const dataInput: ColorMappingInputData = { - type: 'categories', - categories: ['categoryA', 'categoryB'], + mockFormatter.convert = jest.fn( + (v: any) => (typeof v === 'string' ? specialTokens.get(v) ?? v : JSON.stringify(v)) // simple way to check formatting is applied + ); + const onModelUpdateFn = jest.fn(); + + beforeEach(() => { + defaultProps = { + data: categoryData, + isDarkMode: false, + model: { ...DEFAULT_COLOR_MAPPING_CONFIG }, + palettes, + onModelUpdate: onModelUpdateFn, + specialTokens, + formatter: mockFormatter, }; - const onModelUpdateFn = jest.fn(); - const component = mount( - - ); + }); + + const renderCategoricalColorMapping = (props: Partial = {}) => { + return render(); + }; + + it('load a default color mapping', () => { + renderCategoricalColorMapping(); // empty list prompt visible - expect(component.find(ASSIGNMENTS_PROMPT)).toBeTruthy(); - expect(onModelUpdateFn).not.toBeCalled(); + expect(screen.getByTestId(ASSIGNMENTS_PROMPT)).toBeInTheDocument(); + expect(onModelUpdateFn).not.toHaveBeenCalled(); }); it('Add all terms to assignments', () => { - const dataInput: ColorMappingInputData = { - type: 'categories', - categories: ['categoryA', 'categoryB'], - }; - const onModelUpdateFn = jest.fn(); - const component = mount( - - ); - component.find(ASSIGNMENTS_PROMPT_ADD_ALL).hostNodes().simulate('click'); - expect(onModelUpdateFn).toBeCalledTimes(1); - expect(component.find(ASSIGNMENTS_LIST).hostNodes().children().length).toEqual( - dataInput.categories.length - ); - dataInput.categories.forEach((category, index) => { - const assignment = component.find(ASSIGNMENT_ITEM(index)).hostNodes(); - expect(assignment.text()).toEqual(category); - expect(assignment.hasClass('euiComboBox-isDisabled')).toEqual(false); + renderCategoricalColorMapping(); + + fireEvent.click(screen.getByTestId(ASSIGNMENTS_PROMPT_ADD_ALL)); + + expect(onModelUpdateFn).toHaveBeenCalledTimes(1); + const assignmentsList = screen.getByTestId(ASSIGNMENTS_LIST); + expect(assignmentsList.children.length).toEqual(categoryData.categories.length); + + categoryData.categories.forEach((category, index) => { + const assignment = screen.getByTestId(ASSIGNMENT_ITEM(index)); + expect(assignment).toHaveTextContent(String(category)); + expect(assignment).not.toHaveClass('euiComboBox-isDisabled'); }); }); it('handle special tokens, multi-fields keys and non-trimmed whitespaces', () => { - const dataInput: ColorMappingInputData = { + const data: ColorMappingInputData = { type: 'categories', - categories: ['__other__', ['fieldA', 'fieldB'], '__empty__', ' with-whitespaces '], + categories: [ + '__other__', + '__empty__', + '', + ' with-whitespaces ', + { type: 'multiFieldKey', keys: ['gz', 'CN'] }, + { type: 'rangeKey', from: 0, to: 1000, ranges: [{ from: 0, to: 1000, label: '' }] }, + ], }; - const onModelUpdateFn = jest.fn(); - const component = mount( - - ); - component.find(ASSIGNMENTS_PROMPT_ADD_ALL).hostNodes().simulate('click'); - expect(component.find(ASSIGNMENTS_LIST).hostNodes().children().length).toEqual( - dataInput.categories.length - ); - const assignment1 = component.find(ASSIGNMENT_ITEM(0)).hostNodes(); - expect(assignment1.text()).toEqual('Other'); + renderCategoricalColorMapping({ data }); - const assignment2 = component.find(ASSIGNMENT_ITEM(1)).hostNodes(); - expect(assignment2.text()).toEqual(`fieldA${MULTI_FIELD_KEY_SEPARATOR}fieldB`); + fireEvent.click(screen.getByTestId(ASSIGNMENTS_PROMPT_ADD_ALL)); - const assignment3 = component.find(ASSIGNMENT_ITEM(2)).hostNodes(); - expect(assignment3.text()).toEqual('(Empty)'); + const assignmentsList = screen.getByTestId(ASSIGNMENTS_LIST); + expect(assignmentsList.children.length).toEqual(data.categories.length); - const assignment4 = component.find(ASSIGNMENT_ITEM(3)).hostNodes(); - expect(assignment4.text()).toEqual(' with-whitespaces '); + expect(screen.getByTestId(ASSIGNMENT_ITEM(0))).toHaveTextContent('Other'); + expect(screen.getByTestId(ASSIGNMENT_ITEM(1))).toHaveTextContent('(Empty)'); + expect(screen.getByTestId(ASSIGNMENT_ITEM(2))).toHaveTextContent('(Empty)'); + expect(screen.getByTestId(ASSIGNMENT_ITEM(3))).toHaveTextContent(' with-whitespaces ', { + normalizeWhitespace: false, + }); + expect(screen.getByTestId(ASSIGNMENT_ITEM(4))).toHaveTextContent('{"keys":["gz","CN"]}'); + expect(screen.getByTestId(ASSIGNMENT_ITEM(5))).toHaveTextContent( + '{"gte":0,"lt":1000,"label":""}' + ); }); }); diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.tsx index 8bd006ba9c663..695d9ddf3311a 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/categorical_color_mapping.tsx @@ -12,6 +12,8 @@ import { Provider } from 'react-redux'; import { type EnhancedStore, configureStore } from '@reduxjs/toolkit'; import { isEqual } from 'lodash'; import { KbnPalettes } from '@kbn/palettes'; +import { IFieldFormat } from '@kbn/field-formats-plugin/common'; +import { SerializedValue } from '@kbn/data-plugin/common'; import { colorMappingReducer, updateModel } from './state/color_mapping'; import { Container } from './components/container/container'; import { ColorMapping } from './config'; @@ -19,8 +21,10 @@ import { uiReducer } from './state/ui'; export interface ColorMappingInputCategoricalData { type: 'categories'; - /** an ORDERED array of categories rendered in the visualization */ - categories: Array; + /** + * An **ordered** array of serialized categories rendered in the visualization + */ + categories: SerializedValue[]; } export interface ColorMappingInputContinuousData { @@ -42,18 +46,38 @@ export type ColorMappingInputData = * The props of the CategoricalColorMapping component */ export interface ColorMappingProps { - /** The initial color mapping model, usually coming from a the visualization saved object */ + /** + * The initial color mapping model, usually coming from a the visualization saved object + */ model: ColorMapping.Config; - /** A collection of palette configurations */ + /** + * A collection of palette configurations + */ palettes: KbnPalettes; - /** A data description of what needs to be colored */ + /** + * A data description of what needs to be colored + */ data: ColorMappingInputData; - /** Theme dark mode */ + /** + * Theme dark mode + */ isDarkMode: boolean; - /** A map between original and formatted tokens used to handle special cases, like the Other bucket and the empty bucket */ + /** + * A map between original and formatted tokens used to handle special cases, like the Other bucket and the empty bucket + */ specialTokens: Map; - /** A function called at every change in the model */ + /** + * A function called at every change in the model + */ onModelUpdate: (model: ColorMapping.Config) => void; + /** + * Formatter for raw value assignments + */ + formatter?: IFieldFormat; + /** + * Allow custom match rule when no other option is found + */ + allowCustomMatch?: boolean; } /** @@ -88,7 +112,7 @@ export class CategoricalColorMapping extends React.Component } } render() { - const { palettes, data, isDarkMode, specialTokens } = this.props; + const { palettes, data, isDarkMode, specialTokens, formatter, allowCustomMatch } = this.props; return ( data={data} isDarkMode={isDarkMode} specialTokens={specialTokens} + formatter={formatter} + allowCustomMatch={allowCustomMatch} /> ); diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_assignment_matcher.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_assignment_matcher.ts new file mode 100644 index 0000000000000..c80de376d9306 --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_assignment_matcher.ts @@ -0,0 +1,106 @@ +/* + * 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 { RawValue, deserializeField } from '@kbn/data-plugin/common'; +import { ColorMapping } from '../config'; +import { getValueKey } from './utils'; + +type AssignmentMatchCount = [assignmentIndex: number, matchCount: number]; + +/** + * A class to encapsulate assignment logic + */ +export class ColorAssignmentMatcher { + /** + * Reference to original assignments + */ + readonly #assignments: ColorMapping.Assignment[]; + + /** + * Map values (or keys) to assignment index and match count + */ + #assignmentMap: Map; + + constructor(assignments: ColorMapping.Assignment[]) { + this.#assignments = assignments; + this.#assignmentMap = this.#assignments.reduce>( + (acc, assignment, i) => { + assignment.rules.forEach((rule) => { + const key = getKey(rule); + if (key !== null) { + const [index = i, matchCount = 0] = acc.get(key) ?? []; + acc.set(key, [index, matchCount + 1]); + } + }); + return acc; + }, + new Map() + ); + } + + #getMatch(value: RawValue): AssignmentMatchCount { + const key = getValueKey(value); + return this.#assignmentMap.get(key) ?? [-1, 0]; + } + + /** + * Returns count of matching assignments for given value + */ + getCount(value: RawValue) { + const [, count] = this.#getMatch(value); + return count; + } + + /** + * Returns true if given value has multiple matching assignment + */ + hasDuplicate(value: RawValue) { + const [, count] = this.#getMatch(value); + return count > 1; + } + + /** + * Returns true if given value has matching assignment + */ + hasMatch(value: RawValue) { + return this.getCount(value) > 0; + } + + /** + * Returns index of first matching assignment for given value + */ + getIndex(value: RawValue) { + const [index] = this.#getMatch(value); + return index; + } +} + +function getKey(rule: ColorMapping.ColorRule): string | null { + if (rule.type === 'match' && rule.matchEntireWord) { + return rule.matchCase ? rule.pattern : rule.pattern.toLowerCase(); + } + + if (rule.type === 'raw') { + return getValueKey(deserializeField(rule.value)); + } + + // nondeterministic match, cannot assign ambiguous keys + // requires pattern matching all previous rules + return null; +} + +/** + * A simplified map to track assignment match counts + * + * key: stringified value or key of instance methods + * value: count of matching assignments + */ +export function getColorAssignmentMatcher(assignments: ColorMapping.Assignment[]) { + return new ColorAssignmentMatcher(assignments); +} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.test.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.test.ts index 34eb3f4cba100..41eed65d9fafb 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.test.ts +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.test.ts @@ -66,9 +66,11 @@ describe('Color mapping - color generation', () => { color: { type: 'loop', }, - rule: { - type: 'other', - }, + rules: [ + { + type: 'other', + }, + ], touched: false, }, ], @@ -102,9 +104,11 @@ describe('Color mapping - color generation', () => { paletteId: KbnPalette.Neutral, colorIndex: DEFAULT_NEUTRAL_PALETTE_INDEX, }, - rule: { - type: 'other', - }, + rules: [ + { + type: 'other', + }, + ], touched: false, }, ], @@ -143,7 +147,7 @@ describe('Color mapping - color generation', () => { assignments: [ { color: { type: 'colorCode', colorCode: 'red' }, - rule: { type: 'matchExactly', values: ['configuredAssignment'] }, + rules: [{ type: 'raw', value: 'configuredAssignment' }], touched: false, }, ], @@ -168,17 +172,17 @@ describe('Color mapping - color generation', () => { assignments: [ { color: { type: 'colorCode', colorCode: 'red' }, - rule: { type: 'auto' }, + rules: [], // auto touched: false, }, { color: { type: 'colorCode', colorCode: 'blue' }, - rule: { type: 'matchExactly', values: ['blueCat'] }, + rules: [{ type: 'raw', value: 'blueCat' }], touched: false, }, { color: { type: 'colorCode', colorCode: 'green' }, - rule: { type: 'auto' }, + rules: [], // auto touched: false, }, ], @@ -267,8 +271,8 @@ describe('Color mapping - color generation', () => { { ...DEFAULT_COLOR_MAPPING_CONFIG, assignments: [ - { color: { type: 'gradient' }, rule: { type: 'auto' }, touched: false }, - { color: { type: 'gradient' }, rule: { type: 'auto' }, touched: false }, + { color: { type: 'gradient' }, rules: [], touched: false }, + { color: { type: 'gradient' }, rules: [], touched: false }, ], colorMode: { @@ -290,9 +294,11 @@ describe('Color mapping - color generation', () => { colorIndex: DEFAULT_NEUTRAL_PALETTE_INDEX, paletteId: KbnPalette.Neutral, }, - rule: { - type: 'other', - }, + rules: [ + { + type: 'other', + }, + ], touched: false, }, ], diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.ts index 0031ac81908c0..55e4bf900e78f 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.ts +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/color_handling.ts @@ -8,22 +8,25 @@ */ import chroma from 'chroma-js'; -import { findLast } from 'lodash'; import { KbnPalette, KbnPalettes } from '@kbn/palettes'; +import { RawValue, SerializedValue, deserializeField } from '@kbn/data-plugin/common'; import { ColorMapping } from '../config'; import { changeAlpha, combineColors, getValidColor } from './color_math'; import { ColorMappingInputData } from '../categorical_color_mapping'; -import { ruleMatch } from './rule_matching'; import { GradientColorMode } from '../config/types'; import { DEFAULT_NEUTRAL_PALETTE_INDEX, DEFAULT_OTHER_ASSIGNMENT_INDEX, } from '../config/default_color_mapping'; +import { getColorAssignmentMatcher } from './color_assignment_matcher'; +import { getValueKey } from './utils'; + +const FALLBACK_ASSIGNMENT_COLOR = 'red'; export function getAssignmentColor( colorMode: ColorMapping.Config['colorMode'], color: - | ColorMapping.Config['assignments'][number]['color'] + | ColorMapping.Assignment['color'] | (ColorMapping.LoopColor & { paletteId: string; colorIndex: number }), palettes: KbnPalettes, isDarkMode: boolean, @@ -37,11 +40,17 @@ export function getAssignmentColor( return getColor(color, palettes); case 'gradient': { if (colorMode.type === 'categorical') { - return 'red'; + return FALLBACK_ASSIGNMENT_COLOR; } const colorScale = getGradientColorScale(colorMode, palettes, isDarkMode); - return total === 0 ? 'red' : total === 1 ? colorScale(0) : colorScale(index / (total - 1)); + return total === 0 + ? FALLBACK_ASSIGNMENT_COLOR + : total === 1 + ? colorScale(0) + : colorScale(index / (total - 1)); } + default: + return FALLBACK_ASSIGNMENT_COLOR; } } @@ -57,86 +66,94 @@ export function getColor( : getValidColor(palettes.get(color.paletteId).getColor(color.colorIndex)).hex(); } +/** + * Returns a color given a raw value + */ +export type ColorHandlingFn = (rawValue: RawValue) => string; + export function getColorFactory( { assignments, specialAssignments, colorMode, paletteId }: ColorMapping.Config, palettes: KbnPalettes, isDarkMode: boolean, data: ColorMappingInputData -): (category: string | string[]) => string { - // find auto-assigned colors - const autoByOrderAssignments = - data.type === 'categories' - ? assignments.filter((a) => { - return ( - a.rule.type === 'auto' || (a.rule.type === 'matchExactly' && a.rule.values.length === 0) - ); - }) - : []; +): ColorHandlingFn { + const lastCategorical = assignments.findLast(({ color }) => color.type === 'categorical'); + const nextCategoricalIndex = + lastCategorical?.color.type === 'categorical' ? lastCategorical.color.colorIndex + 1 : 0; + + const autoAssignments = assignments + .filter(({ rules }) => rules.length === 0) + .map((assignment, i) => ({ + assignment, + assignmentIndex: i, + })); + const assignmentMatcher = getColorAssignmentMatcher(assignments); // find all categories that don't match with an assignment - const notAssignedCategories = + const unassignedAutoAssignmentsMap = new Map( data.type === 'categories' - ? data.categories.filter((category) => { - return !assignments.some(({ rule }) => ruleMatch(rule, category)); - }) - : []; - - const lastCategorical = findLast(assignments, (d) => { - return d.color.type === 'categorical'; - }); - const nextCategoricalIndex = - lastCategorical?.color.type === 'categorical' ? lastCategorical.color.colorIndex + 1 : 0; + ? data.categories + .map((category: SerializedValue) => deserializeField(category)) + .filter((category: RawValue) => { + return !assignmentMatcher.hasMatch(category); + }) + .map((category: RawValue, i) => { + const key = getValueKey(category); + const autoAssignment = autoAssignments[i]; + return [key, { ...autoAssignment, categoryIndex: i }]; + }) + : [] + ); + + return (rawValue: RawValue) => { + const key = getValueKey(rawValue); - return (category: string | string[]) => { - if (typeof category === 'string' || Array.isArray(category)) { - const nonAssignedCategoryIndex = notAssignedCategories.indexOf(category); - - // this category is not assigned to a specific color - if (nonAssignedCategoryIndex > -1) { - // if the category order is within current number of auto-assigned items pick the defined color - if (nonAssignedCategoryIndex < autoByOrderAssignments.length) { - const autoAssignmentIndex = assignments.findIndex( - (d) => d === autoByOrderAssignments[nonAssignedCategoryIndex] - ); - return getAssignmentColor( - colorMode, - autoByOrderAssignments[nonAssignedCategoryIndex].color, - palettes, - isDarkMode, - autoAssignmentIndex, - assignments.length - ); - } - const totalColorsIfGradient = assignments.length || notAssignedCategories.length; - const indexIfGradient = - (nonAssignedCategoryIndex - autoByOrderAssignments.length) % totalColorsIfGradient; - - // if no auto-assign color rule/color is available then use the color looping palette + if (unassignedAutoAssignmentsMap.has(key)) { + const { + assignment, + assignmentIndex = -1, + categoryIndex = -1, + } = unassignedAutoAssignmentsMap.get(key) ?? {}; + + if (assignment) { + // the category is within the number of available auto-assignments return getAssignmentColor( colorMode, - // TODO: the specialAssignment[0] position is arbitrary, we should fix it better - specialAssignments[DEFAULT_OTHER_ASSIGNMENT_INDEX].color.type === 'loop' - ? colorMode.type === 'gradient' - ? { type: 'gradient' } - : { - type: 'loop', - // those are applied here and depends on the current non-assigned category - auto-assignment list - colorIndex: - nonAssignedCategoryIndex - autoByOrderAssignments.length + nextCategoricalIndex, - paletteId, - } - : specialAssignments[DEFAULT_OTHER_ASSIGNMENT_INDEX].color, + assignment.color, palettes, isDarkMode, - indexIfGradient, - totalColorsIfGradient + assignmentIndex, + assignments.length ); } + + // the category is not assigned to a specific color + const totalColorsIfGradient = assignments.length || unassignedAutoAssignmentsMap.size; + const indexIfGradient = (categoryIndex - autoAssignments.length) % totalColorsIfGradient; + + // if no auto-assign color rule/color is available then use the color looping palette + return getAssignmentColor( + colorMode, + // TODO: the specialAssignment[0] position is arbitrary, we should fix it better + specialAssignments[DEFAULT_OTHER_ASSIGNMENT_INDEX].color.type === 'loop' + ? colorMode.type === 'gradient' + ? { type: 'gradient' } + : { + type: 'loop', + // those are applied here and depends on the current non-assigned category - auto-assignment list + colorIndex: categoryIndex - autoAssignments.length + nextCategoricalIndex, + paletteId, + } + : specialAssignments[DEFAULT_OTHER_ASSIGNMENT_INDEX].color, + palettes, + isDarkMode, + indexIfGradient, + totalColorsIfGradient + ); } + // find the assignment where the category matches the rule - const matchingAssignmentIndex = assignments.findIndex(({ rule }) => { - return ruleMatch(rule, category); - }); + const matchingAssignmentIndex = assignmentMatcher.getIndex(rawValue); if (matchingAssignmentIndex > -1) { const assignment = assignments[matchingAssignmentIndex]; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/rule_matching.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/rule_matching.ts deleted file mode 100644 index aa09ce257d885..0000000000000 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/rule_matching.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 { i18n } from '@kbn/i18n'; -import { ColorMapping } from '../config'; - -export function ruleMatch( - rule: ColorMapping.Config['assignments'][number]['rule'], - value: string | number | string[] -) { - switch (rule.type) { - case 'matchExactly': - if (Array.isArray(value)) { - return rule.values.some( - (v) => - Array.isArray(v) && v.length === value.length && v.every((part, i) => part === value[i]) - ); - } - return rule.values.includes(`${value}`); - case 'matchExactlyCI': - return rule.values.some((d) => d.toLowerCase() === `${value}`.toLowerCase()); - case 'range': - // TODO: color by value not yet possible in all charts in elastic-charts - return typeof value === 'number' ? rangeMatch(rule, value) : false; - default: - return false; - } -} - -export function rangeMatch(rule: ColorMapping.RuleRange, value: number) { - return ( - (rule.min === rule.max && rule.min === value) || - ((rule.minInclusive ? value >= rule.min : value > rule.min) && - (rule.maxInclusive ? value <= rule.max : value < rule.max)) - ); -} - -// TODO: move in some data/table related package -export const SPECIAL_TOKENS_STRING_CONVERSION = new Map([ - [ - '__other__', - i18n.translate('coloring.colorMapping.terms.otherBucketLabel', { - defaultMessage: 'Other', - }), - ], - [ - '', - i18n.translate('coloring.colorMapping.terms.emptyLabel', { - defaultMessage: '(empty)', - }), - ], -]); - -/** - * Returns special string for sake of color mapping/syncing - */ -export const getSpecialString = (value: string) => - SPECIAL_TOKENS_STRING_CONVERSION.get(value) ?? value; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/utils.test.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/utils.test.ts new file mode 100644 index 0000000000000..a92e9c0948c87 --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/utils.test.ts @@ -0,0 +1,41 @@ +/* + * 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". + */ + +/* eslint-disable max-classes-per-file */ + +import { getValueKey } from './utils'; + +describe('Utils', () => { + describe('getValueKey', () => { + class WithoutToString { + value = 'without toString'; + } + class WithToString { + value = 'with toString'; + toString() { + return this.value; + } + } + + it.each<[desc: string, rawValue: unknown, expectedKey: string]>([ + ['object', { test: 'test' }, '{"test":"test"}'], + ['array', [1, 2, 'three'], '1,2,three'], + ['number', 123, '123'], + ['string', 'testing', 'testing'], + ['boolean (false)', false, 'false'], + ['boolean (true)', true, 'true'], + ['null', null, 'null'], + ['undefined', undefined, 'undefined'], + ['class (with toString)', new WithToString(), 'with toString'], + ['class (without toString)', new WithoutToString(), '{"value":"without toString"}'], + ])('should return correct key for %s', (_, rawValue, expectedKey) => { + expect(getValueKey(rawValue)).toBe(expectedKey); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/utils.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/utils.ts new file mode 100644 index 0000000000000..c327da9aac637 --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/color/utils.ts @@ -0,0 +1,18 @@ +/* + * 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 { RawValue } from '@kbn/data-plugin/common'; + +/** + * Returns string key given an unknown raw color assignment value + */ +export function getValueKey(rawValue: RawValue): string { + const key = String(rawValue); + return key !== '[object Object]' ? key : JSON.stringify(rawValue); +} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/assignment.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/assignment.tsx index 5a629a18cf959..bf7ad498ecdaa 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/assignment.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/assignment.tsx @@ -14,10 +14,11 @@ import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; import { IKbnPalette, KbnPalettes } from '@kbn/palettes'; +import { IFieldFormat } from '@kbn/field-formats-plugin/common'; import { removeAssignment, updateAssignmentColor, - updateAssignmentRule, + updateAssignmentRules, } from '../../state/color_mapping'; import { ColorMapping } from '../../config'; import { Range } from './range'; @@ -25,31 +26,36 @@ import { Match } from './match'; import { ColorMappingInputData } from '../../categorical_color_mapping'; import { ColorSwatch } from '../color_picker/color_swatch'; +import { ColorAssignmentMatcher } from '../../color/color_assignment_matcher'; export function Assignment({ data, assignment, + assignments, disableDelete, index, - total, palette, palettes, colorMode, isDarkMode, specialTokens, - assignmentValuesCounter, + formatter, + allowCustomMatch, + assignmentMatcher, }: { data: ColorMappingInputData; index: number; - total: number; colorMode: ColorMapping.Config['colorMode']; - assignment: ColorMapping.Config['assignments'][number]; + assignment: ColorMapping.Assignment; + assignments: ColorMapping.Assignment[]; disableDelete: boolean; palette: IKbnPalette; palettes: KbnPalettes; isDarkMode: boolean; specialTokens: Map; - assignmentValuesCounter: Map; + formatter?: IFieldFormat; + allowCustomMatch?: boolean; + assignmentMatcher: ColorAssignmentMatcher; }) { const dispatch = useDispatch(); @@ -65,34 +71,21 @@ export function Assignment({ index={index} palette={palette} palettes={palettes} - total={total} + total={assignments.length} onColorChange={(color) => { - dispatch(updateAssignmentColor({ assignmentIndex: index, color })); - }} - /> - - - {assignment.rule.type === 'auto' || - assignment.rule.type === 'matchExactly' || - assignment.rule.type === 'matchExactlyCI' ? ( - ) => { dispatch( - updateAssignmentRule({ + updateAssignmentColor({ assignmentIndex: index, - rule: values.length === 0 ? { type: 'auto' } : { type: 'matchExactly', values }, + color, }) ); }} - assignmentValuesCounter={assignmentValuesCounter} /> - ) : assignment.rule.type === 'range' ? ( + + + {assignment.rules[0]?.type === 'range' ? ( { const rule: ColorMapping.RuleRange = { type: 'range', @@ -101,10 +94,33 @@ export function Assignment({ minInclusive, maxInclusive, }; - dispatch(updateAssignmentRule({ assignmentIndex: index, rule })); + dispatch( + updateAssignmentRules({ + assignmentIndex: index, + rules: [rule], + }) + ); + }} + /> + ) : ( + { + dispatch( + updateAssignmentRules({ + assignmentIndex: index, + rules, + }) + ); }} /> - ) : null} + )} + + + ); +} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/match.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/match.tsx index 63741c9e5ceb5..b16d42c8a4c02 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/match.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/match.tsx @@ -8,68 +8,88 @@ */ import React from 'react'; -import { EuiComboBox, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common'; -import { euiThemeVars } from '@kbn/ui-theme'; +import { RawValue, SerializedValue, deserializeField } from '@kbn/data-plugin/common'; +import { IFieldFormat } from '@kbn/field-formats-plugin/common'; import { ColorMapping } from '../../config'; +import { ColorRule, RuleMatch, RuleMatchRaw } from '../../config/types'; +import { ColorAssignmentMatcher } from '../../color/color_assignment_matcher'; +import { DuplicateWarning } from './duplicate_warning'; +import { getValueKey } from '../../color/utils'; + +export const isNotNull = (value: T | null): value is NonNullable => value !== null; export const Match: React.FC<{ index: number; - rule: - | ColorMapping.RuleAuto - | ColorMapping.RuleMatchExactly - | ColorMapping.RuleMatchExactlyCI - | ColorMapping.RuleRegExp; - updateValue: (values: Array) => void; - options: Array; + rules: ColorMapping.ColorRule[]; + updateRules: (rule: ColorMapping.ColorRule[]) => void; + categories: SerializedValue[]; specialTokens: Map; - assignmentValuesCounter: Map; -}> = ({ index, rule, updateValue, options, specialTokens, assignmentValuesCounter }) => { - const duplicateWarning = i18n.translate( - 'coloring.colorMapping.assignments.duplicateCategoryWarning', - { - defaultMessage: - 'This category has already been assigned a different color. Only the first matching assignment will be used.', - } + formatter?: IFieldFormat; + allowCustomMatch?: boolean; + assignmentMatcher: ColorAssignmentMatcher; +}> = ({ + index, + rules, + updateRules, + categories, + specialTokens, + formatter, + allowCustomMatch = false, + assignmentMatcher, +}) => { + const getOptionForRawValue = getOptionForRawValueFn(formatter); + const availableOptions: Array> = []; + + // Map option key to their raw value + const rawCategoryValueMap = categories.reduce>( + (acc, value: SerializedValue) => { + const option = getOptionForRawValue(value); + availableOptions.push(option); + acc.set(option.key, value); + return acc; + }, + new Map() ); - const selectedOptions = - rule.type === 'auto' - ? [] - : typeof rule.values === 'string' - ? [ - { - label: rule.values, - value: rule.values, - append: - (assignmentValuesCounter.get(rule.values) ?? 0) > 1 ? ( - - - - ) : undefined, - }, - ] - : rule.values.map((value) => { - const ruleValues = Array.isArray(value) ? value : [value]; + const selectedOptions = rules + .map | null>((rule) => { + switch (rule.type) { + case 'raw': { + const rawValue = deserializeField(rule.value); + const hasDuplicate = assignmentMatcher.hasDuplicate(rawValue); + const option = getOptionForRawValue(rule.value); + rawCategoryValueMap.set(option.key, rule.value); return { - label: ruleValues.map((v) => specialTokens.get(v) ?? v).join(MULTI_FIELD_KEY_SEPARATOR), - value, - append: - (assignmentValuesCounter.get(value) ?? 0) > 1 ? ( - - - - ) : undefined, + ...option, + append: hasDuplicate && , }; - }); + } + case 'match': { + const key = rule.matchCase ? rule.pattern : rule.pattern.toLowerCase(); + const hasDuplicate = assignmentMatcher.hasDuplicate(key); // non-exhaustive for partial word match - const convertedOptions = options.map((value) => { - const ruleValues = Array.isArray(value) ? value : [value]; - return { - label: ruleValues.map((v) => specialTokens.get(v) ?? v).join(MULTI_FIELD_KEY_SEPARATOR), - value, - }; - }); + return { + label: specialTokens.get(rule.pattern) ?? rule.pattern, + key: rule.pattern, + append: hasDuplicate && , + }; + } + case 'regex': { + // Note: Only basic placeholder logic, not used or fully tested + const hasDuplicate = false; // need to use exhaustive search + + return { + label: rule.pattern, + key: rule.pattern, + append: hasDuplicate && , + }; + } + default: + return null; + } + }) + .filter(isNotNull); return ( @@ -87,26 +107,67 @@ export const Match: React.FC<{ defaultMessage: 'Auto assigning term', } )} - options={convertedOptions} + options={availableOptions} selectedOptions={selectedOptions} - onChange={(changedOptions) => { - updateValue( - changedOptions.reduce>((acc, option) => { - if (option.value !== undefined) { - acc.push(option.value); + onChange={(newOptions) => { + updateRules( + newOptions.reduce((acc, { key = null }) => { + if (key !== null) { + if (rawCategoryValueMap.has(key)) { + acc.push({ + type: 'raw', + value: rawCategoryValueMap.get(key), + } satisfies RuleMatchRaw); + } else { + acc.push({ + type: 'match', + pattern: key, + matchCase: true, + matchEntireWord: true, + } satisfies RuleMatch); + } } return acc; }, []) ); }} - onCreateOption={(label) => { - if (selectedOptions.findIndex((option) => option.label === label) === -1) { - updateValue([...selectedOptions, { label, value: label }].map((d) => d.value)); - } + optionMatcher={({ option, normalizedSearchValue }) => { + return ( + String(option.value ?? '').includes(normalizedSearchValue) || + option.label.includes(normalizedSearchValue) + ); }} + onCreateOption={ + allowCustomMatch + ? (label) => { + return updateRules([ + ...rules, + { + type: 'match', + pattern: label, + matchCase: true, + matchEntireWord: true, + } satisfies RuleMatch, + ]); + } + : undefined + } isCaseSensitive compressed /> ); }; + +function getOptionForRawValueFn(fieldFormat?: IFieldFormat) { + const formatter = fieldFormat?.convert.bind(fieldFormat) ?? String; + return (serializedValue: unknown) => { + const rawValue = deserializeField(serializedValue); + const key = getValueKey(rawValue); + return { + key, + value: typeof rawValue === 'number' ? key : undefined, + label: formatter(rawValue), + } satisfies EuiComboBoxOptionOption; + }; +} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/utils.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/utils.ts new file mode 100644 index 0000000000000..63047358f7ffd --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/assignment/utils.ts @@ -0,0 +1,22 @@ +/* + * 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 { DatatableColumnMeta } from '@kbn/expressions-plugin/common'; + +/** + * Determines is a field can have create a custom pattern to match during color mapping. + */ +export function canCreateCustomMatch(meta?: DatatableColumnMeta): boolean { + if (!meta) return false; + return ( + (meta.type === 'number' || meta.type === 'string') && + meta.params?.id !== 'range' && + meta.params?.id !== 'multi_terms' + ); +} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_picker.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_picker.tsx index eafa07793dd5c..51e856c94382e 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_picker.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_picker.tsx @@ -46,12 +46,20 @@ export function ColorPicker({ }} > - setTab('palette')} isSelected={tab === 'palette'}> + setTab('palette')} + isSelected={tab === 'palette'} + > {i18n.translate('coloring.colorMapping.colorPicker.paletteTabLabel', { defaultMessage: 'Colors', })} - setTab('custom')} isSelected={tab === 'custom'}> + setTab('custom')} + isSelected={tab === 'custom'} + > {i18n.translate('coloring.colorMapping.colorPicker.customTabLabel', { defaultMessage: 'Custom', })} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_swatch.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_swatch.tsx index b1af9d364efd4..370e5e22687e0 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_swatch.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/color_picker/color_swatch.tsx @@ -30,7 +30,7 @@ import { getValidColor } from '../../color/color_math'; interface ColorPickerSwatchProps { colorMode: ColorMapping.Config['colorMode']; - assignmentColor: ColorMapping.Config['assignments'][number]['color']; + assignmentColor: ColorMapping.Assignment['color']; index: number; total: number; palette: IKbnPalette; @@ -163,8 +163,11 @@ export const ColorSwatch = ({ ) : ( + { @@ -139,6 +139,7 @@ export function RGBPicker({ }); } }} + data-test-subj="lns-colorMapping-colorPicker-custom-input" aria-label={i18n.translate( 'coloring.colorMapping.colorPicker.hexColorinputAriaLabel', { diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/assigments.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/assignments.tsx similarity index 84% rename from src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/assigments.tsx rename to src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/assignments.tsx index 6abe702ca8bc3..f467248c24745 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/assigments.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/assignments.tsx @@ -30,6 +30,8 @@ import { i18n } from '@kbn/i18n'; import { useDispatch, useSelector } from 'react-redux'; import { findLast } from 'lodash'; import { KbnPalettes } from '@kbn/palettes'; +import { IFieldFormat } from '@kbn/field-formats-plugin/common'; +import { deserializeField } from '@kbn/data-plugin/common'; import { Assignment } from '../assignment/assignment'; import { addNewAssignment, @@ -39,19 +41,25 @@ import { import { selectColorMode, selectComputedAssignments, selectPalette } from '../../state/selectors'; import { ColorMappingInputData } from '../../categorical_color_mapping'; import { ColorMapping } from '../../config'; -import { ruleMatch } from '../../color/rule_matching'; +import { getColorAssignmentMatcher } from '../../color/color_assignment_matcher'; -export function AssignmentsConfig({ +export function Assignments({ data, palettes, isDarkMode, specialTokens, + formatter, + allowCustomMatch, }: { palettes: KbnPalettes; data: ColorMappingInputData; isDarkMode: boolean; - /** map between original and formatted tokens used to handle special cases, like the Other bucket and the empty bucket */ + /** + * map between original and formatted tokens used to handle special cases, like the Other bucket and the empty bucket + */ specialTokens: Map; + formatter?: IFieldFormat; + allowCustomMatch?: boolean; }) { const [showOtherActions, setShowOtherActions] = useState(false); @@ -59,41 +67,25 @@ export function AssignmentsConfig({ const palette = useSelector(selectPalette(palettes)); const colorMode = useSelector(selectColorMode); const assignments = useSelector(selectComputedAssignments); - + const assignmentMatcher = useMemo(() => getColorAssignmentMatcher(assignments), [assignments]); const unmatchingCategories = useMemo(() => { return data.type === 'categories' ? data.categories.filter((category) => { - return !assignments.some(({ rule }) => ruleMatch(rule, category)); + const rawValue = deserializeField(category); + return !assignmentMatcher.hasMatch(rawValue); }) : []; - }, [data, assignments]); - - const assignmentValuesCounter = assignments.reduce>( - (acc, assignment) => { - const values = assignment.rule.type === 'matchExactly' ? assignment.rule.values : []; - values.forEach((value) => { - acc.set(value, (acc.get(value) ?? 0) + 1); - }); - return acc; - }, - new Map() - ); + }, [data, assignmentMatcher]); const onClickAddNewAssignment = useCallback(() => { - const lastCategorical = findLast(assignments, (d) => { - return d.color.type === 'categorical'; + const lastCategorical = assignments.findLast((a) => { + return a.color.type === 'categorical'; }); const nextCategoricalIndex = lastCategorical?.color.type === 'categorical' ? lastCategorical.color.colorIndex + 1 : 0; dispatch( addNewAssignment({ - rule: - data.type === 'categories' - ? { - type: 'matchExactly', - values: [], - } - : { type: 'range', min: 0, max: 0, minInclusive: true, maxInclusive: true }, + rules: [], color: colorMode.type === 'categorical' ? { @@ -105,7 +97,7 @@ export function AssignmentsConfig({ touched: false, }) ); - }, [assignments, colorMode.type, data.type, dispatch, palette]); + }, [assignments, colorMode.type, dispatch, palette]); const onClickAddAllCurrentCategories = useCallback(() => { if (data.type === 'categories') { @@ -115,25 +107,25 @@ export function AssignmentsConfig({ const nextCategoricalIndex = lastCategorical?.color.type === 'categorical' ? lastCategorical.color.colorIndex + 1 : 0; - const newAssignments: ColorMapping.Config['assignments'] = unmatchingCategories.map( - (c, i) => { - return { - rule: { - type: 'matchExactly', - values: [c], + const newAssignments = unmatchingCategories.map((category, i) => { + return { + rules: [ + { + type: 'raw', + value: category, }, - color: - colorMode.type === 'categorical' - ? { - type: 'categorical', - paletteId: palette.id, - colorIndex: (nextCategoricalIndex + i) % palette.colors().length, - } - : { type: 'gradient' }, - touched: false, - }; - } - ); + ], + color: + colorMode.type === 'categorical' + ? { + type: 'categorical', + paletteId: palette.id, + colorIndex: (nextCategoricalIndex + i) % palette.colors().length, + } + : { type: 'gradient' }, + touched: false, + } satisfies ColorMapping.Assignment; + }); dispatch(addNewAssignments(newAssignments)); } }, [data.type, assignments, unmatchingCategories, dispatch, colorMode.type, palette]); @@ -164,7 +156,7 @@ export function AssignmentsConfig({ key={i} data={data} index={i} - total={assignments.length} + assignments={assignments} colorMode={colorMode} palette={palette} palettes={palettes} @@ -172,7 +164,9 @@ export function AssignmentsConfig({ assignment={assignment} disableDelete={false} specialTokens={specialTokens} - assignmentValuesCounter={assignmentValuesCounter} + formatter={formatter} + allowCustomMatch={allowCustomMatch} + assignmentMatcher={assignmentMatcher} /> ); })} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/container.tsx b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/container.tsx index 49ea5eb99caf8..09fef5b25766b 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/container.tsx +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/components/container/container.tsx @@ -13,6 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiButtonIcon, EuiToolTip } from import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { KbnPalettes } from '@kbn/palettes'; +import { IFieldFormat } from '@kbn/field-formats-plugin/common'; import { PaletteSelector } from '../palette_selector/palette_selector'; import { changeGradientSortOrder } from '../../state/color_mapping'; @@ -21,19 +22,23 @@ import { ColorMappingInputData } from '../../categorical_color_mapping'; import { Gradient } from '../palette_selector/gradient'; import { ScaleMode } from '../palette_selector/scale'; import { UnassignedTermsConfig } from './unassigned_terms_config'; -import { AssignmentsConfig } from './assigments'; +import { Assignments } from './assignments'; export function Container({ data, palettes, isDarkMode, specialTokens, + formatter, + allowCustomMatch, }: { palettes: KbnPalettes; data: ColorMappingInputData; isDarkMode: boolean; /** map between original and formatted tokens used to handle special cases, like the Other bucket and the empty bucket */ specialTokens: Map; + formatter?: IFieldFormat; + allowCustomMatch?: boolean; }) { const dispatch = useDispatch(); const palette = useSelector(selectPalette(palettes)); @@ -111,11 +116,13 @@ export function Container({ defaultMessage: 'Color assignments', })} > - diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/assignments.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/assignments.ts index 5f039c05074db..138a237cb439c 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/assignments.ts +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/assignments.ts @@ -18,11 +18,11 @@ export function updateAssignmentsPalette( preserveColorChanges: boolean ): ColorMapping.Config['assignments'] { const palette = palettes.get(paletteId); - return assignments.map(({ rule, color, touched }, index) => { + return assignments.map(({ rules, color, touched }, index) => { if (preserveColorChanges && touched) { - return { rule, color, touched }; + return { rules, color, touched }; } else { - const newColor: ColorMapping.Config['assignments'][number]['color'] = + const newColor: ColorMapping.Assignment['color'] = colorMode.type === 'categorical' ? { type: 'categorical', @@ -31,7 +31,7 @@ export function updateAssignmentsPalette( } : { type: 'gradient' }; return { - rule, + rules, color: newColor, touched: false, }; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/colors.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/colors.ts new file mode 100644 index 0000000000000..1f3bc32f73288 --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/colors.ts @@ -0,0 +1,41 @@ +/* + * 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". + */ + +/** + * Defines explicit color specified as a CSS color datatype (rgb/a,hex,keywords,lab,lch etc) + */ +export interface ColorCode { + type: 'colorCode'; + colorCode: string; +} + +/** + * Defines categorical color based on the index position of color in palette defined by the paletteId + */ +export interface CategoricalColor { + type: 'categorical'; + paletteId: string; + colorIndex: number; +} + +/** + * Defines color based on looping round-robin assignment + */ +export interface LoopColor { + type: 'loop'; +} + +/** + * Specify that the Color in an Assignment needs to be taken from a gradient defined in the `Config.colorMode` + */ +export interface GradientColor { + type: 'gradient'; +} + +export type Color = ColorCode | CategoricalColor | LoopColor | GradientColor; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/default_color_mapping.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/default_color_mapping.ts index 1005f5d89855a..6d3f6205101cb 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/default_color_mapping.ts +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/default_color_mapping.ts @@ -22,9 +22,11 @@ export const DEFAULT_COLOR_MAPPING_CONFIG: ColorMapping.Config = { assignments: [], specialAssignments: [ { - rule: { - type: 'other', - }, + rules: [ + { + type: 'other', + }, + ], color: { type: 'loop', }, diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/rules.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/rules.ts new file mode 100644 index 0000000000000..d9b01231b3941 --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/rules.ts @@ -0,0 +1,92 @@ +/* + * 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 { SerializedValue } from '@kbn/data-plugin/common'; + +/** + * A rule that matches based on raw values + */ +export interface RuleMatchRaw { + type: 'raw'; + /** + * Serialized form of raw row value + */ + value: SerializedValue; +} + +/** + * Rule to match values, optionally case-sensitive and entire word + */ +export interface RuleMatch { + type: 'match'; + /** + * The string to search for + */ + pattern: string; + /** + * Whether the pattern should match an entire word + * + * @default false + */ + matchEntireWord?: boolean; + /** + * Whether the search should be case-sensitive + * + * @default false + */ + matchCase?: boolean; +} + +/** + * Regex rule + */ +export interface RuleRegExp { + type: 'regex'; + /** + * RegExp pattern as string including flags (i.e. `/[a-z]+/i`) + */ + pattern: string; +} + +/** + * Rule for numerical data range assignments + */ +export interface RuleRange { + type: 'range'; + /** + * The min value of the range + */ + min: number; + /** + * The max value of the range + */ + max: number; + /** + * `true` if the range is left-closed (the `min` value is considered within the range), false otherwise (only values that are + * greater than the `min` are considered within the range) + */ + minInclusive: boolean; + /** + * `true` if the range is right-closed (the `max` value is considered within the range), false otherwise (only values less than + * the `max` are considered within the range) + */ + maxInclusive: boolean; +} + +/** + * A specific catch-everything-else rule + */ +export interface RuleOthers { + type: 'other'; +} + +/** + * All available color rules + */ +export type ColorRule = RuleMatchRaw | RuleMatch | RuleRegExp | RuleRange; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/types.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/types.ts index 79be2aa615c3c..19c8fb00aa128 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/types.ts +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/types.ts @@ -7,146 +7,77 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -/** - * A color specified as a CSS color datatype (rgb/a,hex,keywords,lab,lch etc) - */ -export interface ColorCode { - type: 'colorCode'; - colorCode: string; -} - -/** - * An index specified categorical color, coming from paletteId - */ -export interface CategoricalColor { - type: 'categorical'; - paletteId: string; - colorIndex: number; -} - -/** - * Specify that the Color in an Assignment needs to be taken from a gradient defined in the `Config.colorMode` - */ -export interface GradientColor { - type: 'gradient'; -} - -/** - * An index specified categorical color, coming from paletteId - */ -export interface LoopColor { - type: 'loop'; -} - -/** - * A special rule that match automatically, in order, all the categories that are not matching a specified rule - */ -export interface RuleAuto { - /* tag */ - type: 'auto'; -} -/** - * A rule that match exactly, case sensitive, with the provided strings - */ -export interface RuleMatchExactly { - /* tag */ - type: 'matchExactly'; - values: Array; -} - -/** - * A Match rule to match the values case insensitive - * @ignore not used yet - */ -export interface RuleMatchExactlyCI { - /* tag */ - type: 'matchExactlyCI'; - values: string[]; -} - -/** - * A range rule, not used yet, but can be used for numerical data assignments - */ -export interface RuleRange { - /* tag */ - type: 'range'; - /** - * The min value of the range - */ - min: number; - /** - * The max value of the range - */ - max: number; - /** - * `true` if the range is left-closed (the `min` value is considered within the range), false otherwise (only values that are - * greater than the `min` are considered within the range) - */ - minInclusive: boolean; - /** - * `true` if the range is right-closed (the `max` value is considered within the range), false otherwise (only values less than - * the `max` are considered within the range) - */ - maxInclusive: boolean; -} -/** - * Regex rule. - * @ignore not used yet - */ -export interface RuleRegExp { - /* tag */ - type: 'regex'; - /** - * TODO: not sure how we can store a regexp - */ - values: string; -} - -/** - * A specific catch-everything-else rule - */ -export interface RuleOthers { - /* tag */ - type: 'other'; -} +import { CategoricalColor, Color, ColorCode, GradientColor, LoopColor } from './colors'; +import { ColorRule, RuleOthers } from './rules'; /** * An assignment is the connection link between a rule and a color */ -export interface Assignment { +export interface AssignmentBase { /** * Describe the rule used to assign the color. */ - rule: R; + rules: R[]; /** * The color definition */ color: C; - /** * Specify if the color was changed from the original one - * TODO: rename */ touched: boolean; } +type ColorStep = (CategoricalColor | ColorCode) & { + /** + * A flag to know when assignment has been edited since last saved + */ + touched: boolean; +}; + export interface CategoricalColorMode { type: 'categorical'; } + export interface GradientColorMode { type: 'gradient'; - steps: Array<(CategoricalColor | ColorCode) & { touched: boolean }>; + steps: ColorStep[]; sort: 'asc' | 'desc'; } -export interface Config { +interface BaseConfig { paletteId: string; - colorMode: CategoricalColorMode | GradientColorMode; - assignments: Array< - Assignment< - RuleAuto | RuleMatchExactly | RuleMatchExactlyCI | RuleRange | RuleRegExp, - CategoricalColor | ColorCode | GradientColor - > - >; - specialAssignments: Array>; + specialAssignments: Array>; } + +/** + * Gradient color mapping config + */ +export interface GradientConfig extends BaseConfig { + colorMode: GradientColorMode; + assignments: Array>; +} + +/** + * Categorical color mapping config + */ +export interface CategoricalConfig extends BaseConfig { + colorMode: CategoricalColorMode; + assignments: Array>; +} + +/** + * Polymorphic color mapping config + * + * Merges `GradientConfig` and `CategoricalConfig` for simplicity of type alignment + */ +export type Config = BaseConfig & { + colorMode: CategoricalColorMode | GradientColorMode; + assignments: Array>; +}; + +export type Assignment = Config['assignments'][number]; +export type SpecialAssignment = BaseConfig['specialAssignments'][number]; + +export * from './colors'; +export * from './rules'; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/utils.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/utils.ts new file mode 100644 index 0000000000000..bffb67b5da905 --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/config/utils.ts @@ -0,0 +1,18 @@ +/* + * 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 { Config, CategoricalConfig, GradientConfig } from './types'; + +export function isCategoricalColorConfig(config: Config): config is CategoricalConfig { + return config.colorMode.type === 'categorical'; +} + +export function isGradientColorConfig(config: Config): config is GradientConfig { + return config.colorMode.type === 'gradient'; +} diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/index.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/index.ts index 5cba53f0eadcc..b3b144868e308 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/index.ts +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/index.ts @@ -16,10 +16,13 @@ export { export type { ColorMappingInputData } from './categorical_color_mapping'; export type { ColorMapping } from './config'; export * from './color/color_handling'; -export { SPECIAL_TOKENS_STRING_CONVERSION, getSpecialString } from './color/rule_matching'; +export { getValueKey } from './color/utils'; +export { SPECIAL_TOKENS_STRING_CONVERSION, getSpecialString } from './special_tokens'; +export { type ColorAssignmentMatcher } from './color/color_assignment_matcher'; export { DEFAULT_COLOR_MAPPING_CONFIG, DEFAULT_OTHER_ASSIGNMENT_INDEX, getPaletteColors, getColorsFromMapping, } from './config/default_color_mapping'; +export * from './components/assignment/utils'; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/special_tokens.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/special_tokens.ts new file mode 100644 index 0000000000000..761727b37a96f --- /dev/null +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/special_tokens.ts @@ -0,0 +1,32 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +// TODO: move in some data/table related package +export const SPECIAL_TOKENS_STRING_CONVERSION = new Map([ + [ + '__other__', + i18n.translate('coloring.colorMapping.terms.otherBucketLabel', { + defaultMessage: 'Other', + }), + ], + [ + '', + i18n.translate('coloring.colorMapping.terms.emptyLabel', { + defaultMessage: '(empty)', + }), + ], +]); + +/** + * Returns special string for sake of color mapping/syncing + */ +export const getSpecialString = (value: string) => + SPECIAL_TOKENS_STRING_CONVERSION.get(value) ?? value; diff --git a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/state/color_mapping.ts b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/state/color_mapping.ts index 49287d947b136..2b82b37349375 100644 --- a/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/state/color_mapping.ts +++ b/src/platform/packages/shared/kbn-coloring/src/shared_components/color_mapping/state/color_mapping.ts @@ -59,10 +59,7 @@ export const colorMappingSlice = createSlice({ state.assignments = []; }, - addNewAssignment: ( - state, - action: PayloadAction - ) => { + addNewAssignment: (state, action: PayloadAction) => { state.assignments.push({ ...action.payload }); }, addNewAssignments: (state, action: PayloadAction) => { @@ -72,7 +69,7 @@ export const colorMappingSlice = createSlice({ state, action: PayloadAction<{ assignmentIndex: number; - assignment: ColorMapping.Config['assignments'][number]; + assignment: ColorMapping.Assignment; }> ) => { state.assignments[action.payload.assignmentIndex] = { @@ -84,19 +81,37 @@ export const colorMappingSlice = createSlice({ state, action: PayloadAction<{ assignmentIndex: number; - rule: ColorMapping.Config['assignments'][number]['rule']; + ruleIndex: number; + rule: ColorMapping.ColorRule; + }> + ) => { + const assignment = state.assignments[action.payload.assignmentIndex]; + state.assignments[action.payload.assignmentIndex] = { + ...assignment, + rules: [ + ...assignment.rules.slice(0, action.payload.ruleIndex), + action.payload.rule, + ...assignment.rules.slice(action.payload.ruleIndex + 1), + ], + }; + }, + updateAssignmentRules: ( + state, + action: PayloadAction<{ + assignmentIndex: number; + rules: ColorMapping.ColorRule[]; }> ) => { state.assignments[action.payload.assignmentIndex] = { ...state.assignments[action.payload.assignmentIndex], - rule: action.payload.rule, + rules: action.payload.rules, }; }, updateAssignmentColor: ( state, action: PayloadAction<{ assignmentIndex: number; - color: ColorMapping.Config['assignments'][number]['color']; + color: ColorMapping.Assignment['color']; }> ) => { state.assignments[action.payload.assignmentIndex] = { @@ -219,6 +234,7 @@ export const colorMappingSlice = createSlice({ }, }, }); + // Action creators are generated for each case reducer function export const { updatePalette, @@ -230,6 +246,7 @@ export const { updateAssignmentColor, updateSpecialAssignmentColor, updateAssignmentRule, + updateAssignmentRules, removeAssignment, removeAllAssignments, changeColorMode, diff --git a/src/platform/packages/shared/kbn-coloring/tsconfig.json b/src/platform/packages/shared/kbn-coloring/tsconfig.json index b361a533a10b5..ce657fc873824 100644 --- a/src/platform/packages/shared/kbn-coloring/tsconfig.json +++ b/src/platform/packages/shared/kbn-coloring/tsconfig.json @@ -24,6 +24,8 @@ "@kbn/ui-theme", "@kbn/visualization-utils", "@kbn/palettes", + "@kbn/field-formats-plugin", + "@kbn/expressions-plugin", ], "exclude": [ "target/**/*", diff --git a/src/platform/plugins/private/vis_types/vega/public/data_model/vega_parser.test.js b/src/platform/plugins/private/vis_types/vega/public/data_model/vega_parser.test.js index 17ab552647f85..b9c88c54b046f 100644 --- a/src/platform/plugins/private/vis_types/vega/public/data_model/vega_parser.test.js +++ b/src/platform/plugins/private/vis_types/vega/public/data_model/vega_parser.test.js @@ -17,7 +17,7 @@ import { VegaThemeColors } from './utils'; jest.mock('../services'); -const theme = { name: 'borealis', darkMode: false }; +const theme = { darkMode: false }; describe(`VegaParser.parseAsync`, () => { function check(spec, useResize, expectedSpec, warnCount) { diff --git a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/colors/color_mapping_accessors.ts b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/colors/color_mapping_accessors.ts index 8368e98b83715..6a9142db40c95 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/colors/color_mapping_accessors.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/colors/color_mapping_accessors.ts @@ -9,13 +9,12 @@ import { NodeColorAccessor, PATH_KEY } from '@elastic/charts'; import { decreaseOpacity } from '@kbn/charts-plugin/public'; -import { MultiFieldKey } from '@kbn/data-plugin/common'; import { getColorFactory } from '@kbn/coloring'; -import { isMultiFieldKey } from '@kbn/data-plugin/common'; +import { MultiFieldKey } from '@kbn/data-plugin/common'; import { ChartTypes } from '../../../common/types'; export function getCategoryKeys(category: string | MultiFieldKey): string | string[] { - return isMultiFieldKey(category) ? category.keys.map(String) : `${category}`; + return MultiFieldKey.isInstance(category) ? category.keys.map(String) : `${category}`; } /** diff --git a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts index c717b000bdce8..ba2867f9574d1 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts @@ -99,12 +99,12 @@ describe('get color', () => { const buckets = createMockBucketColumns(); const visParams = createMockPieParams(); const colors = ['color1', 'color2', 'color3', 'color4']; - const categories = (chartType?: ChartTypes) => + const getCategories = (chartType?: ChartTypes) => chartType === ChartTypes.MOSAIC && visData.columns.length === 2 ? getColorCategories(visData.rows, visData.columns[1]?.id) : getColorCategories(visData.rows, visData.columns[0]?.id); - const colorIndexMap = (chartType?: ChartTypes) => - new Map(categories(chartType).map((d, i) => [d[0], i])); + const getColorIndexMap = (chartType?: ChartTypes) => + new Map(getCategories(chartType).map((d, i) => [d, i])); const dataMock = dataPluginMock.createStartContract(); interface RangeProps { gte: number; @@ -146,7 +146,44 @@ describe('get color', () => { getAll: () => [mockPalette1], }; }; - it('should return the correct color based on the parent sortIndex', () => { + + it('should return the correct color based on color map index', () => { + const d: SimplifiedArrayNode = { + depth: 1, + sortIndex: 0, + parent: { + children: [ + ['ES-Air', undefined], + ['Kibana Airlines', undefined], + ], + depth: 0, + sortIndex: 0, + }, + children: [], + }; + + const color = getColor( + ChartTypes.PIE, + 'ES-Air', + d, + 0, + false, + {}, + distinctSeries, + dataLength, + visParams, + getPaletteRegistry(), + { getColor: () => undefined }, + false, + false, + dataMock.fieldFormats, + visData.columns[0], + getColorIndexMap(ChartTypes.PIE) + ); + expect(color).toEqual(colors[2]); + }); + + it('should return the correct color based on the parent sortIndex when no color map index found', () => { const d: SimplifiedArrayNode = { depth: 1, sortIndex: 0, @@ -177,7 +214,7 @@ describe('get color', () => { false, dataMock.fieldFormats, visData.columns[0], - colorIndexMap(ChartTypes.PIE) + new Map() ); expect(color).toEqual(colors[0]); }); @@ -212,7 +249,7 @@ describe('get color', () => { false, dataMock.fieldFormats, visData.columns[0], - colorIndexMap(ChartTypes.PIE) + getColorIndexMap(ChartTypes.PIE) ); expect(color).toEqual('color3'); }); @@ -246,7 +283,7 @@ describe('get color', () => { false, dataMock.fieldFormats, visData.columns[0], - colorIndexMap(ChartTypes.PIE) + getColorIndexMap(ChartTypes.PIE) ); expect(color).toEqual('#000028'); }); @@ -310,7 +347,7 @@ describe('get color', () => { false, dataMock.fieldFormats, column, - colorIndexMap(ChartTypes.PIE) + getColorIndexMap(ChartTypes.PIE) ); expect(color).toEqual('#3F6833'); }); @@ -351,7 +388,7 @@ describe('get color', () => { false, dataMock.fieldFormats, visData.columns[0], - colorIndexMap(ChartTypes.MOSAIC) + getColorIndexMap(ChartTypes.MOSAIC) ); expect(registry.get().getCategoricalColor).toHaveBeenCalledWith( [expect.objectContaining({ name: 'Second level 1' })], diff --git a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts index 6b1953857a3e2..73d18cfde5b2f 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_color.ts @@ -12,6 +12,7 @@ import { isEqual } from 'lodash'; import type { PaletteRegistry, SeriesLayer, PaletteOutput, PaletteDefinition } from '@kbn/coloring'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { lightenColor } from '@kbn/charts-plugin/public'; +import { SerializedValue } from '@kbn/data-plugin/common'; import { BucketColumns, ChartTypes, PartitionVisParams } from '../../../common/types'; import { DistinctSeries } from '../get_distinct_series'; @@ -123,7 +124,7 @@ const createSeriesLayers = ( arrayNode: SimplifiedArrayNode, parentSeries: DistinctSeries['parentSeries'], isSplitChart: boolean, - colorIndexMap: Map + colorIndexMap: Map ): SeriesLayer[] => { const seriesLayers: SeriesLayer[] = []; let tempParent: typeof arrayNode | (typeof arrayNode)['parent'] = arrayNode; @@ -193,7 +194,7 @@ export const getColor = ( isDarkMode: boolean, formatter: FieldFormatsStart, column: Partial, - colorIndexMap: Map + colorIndexMap: Map ) => { // Mind the difference here: the contrast computation for the text ignores the alpha/opacity // therefore change it for dark mode diff --git a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts index 756fa182fb231..45af8a64409cf 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_partition_vis/public/utils/layers/get_layers.ts @@ -8,7 +8,7 @@ */ import { Datum, PartitionLayer } from '@elastic/charts'; -import { PaletteRegistry, getColorFactory } from '@kbn/coloring'; +import { ColorHandlingFn, PaletteRegistry, getColorFactory } from '@kbn/coloring'; import { i18n } from '@kbn/i18n'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; @@ -135,7 +135,7 @@ function getColorFromMappingFactory( palettes: KbnPalettes, visParams: PartitionVisParams, isDarkMode: boolean -): undefined | ((category: string | string[]) => string) { +): undefined | ColorHandlingFn { const { colorMapping, dimensions } = visParams; if (!colorMapping) { diff --git a/src/platform/plugins/shared/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/platform/plugins/shared/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 267de59f1062e..367503dab83b5 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -23,12 +23,11 @@ import { } from '@elastic/charts'; import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { useElasticChartsTheme } from '@kbn/charts-theme'; -import { PaletteRegistry, PaletteOutput, getColorFactory } from '@kbn/coloring'; +import { PaletteRegistry, PaletteOutput, getColorFactory, ColorHandlingFn } from '@kbn/coloring'; import { IInterpreterRenderHandlers, DatatableRow } from '@kbn/expressions-plugin/public'; import { getColorCategories, getOverridesFor } from '@kbn/chart-expressions-common'; import type { AllowedSettingsOverrides, AllowedChartOverrides } from '@kbn/charts-plugin/common'; import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { isMultiFieldKey } from '@kbn/data-plugin/common'; import { KbnPalettes, useKbnPalettes } from '@kbn/palettes'; import { getFormatService } from '../format_service'; import { TagcloudRendererConfig } from '../../common/types'; @@ -130,9 +129,11 @@ export const TagCloudChart = ({ ); return visData.rows.map((row) => { - const tag = tagColumn === undefined ? 'all' : row[tagColumn]; + const { value: tagValue, tag } = + tagColumn === undefined + ? { value: undefined, tag: 'all' } + : { value: row[tagColumn], tag: row[tagColumn] }; - const category = isMultiFieldKey(tag) ? tag.keys.map(String) : `${tag}`; return { text: bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag, weight: @@ -140,7 +141,7 @@ export const TagCloudChart = ({ ? 1 : calculateWeight(row[metricColumn], minValue, maxValue, 0, 1) || 0, color: colorFromMappingFn - ? colorFromMappingFn(category) + ? colorFromMappingFn(tagValue) : getColor(palettesRegistry, palette, tag, values, syncColors) || 'rgba(0,0,0,0)', }; }); @@ -320,7 +321,7 @@ function getColorFromMappingFactory( palettes: KbnPalettes, isDarkMode: boolean, colorMapping?: string -): undefined | ((category: string | string[]) => string) { +): undefined | ColorHandlingFn { if (!colorMapping) { // return undefined, we will use the legacy color mapping instead return undefined; diff --git a/src/platform/plugins/shared/chart_expressions/expression_tagcloud/tsconfig.json b/src/platform/plugins/shared/chart_expressions/expression_tagcloud/tsconfig.json index 8d136460a48f7..b462cf7e26bc4 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_tagcloud/tsconfig.json +++ b/src/platform/plugins/shared/chart_expressions/expression_tagcloud/tsconfig.json @@ -26,7 +26,6 @@ "@kbn/analytics", "@kbn/chart-expressions-common", "@kbn/chart-icons", - "@kbn/data-plugin", "@kbn/react-kibana-context-render", "@kbn/charts-theme", "@kbn/ebt-tools", diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 9ce88786b0b37..8662ce09ba1a1 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -453,6 +453,15 @@ exports[`XYChart component it renders area 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -1549,6 +1558,15 @@ exports[`XYChart component it renders area 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -2208,6 +2226,15 @@ exports[`XYChart component it renders bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -3304,6 +3331,15 @@ exports[`XYChart component it renders bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -3963,6 +3999,15 @@ exports[`XYChart component it renders horizontal bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -5059,6 +5104,15 @@ exports[`XYChart component it renders horizontal bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -5718,6 +5772,15 @@ exports[`XYChart component it renders line 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -6814,6 +6877,15 @@ exports[`XYChart component it renders line 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -7473,6 +7545,15 @@ exports[`XYChart component it renders stacked area 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -8569,6 +8650,15 @@ exports[`XYChart component it renders stacked area 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -9228,6 +9318,15 @@ exports[`XYChart component it renders stacked bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -10324,6 +10423,15 @@ exports[`XYChart component it renders stacked bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -10983,6 +11091,15 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -12079,6 +12196,15 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "formattedColumns": Object { "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map {}, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -12764,6 +12890,18 @@ exports[`XYChart component split chart should render split chart if both, splitR "b": true, "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map { + 2 => 2, + 5 => 5, + }, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -14060,6 +14198,18 @@ exports[`XYChart component split chart should render split chart if both, splitR "b": true, "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map { + 2 => 2, + 5 => 5, + }, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -14759,6 +14909,18 @@ exports[`XYChart component split chart should render split chart if splitColumnA "b": true, "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map { + 2 => 2, + 5 => 5, + }, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -16048,6 +16210,18 @@ exports[`XYChart component split chart should render split chart if splitColumnA "b": true, "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map { + 2 => 2, + 5 => 5, + }, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -16745,6 +16919,18 @@ exports[`XYChart component split chart should render split chart if splitRowAcce "b": true, "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map { + 2 => 2, + 5 => 5, + }, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { @@ -18034,6 +18220,18 @@ exports[`XYChart component split chart should render split chart if splitRowAcce "b": true, "c": true, }, + "invertedRawValueMap": Map { + "a" => Map {}, + "b" => Map { + 2 => 2, + 5 => 5, + }, + "c" => Map { + 1652034840000 => 1652034840000, + 1652122440000 => 1652122440000, + }, + "d" => Map {}, + }, "table": Object { "columns": Array [ Object { diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/data_layers.tsx b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/data_layers.tsx index df51a0869ecea..4f02a806b31be 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/data_layers.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/data_layers.tsx @@ -144,9 +144,6 @@ export const DataLayers: FC = ({ ? JSON.parse(columnToLabel) : {}; - // what if row values are not primitive? That is the case of, for instance, Ranges - // remaps them to their serialized version with the formatHint metadata - // In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on const formattedDatatableInfo = formattedDatatables[layerId]; const yAxis = yAxesConfiguration.find((axisConfiguration) => diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/legend_action.test.tsx b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/legend_action.test.tsx index d9e7385cb0c86..b787e86cf8ee7 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/legend_action.test.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/legend_action.test.tsx @@ -19,7 +19,8 @@ import { getLegendAction } from './legend_action'; import { LegendActionPopover, LegendCellValueActions } from './legend_action_popover'; import { mockPaletteOutput } from '../../common/__mocks__'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; -import { LayerFieldFormats } from '../helpers'; +import { InvertedRawValueMap, LayerFieldFormats } from '../helpers'; +import { RawValue } from '@kbn/data-plugin/common'; const legendCellValueActions: LegendCellValueActions = [ { id: 'action_1', displayName: 'Action 1', iconType: 'testIcon1', execute: () => {} }, @@ -180,6 +181,9 @@ const sampleLayer: DataLayerConfig = { describe('getLegendAction', function () { let wrapperProps: LegendActionProps; + const invertedRawValueMap: InvertedRawValueMap = new Map( + table.columns.map((c) => [c.id, new Map()]) + ); const Component: React.ComponentType = getLegendAction( [sampleLayer], jest.fn(), @@ -201,6 +205,7 @@ describe('getLegendAction', function () { { first: { table, + invertedRawValueMap, formattedColumns: {}, }, }, diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/tooltip/tooltip.test.tsx b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/tooltip/tooltip.test.tsx index 8e3d9c78313e5..f0166c07c808c 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/tooltip/tooltip.test.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/components/tooltip/tooltip.test.tsx @@ -10,10 +10,16 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Tooltip } from './tooltip'; -import { generateSeriesId, LayersAccessorsTitles, LayersFieldFormats } from '../../helpers'; +import { + generateSeriesId, + InvertedRawValueMap, + LayersAccessorsTitles, + LayersFieldFormats, +} from '../../helpers'; import { XYChartSeriesIdentifier } from '@elastic/charts'; import { sampleArgs, sampleLayer } from '../../../common/__mocks__'; import { FieldFormat, FormatFactory } from '@kbn/field-formats-plugin/common'; +import { RawValue } from '@kbn/data-plugin/common'; const getSeriesIdentifier = ({ layerId, @@ -44,6 +50,9 @@ const getSeriesIdentifier = ({ describe('Tooltip', () => { const { data } = sampleArgs(); + const invertedRawValueMap: InvertedRawValueMap = new Map( + data.columns.map((c) => [c.id, new Map()]) + ); const { layerId, xAccessor, splitAccessors = [], accessors } = sampleLayer; const seriesSplitAccessors = new Map(); splitAccessors.forEach((splitAccessor) => { @@ -112,7 +121,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} layers={[sampleLayer]} /> @@ -132,7 +143,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} xDomain={xDomain} layers={[sampleLayer]} @@ -153,7 +166,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} xDomain={xDomain} layers={[sampleLayer]} @@ -171,7 +186,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} xDomain={xDomain2} layers={[sampleLayer]} @@ -191,7 +208,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} layers={[sampleLayer]} /> @@ -217,7 +236,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} layers={[sampleLayer]} /> @@ -245,7 +266,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} layers={[sampleLayer]} /> @@ -274,7 +297,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor, splitRowAccessor }} layers={[sampleLayer]} /> @@ -302,7 +327,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitColumnAccessor }} layers={[sampleLayer]} /> @@ -330,7 +357,9 @@ describe('Tooltip', () => { fieldFormats={fieldFormats} titles={titles} formatFactory={formatFactory} - formattedDatatables={{ [layerId]: { table: data, formattedColumns: {} } }} + formattedDatatables={{ + [layerId]: { table: data, invertedRawValueMap, formattedColumns: {} }, + }} splitAccessors={{ splitRowAccessor }} layers={[sampleLayer]} /> diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color/color_mapping_accessor.ts b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color/color_mapping_accessor.ts index 93b498f5b57e1..c874b57bbd51c 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color/color_mapping_accessor.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color/color_mapping_accessor.ts @@ -9,43 +9,32 @@ import { SeriesColorAccessorFn } from '@elastic/charts'; import { getColorFactory, type ColorMapping, type ColorMappingInputData } from '@kbn/coloring'; -import { MULTI_FIELD_KEY_SEPARATOR } from '@kbn/data-plugin/common'; import { KbnPalettes } from '@kbn/palettes'; +import { InvertedRawValueMap } from '../data_layers'; /** * Return a color accessor function for XY charts depending on the split accessors received. */ export function getColorSeriesAccessorFn( config: ColorMapping.Config, + invertedRawValueMap: InvertedRawValueMap, palettes: KbnPalettes, isDarkMode: boolean, mappingData: ColorMappingInputData, - fieldId: string, - specialTokens: Map + fieldId: string ): SeriesColorAccessorFn { - // inverse map to handle the conversion between the formatted string and their original format - // for any specified special tokens - const specialHandlingInverseMap: Map = new Map( - [...specialTokens.entries()].map((d) => [d[1], d[0]]) - ); - const getColor = getColorFactory(config, palettes, isDarkMode, mappingData); + const rawValueMap = invertedRawValueMap.get(fieldId) ?? new Map(); return ({ splitAccessors }) => { const splitValue = splitAccessors.get(fieldId); - // if there isn't a category associated in the split accessor, let's use the default color - if (splitValue === undefined) { - return null; - } - // category can be also a number, range, ip, multi-field. We need to stringify it to be sure - // we can correctly match it a with user string - // if the separator exist, we de-construct it into a multifieldkey into values. - const categories = `${splitValue}`.split(MULTI_FIELD_KEY_SEPARATOR).map((category) => { - return specialHandlingInverseMap.get(category) ?? category; - }); - // we must keep the array nature of a multi-field key or just use a single string - // This is required because the rule stored are checked differently for single values or multi-values - return getColor(categories.length > 1 ? categories : categories[0]); + // No category associated in the split accessor, use the default color + if (splitValue === undefined) return null; + + const rawValue = + typeof splitValue === 'string' ? rawValueMap.get(splitValue) ?? splitValue : splitValue; + + return getColor(rawValue); }; } diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts index 95a8cb7e2cafb..6e1dc6435e854 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts @@ -13,6 +13,7 @@ import { LayerTypes } from '../../common/constants'; import { Datatable } from '@kbn/expressions-plugin/common'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; import { LayersFieldFormats } from './layers'; +import { DatatablesWithFormatInfo } from './data_layers'; describe('color_assignment', () => { const tables: Record = { @@ -108,14 +109,16 @@ describe('color_assignment', () => { }, } as unknown as LayersFieldFormats; - const formattedDatatables = { + const formattedDatatables: DatatablesWithFormatInfo = { first: { table: tables['1'], formattedColumns: {}, + invertedRawValueMap: new Map(tables['1'].columns.map((c) => [c.id, new Map()])), }, second: { table: tables['2'], formattedColumns: {}, + invertedRawValueMap: new Map(tables['2'].columns.map((c) => [c.id, new Map()])), }, }; @@ -184,6 +187,9 @@ describe('color_assignment', () => { const newFormattedDatatables = { first: { formattedColumns: formattedDatatables.first.formattedColumns, + invertedRawValueMap: new Map( + formattedDatatables.first.table.columns.map((c) => [c.id, new Map()]) + ), table: { ...formattedDatatables.first.table, rows: [{ split1: complexObject }, { split1: 'abc' }], @@ -212,6 +218,7 @@ describe('color_assignment', () => { first: { formattedColumns: formattedDatatables.first.formattedColumns, table: { ...formattedDatatables.first.table, columns: [] }, + invertedRawValueMap: new Map(), }, second: formattedDatatables.second, }; @@ -274,6 +281,9 @@ describe('color_assignment', () => { ...formattedDatatables.first.table, rows: [{ split1: 'abc' }, { split1: { aProp: 123 } }], }, + invertedRawValueMap: new Map( + formattedDatatables.first.table.columns.map((c) => [c.id, new Map()]) + ), }, second: formattedDatatables.second, }; @@ -292,10 +302,11 @@ describe('color_assignment', () => { it('should handle missing columns', () => { const newLayers = [{ ...layers[0], table: { ...tables['1'], columns: [] } }, layers[1]]; - const newFormattedDatatables = { + const newFormattedDatatables: DatatablesWithFormatInfo = { first: { formattedColumns: formattedDatatables.first.formattedColumns, table: { ...formattedDatatables.first.table, columns: [] }, + invertedRawValueMap: new Map(), }, second: formattedDatatables.second, }; diff --git a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/data_layers.tsx index 11cd6b05fc16b..44270be22938d 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -25,9 +25,9 @@ import { Datatable } from '@kbn/expressions-plugin/common'; import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils'; import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; import { PaletteRegistry, SeriesLayer } from '@kbn/coloring'; -import { SPECIAL_TOKENS_STRING_CONVERSION } from '@kbn/coloring'; import { getColorCategories } from '@kbn/chart-expressions-common'; import { KbnPalettes } from '@kbn/palettes'; +import { RawValue } from '@kbn/data-plugin/common'; import { isDataLayer } from '../../common/utils/layer_types_guards'; import { CommonXYDataLayerConfig, CommonXYLayerConfig, XScaleType } from '../../common'; import { AxisModes, SeriesTypes } from '../../common/constants'; @@ -40,6 +40,7 @@ import { getFormat } from './format'; import { getColorSeriesAccessorFn } from './color/color_mapping_accessor'; type SeriesSpec = LineSeriesProps & BarSeriesProps & AreaSeriesProps; +export type InvertedRawValueMap = Map>; type GetSeriesPropsFn = (config: { layer: CommonXYDataLayerConfig; @@ -109,6 +110,10 @@ type GetLineConfigFn = (config: { export interface DatatableWithFormatInfo { table: Datatable; formattedColumns: Record; + /** + * Inverse map per column to link formatted string to complex values (i.e. `RangeKey`). + */ + invertedRawValueMap: InvertedRawValueMap; } export type DatatablesWithFormatInfo = Record; @@ -124,7 +129,8 @@ export const getFormattedRow = ( xAccessor: string | undefined, splitColumnAccessor: string | undefined, splitRowAccessor: string | undefined, - xScaleType: XScaleType + xScaleType: XScaleType, + invertedRawValueMap: InvertedRawValueMap ): { row: Datatable['rows'][number]; formattedColumns: Record } => columns.reduce( (formattedInfo, { id }) => { @@ -137,8 +143,10 @@ export const getFormattedRow = ( id === splitColumnAccessor || id === splitRowAccessor) ) { + const formattedValue = columnsFormatters[id]?.convert(record) ?? ''; + invertedRawValueMap.get(id)?.set(formattedValue, record); return { - row: { ...formattedInfo.row, [id]: columnsFormatters[id]!.convert(record) }, + row: { ...formattedInfo.row, [id]: formattedValue }, formattedColumns: { ...formattedInfo.formattedColumns, [id]: true }, }; } @@ -155,7 +163,7 @@ export const getFormattedTable = ( splitRowAccessor: string | ExpressionValueVisDimension | undefined, accessors: Array, xScaleType: XScaleType -): { table: Datatable; formattedColumns: Record } => { +): DatatableWithFormatInfo => { const columnsFormatters = table.columns.reduce>( (formatters, { id, meta }) => { const accessor: string | ExpressionValueVisDimension | undefined = accessors.find( @@ -170,6 +178,9 @@ export const getFormattedTable = ( {} ); + const invertedRawValueMap: InvertedRawValueMap = new Map( + table.columns.map((c) => [c.id, new Map()]) + ); const formattedTableInfo: { rows: Datatable['rows']; formattedColumns: Record; @@ -185,7 +196,8 @@ export const getFormattedTable = ( xAccessor ? getAccessorByDimension(xAccessor, table.columns) : undefined, splitColumnAccessor ? getAccessorByDimension(splitColumnAccessor, table.columns) : undefined, splitRowAccessor ? getAccessorByDimension(splitRowAccessor, table.columns) : undefined, - xScaleType + xScaleType, + invertedRawValueMap ); formattedTableInfo.rows.push(formattedRowInfo.row); formattedTableInfo.formattedColumns = { @@ -195,6 +207,7 @@ export const getFormattedTable = ( } return { + invertedRawValueMap, table: { ...table, rows: formattedTableInfo.rows }, formattedColumns: formattedTableInfo.formattedColumns, }; @@ -438,10 +451,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ markSizeAccessor ? getFormat(table.columns, markSizeAccessor) : undefined ); - // what if row values are not primitive? That is the case of, for instance, Ranges - // remaps them to their serialized version with the formatHint metadata - // In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on - const { table: formattedTable, formattedColumns } = formattedDatatableInfo; + const { table: formattedTable, formattedColumns, invertedRawValueMap } = formattedDatatableInfo; // For date histogram chart type, we're getting the rows that represent intervals without data. // To not display them in the legend, they need to be filtered out. @@ -488,14 +498,14 @@ export const getSeriesProps: GetSeriesPropsFn = ({ layer.colorMapping && splitColumnIds.length > 0 ? getColorSeriesAccessorFn( JSON.parse(layer.colorMapping), // the color mapping is at this point just a stringified JSON + invertedRawValueMap, palettes, isDarkMode, { type: 'categories', categories: getColorCategories(table.rows, splitColumnIds[0]), }, - splitColumnIds[0], - SPECIAL_TOKENS_STRING_CONVERSION + splitColumnIds[0] ) : (series) => getColor( diff --git a/src/platform/plugins/shared/data/common/index.ts b/src/platform/plugins/shared/data/common/index.ts index 816196191dec8..118a67922efe8 100644 --- a/src/platform/plugins/shared/data/common/index.ts +++ b/src/platform/plugins/shared/data/common/index.ts @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -// TODO: https://github.com/elastic/kibana/issues/109904 - export { DEFAULT_QUERY_LANGUAGE, KIBANA_USER_QUERY_LANGUAGE_KEY, @@ -17,6 +15,10 @@ export { SCRIPT_LANGUAGES_ROUTE_LATEST_VERSION, UI_SETTINGS, } from './constants'; +export type { RawValue } from './serializable_field'; +export { SerializableField } from './serializable_field'; +export type { SerializedField, SerializedValue } from './serialize_utils'; +export { SerializableType, deserializeField, serializeField } from './serialize_utils'; export type { ValueSuggestionsMethod } from './constants'; export { DatatableUtilitiesService } from './datatable_utilities'; export { getEsQueryConfig } from './es_query'; @@ -309,7 +311,7 @@ export { termsAggFilter, getTermsBucketAgg, MultiFieldKey, - isMultiFieldKey, + RangeKey, MULTI_FIELD_KEY_SEPARATOR, aggMultiTermsFnName, aggMultiTerms, diff --git a/src/platform/plugins/shared/data/common/search/aggs/buckets/__snapshots__/range.test.ts.snap b/src/platform/plugins/shared/data/common/search/aggs/buckets/__snapshots__/range.test.ts.snap new file mode 100644 index 0000000000000..a6d57602e1e04 --- /dev/null +++ b/src/platform/plugins/shared/data/common/search/aggs/buckets/__snapshots__/range.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Range Agg RangeKey fully closed range should correctly stringify field 1`] = `"from:0,to:100"`; + +exports[`Range Agg RangeKey fully closed range w/ label should correctly stringify field 1`] = `"from:0,to:100"`; + +exports[`Range Agg RangeKey open lower range should correctly stringify field 1`] = `"from:-Infinity,to:100"`; + +exports[`Range Agg RangeKey open lower range w/ label should correctly stringify field 1`] = `"from:-Infinity,to:100"`; + +exports[`Range Agg RangeKey open range should correctly stringify field 1`] = `"from:-Infinity,to:Infinity"`; + +exports[`Range Agg RangeKey open range should correctly stringify field 2`] = `"from:-Infinity,to:Infinity"`; + +exports[`Range Agg RangeKey open upper range should correctly stringify field 1`] = `"from:0,to:Infinity"`; + +exports[`Range Agg RangeKey open upper range w/ label should correctly stringify field 1`] = `"from:0,to:Infinity"`; diff --git a/src/platform/plugins/shared/data/common/search/aggs/buckets/index.ts b/src/platform/plugins/shared/data/common/search/aggs/buckets/index.ts index 242645729c310..a99e356fb3ff6 100644 --- a/src/platform/plugins/shared/data/common/search/aggs/buckets/index.ts +++ b/src/platform/plugins/shared/data/common/search/aggs/buckets/index.ts @@ -34,13 +34,14 @@ export { TimeBuckets, convertDurationToNormalizedEsInterval } from './lib/time_b export * from './migrate_include_exclude_format'; export * from './range_fn'; export * from './range'; +export * from './range_key'; export * from './significant_terms_fn'; export * from './significant_terms'; export * from './significant_text_fn'; export * from './significant_text'; export * from './terms_fn'; export * from './terms'; -export { MultiFieldKey, isMultiFieldKey, MULTI_FIELD_KEY_SEPARATOR } from './multi_field_key'; +export { MultiFieldKey, MULTI_FIELD_KEY_SEPARATOR } from './multi_field_key'; export * from './multi_terms_fn'; export * from './multi_terms'; export * from './rare_terms_fn'; diff --git a/src/platform/plugins/shared/data/common/search/aggs/buckets/multi_field_key.ts b/src/platform/plugins/shared/data/common/search/aggs/buckets/multi_field_key.ts index 63154eb449b2e..f8e8ba3488679 100644 --- a/src/platform/plugins/shared/data/common/search/aggs/buckets/multi_field_key.ts +++ b/src/platform/plugins/shared/data/common/search/aggs/buckets/multi_field_key.ts @@ -7,7 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -const id = Symbol('id'); +import { SerializableField } from '../../../serializable_field'; +import { SerializableType } from '../../../serialize_utils'; + +/** + * Serialized form of {@link @kbn/data-plugin/common.MultiFieldKey} + */ +export interface SerializedMultiFieldKey { + type: typeof SerializableType.MultiFieldKey; + keys: string[]; +} const isBucketLike = (bucket: unknown): bucket is { key: unknown } => { return Boolean(bucket && typeof bucket === 'object' && 'key' in bucket); @@ -17,31 +26,41 @@ function getKeysFromBucket(bucket: unknown) { if (!isBucketLike(bucket)) { throw new Error('bucket malformed - no key found'); } - return Array.isArray(bucket.key) - ? bucket.key.map((keyPart) => String(keyPart)) - : [String(bucket.key)]; + return Array.isArray(bucket.key) ? bucket.key.map(String) : [String(bucket.key)]; } -export class MultiFieldKey { - [id]: string; +export class MultiFieldKey extends SerializableField { + static isInstance(field: unknown): field is MultiFieldKey { + return field instanceof MultiFieldKey; + } + + static deserialize(value: SerializedMultiFieldKey): MultiFieldKey { + return new MultiFieldKey({ + key: value.keys, // key here is to keep bwc with constructor params + }); + } + + static idBucket(bucket: unknown): string { + return getKeysFromBucket(bucket).join(','); + } + keys: string[]; constructor(bucket: unknown) { + super(); this.keys = getKeysFromBucket(bucket); - - this[id] = MultiFieldKey.idBucket(bucket); - } - static idBucket(bucket: unknown) { - return getKeysFromBucket(bucket).join(','); } - toString() { - return this[id]; + toString(): string { + return this.keys.join(','); } -} -export function isMultiFieldKey(field: unknown): field is MultiFieldKey { - return field instanceof MultiFieldKey; + serialize(): SerializedMultiFieldKey { + return { + type: SerializableType.MultiFieldKey, + keys: this.keys, + }; + } } /** diff --git a/src/platform/plugins/shared/data/common/search/aggs/buckets/range.test.ts b/src/platform/plugins/shared/data/common/search/aggs/buckets/range.test.ts index bdb0341168635..3e76246638820 100644 --- a/src/platform/plugins/shared/data/common/search/aggs/buckets/range.test.ts +++ b/src/platform/plugins/shared/data/common/search/aggs/buckets/range.test.ts @@ -11,55 +11,108 @@ import { AggConfigs } from '../agg_configs'; import { mockAggTypesRegistry } from '../test_helpers'; import { BUCKET_TYPES } from './bucket_agg_types'; import { FieldFormatsGetConfigFn, NumberFormat } from '@kbn/field-formats-plugin/common'; +import { RangeKey } from './range_key'; describe('Range Agg', () => { - const getConfig = (() => {}) as FieldFormatsGetConfigFn; - const getAggConfigs = () => { - const field = { - name: 'bytes', - }; + describe('RangeKey', () => { + const label = 'some label'; + describe.each([ + ['open range', {}, undefined], + ['open upper range', { from: 0 }, undefined], + ['open lower range', { to: 100 }, undefined], + ['fully closed range', { from: 0, to: 100 }, undefined], + ['open range', {}, [{ label }]], + ['open upper range w/ label', { from: 0 }, [{ from: 0, label }]], + ['open lower range w/ label', { to: 100 }, [{ to: 100, label }]], + ['fully closed range w/ label', { from: 0, to: 100 }, [{ from: 0, to: 100, label }]], + ])('%s', (_, bucket: any, ranges) => { + const initial = new RangeKey(bucket, ranges); + + test('should correctly set gte', () => { + expect(initial.gte).toBe(bucket?.from == null ? -Infinity : bucket.from); + }); + + test('should correctly set lt', () => { + expect(initial.lt).toBe(bucket?.to == null ? Infinity : bucket.to); + }); + + test('should correctly set label', () => { + expect(initial.label).toBe(ranges?.[0]?.label); + }); + + test('should correctly stringify field', () => { + expect(initial.toString()).toMatchSnapshot(); + }); + }); + }); - const indexPattern = { - id: '1234', - title: 'logstash-*', - fields: { - getByName: () => field, - filter: () => [field], - }, - getFormatterForField: () => - new NumberFormat( + describe('#fromString', () => { + test.each([ + ['empty range', '', {}], + ['bad buckets', 'from:baddd,to:baddd', {}], + ['open range', 'from:undefined,to:undefined', {}], + ['open upper range', 'from:0,to:undefined', { from: 0 }], + ['open lower range', 'from:undefined,to:100', { to: 100 }], + ['fully closed range', 'from:0,to:100', { from: 0, to: 100 }], + ['mixed closed range', 'from:-100,to:100', { from: -100, to: 100 }], + ['mixed open range', 'from:-100,to:undefined', { from: -100 }], + ['negative closed range', 'from:-100,to:-50', { from: -100, to: -50 }], + ['negative open range', 'from:undefined,to:-50', { to: -50 }], + ])('should correctly build RangeKey from string for %s', (_, rangeString, bucket) => { + const expected = new RangeKey(bucket); + + expect(RangeKey.fromString(rangeString).toString()).toBe(expected.toString()); + }); + }); + + describe('RangeKey with getAggConfigs', () => { + const getConfig = (() => {}) as FieldFormatsGetConfigFn; + const getAggConfigs = () => { + const field = { + name: 'bytes', + }; + + const indexPattern = { + id: '1234', + title: 'logstash-*', + fields: { + getByName: () => field, + filter: () => [field], + }, + getFormatterForField: () => + new NumberFormat( + { + pattern: '0,0.[000] b', + }, + getConfig + ), + } as any; + + return new AggConfigs( + indexPattern, + [ { - pattern: '0,0.[000] b', + type: BUCKET_TYPES.RANGE, + schema: 'segment', + params: { + field: 'bytes', + ranges: [ + { from: 0, to: 1000 }, + { from: 1000, to: 2000 }, + ], + }, }, - getConfig - ), - } as any; - - return new AggConfigs( - indexPattern, - [ + ], { - type: BUCKET_TYPES.RANGE, - schema: 'segment', - params: { - field: 'bytes', - ranges: [ - { from: 0, to: 1000 }, - { from: 1000, to: 2000 }, - ], - }, + typesRegistry: mockAggTypesRegistry(), }, - ], - { - typesRegistry: mockAggTypesRegistry(), - }, - jest.fn() - ); - }; + jest.fn() + ); + }; - test('produces the expected expression ast', () => { - const aggConfigs = getAggConfigs(); - expect(aggConfigs.aggs[0].toExpressionAst()).toMatchInlineSnapshot(` + test('produces the expected expression ast', () => { + const aggConfigs = getAggConfigs(); + expect(aggConfigs.aggs[0].toExpressionAst()).toMatchInlineSnapshot(` Object { "chain": Array [ Object { @@ -120,23 +173,24 @@ describe('Range Agg', () => { "type": "expression", } `); - }); + }); - describe('getSerializedFormat', () => { - test('generates a serialized field format in the expected shape', () => { - const aggConfigs = getAggConfigs(); - const agg = aggConfigs.aggs[0]; - expect(agg.type.getSerializedFormat(agg)).toMatchInlineSnapshot(` - Object { - "id": "range", - "params": Object { - "id": "number", + describe('getSerializedFormat', () => { + test('generates a serialized field format in the expected shape', () => { + const aggConfigs = getAggConfigs(); + const agg = aggConfigs.aggs[0]; + expect(agg.type.getSerializedFormat(agg)).toMatchInlineSnapshot(` + Object { + "id": "range", "params": Object { - "pattern": "0,0.[000] b", + "id": "number", + "params": Object { + "pattern": "0,0.[000] b", + }, }, - }, - } - `); + } + `); + }); }); }); }); diff --git a/src/platform/plugins/shared/data/common/search/aggs/buckets/range_key.ts b/src/platform/plugins/shared/data/common/search/aggs/buckets/range_key.ts index cded5825285c9..683f0daf46ee8 100644 --- a/src/platform/plugins/shared/data/common/search/aggs/buckets/range_key.ts +++ b/src/platform/plugins/shared/data/common/search/aggs/buckets/range_key.ts @@ -7,47 +7,121 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -const id = Symbol('id'); +import { SerializableField } from '../../../serializable_field'; +import { SerializableType } from '../../../serialize_utils'; type Ranges = Array< Partial<{ - from: string | number; - to: string | number; + from: string | number | null; + to: string | number | null; label: string; }> >; -export class RangeKey { - [id]: string; - gte: string | number; - lt: string | number; - label?: string; +type RangeValue = string | number | undefined | null; +interface BucketLike { + from?: RangeValue; + to?: RangeValue; +} + +/** + * Serialized form of {@link @kbn/data-plugin/common.RangeKey} + */ +export interface SerializedRangeKey { + type: typeof SerializableType.RangeKey; + from: string | number | null; + to: string | number | null; + ranges: Ranges; +} + +function findCustomLabel(from: RangeValue, to: RangeValue, ranges?: Ranges) { + return (ranges || []).find( + (range) => + ((from == null && range.from == null) || range.from === from) && + ((to == null && range.to == null) || range.to === to) + )?.label; +} + +const getRangeValue = (bucket: unknown, key: string): RangeValue => { + const value = bucket && typeof bucket === 'object' && key in bucket && (bucket as any)[key]; + return value == null || ['string', 'number'].includes(typeof value) ? value : null; +}; + +const getRangeFromBucket = (bucket: unknown): BucketLike => { + return { + from: getRangeValue(bucket, 'from'), + to: getRangeValue(bucket, 'to'), + }; +}; + +const regex = /^from:(-?\d+?|undefined),to:(-?\d+?|undefined)$/; - private findCustomLabel( - from: string | number | undefined | null, - to: string | number | undefined | null, - ranges?: Ranges - ) { - return (ranges || []).find( - (range) => - ((from == null && range.from == null) || range.from === from) && - ((to == null && range.to == null) || range.to === to) - )?.label; +export class RangeKey extends SerializableField { + static isInstance(field: unknown): field is RangeKey { + return field instanceof RangeKey; } - constructor(bucket: any, allRanges?: Ranges) { - this.gte = bucket.from == null ? -Infinity : bucket.from; - this.lt = bucket.to == null ? +Infinity : bucket.to; - this.label = this.findCustomLabel(bucket.from, bucket.to, allRanges); + static deserialize(value: SerializedRangeKey): RangeKey { + const { to, from, ranges } = value; + return new RangeKey({ to, from }, ranges); + } + + static idBucket(bucket: unknown): string { + const { from, to } = getRangeFromBucket(bucket); + return `from:${from},to:${to}`; + } + + static isRangeKeyString(rangeKey: string): boolean { + return regex.test(rangeKey); + } + + /** + * Returns `RangeKey` from stringified form. Cannot extract labels from stringified form. + * + * Only supports numerical (non-string) values. + */ + static fromString(rangeKey: string): RangeKey { + const [from, to] = (regex.exec(rangeKey) ?? []) + .slice(1) + .map(Number) + .map((n) => (isNaN(n) ? undefined : n)); + + return new RangeKey({ from, to }); + } + + gte: string | number; + lt: string | number; + label?: string; - this[id] = RangeKey.idBucket(bucket); + constructor(bucket: unknown, allRanges?: Ranges) { + super(); + const { from, to } = getRangeFromBucket(bucket); + this.gte = from == null ? -Infinity : from; + this.lt = to == null ? +Infinity : to; + this.label = findCustomLabel(from, to, allRanges); } - static idBucket(bucket: any) { - return `from:${bucket.from},to:${bucket.to}`; + toString(): string { + return `from:${this.gte},to:${this.lt}`; } - toString() { - return this[id]; + serialize(): SerializedRangeKey { + const from = typeof this.gte === 'string' || isFinite(this.gte) ? this.gte : null; + const to = typeof this.lt === 'string' || isFinite(this.lt) ? this.lt : null; + return { + type: SerializableType.RangeKey, + from, + to, + ranges: + this.label === undefined + ? [] + : [ + { + from, + to, + label: this.label, + }, + ], + }; } } diff --git a/src/platform/plugins/shared/data/common/serializable_field.ts b/src/platform/plugins/shared/data/common/serializable_field.ts new file mode 100644 index 0000000000000..5df5a47032d4c --- /dev/null +++ b/src/platform/plugins/shared/data/common/serializable_field.ts @@ -0,0 +1,41 @@ +/* + * 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". + */ + +/** + * Alias for unknown raw field value, could be instance of a field Class + */ +export type RawValue = number | string | unknown; + +/** + * Class to extends that enabled serializing and deserializing instance values + */ +export abstract class SerializableField { + static isSerializable(field: RawValue): field is SerializableField { + return Boolean((field as SerializableField).serialize); + } + + /** + * Serializes the class instance to a known `SerializedValue` that can be used to instantiate a new instance + * + * Ideally this returns the same params as found in the constructor. + */ + abstract serialize(): S; + + /** + * typescript forbids abstract static methods but this is a workaround to require it + * + * @param serializedValue type of `SerializedValue` + * @returns `instanceValue` should same type as instantiating class + */ + static deserialize(serializedValue: unknown): unknown { + throw new Error( + 'Must implement a static `deserialize` method to conform to /`SerializableField/`' + ); + } +} diff --git a/src/platform/plugins/shared/data/common/serialize_utils.test.ts b/src/platform/plugins/shared/data/common/serialize_utils.test.ts new file mode 100644 index 0000000000000..e93577bf3e922 --- /dev/null +++ b/src/platform/plugins/shared/data/common/serialize_utils.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { MultiFieldKey, RangeKey } from '.'; +import { SerializedRangeKey } from './search'; +import { SerializedMultiFieldKey } from './search/aggs/buckets/multi_field_key'; +import { SerializableType, deserializeField, serializeField } from './serialize_utils'; + +describe('serializeField/deserializeField', () => { + describe('MultiFieldKey', () => { + it.each([ + ['single value', { key: 'one' }], + ['multiple values', { key: ['one', 'two', 'three'] }], + ])('should serialize and deserialize %s', (_, bucket) => { + const initial = new MultiFieldKey(bucket); + const serialized = serializeField(initial) as SerializedMultiFieldKey; + expect(serialized.type).toBe(SerializableType.MultiFieldKey); + const deserialized = deserializeField(serialized) as MultiFieldKey; + expect(deserialized).toMatchObject(initial); + expect(deserialized.toString()).toBe(initial.toString()); + expect(deserialized).toBeInstanceOf(MultiFieldKey); + }); + }); + + describe('RangeKey', () => { + const label = 'some label'; + it.each([ + ['open range', {}, undefined], + ['open upper range', { from: 0 }, undefined], + ['open lower range', { to: 100 }, undefined], + ['fully closed range', { from: 0, to: 100 }, undefined], + ['open range', {}, [{ label }]], + ['open upper range w/ label', { from: 0 }, [{ from: 0, label }]], + ['open lower range w/ label', { to: 100 }, [{ to: 100, label }]], + ['fully closed range w/ label', { from: 0, to: 100 }, [{ from: 0, to: 100, label }]], + ])('should serialize and deserialize %s', (_, bucket, ranges) => { + const initial = new RangeKey(bucket, ranges); + const serialized = serializeField(initial) as SerializedRangeKey; + expect(serialized.type).toBe(SerializableType.RangeKey); + expect(serialized.ranges).toHaveLength(initial.label ? 1 : 0); + const deserialized = deserializeField(serialized) as RangeKey; + expect(RangeKey.idBucket(deserialized)).toBe(RangeKey.idBucket(initial)); + expect(deserialized.gte).toBe(initial.gte); + expect(deserialized.lt).toBe(initial.lt); + expect(deserialized.label).toBe(initial.label); + expect(deserialized).toBeInstanceOf(RangeKey); + }); + }); + + describe('Primitive values', () => { + it.each([ + ['strings', 'some string'], + ['strings (empty)', ''], + ['numbers', 123], + ['numbers (0)', 0], + ['boolean (true)', true], + ['boolean (false)', false], + ['object', { test: 1 }], + ['array', ['test', 1]], + ['undefined', undefined], + ['null', null], + ])('should deserialize %s', (_, initial) => { + const serialized = serializeField(initial); + const deserialized = deserializeField(serialized); + expect(deserialized).toEqual(initial); + }); + }); +}); diff --git a/src/platform/plugins/shared/data/common/serialize_utils.ts b/src/platform/plugins/shared/data/common/serialize_utils.ts new file mode 100644 index 0000000000000..b515a4312017b --- /dev/null +++ b/src/platform/plugins/shared/data/common/serialize_utils.ts @@ -0,0 +1,55 @@ +/* + * 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 { MultiFieldKey, RangeKey, SerializedRangeKey } from './search'; +import { SerializedMultiFieldKey } from './search/aggs/buckets/multi_field_key'; +import { RawValue, SerializableField } from './serializable_field'; + +/** + * All available serialized forms of complex/instance fields. Excludes non-complex/primitive fields. + * + * Use `SerializedValue` for all generalize serial values which includes non-complex/primitive fields. + * + * Currently includes: + * - `RangeKey` + * - `MultiFieldKey` + */ +export type SerializedField = SerializedMultiFieldKey | SerializedRangeKey; + +/** + * Alias for unknown serialized value. This value is what we store in the SO and app state + * to persist the color assignment based on the raw row value. + * + * In most cases this is a `string` or `number` or plain `object`, in other cases this is an + * object serialized from an instance of a given field (i.e. `RangeKey` or `MultiFieldKey`). + */ +export type SerializedValue = number | string | SerializedField | unknown; + +export const SerializableType = { + MultiFieldKey: 'multiFieldKey' as const, + RangeKey: 'rangeKey' as const, +}; + +export function deserializeField(field: SerializedValue) { + const type = field != null && (field as any)?.type; + + switch (type) { + case SerializableType.MultiFieldKey: + return MultiFieldKey.deserialize(field as SerializedMultiFieldKey); + case SerializableType.RangeKey: + return RangeKey.deserialize(field as SerializedRangeKey); + default: + return field; + } +} + +export function serializeField(field: RawValue): SerializedValue { + if (field == null || !SerializableField.isSerializable(field)) return field; + return field.serialize(); +} diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 2d98e37d5ab37..48eb31cdebd03 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -346,7 +346,6 @@ "coloring.colorMapping.colorPicker.hexColorinputAriaLabel": "Entrée de couleur hex", "coloring.colorMapping.colorPicker.invalidColorHex": "Veuillez utiliser un code de couleur hex valide", "coloring.colorMapping.colorPicker.lowContrastColor": "La couleur est peu contrastée dans {themes} {errorModes, plural, one {mode} other {# modes}}", - "coloring.colorMapping.colorPicker.newColorAriaLabel": "Sélectionner une nouvelle couleur", "coloring.colorMapping.colorPicker.paletteColorsLabel": "Couleurs de la palette", "coloring.colorMapping.colorPicker.paletteTabLabel": "Couleurs", "coloring.colorMapping.colorPicker.pickAColorAriaLabel": "Sélectionner une couleur", @@ -27136,8 +27135,6 @@ "xpack.lens.colorMapping.editColorMappingTitle": "Attribuer des couleurs aux termes", "xpack.lens.colorMapping.editColors": "Modifier les couleurs", "xpack.lens.colorMapping.editColorsTitle": "Modifier les couleurs", - "xpack.lens.colorMapping.techPreviewLabel": "Préversion technique", - "xpack.lens.colorMapping.tryLabel": "Utiliser la nouvelle fonctionnalité de mapping des couleurs", "xpack.lens.colorSiblingFlyoutTitle": "Couleur", "xpack.lens.config.applyFlyoutAriaLabel": "Appliquer les modifications", "xpack.lens.config.applyFlyoutLabel": "Appliquer et fermer", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 8958b4bbd8c23..d00b28ae07a3d 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -346,7 +346,6 @@ "coloring.colorMapping.colorPicker.hexColorinputAriaLabel": "16進数の色入力", "coloring.colorMapping.colorPicker.invalidColorHex": "有効な16進数の色コードを使用してください", "coloring.colorMapping.colorPicker.lowContrastColor": "この色は{themes} {errorModes, plural, other {# モード}}ではコントラストが低くなります。", - "coloring.colorMapping.colorPicker.newColorAriaLabel": "新しい色を選択", "coloring.colorMapping.colorPicker.paletteColorsLabel": "パレットの色", "coloring.colorMapping.colorPicker.paletteTabLabel": "色", "coloring.colorMapping.colorPicker.pickAColorAriaLabel": "色を選択", @@ -27110,8 +27109,6 @@ "xpack.lens.colorMapping.editColorMappingTitle": "色を用語に割り当て", "xpack.lens.colorMapping.editColors": "色を編集", "xpack.lens.colorMapping.editColorsTitle": "色を編集", - "xpack.lens.colorMapping.techPreviewLabel": "テクニカルプレビュー", - "xpack.lens.colorMapping.tryLabel": "新しい色マッピング機能を使用", "xpack.lens.colorSiblingFlyoutTitle": "色", "xpack.lens.config.applyFlyoutAriaLabel": "変更を適用", "xpack.lens.config.applyFlyoutLabel": "適用して閉じる", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 9c9edab9d5cea..41dcaf1f9a7b2 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -345,7 +345,6 @@ "coloring.colorMapping.colorPicker.hexColorinputAriaLabel": "HEX 颜色输入", "coloring.colorMapping.colorPicker.invalidColorHex": "请使用有效的颜色 HEX 代码", "coloring.colorMapping.colorPicker.lowContrastColor": "此颜色在 {themes} {errorModes, plural, other {# 个模式}}下的对比度较低", - "coloring.colorMapping.colorPicker.newColorAriaLabel": "选择新颜色", "coloring.colorMapping.colorPicker.paletteColorsLabel": "调色板颜色", "coloring.colorMapping.colorPicker.paletteTabLabel": "颜色", "coloring.colorMapping.colorPicker.pickAColorAriaLabel": "选取颜色", @@ -27167,8 +27166,6 @@ "xpack.lens.colorMapping.editColorMappingTitle": "为词分配颜色", "xpack.lens.colorMapping.editColors": "编辑颜色", "xpack.lens.colorMapping.editColorsTitle": "编辑颜色", - "xpack.lens.colorMapping.techPreviewLabel": "技术预览", - "xpack.lens.colorMapping.tryLabel": "使用新的颜色映射功能", "xpack.lens.colorSiblingFlyoutTitle": "颜色", "xpack.lens.config.applyFlyoutAriaLabel": "应用更改", "xpack.lens.config.applyFlyoutLabel": "应用并关闭", diff --git a/x-pack/platform/plugins/shared/lens/common/expressions/datatable/utils.ts b/x-pack/platform/plugins/shared/lens/common/expressions/datatable/utils.ts index 811ecefb299a2..d9efcb7d99737 100644 --- a/x-pack/platform/plugins/shared/lens/common/expressions/datatable/utils.ts +++ b/x-pack/platform/plugins/shared/lens/common/expressions/datatable/utils.ts @@ -44,6 +44,18 @@ export function isNumericField(meta?: DatatableColumnMeta): boolean { ); } +export function getDatatableColumn(table: Datatable | undefined, accessor: string) { + return table?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor); +} + +export function getFieldMetaFromDatatable(table: Datatable | undefined, accessor: string) { + return getDatatableColumn(table, accessor)?.meta; +} + +export function getFieldTypeFromDatatable(table: Datatable | undefined, accessor: string) { + return getFieldMetaFromDatatable(table, accessor)?.type; +} + /** * Returns true for numerical fields, excluding ranges * @@ -54,8 +66,3 @@ export function isNumericFieldForDatatable(table: Datatable | undefined, accesso const meta = getFieldMetaFromDatatable(table, accessor); return isNumericField(meta); } - -export function getFieldMetaFromDatatable(table: Datatable | undefined, accessor: string) { - return table?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor) - ?.meta; -} diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/app.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/app.tsx index 084cd7a0636d7..3708a7b3a1c9a 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/app.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/app.tsx @@ -172,7 +172,6 @@ export function App({ setIndicateNoData(false); } }, [setIndicateNoData, indicateNoData, searchSessionId]); - const getIsByValueMode = useCallback( () => Boolean(isLinkedToOriginatingApp && !savedObjectId), [isLinkedToOriginatingApp, savedObjectId] diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.ts b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.ts index 4fc97882fd926..e3ead85661074 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.ts +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/lens_document_equality.ts @@ -63,12 +63,13 @@ export const isLensEqual = ( const availableDatasourceTypes1 = Object.keys(doc1.state.datasourceStates); const availableDatasourceTypes2 = Object.keys(doc2.state.datasourceStates); + // simple comparison let datasourcesEqual = intersection(availableDatasourceTypes1, availableDatasourceTypes2).length === union(availableDatasourceTypes1, availableDatasourceTypes2).length; if (datasourcesEqual) { - // equal so far, so actually check + // deep comparison datasourcesEqual = availableDatasourceTypes1.every((type) => datasourceMap[type].isEqual( doc1.state.datasourceStates[type], diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx index 57dbfaa4180b5..5c2ae5cfd3172 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx @@ -118,14 +118,14 @@ export function LensEditConfigurationFlyout({ return !visualizationStateIsEqual; }, [ - attributes.references, + datasourceStates, datasourceId, datasourceMap, + attributes.references, + visualization.state, isNewPanel, - datasourceStates, visualizationMap, annotationGroups, - visualization.state, ]); const onCancel = useCallback(() => { diff --git a/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts index c70d43d4d843b..9325941be7a37 100644 --- a/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/platform/plugins/shared/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -267,21 +267,24 @@ export async function initializeSources( options ); + const initializedDatasourceStates = initializeDatasources({ + datasourceMap, + datasourceStates, + initialContext, + indexPatternRefs, + indexPatterns, + references, + }); + return { indexPatterns, indexPatternRefs, annotationGroups, - datasourceStates: initializeDatasources({ - datasourceMap, - datasourceStates, - initialContext, - indexPatternRefs, - indexPatterns, - references, - }), + datasourceStates: initializedDatasourceStates, visualizationState: initializeVisualization({ visualizationMap, visualizationState, + datasourceStates, references, initialContext, annotationGroups, @@ -292,11 +295,13 @@ export async function initializeSources( export function initializeVisualization({ visualizationMap, visualizationState, + datasourceStates, references, annotationGroups, }: { visualizationState: VisualizationState; visualizationMap: VisualizationMap; + datasourceStates: DatasourceStates; references?: SavedObjectReference[]; initialContext?: VisualizeFieldContext | VisualizeEditorContext; annotationGroups: Record; @@ -306,8 +311,9 @@ export function initializeVisualization({ visualizationMap[visualizationState.activeId]?.initialize( () => '', visualizationState.state, - // initialize a new visualization with the color mapping off COLORING_METHOD, + datasourceStates, + // initialize a new visualization with the color mapping off annotationGroups, references ) ?? visualizationState.state @@ -398,15 +404,6 @@ export async function persistedStateToExpression( ); const visualization = visualizations[visualizationType!]; - const activeVisualizationState = initializeVisualization({ - visualizationMap: visualizations, - visualizationState: { - state: persistedVisualizationState, - activeId: visualizationType, - }, - annotationGroups, - references: [...references, ...(internalReferences || [])], - }); const datasourceStatesFromSO = Object.fromEntries( Object.entries(persistedDatasourceStates).map(([id, state]) => [ id, @@ -434,6 +431,17 @@ export async function persistedStateToExpression( indexPatternRefs, }); + const activeVisualizationState = initializeVisualization({ + visualizationMap: visualizations, + visualizationState: { + state: persistedVisualizationState, + activeId: visualizationType, + }, + datasourceStates, + annotationGroups, + references: [...references, ...(internalReferences || [])], + }); + const datasourceLayers = getDatasourceLayers(datasourceStates, datasourceMap, indexPatterns); const datasourceId = getActiveDatasourceIdFromDoc(doc); diff --git a/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.test.ts b/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.test.ts index 677df591b66bb..9a2a9c56d9a20 100644 --- a/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.test.ts @@ -20,8 +20,8 @@ const exampleAssignment = ( valuesCount = 1, type = 'categorical', overrides = {} -): ColorMapping.Config['assignments'][number] => { - const color: ColorMapping.Config['assignments'][number]['color'] = +): ColorMapping.Assignment => { + const color: ColorMapping.Assignment['color'] = type === 'categorical' ? { type: 'categorical', @@ -34,10 +34,10 @@ const exampleAssignment = ( }; return { - rule: { - type: 'matchExactly', - values: Array.from({ length: valuesCount }, () => faker.string.alpha()), - }, + rules: Array.from({ length: valuesCount }, () => faker.string.alpha()).map((value) => ({ + type: 'raw', + value, + })), color, touched: false, ...overrides, @@ -53,9 +53,11 @@ const MANUAL_COLOR_MAPPING_CONFIG: ColorMapping.Config = { ], specialAssignments: [ { - rule: { - type: 'other', - }, + rules: [ + { + type: 'other', + }, + ], color: { type: 'categorical', paletteId: KbnPalette.ElasticClassic, diff --git a/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.ts b/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.ts index 4f956e54c47f6..ae28e46e61733 100644 --- a/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.ts +++ b/x-pack/platform/plugins/shared/lens/public/lens_ui_telemetry/color_telemetry_helpers.ts @@ -92,10 +92,7 @@ const getUnassignedTermsType = ( }; const getTotalTermsCount = (assignments: ColorMapping.Config['assignments']) => - assignments.reduce( - (acc, cur) => ('values' in cur.rule ? acc + cur.rule.values.length : acc + 1), - 0 - ); + assignments.reduce((acc, { rules }) => acc + rules.length, 0); const getAvgCountTermsPerColor = ( assignments: ColorMapping.Config['assignments'], diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.test.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.test.ts index aa3e671972738..407c03d58807c 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.test.ts @@ -6,8 +6,13 @@ */ import { BehaviorSubject } from 'rxjs'; import { defaultDoc } from '../mocks/services_mock'; -import { deserializeState } from './helper'; +import { deserializeState, getStructuredDatasourceStates } from './helper'; import { makeEmbeddableServices } from './mocks'; +import { FormBasedPersistedState } from '../datasources/form_based/types'; +import { TextBasedPersistedState } from '../datasources/form_based/esql_layer/types'; +import expect from 'expect'; +import { DatasourceState } from '../state_management'; +import { StructuredDatasourceStates } from './types'; describe('Embeddable helpers', () => { describe('deserializeState', () => { @@ -114,4 +119,51 @@ describe('Embeddable helpers', () => { }); }); }); + + describe('getStructuredDatasourceStates', () => { + const formBasedDSStateMock: FormBasedPersistedState = { + layers: {}, + }; + const textBasedDSStateMock: TextBasedPersistedState = { + layers: {}, + }; + + it('should return structured datasourceStates from unknown datasourceStates', () => { + const mockDatasourceStates: Record = { + formBased: formBasedDSStateMock, + textBased: textBasedDSStateMock, + other: textBasedDSStateMock, + }; + const result = getStructuredDatasourceStates(mockDatasourceStates); + + expect(result.formBased).toEqual(formBasedDSStateMock); + expect(result.textBased).toEqual(textBasedDSStateMock); + expect('other' in result).toBe(false); + }); + + it('should return structured datasourceStates from nested unknown datasourceStates', () => { + const wrap = (ds: unknown) => ({ state: ds, isLoading: false } satisfies DatasourceState); + const mockDatasourceStates: Record = { + formBased: wrap(formBasedDSStateMock), + textBased: wrap(textBasedDSStateMock), + other: wrap(textBasedDSStateMock), + }; + const result = getStructuredDatasourceStates(mockDatasourceStates); + + expect(result.formBased).toEqual(formBasedDSStateMock); + expect(result.textBased).toEqual(textBasedDSStateMock); + expect('other' in result).toBe(false); + }); + + it('should return structured datasourceStates from structured datasourceStates', () => { + const mockDatasourceStates: StructuredDatasourceStates = { + formBased: formBasedDSStateMock, + textBased: textBasedDSStateMock, + }; + const result = getStructuredDatasourceStates(mockDatasourceStates); + + expect(result.formBased).toEqual(formBasedDSStateMock); + expect(result.textBased).toEqual(textBasedDSStateMock); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts index 65e6014b774b0..7fb5f0b4c4a30 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/helper.ts @@ -18,8 +18,16 @@ import { BehaviorSubject } from 'rxjs'; import { isOfAggregateQueryType } from '@kbn/es-query'; import { RenderMode } from '@kbn/expressions-plugin/common'; import { SavedObjectReference } from '@kbn/core/types'; -import type { LensEmbeddableStartServices, LensRuntimeState, LensSerializedState } from './types'; +import type { + LensEmbeddableStartServices, + LensRuntimeState, + LensSerializedState, + StructuredDatasourceStates, +} from './types'; import { loadESQLAttributes } from './esql'; +import { DatasourceStates, GeneralDatasourceStates } from '../state_management'; +import { FormBasedPersistedState } from '../datasources/form_based/types'; +import { TextBasedPersistedState } from '../datasources/form_based/esql_layer/types'; export function createEmptyLensState( visualizationType: null | string = null, @@ -138,3 +146,16 @@ export function extractInheritedViewModeObservable( } return new BehaviorSubject('view'); } + +export function getStructuredDatasourceStates( + datasourceStates?: Readonly +): StructuredDatasourceStates { + return { + formBased: ((datasourceStates as DatasourceStates)?.formBased?.state ?? + datasourceStates?.formBased ?? + undefined) as FormBasedPersistedState, + textBased: ((datasourceStates as DatasourceStates)?.textBased?.state ?? + datasourceStates?.textBased ?? + undefined) as TextBasedPersistedState, + }; +} diff --git a/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts b/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts index d28946d87137f..a7700d2dcf8da 100644 --- a/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts +++ b/x-pack/platform/plugins/shared/lens/public/react_embeddable/types.ts @@ -486,18 +486,20 @@ export interface ExpressionWrapperProps { export type GetStateType = () => LensRuntimeState; -/** - * Custom Lens component exported by the plugin - * For better DX of Lens component consumers, expose a typed version of the serialized state - */ +export interface StructuredDatasourceStates { + formBased?: FormBasedPersistedState; + textBased?: TextBasedPersistedState; +} -/** Utility function to build typed version for each chart */ +/** Utility type to build typed version for each chart */ type TypedLensAttributes = Simplify< Omit & { visualizationType: TVisType; state: Simplify< Omit & { datasourceStates: { + // This is of type StructuredDatasourceStates but does not conform to Record + // so I am leaving this alone until we improve this datasource typing structure. formBased?: FormBasedPersistedState; textBased?: TextBasedPersistedState; }; diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/converter.test.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/converter.test.ts new file mode 100644 index 0000000000000..783a74b8e4309 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/converter.test.ts @@ -0,0 +1,427 @@ +/* + * 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 { convertToRawColorMappings, isDeprecatedColorMapping } from './converter'; +import { DeprecatedColorMappingConfig } from './types'; +import { DataType } from '../../../types'; +import { ColorMapping } from '@kbn/coloring'; +import { GenericIndexPatternColumn } from '../../../async_services'; +import { SerializedRangeKey } from '@kbn/data-plugin/common/search'; + +type OldAssignment = DeprecatedColorMappingConfig['assignments'][number]; +type OldRule = OldAssignment['rule']; +type OldSpecialRule = DeprecatedColorMappingConfig['specialAssignments'][number]['rule']; + +const baseConfig = { + assignments: [], + specialAssignments: [], + paletteId: 'default', + colorMode: { + type: 'categorical', + }, +} satisfies DeprecatedColorMappingConfig | ColorMapping.Config; + +const getOldColorMapping = ( + rules: OldRule[], + specialRules: OldSpecialRule[] = [] +): DeprecatedColorMappingConfig => ({ + assignments: rules.map((rule, i) => ({ + rule, + color: { + type: 'categorical', + paletteId: 'default', + colorIndex: i, + }, + touched: false, + })), + specialAssignments: specialRules.map((rule) => ({ + rule, + color: { + type: 'loop', + }, + touched: false, + })), + paletteId: 'default', + colorMode: { + type: 'categorical', + }, +}); + +function buildOldColorMapping( + rules: OldRule[], + specialRules: OldSpecialRule[] = [] +): DeprecatedColorMappingConfig { + return getOldColorMapping(rules, specialRules); +} + +describe('converter', () => { + describe('#convertToRawColorMappings', () => { + it('should convert config with no assignments', () => { + const oldConfig = buildOldColorMapping([]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig.assignments).toHaveLength(0); + }); + + it('should keep top-level config', () => { + const oldConfig = buildOldColorMapping([]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig).toMatchObject({ + paletteId: 'default', + colorMode: { + type: 'categorical', + }, + }); + }); + + describe('type - auto', () => { + it('should convert single auto rule', () => { + const oldConfig = buildOldColorMapping([{ type: 'auto' }]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig.assignments).toHaveLength(1); + expect(newConfig.assignments[0].color).toBeDefined(); + expect(newConfig.assignments[0].rules).toEqual([]); + }); + + it('should convert multiple auto rule', () => { + const oldConfig = buildOldColorMapping([ + { type: 'auto' }, + { type: 'matchExactly', values: [] }, + { type: 'auto' }, + ]); + const newConfig = convertToRawColorMappings(oldConfig); + expect(newConfig.assignments).toHaveLength(3); + expect(newConfig.assignments[0].rules).toEqual([]); + expect(newConfig.assignments[2].rules).toEqual([]); + }); + }); + + describe('type - matchExactly', () => { + type ExpectedRule = Partial; + interface ExpectedRulesByType { + types: Array; + expectedRule: ExpectedRule; + } + type MatchExactlyTestCase = [ + oldStringValue: string | string[], + defaultExpectedRule: ExpectedRule, + expectedRulesByType: ExpectedRulesByType[] + ]; + + const buildOldColorMappingFromValues = (values: Array) => + buildOldColorMapping([{ type: 'matchExactly', values }]); + + it('should handle missing column', () => { + const oldConfig = buildOldColorMapping([{ type: 'matchExactly', values: ['test'] }]); + const newConfig = convertToRawColorMappings(oldConfig, undefined); + expect(newConfig.assignments).toHaveLength(1); + expect(newConfig.assignments[0].rules).toHaveLength(1); + expect(newConfig.assignments[0].rules[0]).toEqual({ + type: 'match', + pattern: 'test', + matchEntireWord: true, + matchCase: true, + }); + }); + + describe('multi_terms', () => { + it('should convert array of string values as MultiFieldKey', () => { + const values: string[] = ['some-string', '123', '0', '1', '1744261200000', '__other__']; + const oldConfig = buildOldColorMappingFromValues([values]); + const newConfig = convertToRawColorMappings(oldConfig, {}); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'raw', + value: { + keys: ['some-string', '123', '0', '1', '1744261200000', '__other__'], + type: 'multiFieldKey', + }, + }); + }); + + it('should convert array of strings in multi_terms as MultiFieldKey', () => { + const oldConfig = buildOldColorMappingFromValues([['some-string']]); + const newConfig = convertToRawColorMappings(oldConfig, { fieldType: 'multi_terms' }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'raw', + value: { + keys: ['some-string'], + type: 'multiFieldKey', + }, + }); + }); + + it('should convert single string as basic match even in multi_terms column', () => { + const oldConfig = buildOldColorMappingFromValues(['some-string']); + const newConfig = convertToRawColorMappings(oldConfig, { fieldType: 'multi_terms' }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'match', + pattern: 'some-string', + matchEntireWord: true, + matchCase: true, + }); + }); + }); + + describe('range', () => { + it.each<[rangeString: string, expectedRange: Pick]>([ + ['from:0,to:1000', { from: 0, to: 1000 }], + ['from:-1000,to:1000', { from: -1000, to: 1000 }], + ['from:-1000,to:0', { from: -1000, to: 0 }], + ['from:1000,to:undefined', { from: 1000, to: null }], + ['from:undefined,to:1000', { from: null, to: 1000 }], + ['from:undefined,to:undefined', { from: null, to: null }], + ])('should convert range string %j to RangeKey', (rangeString, expectedRange) => { + const oldConfig = buildOldColorMappingFromValues([rangeString]); + const newConfig = convertToRawColorMappings(oldConfig, { fieldType: 'range' }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'raw', + value: { + ...expectedRange, + type: 'rangeKey', + ranges: [], + }, + }); + }); + + it('should convert non-range string to match', () => { + const oldConfig = buildOldColorMappingFromValues(['not-a-range']); + const newConfig = convertToRawColorMappings(oldConfig, { fieldType: 'range' }); + const rule = newConfig.assignments[0].rules[0]; + + expect(rule).toEqual({ + type: 'match', + pattern: 'not-a-range', + matchEntireWord: true, + matchCase: true, + }); + }); + }); + + describe.each([ + 'number', + 'boolean', + 'date', + 'string', + 'ip', + undefined, // other + ])('Column dataType - %s', (dataType) => { + const column: Partial = { dataType }; + + it.each([ + [ + '123.456', + { + type: 'raw', + value: 123.456, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '123.456' } }, + { + types: ['undefined', 'boolean'], + expectedRule: { type: 'match', pattern: '123.456' }, + }, + ], + ], + [ + '1744261200000', + { + type: 'raw', + value: 1744261200000, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '1744261200000' } }, + { + types: ['undefined', 'boolean'], + expectedRule: { type: 'match', pattern: '1744261200000' }, + }, + ], + ], + [ + '__other__', + { + type: 'raw', + value: '__other__', + }, + [{ types: ['undefined'], expectedRule: { type: 'match', pattern: '__other__' } }], + ], + [ + 'some-string', + { + type: 'raw', + value: 'some-string', + }, + [ + { + types: ['undefined', 'number', 'boolean', 'date'], + expectedRule: { type: 'match', pattern: 'some-string' }, + }, + ], + ], + [ + 'false', + { + type: 'raw', + value: 'false', + }, + [ + { + types: ['undefined', 'number', 'date'], + expectedRule: { type: 'match', pattern: 'false' }, + }, + ], + ], + [ + 'true', + { + type: 'raw', + value: 'true', + }, + [ + { + types: ['undefined', 'number', 'date'], + expectedRule: { type: 'match', pattern: 'true' }, + }, + ], + ], + [ + '0', // false + { + type: 'raw', + value: 0, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '0' } }, + { types: ['undefined'], expectedRule: { type: 'match', pattern: '0' } }, + ], + ], + [ + '1', // true + { + type: 'raw', + value: 1, + }, + [ + { types: ['string', 'ip'], expectedRule: { type: 'raw', value: '1' } }, + { types: ['undefined'], expectedRule: { type: 'match', pattern: '1' } }, + ], + ], + [ + '127.0.0.1', + { + type: 'raw', + value: '127.0.0.1', + }, + [ + { + types: ['undefined', 'number', 'boolean', 'date'], + expectedRule: { type: 'match', pattern: '127.0.0.1' }, + }, + ], + ], + ])('should correctly convert %j', (value, defaultExpectedRule, expectedRulesByType) => { + const oldConfig = buildOldColorMappingFromValues([value]); + const expectedRule = + expectedRulesByType.find((r) => r.types.includes(dataType ?? 'undefined')) + ?.expectedRule ?? defaultExpectedRule; + const newConfig = convertToRawColorMappings(oldConfig, column); + const rule = newConfig.assignments[0].rules[0]; + + if (expectedRule.type === 'match') { + // decorate match type with default constants + expectedRule.matchEntireWord = true; + expectedRule.matchCase = true; + } + + expect(rule).toEqual(expectedRule); + }); + }); + }); + }); + + describe('#isDeprecatedColorMapping', () => { + const baseAssignment = { + color: { + type: 'categorical', + paletteId: 'default', + colorIndex: 3, + }, + touched: false, + } satisfies Omit; + + it('should return true if assignments.rule exists', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + assignments: [ + { + ...baseAssignment, + rule: { + type: 'auto', + }, + }, + ], + }); + expect(isDeprecated).toBe(true); + }); + + it('should return true if specialAssignments.rule exists', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + specialAssignments: [ + { + ...baseAssignment, + rule: { + type: 'other', + }, + }, + ], + }); + expect(isDeprecated).toBe(true); + }); + + it('should return false if assignments.rule does not exist', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + assignments: [ + { + ...baseAssignment, + rules: [ + { + type: 'match', + pattern: 'test', + }, + ], + }, + ], + }); + expect(isDeprecated).toBe(false); + }); + + it('should return false if specialAssignments.rule does not exist', () => { + const isDeprecated = isDeprecatedColorMapping({ + ...baseConfig, + specialAssignments: [ + { + ...baseAssignment, + rules: [ + { + type: 'other', + }, + ], + }, + ], + }); + expect(isDeprecated).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/converter.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/converter.ts new file mode 100644 index 0000000000000..1304c6544f461 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/converter.ts @@ -0,0 +1,160 @@ +/* + * 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 { ColorMapping } from '@kbn/coloring'; +import { MultiFieldKey, RangeKey, SerializedValue } from '@kbn/data-plugin/common'; +import { DeprecatedColorMappingConfig } from './types'; +import { ColumnMeta } from './utils'; + +/** + * Converts old stringified colorMapping configs to new raw value configs + */ +export function convertToRawColorMappings( + colorMapping: DeprecatedColorMappingConfig | ColorMapping.Config, + columnMeta?: ColumnMeta | null +): ColorMapping.Config { + return { + ...colorMapping, + assignments: colorMapping.assignments.map((oldAssignment) => { + if (isValidColorMappingAssignment(oldAssignment)) return oldAssignment; + return convertColorMappingAssignment(oldAssignment, columnMeta); + }), + specialAssignments: colorMapping.specialAssignments.map((oldAssignment) => { + if (isValidColorMappingAssignment(oldAssignment)) return oldAssignment; + return { + color: oldAssignment.color, + touched: oldAssignment.touched, + rules: [oldAssignment.rule], + }; + }), + }; +} + +function convertColorMappingAssignment( + oldAssignment: DeprecatedColorMappingConfig['assignments'][number], + columnMeta?: ColumnMeta | null +): ColorMapping.Assignment { + return { + color: oldAssignment.color, + touched: oldAssignment.touched, + rules: convertColorMappingRule(oldAssignment.rule, columnMeta), + }; +} + +const NO_VALUE = Symbol('no-value'); + +function convertColorMappingRule( + rule: DeprecatedColorMappingConfig['assignments'][number]['rule'], + columnMeta?: ColumnMeta | null +): ColorMapping.ColorRule[] { + switch (rule.type) { + case 'auto': + return []; + case 'matchExactly': + return rule.values.map((value) => { + const rawValue = convertToRawValue(value, columnMeta); + + if (rawValue !== NO_VALUE) { + return { + type: 'raw', + value: rawValue, + }; + } + + return { + type: 'match', + pattern: String(value), + matchEntireWord: true, + matchCase: true, + }; + }); + + // Rules below not yet used, adding conversions for completeness + case 'matchExactlyCI': + return rule.values.map((value) => ({ + type: 'match', + pattern: Array.isArray(value) ? value.join(' ') : value, + matchEntireWord: true, + matchCase: false, + })); + case 'regex': + return [{ type: rule.type, pattern: rule.values }]; + case 'range': + default: + return [rule]; + } +} + +/** + * Attempts to convert the previously stringified raw values into their raw/serialized form + * + * Note: we use the `NO_VALUE` symbol to avoid collisions with falsy raw values + */ +function convertToRawValue( + value: string | string[], + columnMeta?: ColumnMeta | null +): SerializedValue | symbol { + if (!columnMeta) return NO_VALUE; + + // all array values are multi-term + if (columnMeta.fieldType === 'multi_terms' || Array.isArray(value)) { + if (typeof value === 'string') return NO_VALUE; // cannot assume this as multi-field + return new MultiFieldKey({ key: value }).serialize(); + } + + if (columnMeta.fieldType === 'range') { + return RangeKey.isRangeKeyString(value) ? RangeKey.fromString(value).serialize() : NO_VALUE; + } + + switch (columnMeta.dataType) { + case 'boolean': + if (value === '__other__' || value === 'true' || value === 'false') return value; // bool could have __other__ as a string + if (value === '0' || value === '1') return Number(value); + break; + case 'number': + case 'date': + if (value === '__other__') return value; // numbers can have __other__ as a string + const numberValue = Number(value); + if (isFinite(numberValue)) return numberValue; + break; + case 'string': + case 'ip': + return value; // unable to distinguish manually added values + default: + return NO_VALUE; // treat all other other dataType as custom match string values + } + return NO_VALUE; +} + +function isValidColorMappingAssignment< + T extends + | DeprecatedColorMappingConfig['assignments'][number] + | DeprecatedColorMappingConfig['specialAssignments'][number] + | ColorMapping.Config['assignments'][number] + | ColorMapping.Config['specialAssignments'][number] +>( + assignment: T +): assignment is Exclude< + T, + | DeprecatedColorMappingConfig['assignments'][number] + | DeprecatedColorMappingConfig['specialAssignments'][number] +> { + return 'rules' in assignment; +} + +export function isDeprecatedColorMapping< + T extends DeprecatedColorMappingConfig | ColorMapping.Config +>(colorMapping?: T): colorMapping is Exclude { + if (!colorMapping) return false; + return Boolean( + colorMapping.assignments && + (colorMapping.assignments.some((assignment) => !isValidColorMappingAssignment(assignment)) || + colorMapping.specialAssignments.some( + (specialAssignment) => !isValidColorMappingAssignment(specialAssignment) + )) + ); +} diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/index.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/index.ts new file mode 100644 index 0000000000000..9e05cb0b43aa1 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { DeprecatedColorMappingConfig } from './types'; +export { convertToRawColorMappings, isDeprecatedColorMapping } from './converter'; +export { getColumnMetaFn } from './utils'; diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/types.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/types.ts new file mode 100644 index 0000000000000..be3c2dc239097 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/types.ts @@ -0,0 +1,117 @@ +/* + * 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. + */ + +/** @deprecated */ +interface DeprecatedColorMappingColorCode { + type: 'colorCode'; + colorCode: string; +} + +/** @deprecated */ +interface DeprecatedColorMappingCategoricalColor { + type: 'categorical'; + paletteId: string; + colorIndex: number; +} + +/** @deprecated */ +interface DeprecatedColorMappingGradientColor { + type: 'gradient'; +} + +/** @deprecated */ +interface DeprecatedColorMappingLoopColor { + type: 'loop'; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleAuto { + type: 'auto'; +} +/** @deprecated */ +interface DeprecatedColorMappingRuleMatchExactly { + type: 'matchExactly'; + values: Array; +} +/** @deprecated */ +interface DeprecatedColorMappingRuleMatchExactlyCI { + type: 'matchExactlyCI'; + values: string[]; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleRange { + type: 'range'; + min: number; + max: number; + minInclusive: boolean; + maxInclusive: boolean; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleRegExp { + type: 'regex'; + values: string; +} + +/** @deprecated */ +interface DeprecatedColorMappingRuleOthers { + type: 'other'; +} + +/** @deprecated */ +interface DeprecatedColorMappingAssignment { + rule: R; + color: C; + touched: boolean; +} + +/** @deprecated */ +interface DeprecatedColorMappingCategoricalColorMode { + type: 'categorical'; +} + +/** @deprecated */ +interface DeprecatedColorMappingGradientColorMode { + type: 'gradient'; + steps: Array< + (DeprecatedColorMappingCategoricalColor | DeprecatedColorMappingColorCode) & { + touched: boolean; + } + >; + sort: 'asc' | 'desc'; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated Use `ColorMapping.Config` + */ +export interface DeprecatedColorMappingConfig { + paletteId: string; + colorMode: DeprecatedColorMappingCategoricalColorMode | DeprecatedColorMappingGradientColorMode; + assignments: Array< + DeprecatedColorMappingAssignment< + | DeprecatedColorMappingRuleAuto + | DeprecatedColorMappingRuleMatchExactly + | DeprecatedColorMappingRuleMatchExactlyCI + | DeprecatedColorMappingRuleRange + | DeprecatedColorMappingRuleRegExp, + | DeprecatedColorMappingCategoricalColor + | DeprecatedColorMappingColorCode + | DeprecatedColorMappingGradientColor + > + >; + specialAssignments: Array< + DeprecatedColorMappingAssignment< + DeprecatedColorMappingRuleOthers, + | DeprecatedColorMappingCategoricalColor + | DeprecatedColorMappingColorCode + | DeprecatedColorMappingLoopColor + > + >; +} diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/utils.test.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/utils.test.ts new file mode 100644 index 0000000000000..c573acf56c03c --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/utils.test.ts @@ -0,0 +1,152 @@ +/* + * 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 { + TextBasedLayer, + TextBasedPersistedState, +} from '../../../datasources/form_based/esql_layer/types'; +import { FormBasedLayer, FormBasedPersistedState } from '../../../datasources/form_based/types'; +import { StructuredDatasourceStates } from '../../../react_embeddable/types'; +import { ColumnMeta, getColumnMetaFn } from './utils'; + +const layerId = 'layer-1'; +const columnId = 'column-1'; + +const getDatasourceStatesMock = ( + type: keyof StructuredDatasourceStates, + dataType?: ColumnMeta['dataType'], + fieldType?: ColumnMeta['fieldType'] +) => { + if (type === 'formBased') { + return { + formBased: { + layers: { + [layerId]: { + columns: { + [columnId]: { + dataType, + params: { + parentFormat: { id: fieldType }, + }, + }, + }, + } as unknown as FormBasedLayer, + }, + } satisfies FormBasedPersistedState, + }; + } + + if (type === 'textBased') { + return { + textBased: { + layers: { + [layerId]: { + columns: [{ columnId, meta: { type: dataType } }], + } as unknown as TextBasedLayer, + }, + } satisfies TextBasedPersistedState, + }; + } +}; + +describe('utils', () => { + describe('getColumnMetaFn', () => { + const mockDataType = 'string'; + const mockFieldType = 'terms'; + + it('should return null if neither type exists', () => { + const mockDatasourceState = { 'not-supported': {} }; + const resultFn = getColumnMetaFn(mockDatasourceState); + + expect(resultFn).toBeNull(); + }); + + describe('formBased datasourceState', () => { + it('should correct dataType and fieldType', () => { + const mockDatasourceState = getDatasourceStatesMock( + 'formBased', + mockDataType, + mockFieldType + ); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBe(mockDataType); + expect(result.fieldType).toBe(mockFieldType); + }); + + it('should undefined dataType and fieldType if column not found', () => { + const mockDatasourceState = getDatasourceStatesMock('formBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, 'bad-column'); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if layer not found', () => { + const mockDatasourceState = getDatasourceStatesMock('formBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn('bad-layer', columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if missing', () => { + const mockDatasourceState = getDatasourceStatesMock('formBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + }); + + describe('textBased datasourceState', () => { + it('should correct dataType and fieldType', () => { + const mockDatasourceState = getDatasourceStatesMock( + 'textBased', + mockDataType, + mockFieldType + ); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBe(mockDataType); + expect(result.fieldType).toBeUndefined(); // no fieldType needed for textBased + }); + + it('should undefined dataType and fieldType if column not found', () => { + const mockDatasourceState = getDatasourceStatesMock('textBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, 'bad-column'); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if layer not found', () => { + const mockDatasourceState = getDatasourceStatesMock('textBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn('bad-layer', columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + + it('should undefined dataType and fieldType if missing', () => { + const mockDatasourceState = getDatasourceStatesMock('textBased'); + const resultFn = getColumnMetaFn(mockDatasourceState)!; + const result = resultFn(layerId, columnId); + + expect(result.dataType).toBeUndefined(); + expect(result.fieldType).toBeUndefined(); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/utils.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/utils.ts new file mode 100644 index 0000000000000..f1becae440f6b --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/converters/raw_color_mappings/utils.ts @@ -0,0 +1,51 @@ +/* + * 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 { DatatableColumnType } from '@kbn/expressions-plugin/common'; +import { GenericIndexPatternColumn } from '../../../datasources/form_based/types'; +import { getStructuredDatasourceStates } from '../../../react_embeddable/helper'; +import { GeneralDatasourceStates } from '../../../state_management'; + +export interface ColumnMeta { + fieldType?: string | 'multi_terms' | 'range'; + dataType?: GenericIndexPatternColumn['dataType'] | DatatableColumnType; +} + +export function getColumnMetaFn( + datasourceStates?: Readonly +): ((layerId: string, columnId: string) => ColumnMeta) | null { + const datasources = getStructuredDatasourceStates(datasourceStates); + + if (datasources.formBased?.layers) { + const layers = datasources.formBased.layers; + + return (layerId, columnId) => { + const column = layers[layerId]?.columns?.[columnId]; + return { + fieldType: + column && 'params' in column + ? (column.params as { parentFormat?: { id?: string } })?.parentFormat?.id + : undefined, + dataType: column?.dataType, + }; + }; + } + + if (datasources.textBased?.layers) { + const layers = datasources.textBased.layers; + + return (layerId, columnId) => { + const column = layers[layerId]?.columns?.find((c) => c.columnId === columnId); + + return { + dataType: column?.meta?.type, + }; + }; + } + + return null; +} diff --git a/x-pack/platform/plugins/shared/lens/public/runtime_state/index.ts b/x-pack/platform/plugins/shared/lens/public/runtime_state/index.ts new file mode 100644 index 0000000000000..58472aed8d903 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/runtime_state/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './converters/raw_color_mappings'; diff --git a/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/color_mapping_by_terms.tsx b/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/color_mapping_by_terms.tsx index ffc23d68bc54a..3460146b497b4 100644 --- a/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/color_mapping_by_terms.tsx +++ b/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/color_mapping_by_terms.tsx @@ -12,9 +12,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiIconTip, EuiSpacer, EuiSwitch, EuiText, + useEuiTheme, } from '@elastic/eui'; import { ColorMapping, @@ -27,6 +29,8 @@ import { } from '@kbn/coloring'; import { i18n } from '@kbn/i18n'; import { KbnPalettes } from '@kbn/palettes'; +import { IFieldFormat } from '@kbn/field-formats-plugin/common'; +import { SerializedValue } from '@kbn/data-plugin/common'; import { trackUiCounterEvents } from '../../lens_ui_telemetry'; import { PalettePicker } from '../palette_picker'; import { PalettePanelContainer } from './palette_panel_container'; @@ -42,7 +46,9 @@ interface ColorMappingByTermsProps { setColorMapping: (colorMapping?: ColorMapping.Config) => void; paletteService: PaletteRegistry; panelRef: MutableRefObject; - categories: Array; + categories: SerializedValue[]; + formatter?: IFieldFormat; + allowCustomMatch?: boolean; } export function ColorMappingByTerms({ @@ -56,7 +62,10 @@ export function ColorMappingByTerms({ paletteService, panelRef, categories, + formatter, + allowCustomMatch, }: ColorMappingByTermsProps) { + const { euiTheme } = useEuiTheme(); const [useNewColorMapping, setUseNewColorMapping] = useState(Boolean(colorMapping)); return ( @@ -101,6 +110,20 @@ export function ColorMappingByTerms({ {i18n.translate('xpack.lens.colorMapping.tryLabel', { defaultMessage: 'Use the new Color Mapping feature', })}{' '} + {(colorMapping?.assignments.length ?? 0) > 0 && ( + + )}{' '} {i18n.translate('xpack.lens.colorMapping.techPreviewLabel', { defaultMessage: 'Tech preview', @@ -128,6 +151,8 @@ export function ColorMappingByTerms({ onModelUpdate={setColorMapping} specialTokens={SPECIAL_TOKENS_STRING_CONVERSION} palettes={palettes} + formatter={formatter} + allowCustomMatch={allowCustomMatch} data={{ type: 'categories', categories, diff --git a/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/get_cell_color_fn.ts b/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/get_cell_color_fn.ts index f2db77f41b103..ab98dd6055544 100644 --- a/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/get_cell_color_fn.ts +++ b/x-pack/platform/plugins/shared/lens/public/shared_components/coloring/get_cell_color_fn.ts @@ -10,12 +10,14 @@ import { PaletteOutput, PaletteRegistry, getSpecialString, + getValueKey, } from '@kbn/coloring'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; import { KbnPalettes } from '@kbn/palettes'; +import { RawValue } from '@kbn/data-plugin/common'; import { getColorAccessorFn } from './color_mapping_accessor'; -export type CellColorFn = (value?: number | string | null) => string | null; +export type CellColorFn = (value: RawValue) => string | null; export function getCellColorFn( paletteService: PaletteRegistry, @@ -41,17 +43,17 @@ export function getCellColorFn( if (colorMapping) { return getColorAccessorFn(palettes, colorMapping, data, isDarkMode); } else if (palette) { - return (category) => { - if (category === undefined || category === null) return null; + return (value: RawValue) => { + if (value === undefined || value === null) return null; - const strCategory = String(category); // can be a number as a string + const key = getValueKey(value); return paletteService.get(palette.name).getCategoricalColor( [ { - name: getSpecialString(strCategory), // needed to sync special categories (i.e. '') + name: getSpecialString(key), // needed to sync special categories (i.e. '') rankAtDepth: Math.max( - data.categories.findIndex((v) => v === strCategory), + data.categories.findIndex((v) => v === key), 0 ), totalSeriesAtDepth: data.categories.length || 1, diff --git a/x-pack/platform/plugins/shared/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap b/x-pack/platform/plugins/shared/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap index 8af3d61bf668d..1fd35e72c7a37 100644 --- a/x-pack/platform/plugins/shared/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap +++ b/x-pack/platform/plugins/shared/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap @@ -84,7 +84,7 @@ Object { "language": "lucene", "query": "", }, - "visualization": Object {}, + "visualization": "testVis initial state", }, "title": "", "type": "lens", diff --git a/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts index 458285096f7e7..329c80be52423 100644 --- a/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/platform/plugins/shared/lens/public/state_management/init_middleware/load_initial.ts @@ -286,7 +286,13 @@ async function loadFromSavedObject( : !inlineEditing ? data.search.session.start() : undefined, - persistedDoc: doc, + persistedDoc: { + ...doc, + state: { + ...doc.state, + visualization: visualizationState, + }, + }, activeDatasourceId: getInitialDatasourceId(loaderSharedArgs.datasourceMap, doc), visualization: { activeId: doc.visualizationType, diff --git a/x-pack/platform/plugins/shared/lens/public/state_management/load_initial.test.tsx b/x-pack/platform/plugins/shared/lens/public/state_management/load_initial.test.tsx index f23b33eca7e98..81e982b063b32 100644 --- a/x-pack/platform/plugins/shared/lens/public/state_management/load_initial.test.tsx +++ b/x-pack/platform/plugins/shared/lens/public/state_management/load_initial.test.tsx @@ -234,7 +234,14 @@ describe('Initializing the store', () => { expect(store.getState()).toEqual({ lens: expect.objectContaining({ - persistedDoc: { ...defaultDoc, type: DOC_TYPE }, + persistedDoc: expect.objectContaining({ + ...defaultDoc, + type: DOC_TYPE, + state: { + ...defaultDoc.state, + visualization: 'testVis initial state', + }, + }), query: defaultDoc.state.query, isLoading: false, activeDatasourceId: 'testDatasource', diff --git a/x-pack/platform/plugins/shared/lens/public/state_management/types.ts b/x-pack/platform/plugins/shared/lens/public/state_management/types.ts index 754498ee0d4f8..63f93badf083e 100644 --- a/x-pack/platform/plugins/shared/lens/public/state_management/types.ts +++ b/x-pack/platform/plugins/shared/lens/public/state_management/types.ts @@ -9,6 +9,7 @@ import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; import type { EmbeddableEditorState } from '@kbn/embeddable-plugin/public'; import type { AggregateQuery, Filter, Query } from '@kbn/es-query'; import type { SavedQuery } from '@kbn/data-plugin/public'; +import { $Values } from 'utility-types'; import type { MainHistoryLocationState } from '../../common/locator/locator'; import type { LensDocument } from '../persistence'; @@ -24,6 +25,7 @@ import type { IndexPatternRef, AnnotationGroups, } from '../types'; +import { StructuredDatasourceStates } from '../react_embeddable/types'; export interface VisualizationState { activeId: string | null; state: unknown; @@ -34,12 +36,29 @@ export interface DataViewsState { indexPatterns: Record; } -export interface DatasourceState { +export interface DatasourceState { isLoading: boolean; - state: unknown; + state: S; } - export type DatasourceStates = Record; + +/** + * A type to encompass all variants of DatasourceState types + * + * TODO: cleanup types/structure of datasources + */ +export type GeneralDatasourceState = + | unknown + | $Values + | DatasourceState + | DatasourceState<$Values>; +/** + * A type to encompass all variants of DatasourceStates types + */ +export type GeneralDatasourceStates = + | Record + | StructuredDatasourceStates; + export interface PreviewState { visualization: VisualizationState; datasourceStates: DatasourceStates; diff --git a/x-pack/platform/plugins/shared/lens/public/state_management/utils.ts b/x-pack/platform/plugins/shared/lens/public/state_management/utils.ts index 3d19326938d35..b9a5c3a29eb82 100644 --- a/x-pack/platform/plugins/shared/lens/public/state_management/utils.ts +++ b/x-pack/platform/plugins/shared/lens/public/state_management/utils.ts @@ -13,7 +13,7 @@ export const getDatasourceLayers = memoizeOne(function getDatasourceLayers( datasourceStates: DatasourceStates, datasourceMap: DatasourceMap, indexPatterns: DataViewsState['indexPatterns'] -) { +): DatasourceLayers { const datasourceLayers: DatasourceLayers = {}; Object.keys(datasourceMap) .filter((id) => datasourceStates[id] && !datasourceStates[id].isLoading) diff --git a/x-pack/platform/plugins/shared/lens/public/types.ts b/x-pack/platform/plugins/shared/lens/public/types.ts index 84be2e45a8662..4e013480da12f 100644 --- a/x-pack/platform/plugins/shared/lens/public/types.ts +++ b/x-pack/platform/plugins/shared/lens/public/types.ts @@ -62,7 +62,7 @@ import { LENS_EDIT_PAGESIZE_ACTION, } from './visualizations/datatable/components/constants'; import type { LensInspector } from './lens_inspector_service'; -import type { DataViewsState } from './state_management/types'; +import type { DataViewsState, GeneralDatasourceStates } from './state_management/types'; import type { IndexPatternServiceAPI } from './data_views_service/service'; import type { LensDocument } from './persistence/saved_object_store'; import { TableInspectorAdapter } from './editor_frame_service/types'; @@ -1076,12 +1076,14 @@ export interface Visualization string, nonPersistedState?: T, - mainPalette?: SuggestionRequest['mainPalette'] + mainPalette?: SuggestionRequest['mainPalette'], + datasourceStates?: GeneralDatasourceStates ): T; ( addNewLayer: () => string, persistedState: P, mainPalette?: SuggestionRequest['mainPalette'], + datasourceStates?: GeneralDatasourceStates, annotationGroups?: AnnotationGroups, references?: SavedObjectReference[] ): T; diff --git a/x-pack/platform/plugins/shared/lens/public/utils.test.ts b/x-pack/platform/plugins/shared/lens/public/utils.test.ts index 58366d38161b0..e775059586aff 100644 --- a/x-pack/platform/plugins/shared/lens/public/utils.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/utils.test.ts @@ -7,7 +7,7 @@ import { createDatatableUtilitiesMock } from '@kbn/data-plugin/common/mocks'; import { Datatable } from '@kbn/expressions-plugin/public'; -import { getUniqueLabelGenerator, inferTimeField, isLensRange, renewIDs } from './utils'; +import { getUniqueLabelGenerator, inferTimeField, renewIDs } from './utils'; const datatableUtilities = createDatatableUtilitiesMock(); @@ -187,24 +187,4 @@ describe('utils', () => { expect([' ', ' '].map(labelGenerator)).toEqual(['[Untitled]', '[Untitled] [1]']); }); }); - - describe('isRange', () => { - it.each<[expected: boolean, input: unknown]>([ - [true, { from: 0, to: 100, label: '' }], - [true, { from: 0, to: null, label: '' }], - [true, { from: null, to: 100, label: '' }], - [false, { from: 0, to: 100 }], - [false, { from: 0, to: null }], - [false, { from: null, to: 100 }], - [false, { from: 0 }], - [false, { to: 100 }], - [false, null], - [false, undefined], - [false, 123], - [false, 'string'], - [false, {}], - ])('should return %s for %j', (expected, input) => { - expect(isLensRange(input)).toBe(expected); - }); - }); }); diff --git a/x-pack/platform/plugins/shared/lens/public/utils.ts b/x-pack/platform/plugins/shared/lens/public/utils.ts index fb62ff342bdad..c306657c6c603 100644 --- a/x-pack/platform/plugins/shared/lens/public/utils.ts +++ b/x-pack/platform/plugins/shared/lens/public/utils.ts @@ -39,7 +39,6 @@ import { import type { DatasourceStates, VisualizationState } from './state_management'; import type { IndexPatternServiceAPI } from './data_views_service/service'; import { COLOR_MAPPING_OFF_BY_DEFAULT } from '../common/constants'; -import type { RangeTypeLens } from './datasources/form_based/operations/definitions/ranges'; export function getVisualizeGeoFieldMessage(fieldType: string) { return i18n.translate('xpack.lens.visualizeGeoFieldMessage', { @@ -48,17 +47,6 @@ export function getVisualizeGeoFieldMessage(fieldType: string) { }); } -export function isLensRange(range: unknown = {}): range is RangeTypeLens { - if (!range || typeof range !== 'object') return false; - const { from, to, label } = range as RangeTypeLens; - - return ( - label !== undefined && - (typeof from === 'number' || from === null) && - (typeof to === 'number' || to === null) - ); -} - export function getResolvedDateRange(timefilter: TimefilterContract) { const { from, to } = timefilter.getTime(); return { fromDate: from, toDate: to }; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx index 97e7e755ac36e..20181d6d0ac7a 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx @@ -10,24 +10,13 @@ import { EuiDataGridCellValueElementProps, EuiLink } from '@elastic/eui'; import classNames from 'classnames'; import { PaletteOutput } from '@kbn/coloring'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import { RawValue } from '@kbn/data-plugin/common'; import type { FormatFactory } from '../../../../common/types'; import type { DatatableColumnConfig } from '../../../../common/expressions'; import type { DataContextType } from './types'; import { getContrastColor } from '../../../shared_components/coloring/utils'; import { CellColorFn } from '../../../shared_components/coloring/get_cell_color_fn'; -import { isLensRange } from '../../../utils'; - -const getParsedValue = (v: unknown) => { - if (v == null || typeof v === 'number') { - return v; - } - if (isLensRange(v)) { - return v.toString(); - } - return String(v); -}; - export const createGridCell = ( formatters: Record>, columnConfig: DatatableColumnConfig, @@ -42,8 +31,8 @@ export const createGridCell = ( ) => { return ({ rowIndex, columnId, setCellProps, isExpanded }: EuiDataGridCellValueElementProps) => { const { table, alignments, handleFilterClick } = useContext(DataContext); - const rawRowValue = table?.rows[rowIndex]?.[columnId]; - const rowValue = getParsedValue(rawRowValue); + const formatter = formatters[columnId]; + const rawValue: RawValue = table?.rows[rowIndex]?.[columnId]; const colIndex = columnConfig.columns.findIndex(({ columnId: id }) => id === columnId); const { oneClickFilter, @@ -52,13 +41,13 @@ export const createGridCell = ( colorMapping, } = columnConfig.columns[colIndex] ?? {}; const filterOnClick = oneClickFilter && handleFilterClick; - const content = formatters[columnId]?.convert(rawRowValue, filterOnClick ? 'text' : 'html'); + const content = formatter?.convert(rawValue, filterOnClick ? 'text' : 'html'); const currentAlignment = alignments?.get(columnId); useEffect(() => { let colorSet = false; if (colorMode !== 'none' && (palette || colorMapping)) { - const color = getCellColor(columnId, palette, colorMapping)(rowValue); + const color = getCellColor(columnId, palette, colorMapping)(rawValue); if (color) { const style = { [colorMode === 'cell' ? 'backgroundColor' : 'color']: color }; @@ -82,7 +71,7 @@ export const createGridCell = ( }); }; } - }, [rowValue, columnId, setCellProps, colorMode, palette, colorMapping, isExpanded]); + }, [rawValue, columnId, setCellProps, colorMode, palette, colorMapping, isExpanded]); if (filterOnClick) { return ( @@ -95,7 +84,7 @@ export const createGridCell = ( > { - handleFilterClick?.(columnId, rawRowValue, colIndex, rowIndex); + handleFilterClick?.(columnId, rawValue, colIndex, rowIndex); }} > {content} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.test.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.test.tsx index b5d9193537030..f5725269854fd 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.test.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.test.tsx @@ -10,6 +10,7 @@ import { DEFAULT_COLOR_MAPPING_CONFIG } from '@kbn/coloring'; import { act, screen } from '@testing-library/react'; import userEvent, { type UserEvent } from '@testing-library/user-event'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; +import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { EuiButtonGroupTestHarness } from '@kbn/test-eui-helpers'; import { getKbnPalettes } from '@kbn/palettes'; @@ -21,6 +22,8 @@ import { ColumnState } from '../../../../common/expressions'; import { capitalize } from 'lodash'; import { renderWithProviders } from '../../../test_utils/test_utils'; +const fieldFormatsMock = fieldFormatsServiceMock.createStartContract(); + describe('data table dimension editor', () => { let user: UserEvent; let frame: FramePublicAPI; @@ -94,6 +97,7 @@ describe('data table dimension editor', () => { addLayer: jest.fn(), removeLayer: jest.fn(), datasource: {} as DatasourcePublicAPI, + formatFactory: fieldFormatsMock.deserialize, }; mockOperationForFirstColumn = (overrides: Partial = {}) => { diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.tsx index 25f0fd80a8b04..d6be4b0db7add 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/dimension_editor.tsx @@ -14,6 +14,7 @@ import { PaletteOutput, PaletteRegistry, applyPaletteParams, + canCreateCustomMatch, getFallbackDataBounds, } from '@kbn/coloring'; import { getColorCategories } from '@kbn/chart-expressions-common'; @@ -33,6 +34,8 @@ import { ColorMappingByValues } from '../../../shared_components/coloring/color_ import { ColorMappingByTerms } from '../../../shared_components/coloring/color_mapping_by_terms'; import { getColumnAlignment } from '../utils'; import { DatatableInspectorTables } from '../../../../common/expressions/datatable/datatable_fn'; +import { FormatFactory } from '../../../../common/types'; +import { getDatatableColumn } from '../../../../common/expressions/datatable/utils'; const idPrefix = htmlIdGenerator()(); @@ -57,10 +60,11 @@ export type TableDimensionEditorProps = paletteService: PaletteRegistry; palettes: KbnPalettes; isDarkMode: boolean; + formatFactory: FormatFactory; }; export function TableDimensionEditor(props: TableDimensionEditorProps) { - const { frame, accessor, isInlineEditing, isDarkMode } = props; + const { frame, accessor, isInlineEditing, isDarkMode, formatFactory } = props; const column = props.state.columns.find(({ columnId }) => accessor === columnId); const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ @@ -83,6 +87,9 @@ export function TableDimensionEditor(props: TableDimensionEditorProps) { const currentData = frame.activeData?.[localState.layerId] ?? frame.activeData?.[DatatableInspectorTables.Default]; + const columnMeta = getDatatableColumn(currentData, accessor)?.meta; + const formatter = formatFactory(columnMeta?.params); + const allowCustomMatch = canCreateCustomMatch(columnMeta); const datasource = frame.datasourceLayers?.[localState.layerId]; const { isNumeric, isCategory: isBucketable } = getAccessorType(datasource, accessor); @@ -109,7 +116,6 @@ export function TableDimensionEditor(props: TableDimensionEditorProps) { }; // need to tell the helper that the colorStops are required to display const displayStops = applyPaletteParams(props.paletteService, activePalette, currentMinMax); - const categories = getColorCategories(currentData?.rows, accessor, [null]); if (activePalette.name !== CUSTOM_PALETTE && activePalette.params?.stops) { activePalette.params.stops = applyPaletteParams( @@ -247,7 +253,9 @@ export function TableDimensionEditor(props: TableDimensionEditorProps) { }} paletteService={props.paletteService} panelRef={props.panelRef} - categories={categories} + categories={getColorCategories(currentData?.rows, accessor, [null])} + formatter={formatter} + allowCustomMatch={allowCustomMatch} /> ) : ( { const { getType, dispatchEvent, renderMode, formatFactory, syncColors } = props; - const formatters: Record> = useMemo( + const formatters: Record = useMemo( () => firstLocalTable.columns.reduce( (map, column) => ({ @@ -399,15 +396,17 @@ export const DatatableComponent = (props: DatatableRenderProps) => { return cellColorFnMap.get(originalId)!; } - const dataType = getFieldMetaFromDatatable(firstLocalTable, originalId)?.type; + const colInfo = getDatatableColumn(firstLocalTable, originalId); const isBucketed = bucketedColumns.some((id) => id === columnId); - const colorByTerms = shouldColorByTerms(dataType, isBucketed); + const colorByTerms = shouldColorByTerms(colInfo?.meta.type, isBucketed); const categoryRows = (untransposedDataRef.current ?? firstLocalTable)?.rows; + const data: ColorMappingInputData = colorByTerms ? { type: 'categories', - // Must use non-transposed data here to correctly collate categories across transposed columns - categories: getColorCategories(categoryRows, originalId, [null]), + categories: colorMapping + ? getColorCategories(categoryRows, originalId, [null]) + : getLegacyColorCategories(categoryRows, originalId, [null]), } : { type: 'ranges', diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/index.ts index 6261b8c3dde45..b9ec2967f5090 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/index.ts +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/index.ts @@ -9,8 +9,8 @@ import type { CoreSetup } from '@kbn/core/public'; import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; import type { ExpressionsSetup } from '@kbn/expressions-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { FormatFactory } from '@kbn/visualization-ui-components'; import type { EditorFrameSetup } from '../../types'; -import type { FormatFactory } from '../../../common/types'; interface DatatableVisualizationPluginStartPlugins { data: DataPublicPluginStart; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/converters/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/converters/index.ts new file mode 100644 index 0000000000000..38e4a30fd5d4e --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/converters/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { GeneralDatasourceStates } from '../../../../state_management'; +import { convertToRawColorMappingsFn } from './raw_color_mappings'; + +export const getRuntimeConverters = (datasourceStates?: Readonly) => [ + convertToRawColorMappingsFn(datasourceStates), +]; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/converters/raw_color_mappings.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/converters/raw_color_mappings.ts new file mode 100644 index 0000000000000..19b6d9f3dd504 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/converters/raw_color_mappings.ts @@ -0,0 +1,64 @@ +/* + * 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 { ColumnState } from '../../../../../common/expressions'; +import { + DeprecatedColorMappingConfig, + convertToRawColorMappings, + getColumnMetaFn, + isDeprecatedColorMapping, +} from '../../../../runtime_state/converters/raw_color_mappings'; +import { GeneralDatasourceStates } from '../../../../state_management'; +import { DatatableVisualizationState } from '../../visualization'; + +/** @deprecated */ +interface DeprecatedColorMappingColumn extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated Use respective vis state (i.e. `DatatableVisualizationState`) + */ +export interface DeprecatedColorMappingsState extends Omit { + columns: Array; +} + +export const convertToRawColorMappingsFn = ( + datasourceStates?: Readonly +) => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + + return ( + state: DeprecatedColorMappingsState | DatatableVisualizationState + ): DatatableVisualizationState => { + const hasDeprecatedColorMappings = state.columns.some((column) => { + return isDeprecatedColorMapping(column.colorMapping); + }); + + if (!hasDeprecatedColorMappings) return state as DatatableVisualizationState; + + const convertedColumns = state.columns.map((column) => { + if (column.colorMapping?.assignments || column.colorMapping?.specialAssignments) { + const columnMeta = getColumnMeta?.(state.layerId, column.columnId); + + return { + ...column, + colorMapping: convertToRawColorMappings(column.colorMapping, columnMeta), + } satisfies ColumnState; + } + + return column as ColumnState; + }); + + return { + ...state, + columns: convertedColumns, + }; + }; +}; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/index.ts new file mode 100644 index 0000000000000..a4638af96a6ab --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/runtime_state/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { GeneralDatasourceStates } from '../../../state_management'; +import { DatatableVisualizationState } from '../datatable_visualization'; +import { getRuntimeConverters } from './converters'; + +export function convertToRuntimeState( + state: DatatableVisualizationState, + datasourceStates?: Readonly +): DatatableVisualizationState { + return getRuntimeConverters(datasourceStates).reduce((newState, fn) => fn(newState), state); +} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx index 2af55599c5ffb..ca5585eba43e4 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; + import { Ast } from '@kbn/interpreter'; import { i18n } from '@kbn/i18n'; import { ThemeServiceStart } from '@kbn/core/public'; @@ -62,6 +63,8 @@ import { import { getColorMappingTelemetryEvents } from '../../lens_ui_telemetry/color_telemetry_helpers'; import { DatatableInspectorTables } from '../../../common/expressions/datatable/datatable_fn'; import { getSimpleColumnType } from './components/table_actions'; +import { convertToRuntimeState } from './runtime_state'; + export interface DatatableVisualizationState { columns: ColumnState[]; layerId: string; @@ -128,14 +131,18 @@ export const getDatatableVisualization = ({ triggers: [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.tableRowContextMenuClick], - initialize(addNewLayer, state) { - return ( - state || { - columns: [], - layerId: addNewLayer(), - layerType: LayerTypes.DATA, - } - ); + initialize(addNewLayer, state, mainPalette, datasourceStates) { + if (state) return convertToRuntimeState(state, datasourceStates); + + return { + columns: [], + layerId: addNewLayer(), + layerType: LayerTypes.DATA, + }; + }, + + convertToRuntimeState(state, datasourceStates) { + return convertToRuntimeState(state, datasourceStates); }, onDatasourceUpdate(state, frame) { @@ -497,6 +504,7 @@ export const getDatatableVisualization = ({ isDarkMode={isDarkMode} palettes={palettes} paletteService={paletteService} + formatFactory={formatFactory} /> ); }, diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.test.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.test.tsx index 6553c6b1ccda9..09520ff676dbc 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.test.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.test.tsx @@ -13,6 +13,7 @@ import { PieVisualizationState } from '../../../common/types'; import { DimensionEditor, DimensionEditorProps } from './dimension_editor'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { getKbnPalettes } from '@kbn/palettes'; +import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; const darkMode = false; const paletteServiceMock = chartPluginMock.createPaletteRegistry(); @@ -39,7 +40,7 @@ describe('DimensionEditor', () => { colorMapping: { assignments: [], specialAssignments: [ - { rule: { type: 'other' }, color: { type: 'loop' }, touched: false }, + { rules: [{ type: 'other' }], color: { type: 'loop' }, touched: false }, ], paletteId: 'default', colorMode: { type: 'categorical' }, @@ -49,6 +50,7 @@ describe('DimensionEditor', () => { }; const mockFrame = createMockFramePublicAPI(); + const fieldFormatsMock = fieldFormatsServiceMock.createStartContract(); mockFrame.datasourceLayers = Object.fromEntries( defaultState.layers.map(({ layerId: id }) => [id, createMockDatasource(id).publicAPIMock]) ); @@ -67,6 +69,7 @@ describe('DimensionEditor', () => { isDarkMode: darkMode, palettes, paletteService: paletteServiceMock, + formatFactory: fieldFormatsMock.deserialize, }; buildProps = (props = {}) => { diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.tsx index 0016aea86cc68..1ba33389a6bf2 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/dimension_editor.tsx @@ -14,7 +14,7 @@ import { ColorMapping, SPECIAL_TOKENS_STRING_CONVERSION, } from '@kbn/coloring'; -import { ColorPicker } from '@kbn/visualization-ui-components'; +import { ColorPicker, FormatFactory } from '@kbn/visualization-ui-components'; import { useDebouncedValue } from '@kbn/visualization-utils'; import { EuiFormRow, EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiText, EuiBadge } from '@elastic/eui'; import { useState, useCallback } from 'react'; @@ -34,9 +34,11 @@ import { isCollapsed, } from './visualization'; import { trackUiCounterEvents } from '../../lens_ui_telemetry'; +import { getDatatableColumn } from '../../../common/expressions/datatable/utils'; import { getSortedAccessorsForGroup } from './to_expression'; export type DimensionEditorProps = VisualizationDimensionEditorProps & { + formatFactory: FormatFactory; paletteService: PaletteRegistry; palettes: KbnPalettes; isDarkMode: boolean; @@ -50,9 +52,7 @@ export function DimensionEditor(props: DimensionEditorProps) { }); const currentLayer = localState.layers.find((layer) => layer.layerId === props.layerId); - - const canUseColorMapping = currentLayer && currentLayer.colorMapping ? true : false; - const [useNewColorMapping, setUseNewColorMapping] = useState(canUseColorMapping); + const [useNewColorMapping, setUseNewColorMapping] = useState(Boolean(currentLayer?.colorMapping)); const setConfig = useCallback( ({ color }: { color?: string }) => { @@ -132,8 +132,9 @@ export function DimensionEditor(props: DimensionEditorProps) { props.state.palette, currentLayer.colorMapping ); - const table = props.frame.activeData?.[currentLayer.layerId]; - const splitCategories = getColorCategories(table?.rows, props.accessor); + const currentData = props.frame.activeData?.[currentLayer.layerId]; + const columnMeta = getDatatableColumn(currentData, props.accessor)?.meta; + const formatter = props.formatFactory(columnMeta?.params); return ( <> @@ -143,7 +144,7 @@ export function DimensionEditor(props: DimensionEditorProps) { label={i18n.translate('xpack.lens.colorMapping.editColorMappingSectionLabel', { defaultMessage: 'Color mapping', })} - style={{ alignItems: 'center' }} + css={{ alignItems: 'center' }} fullWidth > - {canUseColorMapping || useNewColorMapping ? ( + {useNewColorMapping ? ( ) : ( { const { getPieVisualization } = await import('../../async_services'); - const palettes = await charts.palettes.getPalettes(); + const paletteService = await charts.palettes.getPalettes(); - return getPieVisualization({ paletteService: palettes, kibanaTheme: core.theme }); + return getPieVisualization({ paletteService, kibanaTheme: core.theme, formatFactory }); }); } } diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/persistence.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/persistence.tsx deleted file mode 100644 index ba8632659836a..0000000000000 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/persistence.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { LegendValue } from '@elastic/charts'; -import { cloneDeep } from 'lodash'; -import { PieLayerState, PieVisualizationState } from '../../../common/types'; - -type PersistedPieLayerState = PieLayerState & { - showValuesInLegend?: boolean; -}; - -export type PersistedPieVisualizationState = Omit & { - layers: PersistedPieLayerState[]; -}; - -export function convertToRuntime(state: PersistedPieVisualizationState) { - let newState = cloneDeep(state) as unknown as PieVisualizationState; - newState = convertToLegendStats(newState); - return newState; -} - -function convertToLegendStats(state: PieVisualizationState) { - state.layers.forEach((l) => { - if ('showValuesInLegend' in l) { - l.legendStats = [ - ...new Set([ - ...(l.showValuesInLegend ? [LegendValue.Value] : []), - ...(l.legendStats || []), - ]), - ]; - } - delete (l as PersistedPieLayerState).showValuesInLegend; - }); - - return state; -} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/index.ts new file mode 100644 index 0000000000000..8c70d7b60a7dc --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { GeneralDatasourceStates } from '../../../../state_management'; +import { convertToLegendStats } from './legend_stats'; +import { convertToRawColorMappingsFn } from './raw_color_mappings'; + +export const getRuntimeConverters = (datasourceStates?: Readonly) => [ + convertToLegendStats, + convertToRawColorMappingsFn(datasourceStates), +]; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/legend_stats.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/legend_stats.tsx new file mode 100644 index 0000000000000..3eb741e5a072f --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/legend_stats.tsx @@ -0,0 +1,42 @@ +/* + * 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 { LegendValue } from '@elastic/charts'; + +import { PieLayerState, PieVisualizationState } from '../../../../../common/types'; + +/** @deprecated */ +type DeprecatedLegendValueLayer = PieLayerState & { + showValuesInLegend?: boolean; +}; + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export type DeprecatedLegendValuePieVisualizationState = Omit & { + layers: DeprecatedLegendValueLayer[]; +}; + +export function convertToLegendStats( + state: DeprecatedLegendValuePieVisualizationState | PieVisualizationState +) { + state.layers.forEach((l) => { + if ('showValuesInLegend' in l) { + l.legendStats = [ + ...new Set([ + ...(l.showValuesInLegend ? [LegendValue.Value] : []), + ...(l.legendStats ?? []), + ]), + ]; + } + delete (l as DeprecatedLegendValueLayer).showValuesInLegend; + }); + + return state; +} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/raw_color_mappings.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/raw_color_mappings.ts new file mode 100644 index 0000000000000..71cf8b120f70d --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/converters/raw_color_mappings.ts @@ -0,0 +1,68 @@ +/* + * 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 { PieLayerState, PieVisualizationState } from '../../../../../common/types'; +import { + DeprecatedColorMappingConfig, + convertToRawColorMappings, + isDeprecatedColorMapping, + getColumnMetaFn, +} from '../../../../runtime_state/converters/raw_color_mappings'; +import { GeneralDatasourceStates } from '../../../../state_management'; + +/** @deprecated */ +interface DeprecatedColorMappingLayer extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export interface DeprecatedColorMappingPieVisualizationState + extends Omit { + layers: Array; +} + +export const convertToRawColorMappingsFn = ( + datasourceStates?: Readonly +) => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + + return ( + state: DeprecatedColorMappingPieVisualizationState | PieVisualizationState + ): PieVisualizationState => { + const hasDeprecatedColorMappings = state.layers.some((layer) => { + return layer.layerType === 'data' && isDeprecatedColorMapping(layer.colorMapping); + }); + + if (!hasDeprecatedColorMappings) return state as PieVisualizationState; + + const convertedLayers = state.layers.map((layer) => { + if ( + layer.layerType === 'data' && + (layer.colorMapping?.assignments || layer.colorMapping?.specialAssignments) + ) { + const [accessor] = layer.primaryGroups; + const columnMeta = accessor ? getColumnMeta?.(layer.layerId, accessor) : null; + + return { + ...layer, + colorMapping: convertToRawColorMappings(layer.colorMapping, columnMeta), + } satisfies PieLayerState; + } + + return layer as PieLayerState; + }); + + return { + ...state, + layers: convertedLayers, + } satisfies PieVisualizationState; + }; +}; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/index.ts new file mode 100644 index 0000000000000..f40d4a3e9e635 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/runtime_state/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { cloneDeep } from 'lodash'; + +import { PieVisualizationState } from '../../../../common/types'; +import { GeneralDatasourceStates } from '../../../state_management'; + +import { getRuntimeConverters } from './converters'; + +export function convertToRuntimeState( + state: PieVisualizationState, + datasourceStates?: Readonly +): PieVisualizationState { + return getRuntimeConverters(datasourceStates).reduce( + (newState, fn) => fn(newState), + cloneDeep(state) + ); +} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.test.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.test.ts index 5a035c192ea54..001cb521a0727 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.test.ts @@ -15,6 +15,7 @@ import { } from '../../../common/constants'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; +import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; import { FramePublicAPI, OperationDescriptor, Visualization } from '../../types'; import { themeServiceMock } from '@kbn/core/public/mocks'; @@ -22,8 +23,8 @@ import { cloneDeep } from 'lodash'; import { PartitionChartsMeta } from './partition_charts_meta'; import { CollapseFunction } from '../../../common/expressions'; import { PaletteOutput } from '@kbn/coloring'; -import { PersistedPieVisualizationState } from './persistence'; import { LegendValue } from '@elastic/charts'; +import { DeprecatedLegendValuePieVisualizationState } from './runtime_state/converters/legend_stats'; jest.mock('../../id_generator'); @@ -40,6 +41,7 @@ const paletteServiceMock = chartPluginMock.createPaletteRegistry(); const pieVisualization = getPieVisualization({ paletteService: paletteServiceMock, kibanaTheme: themeServiceMock.createStartContract(), + formatFactory: fieldFormatsServiceMock.createStartContract().deserialize, }); function getExampleState(): PieVisualizationState { @@ -177,7 +179,7 @@ describe('pie_visualization', () => { expect('showValuesInLegend' in runtimeState.layers[0]).toEqual(false); }); it('loads a xy chart with `showValuesInLegend` property equal to false and converts to legendStats: []', () => { - const persistedState: PersistedPieVisualizationState = getExampleState(); + const persistedState: DeprecatedLegendValuePieVisualizationState = getExampleState(); persistedState.layers[0].showValuesInLegend = false; const runtimeState = pieVisualization.initialize(() => 'first', persistedState); @@ -187,7 +189,7 @@ describe('pie_visualization', () => { }); it('loads a xy chart with `showValuesInLegend` property equal to true and converts to legendStats: [`values`]', () => { - const persistedState: PersistedPieVisualizationState = getExampleState(); + const persistedState: DeprecatedLegendValuePieVisualizationState = getExampleState(); persistedState.layers[0].showValuesInLegend = true; const runtimeState = pieVisualization.initialize(() => 'first', persistedState); diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.tsx index 2c9fab187e17e..f87959492838b 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/partition/visualization.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { @@ -19,9 +20,10 @@ import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public'; import { EuiSpacer } from '@elastic/eui'; import { PartitionVisConfiguration } from '@kbn/visualizations-plugin/common/convert_to_lens'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; -import { AccessorConfig } from '@kbn/visualization-ui-components'; +import type { AccessorConfig, FormatFactory } from '@kbn/visualization-ui-components'; import useObservable from 'react-use/lib/useObservable'; import { getKbnPalettes } from '@kbn/palettes'; + import type { FormBasedPersistedState } from '../../datasources/form_based/types'; import type { Visualization, @@ -54,12 +56,12 @@ import { checkTableForContainsSmallValues } from './render_helpers'; import { DatasourcePublicAPI } from '../..'; import { nonNullable, getColorMappingDefaults } from '../../utils'; import { getColorMappingTelemetryEvents } from '../../lens_ui_telemetry/color_telemetry_helpers'; -import { PersistedPieVisualizationState, convertToRuntime } from './persistence'; import { PIE_RENDER_ARRAY_VALUES, PIE_TOO_MANY_DIMENSIONS, WAFFLE_SMALL_VALUES, } from '../../user_messages_ids'; +import { convertToRuntimeState } from './runtime_state'; const metricLabel = i18n.translate('xpack.lens.pie.groupMetricLabelSingular', { defaultMessage: 'Metric', @@ -127,10 +129,12 @@ export const getDefaultColorForMultiMetricDimension = ({ export const getPieVisualization = ({ paletteService, kibanaTheme, + formatFactory, }: { paletteService: PaletteRegistry; kibanaTheme: ThemeServiceStart; -}): Visualization => ({ + formatFactory: FormatFactory; +}): Visualization => ({ id: 'lnsPie', visualizationTypes, getVisualizationTypeId(state) { @@ -161,10 +165,9 @@ export const getPieVisualization = ({ triggers: [VIS_EVENT_TO_TRIGGER.filter], - initialize(addNewLayer, state, mainPalette) { - if (state) { - return convertToRuntime(state); - } + initialize(addNewLayer, state, mainPalette, datasourceStates) { + if (state) return convertToRuntimeState(state, datasourceStates); + return { shape: PieChartTypes.DONUT, layers: [ @@ -177,6 +180,10 @@ export const getPieVisualization = ({ }; }, + convertToRuntimeState(state, datasourceStates) { + return convertToRuntimeState(state, datasourceStates); + }, + getMainPalette: (state) => { if (!state) { return undefined; @@ -506,6 +513,7 @@ export const getPieVisualization = ({ paletteService={paletteService} palettes={palettes} isDarkMode={theme.darkMode} + formatFactory={formatFactory} /> ); }, diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/index.ts index e58f8fe673127..a76a2f3f49a35 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/index.ts +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/index.ts @@ -7,19 +7,24 @@ import type { CoreSetup } from '@kbn/core/public'; import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import { FormatFactory } from '@kbn/visualization-ui-components'; import type { EditorFrameSetup } from '../../types'; export interface TagcloudVisualizationPluginSetupPlugins { editorFrame: EditorFrameSetup; charts: ChartsPluginSetup; + formatFactory: FormatFactory; } export class TagcloudVisualization { - setup(core: CoreSetup, { editorFrame, charts }: TagcloudVisualizationPluginSetupPlugins) { + setup( + core: CoreSetup, + { editorFrame, formatFactory, charts }: TagcloudVisualizationPluginSetupPlugins + ) { editorFrame.registerVisualization(async () => { const { getTagcloudVisualization } = await import('../../async_services'); - const palettes = await charts.palettes.getPalettes(); - return getTagcloudVisualization({ paletteService: palettes, kibanaTheme: core.theme }); + const paletteService = await charts.palettes.getPalettes(); + return getTagcloudVisualization({ paletteService, kibanaTheme: core.theme, formatFactory }); }); } } diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/converters/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/converters/index.ts new file mode 100644 index 0000000000000..38e4a30fd5d4e --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/converters/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { GeneralDatasourceStates } from '../../../../state_management'; +import { convertToRawColorMappingsFn } from './raw_color_mappings'; + +export const getRuntimeConverters = (datasourceStates?: Readonly) => [ + convertToRawColorMappingsFn(datasourceStates), +]; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/converters/raw_color_mappings.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/converters/raw_color_mappings.ts new file mode 100644 index 0000000000000..736446dec757b --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/converters/raw_color_mappings.ts @@ -0,0 +1,45 @@ +/* + * 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 { + DeprecatedColorMappingConfig, + convertToRawColorMappings, + getColumnMetaFn, +} from '../../../../runtime_state/converters/raw_color_mappings'; +import { isDeprecatedColorMapping } from '../../../../runtime_state/converters/raw_color_mappings/converter'; +import { GeneralDatasourceStates } from '../../../../state_management'; +import { TagcloudState } from '../../types'; + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export interface DeprecatedColorMappingTagcloudState extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +export const convertToRawColorMappingsFn = ( + datasourceStates?: Readonly +) => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + + return (state: DeprecatedColorMappingTagcloudState | TagcloudState): TagcloudState => { + const hasDeprecatedColorMapping = state.colorMapping + ? isDeprecatedColorMapping(state.colorMapping) + : false; + + if (!hasDeprecatedColorMapping) return state as TagcloudState; + + const columnMeta = state.tagAccessor ? getColumnMeta?.(state.layerId, state.tagAccessor) : null; + + return { + ...state, + colorMapping: state.colorMapping && convertToRawColorMappings(state.colorMapping, columnMeta), + } satisfies TagcloudState; + }; +}; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/index.ts new file mode 100644 index 0000000000000..d67bb5c6e61ce --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/runtime_state/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { GeneralDatasourceStates } from '../../../state_management'; +import { TagcloudState } from '../types'; +import { getRuntimeConverters } from './converters'; + +export function convertToRuntimeState( + state: TagcloudState, + datasourceStates?: Readonly +): TagcloudState { + return getRuntimeConverters(datasourceStates).reduce((newState, fn) => fn(newState), state); +} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tagcloud_visualization.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tagcloud_visualization.tsx index 33b99530951df..f7148efef60c8 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tagcloud_visualization.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tagcloud_visualization.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; + import { i18n } from '@kbn/i18n'; import { CoreTheme, ThemeServiceStart } from '@kbn/core/public'; import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public'; @@ -21,6 +22,7 @@ import { IconChartTagcloud } from '@kbn/chart-icons'; import { SystemPaletteExpressionFunctionDefinition } from '@kbn/charts-plugin/common'; import useObservable from 'react-use/lib/useObservable'; import { getKbnPalettes } from '@kbn/palettes'; +import { FormatFactory } from '@kbn/visualization-ui-components'; import type { OperationMetadata, Visualization } from '../..'; import { getColorMappingDefaults } from '../../utils'; import type { TagcloudState } from './types'; @@ -29,6 +31,7 @@ import { TagcloudToolbar } from './tagcloud_toolbar'; import { TagsDimensionEditor } from './tags_dimension_editor'; import { DEFAULT_STATE, TAGCLOUD_LABEL } from './constants'; import { getColorMappingTelemetryEvents } from '../../lens_ui_telemetry/color_telemetry_helpers'; +import { convertToRuntimeState } from './runtime_state'; const TAG_GROUP_ID = 'tags'; const METRIC_GROUP_ID = 'metric'; @@ -36,9 +39,11 @@ const METRIC_GROUP_ID = 'metric'; export const getTagcloudVisualization = ({ paletteService, kibanaTheme, + formatFactory, }: { paletteService: PaletteRegistry; kibanaTheme: ThemeServiceStart; + formatFactory: FormatFactory; }): Visualization => ({ id: 'lnsTagcloud', @@ -104,16 +109,20 @@ export const getTagcloudVisualization = ({ triggers: [VIS_EVENT_TO_TRIGGER.filter], - initialize(addNewLayer, state, mainPalette) { - return ( - state || { - layerId: addNewLayer(), - layerType: LayerTypes.DATA, - ...DEFAULT_STATE, - colorMapping: - mainPalette?.type === 'colorMapping' ? mainPalette.value : getColorMappingDefaults(), - } - ); + initialize(addNewLayer, state, mainPalette, datasourceStates) { + if (state) return convertToRuntimeState(state, datasourceStates); + + return { + layerId: addNewLayer(), + layerType: LayerTypes.DATA, + ...DEFAULT_STATE, + colorMapping: + mainPalette?.type === 'colorMapping' ? mainPalette.value : getColorMappingDefaults(), + }; + }, + + convertToRuntimeState(state, datasourceStates) { + return convertToRuntimeState(state, datasourceStates); }, getConfiguration({ state }) { @@ -310,6 +319,7 @@ export const getTagcloudVisualization = ({ frame={props.frame} panelRef={props.panelRef} isInlineEditing={props.isInlineEditing} + formatFactory={formatFactory} /> ); } diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tags_dimension_editor.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tags_dimension_editor.tsx index 2e9b451015a4f..6f7114d383f1a 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tags_dimension_editor.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/tagcloud/tags_dimension_editor.tsx @@ -20,6 +20,7 @@ import { useState, MutableRefObject, useCallback } from 'react'; import { useDebouncedValue } from '@kbn/visualization-utils'; import { getColorCategories } from '@kbn/chart-expressions-common'; import { KbnPalettes } from '@kbn/palettes'; +import type { FormatFactory } from '@kbn/visualization-ui-components'; import type { TagcloudState } from './types'; import { PalettePanelContainer, @@ -28,6 +29,7 @@ import { } from '../../shared_components'; import { FramePublicAPI } from '../../types'; import { trackUiCounterEvents } from '../../lens_ui_telemetry'; +import { getDatatableColumn } from '../../../common/expressions/datatable/utils'; interface Props { paletteService: PaletteRegistry; @@ -38,6 +40,7 @@ interface Props { panelRef: MutableRefObject; isDarkMode: boolean; isInlineEditing?: boolean; + formatFactory: FormatFactory; } export function TagsDimensionEditor({ @@ -49,13 +52,14 @@ export function TagsDimensionEditor({ palettes, paletteService, isInlineEditing, + formatFactory, }: Props) { const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ value: state, onChange: setState, }); - const [useNewColorMapping, setUseNewColorMapping] = useState(state.colorMapping ? true : false); + const [useNewColorMapping, setUseNewColorMapping] = useState(Boolean(state.colorMapping)); const colors = getPaletteDisplayColors( paletteService, @@ -64,8 +68,10 @@ export function TagsDimensionEditor({ state.palette, state.colorMapping ); - const table = frame.activeData?.[state.layerId]; - const splitCategories = getColorCategories(table?.rows, state.tagAccessor); + const currentData = frame.activeData?.[state.layerId]; + const formatter = !state.tagAccessor + ? undefined + : formatFactory(getDatatableColumn(currentData, state.tagAccessor)?.meta?.params); const setColorMapping = useCallback( (colorMapping?: ColorMapping.Config) => { @@ -88,15 +94,13 @@ export function TagsDimensionEditor({ [localState, setLocalState] ); - const canUseColorMapping = state.colorMapping; - return ( - {canUseColorMapping || useNewColorMapping ? ( + {useNewColorMapping ? ( ) : ( & { valuesInLegend?: boolean; }; -export function convertToRuntime( +export function convertPersistedState( state: XYPersistedState, annotationGroups?: AnnotationGroups, references?: SavedObjectReference[] ) { - let newState = cloneDeep(injectReferences(state, annotationGroups, references)); - newState = convertToLegendStats(newState); - return newState; + return cloneDeep(injectReferences(state, annotationGroups, references)); } export function convertToPersistable(state: XYState) { @@ -276,25 +273,3 @@ function injectReferences( .filter(nonNullable), }; } - -function convertToLegendStats(state: XYState & { valuesInLegend?: unknown }) { - if ('valuesInLegend' in state) { - const valuesInLegend = state.valuesInLegend; - delete state.valuesInLegend; - const result: XYState = { - ...state, - legend: { - ...state.legend, - legendStats: [ - ...new Set([ - ...(valuesInLegend ? [LegendValue.CurrentAndLastValue] : []), - ...(state.legend.legendStats || []), - ]), - ], - }, - }; - - return result; - } - return state; -} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/index.ts new file mode 100644 index 0000000000000..8c70d7b60a7dc --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { GeneralDatasourceStates } from '../../../../state_management'; +import { convertToLegendStats } from './legend_stats'; +import { convertToRawColorMappingsFn } from './raw_color_mappings'; + +export const getRuntimeConverters = (datasourceStates?: Readonly) => [ + convertToLegendStats, + convertToRawColorMappingsFn(datasourceStates), +]; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/legend_stats.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/legend_stats.ts new file mode 100644 index 0000000000000..f6b7defb0a645 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/legend_stats.ts @@ -0,0 +1,42 @@ +/* + * 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 { LegendValue } from '@elastic/charts'; + +import { XYState } from '../../types'; + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +interface DeprecatedLegendValueXYState extends XYState { + valuesInLegend?: boolean; +} + +export function convertToLegendStats(state: DeprecatedLegendValueXYState | XYState): XYState { + if ('valuesInLegend' in state) { + const valuesInLegend = state.valuesInLegend; + delete state.valuesInLegend; + + const result: XYState = { + ...state, + legend: { + ...state.legend, + legendStats: [ + ...new Set([ + ...(valuesInLegend ? [LegendValue.CurrentAndLastValue] : []), + ...(state.legend.legendStats ?? []), + ]), + ], + }, + }; + + return result; + } + return state; +} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/raw_color_mappings.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/raw_color_mappings.ts new file mode 100644 index 0000000000000..ef32ede5d0ece --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/converters/raw_color_mappings.ts @@ -0,0 +1,65 @@ +/* + * 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 { + DeprecatedColorMappingConfig, + convertToRawColorMappings, + getColumnMetaFn, + isDeprecatedColorMapping, +} from '../../../../runtime_state/converters/raw_color_mappings'; +import { GeneralDatasourceStates } from '../../../../state_management'; +import { XYDataLayerConfig, XYLayerConfig, XYState } from '../../types'; + +/** @deprecated */ +interface DeprecatedColorMappingLayer extends Omit { + colorMapping: DeprecatedColorMappingConfig; +} + +/** + * Old color mapping state meant for type safety during runtime migrations of old configurations + * + * @deprecated + */ +export interface DeprecatedColorMappingsState extends Omit { + layers: Array; +} + +export const convertToRawColorMappingsFn = ( + datasourceStates?: Readonly +) => { + const getColumnMeta = getColumnMetaFn(datasourceStates); + + return (state: DeprecatedColorMappingsState | XYState): XYState => { + const hasDeprecatedColorMappings = state.layers.some((layer) => { + return layer.layerType === 'data' && isDeprecatedColorMapping(layer.colorMapping); + }); + + if (!hasDeprecatedColorMappings) return state as XYState; + + const convertedLayers = state.layers.map((layer) => { + if ( + layer.layerType === 'data' && + (layer.colorMapping?.assignments || layer.colorMapping?.specialAssignments) + ) { + const accessor = layer.splitAccessor; + const columnMeta = accessor ? getColumnMeta?.(layer.layerId, accessor) : null; + + return { + ...layer, + colorMapping: convertToRawColorMappings(layer.colorMapping, columnMeta), + } satisfies XYDataLayerConfig; + } + + return layer as XYLayerConfig; + }); + + return { + ...state, + layers: convertedLayers, + }; + }; +}; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/index.ts b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/index.ts new file mode 100644 index 0000000000000..3366ae852cfc3 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/runtime_state/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { cloneDeep } from 'lodash'; + +import { GeneralDatasourceStates } from '../../../state_management'; +import { XYState } from '../types'; +import { getRuntimeConverters } from './converters'; + +export function convertToRuntimeState( + state: XYState, + datasourceStates?: Readonly +): XYState { + return getRuntimeConverters(datasourceStates).reduce( + (newState, fn) => fn(newState), + cloneDeep(state) + ); +} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.test.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.test.tsx index ecb3c16fb6dda..33c59de1b22ae 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.test.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.test.tsx @@ -230,9 +230,11 @@ describe('xy_visualization', () => { "color": Object { "type": "loop", }, - "rule": Object { - "type": "other", - }, + "rules": Array [ + Object { + "type": "other", + }, + ], "touched": false, }, ], @@ -278,6 +280,7 @@ describe('xy_visualization', () => { ], } as XYPersistedState, undefined, + undefined, {}, [ { @@ -320,6 +323,7 @@ describe('xy_visualization', () => { ], }, undefined, + undefined, {}, [ { @@ -393,6 +397,7 @@ describe('xy_visualization', () => { ], } as XYPersistedState, undefined, + undefined, { [annotationGroupId1]: { annotations: [exampleAnnotation], @@ -519,6 +524,7 @@ describe('xy_visualization', () => { layers: [...baseState.layers, ...persistedAnnotationLayers], } as XYPersistedState, undefined, + undefined, libraryAnnotationGroups, references ).layers @@ -593,6 +599,7 @@ describe('xy_visualization', () => { layers: [...baseState.layers, ...persistedAnnotationLayers], } as XYPersistedState, undefined, + undefined, libraryAnnotationGroups, references ).layers diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.tsx index 0a596ca1ea224..f251a028257a9 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.tsx @@ -6,7 +6,12 @@ */ import React, { useState } from 'react'; +import { isEqual } from 'lodash'; +import useObservable from 'react-use/lib/useObservable'; + import { Position } from '@elastic/charts'; +import { EuiPopover, EuiSelectable } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import type { PaletteRegistry } from '@kbn/coloring'; @@ -22,14 +27,12 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/ import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; import type { EventAnnotationGroupConfig } from '@kbn/event-annotation-common'; -import { isEqual } from 'lodash'; import { type AccessorConfig, DimensionTrigger } from '@kbn/visualization-ui-components'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { getColorsFromMapping } from '@kbn/coloring'; -import useObservable from 'react-use/lib/useObservable'; -import { EuiPopover, EuiSelectable } from '@elastic/eui'; import { ToolbarButton } from '@kbn/shared-ux-button-toolbar'; import { getKbnPalettes } from '@kbn/palettes'; + import { generateId } from '../../id_generator'; import { isDraggedDataViewField, @@ -121,7 +124,7 @@ import { LayerSettings } from './layer_settings'; import { IgnoredGlobalFiltersEntries } from '../../shared_components/ignore_global_filter'; import { getColorMappingTelemetryEvents } from '../../lens_ui_telemetry/color_telemetry_helpers'; import { getLegendStatsTelemetryEvents } from './legend_stats_telemetry_helpers'; -import { XYPersistedState, convertToPersistable, convertToRuntime } from './persistence'; +import { XYPersistedState, convertPersistedState, convertToPersistable } from './persistence'; import { shouldDisplayTable } from '../../shared_components/legend/legend_settings_popover'; import { ANNOTATION_MISSING_DATE_HISTOGRAM, @@ -134,6 +137,7 @@ import { } from '../../user_messages_ids'; import { AnnotationsPanel } from './xy_config_panel/annotations_config_panel/annotations_panel'; import { ReferenceLinePanel } from './xy_config_panel/reference_line_config_panel/reference_line_panel'; +import { convertToRuntimeState } from './runtime_state'; const XY_ID = 'lnsXY'; @@ -298,14 +302,17 @@ export const getXyVisualization = ({ initialize( addNewLayer, - state, + persistedState, mainPalette?, + datasourceStates?, annotationGroups?: AnnotationGroups, references?: SavedObjectReference[] ) { - if (state) { - return convertToRuntime(state, annotationGroups!, references); + if (persistedState) { + const convertedState = convertPersistedState(persistedState, annotationGroups, references); + return convertToRuntimeState(convertedState, datasourceStates); } + return { title: 'Empty XY chart', legend: { isVisible: true, position: Position.Right }, @@ -327,6 +334,10 @@ export const getXyVisualization = ({ }; }, + convertToRuntimeState(state, datasourceStates) { + return convertToRuntimeState(state, datasourceStates); + }, + getLayerType(layerId, state) { return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType; }, @@ -1091,10 +1102,11 @@ export const getXyVisualization = ({ return suggestion; }, - isEqual(state1, references1, state2, references2, annotationGroups) { - const injected1 = convertToRuntime(state1, annotationGroups, references1); - const injected2 = convertToRuntime(state2, annotationGroups, references2); - return isEqual(injected1, injected2); + isEqual(persistedState1, references1, persistedState2, references2, annotationGroups) { + const state1 = convertPersistedState(persistedState1, annotationGroups, references1); + const state2 = convertPersistedState(persistedState2, annotationGroups, references2); + + return isEqual(state1, state2); }, getVisualizationInfo(state, frame) { diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx index fd31ffcfd1245..a07468e25fb9c 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/xy/xy_config_panel/dimension_editor.tsx @@ -11,7 +11,7 @@ import { useDebouncedValue } from '@kbn/visualization-utils'; import { ColorPicker } from '@kbn/visualization-ui-components'; import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; -import { PaletteRegistry, ColorMapping, PaletteOutput } from '@kbn/coloring'; +import { PaletteRegistry, ColorMapping, PaletteOutput, canCreateCustomMatch } from '@kbn/coloring'; import { getColorCategories } from '@kbn/chart-expressions-common'; import type { ValuesType } from 'utility-types'; import { KbnPalette, KbnPalettes } from '@kbn/palettes'; @@ -132,11 +132,13 @@ export function DataDimensionEditor( ).color; }, [props.frame, props.paletteService, state.layers, accessor, props.formatFactory, layer]); - const table = props.frame.activeData?.[layer.layerId]; - const { splitAccessor } = layer; - const splitCategories = getColorCategories(table?.rows, splitAccessor); - if (props.groupId === 'breakdown') { + const currentData = props.frame.activeData?.[layer.layerId]; + const splitCategories = getColorCategories(currentData?.rows, layer.splitAccessor); + const columnMeta = currentData?.columns?.find(({ id }) => id === layer.splitAccessor)?.meta; + const allowCustomMatch = canCreateCustomMatch(columnMeta); + const formatter = props.formatFactory(columnMeta?.params); + return !layer.collapseFn ? ( ) : null; } diff --git a/x-pack/test/functional/apps/lens/group2/color_mapping_runtime_migrations.ts b/x-pack/test/functional/apps/lens/group2/color_mapping_runtime_migrations.ts new file mode 100644 index 0000000000000..93dd737db34b0 --- /dev/null +++ b/x-pack/test/functional/apps/lens/group2/color_mapping_runtime_migrations.ts @@ -0,0 +1,456 @@ +/* + * 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 expect from '@kbn/expect'; + +import { getKbnPalettes, KbnPalette } from '@kbn/palettes'; +import { DebugState } from '@elastic/charts'; +import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; +import chroma from 'chroma-js'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const oldColorMappingsDashboardFixture = + 'x-pack/test/functional/fixtures/kbn_archiver/lens/old_color_mapping_dashboard.json'; + +const palettes = getKbnPalettes({ darkMode: false }); +const defaultPaletteColors = palettes + .get(KbnPalette.Default) + .colors() + .map((c) => c.toLowerCase()); +const neutralPaletteColors = palettes + .get(KbnPalette.Neutral) + .colors() + .map((c) => c.toLowerCase()); + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { lens, dashboard } = getPageObjects(['lens', 'dashboard']); + const log = getService('log'); + const retry = getService('retry'); + const browser = getService('browser'); + const elasticChart = getService('elasticChart'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + describe('lens old color mapping runtime migrations', () => { + let panels: WebElementWrapper[] = []; + let panelTitleIndex = new Map(); + + before(async () => { + await kibanaServer.importExport.load(oldColorMappingsDashboardFixture); + await dashboard.navigateToApp(); + await dashboard.gotoDashboardEditMode('Old Color Mappings'); + await elasticChart.setNewChartUiDebugFlag(true); + await testSubjects.click('querySubmitButton'); + + const titles = await dashboard.getPanelTitles(); + panelTitleIndex = new Map(titles.map((t, i) => [t, i])); + panels = await dashboard.getDashboardPanels(); + }); + + after(async () => { + await kibanaServer.importExport.unload(oldColorMappingsDashboardFixture); + }); + + function getPaneIndex(title: string) { + const panelIndex = panelTitleIndex.get(title); + if (panelIndex === undefined) { + throw new Error(`Panel "${title}" not found`); + } + + return panelIndex; + } + async function getPanelChartDebugState(title: string) { + const panelIndex = getPaneIndex(title); + return dashboard.getPanelChartDebugState(panelIndex); + } + + function getDefaultAutoColors(seriesNames: string[] = []) { + return seriesNames.map((name, i) => [name, defaultPaletteColors[i]]); + } + + describe('General', () => { + describe('XY chart (Bar)', () => { + const getBarColors = ({ bars = [] }: DebugState) => + bars.map(({ name, color }) => [name, color]); + + it('should render correct "Auto Assigned" color mappings', async () => { + const chartData = await getPanelChartDebugState('Auto Assigned'); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Manually Assigned" color mapping', async () => { + const chartData = await getPanelChartDebugState('Manually Assigned'); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql([ + ['CN', defaultPaletteColors[1]], // first 2 switched to ensure correct assignment + ['IN', defaultPaletteColors[0]], + ...defaultColors.slice(2), + ]); + }); + + it('should render correct "Fixed Assigned" color mapping', async () => { + const chartData = await getPanelChartDebugState('Fixed Assigned'); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + // First and last 2 are set to natural colors + expect(seriesColors).to.eql([ + ['CN', neutralPaletteColors[3]], + ['IN', neutralPaletteColors[2]], + ...defaultColors.slice(2, 13), + ['VN', neutralPaletteColors[1]], + ['EG', neutralPaletteColors[0]], + ]); + }); + + it('should render correct "Custom assigned" color mapping', async () => { + const chartData = await getPanelChartDebugState('Custom assigned'); + const seriesColors = getBarColors(chartData); + + expect(seriesColors).to.eql([ + ['CN', neutralPaletteColors[3]], + ['IN', neutralPaletteColors[2]], + ['US', '#702339'], // custom color + ['ID', '#08b725'], // custom color + ['PK', '#f1ff86'], // custom color + ['BR', defaultPaletteColors[5]], + ['RU', defaultPaletteColors[6]], + ['NG', defaultPaletteColors[7]], // dual color assignment with JP below + ['JP', defaultPaletteColors[7]], + ['BD', defaultPaletteColors[8]], + ['MX', defaultPaletteColors[9]], + ['PH', defaultPaletteColors[10]], // custom color + ['IR', '#d76042'], // custom color + ['VN', neutralPaletteColors[4]], // rest are same color + ['EG', neutralPaletteColors[4]], + ]); + }); + + it('should render correct "Multi-field auto assigned" color mapping', async () => { + const chartData = await getPanelChartDebugState('Multi-field auto assigned'); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Multi-field manually assigned" color mapping', async () => { + const chartData = await getPanelChartDebugState('Multi-field manually assigned'); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql([ + ['CN › jpg', defaultPaletteColors[1]], // first 2 switched to ensure correct assignment + ['IN › jpg', defaultPaletteColors[0]], + ...defaultColors.slice(2, 13), + ['CN › gif', defaultPaletteColors[13]], // last 2 assigned same color + ['JP › jpg', defaultPaletteColors[13]], + ]); + }); + + it('should render correct "RangeKey no label auto assigned" color mapping', async () => { + const chartData = await getPanelChartDebugState('RangeKey no label auto assigned'); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "RangeKey no label manually assigned" color mapping', async () => { + const chartData = await getPanelChartDebugState('RangeKey no label manually assigned'); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "RangeKey no label manually assigned before putting labels" color mapping', async () => { + const chartData = await getPanelChartDebugState( + 'RangeKey no label manually assigned before putting labels' + ); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "RangeKey no label manually assigned after putting labels with label" color mapping', async () => { + const chartData = await getPanelChartDebugState( + 'RangeKey no label manually assigned after putting labels with label' + ); + const seriesColors = getBarColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + }); + + describe('Pie chart', () => { + const getPartitionColors = ({ partition = [] }: DebugState) => { + const colors = partition[0].partitions.map(({ name, color }) => [name, color]); + const firstColor = colors.pop() as any[]; + return [firstColor, ...colors]; // fix order - first color is left of center + }; + it('should render correct "Auto Assigned - Pie" color mapping', async () => { + const chartData = await getPanelChartDebugState('Auto Assigned - Pie'); + const seriesColors = getPartitionColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Manually Assigned - Pie" color mapping', async () => { + const chartData = await getPanelChartDebugState('Manually Assigned - Pie'); + const seriesColors = getPartitionColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Multi-field auto assigned - Pie" color mapping', async () => { + const chartData = await getPanelChartDebugState('Multi-field auto assigned - Pie'); + const seriesColors = getPartitionColors(chartData); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "RangeKey no label auto assigned - Pie" color mapping', async () => { + const chartData = await getPanelChartDebugState('RangeKey no label auto assigned - Pie'); + const seriesColors = getPartitionColors(chartData).sort( + ([{ gte: a }], [{ gte: b }]) => a - b // need to fix order to match legend not by ratio + ); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + }); + + describe('Tag Cloud', () => { + const getTagCloudColors = async (title: string) => { + const panel = panels[getPaneIndex(title)]; + const tags = await panel.findAllByCssSelector(`svg text`); + const colors = await Promise.all( + tags.map(async (t) => ({ + text: await t.getVisibleText(), + color: chroma(await t.getComputedStyle('fill')).hex(), // eui converts hex to rgb + fontSize: await t.getComputedStyle('font-size'), + })) + ); + return colors + .sort(({ fontSize: a }, { fontSize: b }) => parseInt(b, 10) - parseInt(a, 10)) + .map(({ text, color }) => [text, color]); + }; + + it('should render correct "Auto Assigned - Tag Cloud" color mapping', async () => { + const seriesColors = await getTagCloudColors('Auto Assigned - Tag Cloud'); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Manually Assigned - Tag Cloud" color mapping', async () => { + const seriesColors = await getTagCloudColors('Manually Assigned - Tag Cloud'); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Multi-field manually assigned - Tag Cloud" color mapping', async () => { + const seriesColors = await getTagCloudColors('Multi-field manually assigned - Tag Cloud'); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "RangeKey no label auto assigned - Tag Cloud" color mapping', async () => { + const seriesColors = await getTagCloudColors( + 'RangeKey no label auto assigned - Tag Cloud' + ); + + expect(seriesColors).to.eql([ + // filter order not based on tag size + ['5,000 → 10,000', defaultPaletteColors[2]], + ['1,000 → 5,000', defaultPaletteColors[1]], + ['0 → 1,000', defaultPaletteColors[0]], + ['10,000 → +∞', defaultPaletteColors[3]], + ]); + }); + }); + + describe('Table', () => { + const getTableColors = async (title: string) => { + const panel = panels[getPaneIndex(title)]; + const cells = await panel.findAllByCssSelector( + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRowCell"][data-gridcell-column-index="0"]` + ); + return Promise.all( + cells.map(async (c) => [ + await c.getVisibleText(), + chroma(await c.getComputedStyle('background-color')).hex(), // eui converts hex to rgb + ]) + ); + }; + + it('should render correct "Auto Assigned - Table" color mapping', async () => { + const seriesColors = await getTableColors('Auto Assigned - Table'); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Manually Assigned - Table" color mapping', async () => { + const seriesColors = await getTableColors('Manually Assigned - Table'); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "Multi-field auto assigned - Table" color mapping', async () => { + const seriesColors = await getTableColors('Multi-field auto assigned - Table'); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + + it('should render correct "RangeKey no label auto assigned - Table" color mapping', async () => { + const seriesColors = await getTableColors('RangeKey no label auto assigned - Table'); + const defaultColors = getDefaultAutoColors(seriesColors.map(([name]) => name)); + + expect(seriesColors).to.eql(defaultColors); + }); + }); + }); + + // tests below are intended to be run in series + describe('Saving with new color mapping config', () => { + const customColor = '#0118d8'; + + async function editAndApplyColorMapping(panelTitle: string, dimension: string, j = 1) { + await log.debug(`editAndApplyColorMapping: "${panelTitle}"`); + const panel = panels[getPaneIndex(panelTitle)]; + await panel.moveMouseTo({ xOffset: 10, yOffset: -10 }); + await dashboardPanelActions.clickInlineEdit(panel); + await testSubjects.click(dimension); + await testSubjects.click('lns_colorEditing_trigger'); + await testSubjects.click('lns-colorMapping-colorSwatch-0'); + + await testSubjects.click('lns-colorMapping-colorPicker-tab-custom'); + await testSubjects.setValue('lns-colorMapping-colorPicker-custom-input', customColor, { + typeCharByChar: true, + clearWithKeyboard: true, + }); + + await retry.waitFor('verify color is correct before continuing', async () => { + const swatch = await testSubjects.find('lns-colorMapping-colorSwatch-0'); + const color = chroma(await swatch.getComputedStyle('background-color')).hex(); // eui converts hex to rgb + return color === customColor; + }); + + await browser.pressKeys(browser.keys.ESCAPE); + await testSubjects.click('lns-indexPattern-SettingWithSiblingFlyoutBack'); + await lens.closeDimensionEditor(); + + await retry.try(async () => { + // Clicking apply on flyout is ridiculously flaky :( + await testSubjects.existOrFail('applyFlyoutButton'); + await testSubjects.click('applyFlyoutButton'); + await testSubjects.missingOrFail('applyFlyoutButton'); + }); + } + + const testParams = { + xy1: ['Manually Assigned', 'lnsXY_splitDimensionPanel'], + xy2: ['Multi-field manually assigned', 'lnsXY_splitDimensionPanel'], + xy3: ['RangeKey no label manually assigned', 'lnsXY_splitDimensionPanel'], + pie: ['Manually Assigned - Pie', 'lnsPie_sliceByDimensionPanel'], + tagCloud: ['Manually Assigned - Tag Cloud', 'lnsTagcloud_tagDimensionPanel'], + table: ['Manually Assigned - Table', 'lnsDatatable_rows'], + } satisfies Record>; + + it('should apply new mappings for xy charts', async () => { + // normal values + await editAndApplyColorMapping(...testParams.xy1); + // multi-field values + await editAndApplyColorMapping(...testParams.xy2); + // range-key values + await editAndApplyColorMapping(...testParams.xy3); + + await dashboard.expectUnsavedChangesBadge(); + await dashboard.clickQuickSave(); + await dashboard.expectMissingUnsavedChangesBadge(); + }); + + it('should apply new mappings for pie vis', async () => { + await editAndApplyColorMapping(...testParams.pie); + + await dashboard.expectUnsavedChangesBadge(); + await dashboard.clickQuickSave(); + await dashboard.expectMissingUnsavedChangesBadge(); + }); + + it('should apply new mappings for tag clouds vis', async () => { + await editAndApplyColorMapping(...testParams.tagCloud); + + await dashboard.expectUnsavedChangesBadge(); + await dashboard.clickQuickSave(); + await dashboard.expectMissingUnsavedChangesBadge(); + }); + + it('should apply new mappings for table vis', async () => { + await editAndApplyColorMapping(...testParams.table); + + await dashboard.expectUnsavedChangesBadge(); + await dashboard.clickQuickSave(); + await dashboard.expectMissingUnsavedChangesBadge(); + }); + + async function verifyCustomColor(panelTitle: string, dimension: string) { + const panel = panels[getPaneIndex(panelTitle)]; + await panel.moveMouseTo(); + await dashboardPanelActions.clickInlineEdit(panel); + await testSubjects.click(dimension); + await testSubjects.click('lns_colorEditing_trigger'); + const swatch = await testSubjects.find('lns-colorMapping-colorSwatch-0'); + const color = chroma(await swatch.getComputedStyle('background-color')).hex(); // eui converts hex to rgb + + expect(color).to.be(customColor); + + await testSubjects.click('lns-indexPattern-SettingWithSiblingFlyoutBack'); + await lens.closeDimensionEditor(); + await testSubjects.click('cancelFlyoutButton'); + } + + it('should save xy chart custom color mappings from above', async () => { + await browser.refresh(); + await dashboard.waitForRenderComplete(); + await dashboard.switchToEditMode(); + panels = await dashboard.getDashboardPanels(); + await verifyCustomColor(...testParams.xy1); + await verifyCustomColor(...testParams.xy2); + await verifyCustomColor(...testParams.xy3); + }); + + it('should save pie chart custom color mappings from above', async () => { + await verifyCustomColor(...testParams.pie); + }); + + it('should save tagCloud chart custom color mappings from above', async () => { + await verifyCustomColor(...testParams.tagCloud); + }); + + it('should save table chart custom color mappings from above', async () => { + await verifyCustomColor(...testParams.tagCloud); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/group2/index.ts b/x-pack/test/functional/apps/lens/group2/index.ts index 8d74dd4930b89..565464a55dc31 100644 --- a/x-pack/test/functional/apps/lens/group2/index.ts +++ b/x-pack/test/functional/apps/lens/group2/index.ts @@ -62,5 +62,6 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./fields_list')); // 2m 7s loadTestFile(require.resolve('./layer_actions')); // 1m 45s loadTestFile(require.resolve('./field_formatters')); // 1m 30s + loadTestFile(require.resolve('./color_mapping_runtime_migrations')); }); }; diff --git a/x-pack/test/functional/fixtures/kbn_archiver/lens/old_color_mapping_dashboard.json b/x-pack/test/functional/fixtures/kbn_archiver/lens/old_color_mapping_dashboard.json new file mode 100644 index 0000000000000..19a99a8ecc815 --- /dev/null +++ b/x-pack/test/functional/fixtures/kbn_archiver/lens/old_color_mapping_dashboard.json @@ -0,0 +1,144 @@ +{ + "id": "8f18975d-bffc-43e2-8a38-972fde01a063", + "type": "dashboard", + "namespaces": [ + "default" + ], + "updated_at": "2025-04-15T01:33:37.884Z", + "created_at": "2025-04-15T01:16:41.078Z", + "updated_by": "u_EWATCHX9oIEsmcXj8aA1FkcaY3DE-XEpsiGTjrR2PmM_0", + "version": "WzEwMSwxXQ==", + "attributes": { + "version": 3, + "description": "A dashboard used to test runtime migrations of old color mapping configurations.", + "timeRestore": false, + "title": "Old Color Mappings", + "controlGroupInput": { + "chainingSystem": "HIERARCHICAL", + "controlStyle": "oneLine", + "showApplySelections": false, + "ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}", + "panelsJSON": "{}" + }, + "optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}", + "panelsJSON": "[{\"type\":\"lens\",\"title\":\"RangeKey no label auto assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"showSingleSeries\":true},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"xAccessor\":\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"splitAccessor\":\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"eac02d6a-ab1b-4faf-a19f-9d19db616662\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\":{\"label\":\"bytes\",\"dataType\":\"string\",\"operationType\":\"range\",\"sourceField\":\"bytes\",\"isBucketed\":true,\"scale\":\"ordinal\",\"params\":{\"type\":\"range\",\"ranges\":[{\"from\":0,\"to\":1000,\"label\":\"\"},{\"from\":1000,\"to\":5000,\"label\":\"\"},{\"from\":5000,\"to\":10000,\"label\":\"\"},{\"from\":10000,\"to\":null,\"label\":\"\"}],\"maxBars\":499.5,\"parentFormat\":{\"id\":\"range\",\"params\":{\"template\":\"arrow_right\",\"replaceInfinity\":true}}}}},\"columnOrder\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"c5b09691-c1c3-438a-9363-bdc17d9ce7c6\",\"gridData\":{\"i\":\"c5b09691-c1c3-438a-9363-bdc17d9ce7c6\",\"y\":14,\"x\":0,\"w\":12,\"h\":9}},{\"type\":\"lens\",\"title\":\"RangeKey no label manually assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"showSingleSeries\":true},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:0,to:1000\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:1000,to:5000\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:5000,to:10000\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:10000,to:undefined\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"xAccessor\":\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"splitAccessor\":\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"eac02d6a-ab1b-4faf-a19f-9d19db616662\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\":{\"label\":\"bytes\",\"dataType\":\"string\",\"operationType\":\"range\",\"sourceField\":\"bytes\",\"isBucketed\":true,\"scale\":\"ordinal\",\"params\":{\"type\":\"range\",\"ranges\":[{\"from\":0,\"to\":1000,\"label\":\"\"},{\"from\":1000,\"to\":5000,\"label\":\"\"},{\"from\":5000,\"to\":10000,\"label\":\"\"},{\"from\":10000,\"to\":null,\"label\":\"\"}],\"maxBars\":499.5,\"parentFormat\":{\"id\":\"range\",\"params\":{\"template\":\"arrow_right\",\"replaceInfinity\":true}}}}},\"columnOrder\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"74edde3c-e78d-4c03-9fc4-7a0d17197777\",\"gridData\":{\"i\":\"74edde3c-e78d-4c03-9fc4-7a0d17197777\",\"y\":14,\"x\":12,\"w\":12,\"h\":9}},{\"type\":\"lens\",\"title\":\"RangeKey no label manually assigned before putting labels\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"showSingleSeries\":true},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:0,to:1000\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:1000,to:5000\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:5000,to:10000\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:10000,to:undefined\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"xAccessor\":\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"splitAccessor\":\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"eac02d6a-ab1b-4faf-a19f-9d19db616662\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\":{\"label\":\"bytes\",\"dataType\":\"string\",\"operationType\":\"range\",\"sourceField\":\"bytes\",\"isBucketed\":true,\"scale\":\"ordinal\",\"params\":{\"type\":\"range\",\"ranges\":[{\"from\":0,\"to\":1000,\"label\":\"up to 1k\"},{\"from\":1000,\"to\":5000,\"label\":\"1k to 5k\"},{\"from\":5000,\"to\":10000,\"label\":\"5k to 10k\"},{\"from\":10000,\"to\":null,\"label\":\"over 10k\"}],\"maxBars\":499.5,\"parentFormat\":{\"id\":\"range\",\"params\":{\"template\":\"arrow_right\",\"replaceInfinity\":true}}}}},\"columnOrder\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"7dc6a2a0-62fa-4474-b48a-ea7204c80921\",\"gridData\":{\"i\":\"7dc6a2a0-62fa-4474-b48a-ea7204c80921\",\"y\":14,\"x\":24,\"w\":12,\"h\":9}},{\"type\":\"lens\",\"title\":\"RangeKey no label manually assigned after putting labels with label\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"showSingleSeries\":true},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:0,to:1000\",\"up to 1k\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:1000,to:5000\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:5000,to:10000\",\"5k to 10k\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"from:10000,to:undefined\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"xAccessor\":\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"splitAccessor\":\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"eac02d6a-ab1b-4faf-a19f-9d19db616662\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\":{\"label\":\"bytes\",\"dataType\":\"string\",\"operationType\":\"range\",\"sourceField\":\"bytes\",\"isBucketed\":true,\"scale\":\"ordinal\",\"params\":{\"type\":\"range\",\"ranges\":[{\"from\":0,\"to\":1000,\"label\":\"up to 1k\"},{\"from\":1000,\"to\":5000,\"label\":\"1k to 5k\"},{\"from\":5000,\"to\":10000,\"label\":\"5k to 10k\"},{\"from\":10000,\"to\":null,\"label\":\"over 10k\"}],\"maxBars\":499.5,\"parentFormat\":{\"id\":\"range\",\"params\":{\"template\":\"arrow_right\",\"replaceInfinity\":true}}}}},\"columnOrder\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"eac02d6a-ab1b-4faf-a19f-9d19db616662\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1}}}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"1aa8bd7f-c7f9-4a0f-83f4-575dc3086d87\",\"gridData\":{\"i\":\"1aa8bd7f-c7f9-4a0f-83f4-575dc3086d87\",\"y\":14,\"x\":36,\"w\":12,\"h\":9}},{\"type\":\"lens\",\"title\":\"Auto Assigned - Pie\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"shape\":\"pie\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"primaryGroups\":[\"5976291c-6998-473f-a8d1-611bc5164bae\"],\"metrics\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"show\",\"nestedLegend\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"b32e4467-4219-4657-9526-45dbfd98928e\",\"gridData\":{\"i\":\"b32e4467-4219-4657-9526-45dbfd98928e\",\"y\":23,\"x\":0,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Multi-field manually assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":false,\"position\":\"right\",\"showSingleSeries\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_horizontal_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"IN\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"US\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"css\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"IN\",\"css\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":4},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"ID\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"png\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"PK\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"BR\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"IN\",\"png\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"RU\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"US\",\"css\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":11},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"NG\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":12},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"gif\"],[\"JP\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":13},\"touched\":false},{\"rule\":{\"type\":\"auto\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":14},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"splitAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"xAccessor\":\"facbdcce-800e-4a35-9b99-533dd3bffa9c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top values of geo.dest + 1 other\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"extension.raw\"]}},\"facbdcce-800e-4a35-9b99-533dd3bffa9c\":{\"label\":\"Top values of geo.dest + 1 other\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"extension.raw\"]}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"facbdcce-800e-4a35-9b99-533dd3bffa9c\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"b065b715-1acd-4976-9308-a7afce4632be\",\"gridData\":{\"i\":\"b065b715-1acd-4976-9308-a7afce4632be\",\"y\":0,\"x\":40,\"w\":8,\"h\":14}},{\"type\":\"lens\",\"title\":\"Multi-field auto assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":false,\"position\":\"right\",\"showSingleSeries\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_horizontal_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"splitAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"xAccessor\":\"facbdcce-800e-4a35-9b99-533dd3bffa9c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top values of geo.dest + 1 other\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"extension.raw\"]}},\"facbdcce-800e-4a35-9b99-533dd3bffa9c\":{\"label\":\"Top values of geo.dest + 1 other\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"extension.raw\"]}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"facbdcce-800e-4a35-9b99-533dd3bffa9c\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"730007b3-8876-4d16-bb01-0cd32a541030\",\"gridData\":{\"i\":\"730007b3-8876-4d16-bb01-0cd32a541030\",\"y\":0,\"x\":32,\"w\":8,\"h\":14}},{\"type\":\"lens\",\"title\":\"Custom assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":false,\"position\":\"right\",\"showSingleSeries\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_horizontal_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"auto\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"neutral\",\"colorIndex\":3},\"touched\":true},{\"rule\":{\"type\":\"auto\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"neutral\",\"colorIndex\":2},\"touched\":true},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"US\"]},\"color\":{\"type\":\"colorCode\",\"colorCode\":\"#702339\"},\"touched\":true},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"ID\"]},\"color\":{\"type\":\"colorCode\",\"colorCode\":\"#08b725\"},\"touched\":true},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PK\"]},\"color\":{\"type\":\"colorCode\",\"colorCode\":\"#f1ff86\"},\"touched\":true},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"RU\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"NG\",\"JP\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BD\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"MX\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PH\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IR\"]},\"color\":{\"type\":\"colorCode\",\"colorCode\":\"#d76042\"},\"touched\":true}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"neutral\",\"colorIndex\":4},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"splitAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"xAccessor\":\"facbdcce-800e-4a35-9b99-533dd3bffa9c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"facbdcce-800e-4a35-9b99-533dd3bffa9c\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"facbdcce-800e-4a35-9b99-533dd3bffa9c\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"0107c1b3-1c31-4dd8-bf52-b7e09285bb9d\",\"gridData\":{\"i\":\"0107c1b3-1c31-4dd8-bf52-b7e09285bb9d\",\"y\":0,\"x\":24,\"w\":8,\"h\":14}},{\"type\":\"lens\",\"title\":\"Fixed Assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":false,\"position\":\"right\",\"showSingleSeries\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_horizontal_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"auto\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"neutral\",\"colorIndex\":3},\"touched\":true},{\"rule\":{\"type\":\"auto\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"neutral\",\"colorIndex\":2},\"touched\":true},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"US\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"ID\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PK\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":4},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"RU\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"NG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"JP\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BD\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"MX\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PH\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":11},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":12},\"touched\":false},{\"rule\":{\"type\":\"auto\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"neutral\",\"colorIndex\":1},\"touched\":true},{\"rule\":{\"type\":\"auto\"},\"color\":{\"type\":\"categorical\",\"paletteId\":\"neutral\",\"colorIndex\":0},\"touched\":true}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"splitAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"xAccessor\":\"facbdcce-800e-4a35-9b99-533dd3bffa9c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"facbdcce-800e-4a35-9b99-533dd3bffa9c\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"facbdcce-800e-4a35-9b99-533dd3bffa9c\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"b80c1b61-689f-40b8-8e4c-5bdd8afa7b24\",\"gridData\":{\"i\":\"b80c1b61-689f-40b8-8e4c-5bdd8afa7b24\",\"y\":0,\"x\":16,\"w\":8,\"h\":14}},{\"type\":\"lens\",\"title\":\"Manually Assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":false,\"position\":\"right\",\"showSingleSeries\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_horizontal_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"CN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"US\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"ID\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PK\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":4},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"RU\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"NG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"JP\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BD\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"MX\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PH\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":11},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":12},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"VN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":13},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"EG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":14},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"splitAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"xAccessor\":\"facbdcce-800e-4a35-9b99-533dd3bffa9c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"facbdcce-800e-4a35-9b99-533dd3bffa9c\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"facbdcce-800e-4a35-9b99-533dd3bffa9c\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"fcd54a3f-c1de-4160-890c-f2f49c32d3e5\",\"gridData\":{\"i\":\"fcd54a3f-c1de-4160-890c-f2f49c32d3e5\",\"y\":0,\"x\":8,\"w\":8,\"h\":14}},{\"type\":\"lens\",\"title\":\"Auto Assigned\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":false,\"position\":\"right\",\"showSingleSeries\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"accessors\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"position\":\"top\",\"seriesType\":\"bar_horizontal_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"splitAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"xAccessor\":\"facbdcce-800e-4a35-9b99-533dd3bffa9c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}},\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"facbdcce-800e-4a35-9b99-533dd3bffa9c\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"facbdcce-800e-4a35-9b99-533dd3bffa9c\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"adcebcdf-e7c3-47a6-b7f1-0985d3c5df71\",\"gridData\":{\"i\":\"adcebcdf-e7c3-47a6-b7f1-0985d3c5df71\",\"y\":0,\"x\":0,\"w\":8,\"h\":14}},{\"type\":\"lens\",\"title\":\"RangeKey no label auto assigned - Pie\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"shape\":\"pie\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"primaryGroups\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\"],\"metrics\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"show\",\"nestedLegend\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\":{\"label\":\"bytes\",\"dataType\":\"string\",\"operationType\":\"range\",\"sourceField\":\"bytes\",\"isBucketed\":true,\"scale\":\"ordinal\",\"params\":{\"type\":\"range\",\"ranges\":[{\"from\":0,\"to\":1000,\"label\":\"\"},{\"from\":1000,\"to\":5000,\"label\":\"\"},{\"from\":5000,\"to\":10000,\"label\":\"\"},{\"from\":10000,\"to\":null,\"label\":\"\"}],\"maxBars\":499.5,\"parentFormat\":{\"id\":\"range\",\"params\":{\"template\":\"arrow_right\",\"replaceInfinity\":true}}}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"c903e5e1-c7ce-473c-b73f-7c90274ededc\",\"gridData\":{\"i\":\"c903e5e1-c7ce-473c-b73f-7c90274ededc\",\"y\":23,\"x\":36,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Multi-field auto assigned - Pie\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"shape\":\"pie\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"primaryGroups\":[\"5976291c-6998-473f-a8d1-611bc5164bae\"],\"metrics\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"show\",\"nestedLegend\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top values of geo.dest + 1 other\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"extension.raw\"]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"7107e929-d477-407d-8219-9f754895d812\",\"gridData\":{\"i\":\"7107e929-d477-407d-8219-9f754895d812\",\"y\":23,\"x\":24,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Manually Assigned - Pie\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"shape\":\"pie\",\"layers\":[{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"primaryGroups\":[\"5976291c-6998-473f-a8d1-611bc5164bae\"],\"metrics\":[\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"show\",\"nestedLegend\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"CN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"US\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"ID\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PK\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":4},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"RU\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"NG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"JP\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BD\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"MX\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PH\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":11},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":12},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"VN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":13},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"EG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":14},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"3c044a2a-fbf7-496d-84ad-9fe4f3262929\",\"gridData\":{\"i\":\"3c044a2a-fbf7-496d-84ad-9fe4f3262929\",\"y\":23,\"x\":12,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Auto Assigned - Tag Cloud\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsTagcloud\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"tagAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"valueAccessor\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\",\"maxFontSize\":72,\"minFontSize\":18,\"orientation\":\"single\",\"showLabel\":true,\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"8857b757-db5b-4a8e-bdfd-1cdcbcae5167\",\"gridData\":{\"i\":\"8857b757-db5b-4a8e-bdfd-1cdcbcae5167\",\"y\":36,\"x\":0,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Manually Assigned - Tag Cloud\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsTagcloud\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"tagAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"valueAccessor\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\",\"maxFontSize\":72,\"minFontSize\":18,\"orientation\":\"single\",\"showLabel\":true,\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"CN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"US\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"ID\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PK\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":4},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"RU\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"NG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"JP\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BD\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"MX\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PH\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":11},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":12},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"VN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":13},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"EG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":14},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"e560d346-403c-496e-a5eb-d8583e66c31a\",\"gridData\":{\"i\":\"e560d346-403c-496e-a5eb-d8583e66c31a\",\"y\":36,\"x\":12,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Multi-field manually assigned - Tag Cloud\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsTagcloud\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"tagAccessor\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"valueAccessor\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\",\"maxFontSize\":50,\"minFontSize\":10,\"orientation\":\"single\",\"showLabel\":true,\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"IN\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"US\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"css\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"IN\",\"css\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":4},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"ID\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"png\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"PK\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"BR\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"IN\",\"png\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"RU\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"US\",\"css\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":11},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"NG\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":12},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"CN\",\"gif\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":13},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[[\"JP\",\"jpg\"]]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":14},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top values of geo.dest + 1 other\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"extension.raw\"]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"c676dfba-178b-49c5-bb38-38f0f315e9ab\",\"gridData\":{\"i\":\"c676dfba-178b-49c5-bb38-38f0f315e9ab\",\"y\":36,\"x\":24,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"RangeKey no label auto assigned - Tag Cloud\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsTagcloud\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"tagAccessor\":\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"valueAccessor\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\",\"maxFontSize\":30,\"minFontSize\":10,\"orientation\":\"single\",\"showLabel\":true,\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\":{\"label\":\"bytes\",\"dataType\":\"string\",\"operationType\":\"range\",\"sourceField\":\"bytes\",\"isBucketed\":true,\"scale\":\"ordinal\",\"params\":{\"type\":\"range\",\"ranges\":[{\"from\":0,\"to\":1000,\"label\":\"\"},{\"from\":1000,\"to\":5000,\"label\":\"\"},{\"from\":5000,\"to\":10000,\"label\":\"\"},{\"from\":10000,\"to\":null,\"label\":\"\"}],\"maxBars\":499.5,\"parentFormat\":{\"id\":\"range\",\"params\":{\"template\":\"arrow_right\",\"replaceInfinity\":true}}}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"85e6c4c0-f88b-4bf6-8d23-f9c4a915e623\",\"gridData\":{\"i\":\"85e6c4c0-f88b-4bf6-8d23-f9c4a915e623\",\"y\":36,\"x\":36,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Auto Assigned - Table\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsDatatable\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"layerType\":\"data\",\"columns\":[{\"columnId\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"colorMode\":\"cell\",\"palette\":{\"type\":\"palette\",\"name\":\"default\",\"params\":{\"stops\":[{\"color\":\"#16c5c0\",\"stop\":20},{\"color\":\"#a6edea\",\"stop\":40},{\"color\":\"#61a2ff\",\"stop\":60},{\"color\":\"#bfdbff\",\"stop\":80},{\"color\":\"#ee72a6\",\"stop\":100}]}},\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},{\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"638689e9-0353-4dbb-af12-ef1e745bb626\",\"gridData\":{\"i\":\"638689e9-0353-4dbb-af12-ef1e745bb626\",\"y\":49,\"x\":0,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Manually Assigned - Table\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsDatatable\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"layerType\":\"data\",\"columns\":[{\"columnId\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"colorMode\":\"cell\",\"palette\":{\"type\":\"palette\",\"name\":\"default\",\"params\":{\"stops\":[{\"color\":\"#16c5c0\",\"stop\":20},{\"color\":\"#a6edea\",\"stop\":40},{\"color\":\"#61a2ff\",\"stop\":60},{\"color\":\"#bfdbff\",\"stop\":80},{\"color\":\"#ee72a6\",\"stop\":100}]}},\"colorMapping\":{\"assignments\":[{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"CN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":0},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":1},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"US\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":2},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"ID\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":3},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PK\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":4},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":5},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"RU\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":6},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"NG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":7},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"JP\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":8},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"BD\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":9},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"MX\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":10},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"PH\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":11},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"IR\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":12},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"VN\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":13},\"touched\":false},{\"rule\":{\"type\":\"matchExactly\",\"values\":[\"EG\"]},\"color\":{\"type\":\"categorical\",\"paletteId\":\"default\",\"colorIndex\":14},\"touched\":false}],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},{\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top 15 values of geo.dest\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"logstash-*\",\"timeField\":\"@timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"acb4ebea-d2fa-4f9a-ad6a-a59b40c8def9\",\"gridData\":{\"i\":\"acb4ebea-d2fa-4f9a-ad6a-a59b40c8def9\",\"y\":49,\"x\":12,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"Multi-field auto assigned - Table\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsDatatable\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"layerType\":\"data\",\"columns\":[{\"columnId\":\"5976291c-6998-473f-a8d1-611bc5164bae\",\"colorMode\":\"cell\",\"palette\":{\"type\":\"palette\",\"name\":\"default\",\"params\":{\"stops\":[{\"color\":\"#16c5c0\",\"stop\":20},{\"color\":\"#a6edea\",\"stop\":40},{\"color\":\"#61a2ff\",\"stop\":60},{\"color\":\"#bfdbff\",\"stop\":80},{\"color\":\"#ee72a6\",\"stop\":100}]}},\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":true}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},{\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"5976291c-6998-473f-a8d1-611bc5164bae\":{\"label\":\"Top values of geo.dest + 1 other\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"geo.dest\",\"isBucketed\":true,\"params\":{\"size\":15,\"orderBy\":{\"type\":\"column\",\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"},\"orderDirection\":\"desc\",\"otherBucket\":false,\"missingBucket\":false,\"parentFormat\":{\"id\":\"multi_terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[\"extension.raw\"]}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"5976291c-6998-473f-a8d1-611bc5164bae\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"2393c634-ba66-45ab-9040-deb00be3fdbc\",\"gridData\":{\"i\":\"2393c634-ba66-45ab-9040-deb00be3fdbc\",\"y\":49,\"x\":24,\"w\":12,\"h\":13}},{\"type\":\"lens\",\"title\":\"RangeKey no label auto assigned - Table\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsDatatable\",\"type\":\"lens\",\"references\":[{\"id\":\"logstash-*\",\"name\":\"indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d\",\"type\":\"index-pattern\"}],\"state\":{\"visualization\":{\"layerId\":\"e777cb89-8599-4122-973a-0548f56d7c1d\",\"layerType\":\"data\",\"columns\":[{\"columnId\":\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"colorMode\":\"cell\",\"palette\":{\"type\":\"palette\",\"name\":\"default\",\"params\":{\"stops\":[{\"color\":\"#16c5c0\",\"stop\":20},{\"color\":\"#a6edea\",\"stop\":40},{\"color\":\"#61a2ff\",\"stop\":60},{\"color\":\"#bfdbff\",\"stop\":80},{\"color\":\"#ee72a6\",\"stop\":100}]}},\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}}},{\"columnId\":\"d7d77a2e-32c7-466b-81ed-31a01750604c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"e777cb89-8599-4122-973a-0548f56d7c1d\":{\"columns\":{\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\":{\"label\":\"bytes\",\"dataType\":\"string\",\"operationType\":\"range\",\"sourceField\":\"bytes\",\"isBucketed\":true,\"scale\":\"ordinal\",\"params\":{\"type\":\"range\",\"ranges\":[{\"from\":0,\"to\":1000,\"label\":\"\"},{\"from\":1000,\"to\":5000,\"label\":\"\"},{\"from\":5000,\"to\":10000,\"label\":\"\"},{\"from\":10000,\"to\":null,\"label\":\"\"}],\"maxBars\":499.5,\"parentFormat\":{\"id\":\"range\",\"params\":{\"template\":\"arrow_right\",\"replaceInfinity\":true}}}},\"d7d77a2e-32c7-466b-81ed-31a01750604c\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"b1399cfd-48ec-4eeb-96d3-cb79221a5540\",\"d7d77a2e-32c7-466b-81ed-31a01750604c\"],\"incompleteColumns\":{},\"sampling\":1,\"indexPatternId\":\"logstash-*\"}},\"currentIndexPatternId\":\"logstash-*\"},\"indexpattern\":{\"layers\":{},\"currentIndexPatternId\":\"logstash-*\"},\"textBased\":{\"layers\":{},\"indexPatternRefs\":[{\"id\":\"logstash-*\",\"title\":\"kibana_sample_data_logs\",\"timeField\":\"timestamp\"}]}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"90870c5d-698a-4adb-a795-6def0adbf6a5\",\"gridData\":{\"i\":\"90870c5d-698a-4adb-a795-6def0adbf6a5\",\"y\":49,\"x\":36,\"w\":12,\"h\":13}}]", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"}}" + } + }, + "references": [ + { + "id": "logstash-*", + "name": "c5b09691-c1c3-438a-9363-bdc17d9ce7c6:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "74edde3c-e78d-4c03-9fc4-7a0d17197777:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "7dc6a2a0-62fa-4474-b48a-ea7204c80921:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "1aa8bd7f-c7f9-4a0f-83f4-575dc3086d87:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "b32e4467-4219-4657-9526-45dbfd98928e:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "b065b715-1acd-4976-9308-a7afce4632be:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "id": "logstash-*", + "name": "730007b3-8876-4d16-bb01-0cd32a541030:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "0107c1b3-1c31-4dd8-bf52-b7e09285bb9d:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "b80c1b61-689f-40b8-8e4c-5bdd8afa7b24:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "fcd54a3f-c1de-4160-890c-f2f49c32d3e5:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "id": "logstash-*", + "name": "adcebcdf-e7c3-47a6-b7f1-0985d3c5df71:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "c903e5e1-c7ce-473c-b73f-7c90274ededc:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "7107e929-d477-407d-8219-9f754895d812:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "3c044a2a-fbf7-496d-84ad-9fe4f3262929:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "id": "logstash-*", + "name": "8857b757-db5b-4a8e-bdfd-1cdcbcae5167:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "e560d346-403c-496e-a5eb-d8583e66c31a:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "c676dfba-178b-49c5-bb38-38f0f315e9ab:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "id": "logstash-*", + "name": "85e6c4c0-f88b-4bf6-8d23-f9c4a915e623:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "638689e9-0353-4dbb-af12-ef1e745bb626:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "type": "index-pattern", + "id": "logstash-*", + "name": "acb4ebea-d2fa-4f9a-ad6a-a59b40c8def9:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d" + }, + { + "id": "logstash-*", + "name": "2393c634-ba66-45ab-9040-deb00be3fdbc:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + }, + { + "id": "logstash-*", + "name": "90870c5d-698a-4adb-a795-6def0adbf6a5:indexpattern-datasource-layer-e777cb89-8599-4122-973a-0548f56d7c1d", + "type": "index-pattern" + } + ], + "managed": false, + "coreMigrationVersion": "8.8.0", + "typeMigrationVersion": "10.2.0" +} \ No newline at end of file