diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index 25d99ed9bfd41..f0d2db651b251 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -19,9 +19,13 @@ import { LayerPanel } from './layer_panel'; import { coreMock } from 'src/core/public/mocks'; import { generateId } from '../../../id_generator'; import { mountWithProvider } from '../../../mocks'; +import { layerTypes } from '../../../../common'; +import { ReactWrapper } from 'enzyme'; jest.mock('../../../id_generator'); +const waitMs = (time: number) => new Promise((r) => setTimeout(r, time)); + let container: HTMLDivElement | undefined; beforeEach(() => { @@ -137,7 +141,7 @@ describe('ConfigPanel', () => { const updater = () => 'updated'; updateDatasource('mockindexpattern', updater); - await new Promise((r) => setTimeout(r, 0)); + await waitMs(0); expect(lensStore.dispatch).toHaveBeenCalledTimes(1); expect( (lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater( @@ -147,7 +151,7 @@ describe('ConfigPanel', () => { updateAll('mockindexpattern', updater, props.visualizationState); // wait for one tick so async updater has a chance to trigger - await new Promise((r) => setTimeout(r, 0)); + await waitMs(0); expect(lensStore.dispatch).toHaveBeenCalledTimes(2); expect( (lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater( @@ -293,4 +297,134 @@ describe('ConfigPanel', () => { expect(focusedEl?.children[0].getAttribute('data-test-subj')).toEqual('lns-layerPanel-1'); }); }); + + describe('initial default value', () => { + function prepareAndMountComponent(props: ReturnType) { + (generateId as jest.Mock).mockReturnValue(`newId`); + return mountWithProvider( + , + + { + preloadedState: { + datasourceStates: { + mockindexpattern: { + isLoading: false, + state: 'state', + }, + }, + activeDatasourceId: 'mockindexpattern', + }, + }, + { + attachTo: container, + } + ); + } + function clickToAddLayer(instance: ReactWrapper) { + act(() => { + instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); + }); + instance.update(); + act(() => { + instance + .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.THRESHOLD}"]`) + .first() + .simulate('click'); + }); + instance.update(); + + return waitMs(0); + } + + function clickToAddDimension(instance: ReactWrapper) { + act(() => { + instance.find('[data-test-subj="lns-empty-dimension"]').last().simulate('click'); + }); + return waitMs(0); + } + + it('should not add an initial dimension when not specified', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { type: layerTypes.DATA, label: 'Data Layer' }, + { + type: layerTypes.THRESHOLD, + label: 'Threshold layer', + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance); + + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + }); + + it('should not add an initial dimension when datasource does not support it', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { type: layerTypes.DATA, label: 'Data Layer' }, + { + type: layerTypes.THRESHOLD, + label: 'Threshold layer', + }, + ]); + + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance); + + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + }); + + it('should use group initial dimension value when adding a new layer if available', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { type: layerTypes.DATA, label: 'Data Layer' }, + { + type: layerTypes.THRESHOLD, + label: 'Threshold layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }, + ], + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance); + + expect(lensStore.dispatch).toHaveBeenCalledTimes(2); + }); + + it('should add an initial dimension value when clicking on the empty dimension button', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'a', + columnId: 'myColumn', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }, + ], + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + + await clickToAddDimension(instance); + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 5a5265c515a86..7c1da98dc1a45 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -178,7 +178,7 @@ export function LayerPanels( ) === 'clear' } onEmptyDimensionAdd={(columnId, { groupId }) => { - addMaybeDefaultThreshold({ + addInitialValueIfAvailable({ ...props, visualization, activeDatasourceId: activeDatasourceId!, @@ -255,7 +255,7 @@ export function LayerPanels( }), }) ); - addMaybeDefaultThreshold({ + addInitialValueIfAvailable({ ...props, activeDatasourceId: activeDatasourceId!, visualization, @@ -270,7 +270,7 @@ export function LayerPanels( ); } -function addMaybeDefaultThreshold({ +function addInitialValueIfAvailable({ activeVisualization, visualization, framePublicAPI, @@ -298,6 +298,7 @@ function addMaybeDefaultThreshold({ const layerInfo = activeVisualization .getSupportedLayers(visualization.state, framePublicAPI) .find(({ type }) => type === layerType); + if (layerInfo?.initialDimensions && datasourceMap[activeDatasourceId]?.initializeDimension) { const info = groupId ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 1c54cda5d2cc8..f777fd0976dfd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -921,4 +921,33 @@ describe('LayerPanel', () => { expect(updateVisualization).toHaveBeenCalledTimes(1); }); }); + + describe('add a new dimension', () => { + it('should call onEmptyDimensionAdd callback on new dimension creation', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + const props = getDefaultProps(); + const { instance } = await mountWithProvider(); + + act(() => { + instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + }); + instance.update(); + + expect(props.onEmptyDimensionAdd).toHaveBeenCalledWith( + 'newid', + expect.objectContaining({ groupId: 'a' }) + ); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx new file mode 100644 index 0000000000000..04c430143a3c8 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + createMockFramePublicAPI, + createMockVisualization, + mountWithProvider, +} from '../../../mocks'; +import { Visualization } from '../../../types'; +import { LayerSettings } from './layer_settings'; + +describe('LayerSettings', () => { + let mockVisualization: jest.Mocked; + const frame = createMockFramePublicAPI(); + + function getDefaultProps() { + return { + activeVisualization: mockVisualization, + layerConfigProps: { + layerId: 'myLayer', + state: {}, + frame, + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + activeData: frame.activeData, + setState: jest.fn(), + }, + }; + } + + beforeEach(() => { + mockVisualization = { + ...createMockVisualization(), + id: 'testVis', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis', + label: 'TEST1', + groupLabel: 'testVisGroup', + }, + ], + }; + }); + + it('should render nothing with no custom renderer nor description', async () => { + // @ts-expect-error + mockVisualization.getDescription.mockReturnValue(undefined); + const { instance } = await mountWithProvider(); + expect(instance.html()).toBe(null); + }); + + it('should render a static header if visualization has only a description value', async () => { + mockVisualization.getDescription.mockReturnValue({ + icon: 'myIcon', + label: 'myVisualizationType', + }); + const { instance } = await mountWithProvider(); + expect(instance.find('StaticHeader').first().prop('label')).toBe('myVisualizationType'); + }); + + it('should call the custom renderer if available', async () => { + mockVisualization.renderLayerHeader = jest.fn(); + await mountWithProvider(); + expect(mockVisualization.renderLayerHeader).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 2933c484d627d..2517d9584119f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -678,7 +678,11 @@ export function DimensionEditor(props: DimensionEditorProps) { return (
{hasTabs ? ( - + {supportStaticValue ? ( { }; }); jest.mock('../../id_generator'); +// Mock the Monaco Editor component +jest.mock('../operations/definitions/formula/editor/formula_editor', () => { + return { + WrappedFormulaEditor: () =>
, + FormulaEditor: () =>
, + }; +}); const fields = [ { @@ -403,8 +410,9 @@ describe('IndexPatternDimensionEditorPanel', () => { const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || []; - expect(items.find(({ id }) => id === 'math')).toBeUndefined(); - expect(items.find(({ id }) => id === 'formula')).toBeUndefined(); + ['math', 'formula', 'static_value'].forEach((hiddenOp) => { + expect(items.some(({ id }) => id === hiddenOp)).toBe(false); + }); }); it('should indicate that reference-based operations are not compatible when they are incomplete', () => { @@ -2218,4 +2226,130 @@ describe('IndexPatternDimensionEditorPanel', () => { 0 ); }); + + it('should not show tabs when formula and static_value operations are not available', () => { + const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Average of memory', + dataType: 'number', + isBucketed: false, + // Private + operationType: 'average', + sourceField: 'memory', + params: { + format: { id: 'bytes', params: { decimals: 2 } }, + }, + }, + }); + + const props = { + ...defaultProps, + filterOperations: jest.fn((op) => { + // the formula operation will fall into this metadata category + return !(op.dataType === 'number' && op.scale === 'ratio'); + }), + }; + + wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="lens-dimensionTabs"]').exists()).toBeFalsy(); + }); + + it('should show the formula tab when supported', () => { + const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Formula', + dataType: 'number', + isBucketed: false, + operationType: 'formula', + references: ['ref1'], + params: {}, + }, + }); + + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="lens-dimensionTabs-formula"]').first().prop('isSelected') + ).toBeTruthy(); + }); + + it('should now show the static_value tab when not supported', () => { + const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Formula', + dataType: 'number', + isBucketed: false, + operationType: 'formula', + references: ['ref1'], + params: {}, + }, + }); + + wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists()).toBeFalsy(); + }); + + it('should show the static value tab when supported', () => { + const staticWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({ + col1: { + label: 'Formula', + dataType: 'number', + isBucketed: false, + operationType: 'formula', + references: ['ref1'], + params: {}, + }, + }); + + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists() + ).toBeTruthy(); + }); + + it('should select the quick function tab by default', () => { + const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({}); + + wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="lens-dimensionTabs-quickFunctions"]') + .first() + .prop('isSelected') + ).toBeTruthy(); + }); + + it('should select the static value tab when supported by default', () => { + const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({}); + + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').first().prop('isSelected') + ).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 678644101d5ce..b078f8ed019a3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -1623,4 +1623,87 @@ describe('IndexPattern Data Source', () => { expect(indexPatternDatasource.isTimeBased(state)).toEqual(false); }); }); + + describe('#initializeDimension', () => { + it('should return the same state if no static value is passed', () => { + const state = enrichBaseState({ + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['metric'], + columns: { + metric: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }); + expect( + indexPatternDatasource.initializeDimension!(state, 'first', { + columnId: 'newStatic', + label: 'MyNewColumn', + groupId: 'a', + dataType: 'number', + }) + ).toBe(state); + }); + + it('should add a new static value column if a static value is passed', () => { + const state = enrichBaseState({ + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['metric'], + columns: { + metric: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }, + }, + }); + expect( + indexPatternDatasource.initializeDimension!(state, 'first', { + columnId: 'newStatic', + label: 'MyNewColumn', + groupId: 'a', + dataType: 'number', + staticValue: 0, // use a falsy value to check also this corner case + }) + ).toEqual({ + ...state, + layers: { + ...state.layers, + first: { + ...state.layers.first, + incompleteColumns: {}, + columnOrder: ['metric', 'newStatic'], + columns: { + ...state.layers.first.columns, + newStatic: { + dataType: 'number', + isBucketed: false, + label: 'Static Value: 0', + operationType: 'static_value', + params: { value: 0 }, + references: [], + scale: 'ratio', + }, + }, + }, + }, + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index faea2fcc24cb7..835b8203ef599 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -196,6 +196,9 @@ export function getIndexPatternDatasource({ initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) { const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId]; + if (staticValue == null) { + return state; + } return mergeLayer({ state, layerId, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 4b8bbc09c6799..a5d6db4be3319 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1198,6 +1198,91 @@ describe('IndexPattern Data Source suggestions', () => { }) ); }); + + it('should apply layers filter if passed and model the suggestion based on that', () => { + (generateId as jest.Mock).mockReturnValue('newid'); + const initialState = stateWithNonEmptyTables(); + + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + thresholdLayer: { + indexPatternId: '1', + columnOrder: ['threshold'], + columns: { + threshold: { + dataType: 'number', + isBucketed: false, + label: 'Static Value: 0', + operationType: 'static_value', + params: { value: '0' }, + references: [], + scale: 'ratio', + }, + }, + }, + currentLayer: { + indexPatternId: '1', + columnOrder: ['metric', 'ref'], + columns: { + metric: { + label: '', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'average', + sourceField: 'bytes', + }, + ref: { + label: '', + customLabel: true, + dataType: 'number', + isBucketed: false, + operationType: 'cumulative_sum', + references: ['metric'], + }, + }, + }, + }, + }; + + const suggestions = getSuggestionSubset( + getDatasourceSuggestionsForField( + modifiedState, + '1', + documentField, + (layerId) => layerId !== 'thresholdLayer' + ) + ); + // should ignore the threshold layer + expect(suggestions).toContainEqual( + expect.objectContaining({ + table: expect.objectContaining({ + changeType: 'extended', + columns: [ + { + columnId: 'ref', + operation: { + dataType: 'number', + isBucketed: false, + label: '', + scale: undefined, + }, + }, + { + columnId: 'newid', + operation: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + scale: 'ratio', + }, + }, + ], + }), + }) + ); + }); }); describe('finding the layer that is using the current index pattern', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx index 45abbcd3d9cf9..a399183694863 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { IndexPatternColumn, operationDefinitionMap } from '.'; -import { FieldBasedIndexPatternColumn } from './column_types'; +import { FieldBasedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; import { IndexPattern } from '../../types'; export function getInvalidFieldMessage( @@ -81,8 +81,7 @@ export function isValidNumber( const inputValueAsNumber = Number(inputValue); return ( inputValue !== '' && - inputValue !== null && - inputValue !== undefined && + inputValue != null && !Number.isNaN(inputValueAsNumber) && Number.isFinite(inputValueAsNumber) && (!integer || Number.isInteger(inputValueAsNumber)) && @@ -91,7 +90,9 @@ export function isValidNumber( ); } -export function getFormatFromPreviousColumn(previousColumn: IndexPatternColumn | undefined) { +export function getFormatFromPreviousColumn( + previousColumn: IndexPatternColumn | ReferenceBasedIndexPatternColumn | undefined +) { return previousColumn?.dataType === 'number' && previousColumn.params && 'format' in previousColumn.params && diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx new file mode 100644 index 0000000000000..0ecb4e201de0e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx @@ -0,0 +1,402 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { createMockedIndexPattern } from '../../mocks'; +import { staticValueOperation } from './index'; +import { IndexPattern, IndexPatternLayer } from '../../types'; +import { StaticValueIndexPatternColumn } from './static_value'; +import { EuiFieldNumber } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; + +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (fn: unknown) => fn, + }; +}); + +const uiSettingsMock = {} as IUiSettingsClient; + +const defaultProps = { + storage: {} as IStorageWrapper, + uiSettings: uiSettingsMock, + savedObjectsClient: {} as SavedObjectsClientContract, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + data: dataPluginMock.createStartContract(), + http: {} as HttpSetup, + indexPattern: { + ...createMockedIndexPattern(), + hasRestrictions: false, + } as IndexPattern, + operationDefinitionMap: {}, + isFullscreen: false, + toggleFullscreen: jest.fn(), + setIsCloseable: jest.fn(), + layerId: '1', +}; + +describe('static_value', () => { + let layer: IndexPatternLayer; + + beforeEach(() => { + layer = { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }, + col2: { + label: 'Static value: 23', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '23', + }, + }, + }, + }; + }); + + function getLayerWithStaticValue(newValue: string): IndexPatternLayer { + return { + ...layer, + columns: { + ...layer.columns, + col2: { + ...layer.columns.col2, + label: `Static value: ${newValue}`, + params: { + value: newValue, + }, + } as StaticValueIndexPatternColumn, + }, + }; + } + + describe('getDefaultLabel', () => { + it('should return the label for the given value', () => { + expect( + staticValueOperation.getDefaultLabel( + { + label: 'Static value: 23', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '23', + }, + }, + createMockedIndexPattern(), + layer.columns + ) + ).toBe('Static value: 23'); + }); + + it('should return the default label for non valid value', () => { + expect( + staticValueOperation.getDefaultLabel( + { + label: 'Static value', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '', + }, + }, + createMockedIndexPattern(), + layer.columns + ) + ).toBe('Static value'); + }); + }); + + describe('getErrorMessage', () => { + it('should return no error for valid values', () => { + expect( + staticValueOperation.getErrorMessage!( + getLayerWithStaticValue('23'), + 'col2', + createMockedIndexPattern() + ) + ).toBeUndefined(); + // test for potential falsy value + expect( + staticValueOperation.getErrorMessage!( + getLayerWithStaticValue('0'), + 'col2', + createMockedIndexPattern() + ) + ).toBeUndefined(); + }); + + it('should return error for invalid values', () => { + for (const value of ['NaN', 'Infinity', 'string']) { + expect( + staticValueOperation.getErrorMessage!( + getLayerWithStaticValue(value), + 'col2', + createMockedIndexPattern() + ) + ).toEqual(expect.arrayContaining([expect.stringMatching('is not a valid number')])); + } + }); + }); + + describe('toExpression', () => { + it('should return a mathColumn operation with valid value', () => { + for (const value of ['23', '0', '-1']) { + expect( + staticValueOperation.toExpression( + getLayerWithStaticValue(value), + 'col2', + createMockedIndexPattern() + ) + ).toEqual([ + { + type: 'function', + function: 'mathColumn', + arguments: { + id: ['col2'], + name: [`Static value: ${value}`], + expression: [value], + }, + }, + ]); + } + }); + + it('should fallback to mapColumn for invalid value', () => { + for (const value of ['NaN', '', 'Infinity']) { + expect( + staticValueOperation.toExpression( + getLayerWithStaticValue(value), + 'col2', + createMockedIndexPattern() + ) + ).toEqual([ + { + type: 'function', + function: 'mapColumn', + arguments: { + id: ['col2'], + name: [`Static value`], + expression: ['100'], + }, + }, + ]); + } + }); + }); + + describe('buildColumn', () => { + it('should set default static value', () => { + expect( + staticValueOperation.buildColumn({ + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + }) + ).toEqual({ + label: 'Static value', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '100' }, + references: [], + }); + }); + + it('should merge a previousColumn', () => { + expect( + staticValueOperation.buildColumn({ + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + previousColumn: { + label: 'Static value', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }, + }) + ).toEqual({ + label: 'Static value: 23', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }); + }); + + it('should create a static_value from passed arguments', () => { + expect( + staticValueOperation.buildColumn( + { + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + }, + { value: '23' } + ) + ).toEqual({ + label: 'Static value: 23', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }); + }); + + it('should prioritize passed arguments over previousColumn', () => { + expect( + staticValueOperation.buildColumn( + { + indexPattern: createMockedIndexPattern(), + layer: { columns: {}, columnOrder: [], indexPatternId: '' }, + previousColumn: { + label: 'Static value', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '23' }, + references: [], + }, + }, + { value: '53' } + ) + ).toEqual({ + label: 'Static value: 53', + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { value: '53' }, + references: [], + }); + }); + }); + + describe('paramEditor', () => { + const ParamEditor = staticValueOperation.paramEditor!; + it('should render current static_value', () => { + const updateLayerSpy = jest.fn(); + const instance = shallow( + + ); + + const input = instance.find('[data-test-subj="lns-indexPattern-static_value-input"]'); + + expect(input.prop('value')).toEqual('23'); + }); + + it('should update state on change', async () => { + const updateLayerSpy = jest.fn(); + const instance = mount( + + ); + + const input = instance + .find('[data-test-subj="lns-indexPattern-static_value-input"]') + .find(EuiFieldNumber); + + await act(async () => { + input.prop('onChange')!({ + currentTarget: { value: '27' }, + } as React.ChangeEvent); + }); + + instance.update(); + + expect(updateLayerSpy).toHaveBeenCalledWith({ + ...layer, + columns: { + ...layer.columns, + col2: { + ...layer.columns.col2, + params: { + value: '27', + }, + label: 'Static value: 27', + }, + }, + }); + }); + + it('should not update on invalid input, but show invalid value locally', async () => { + const updateLayerSpy = jest.fn(); + const instance = mount( + + ); + + const input = instance + .find('[data-test-subj="lns-indexPattern-static_value-input"]') + .find(EuiFieldNumber); + + await act(async () => { + input.prop('onChange')!({ + currentTarget: { value: '' }, + } as React.ChangeEvent); + }); + + instance.update(); + + expect(updateLayerSpy).not.toHaveBeenCalled(); + expect( + instance + .find('[data-test-subj="lns-indexPattern-static_value-input"]') + .find(EuiFieldNumber) + .prop('value') + ).toEqual(''); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx index 1bfa88428cf57..4595110449c18 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx @@ -11,9 +11,10 @@ import { OperationDefinition } from './index'; import { ReferenceBasedIndexPatternColumn } from './column_types'; import type { IndexPattern } from '../../types'; import { useDebouncedValue } from '../../../shared_components'; +import { getFormatFromPreviousColumn, isValidNumber } from './helpers'; const defaultLabel = i18n.translate('xpack.lens.indexPattern.staticValueLabelDefault', { - defaultMessage: 'Static Value', + defaultMessage: 'Static value', }); const defaultValue = 100; @@ -27,15 +28,11 @@ function ofName(value: number | string | undefined) { return defaultLabel; } return i18n.translate('xpack.lens.indexPattern.staticValueLabelWithValue', { - defaultMessage: 'Static Value: {value}', + defaultMessage: 'Static value: {value}', values: { value }, }); } -function isValidValue(value: string | undefined): value is string { - return !isEmptyValue(value) && !Number.isNaN(value); -} - export interface StaticValueIndexPatternColumn extends ReferenceBasedIndexPatternColumn { operationType: 'static_value'; params: { @@ -67,7 +64,7 @@ export const staticValueOperation: OperationDefinition< return; } - return !isValidValue(column.params.value) + return !isValidNumber(column.params.value) ? [ i18n.translate('xpack.lens.indexPattern.staticValueError', { defaultMessage: 'The static value of {value} is not a valid number', @@ -88,7 +85,7 @@ export const staticValueOperation: OperationDefinition< const params = currentColumn.params; // TODO: improve this logic const useDisplayLabel = currentColumn.label !== defaultLabel; - const label = !isValidValue(params.value) + const label = isValidNumber(params.value) ? useDisplayLabel ? currentColumn.label : params?.value ?? defaultLabel @@ -97,18 +94,25 @@ export const staticValueOperation: OperationDefinition< return [ { type: 'function', - function: isValidValue(params.value) ? 'mathColumn' : 'mapColumn', + function: isValidNumber(params.value) ? 'mathColumn' : 'mapColumn', arguments: { id: [columnId], name: [label || defaultLabel], - expression: [isValidValue(params.value) ? params.value : defaultValue], + expression: [isValidNumber(params.value) ? params.value! : String(defaultValue)], }, }, ]; }, buildColumn({ previousColumn, layer, indexPattern }, columnParams, operationDefinitionMap) { + const existingStaticValue = + previousColumn?.operationType === 'static_value' && + previousColumn.params && + 'value' in previousColumn.params + ? previousColumn.params.value + : undefined; const previousParams: StaticValueIndexPatternColumn['params'] = { - ...previousColumn?.params, + ...{ value: existingStaticValue }, + ...getFormatFromPreviousColumn(previousColumn), ...columnParams, }; return { @@ -117,7 +121,7 @@ export const staticValueOperation: OperationDefinition< operationType: 'static_value', isBucketed: false, scale: 'ratio', - params: previousParams, + params: { ...previousParams, value: previousParams.value ?? String(defaultValue) }, references: [], }; }, @@ -146,6 +150,10 @@ export const staticValueOperation: OperationDefinition< }) { const onChange = useCallback( (newValue) => { + // even if debounced it's triggering for empty string with the previous valid value + if (currentColumn.params.value === newValue) { + return; + } updateLayer({ ...layer, columns: { @@ -175,7 +183,7 @@ export const staticValueOperation: OperationDefinition< const onChangeHandler = useCallback( (e: React.ChangeEvent) => { const value = e.currentTarget.value; - handleInputChange(value === '' ? undefined : value); + handleInputChange(isValidNumber(value) ? value : undefined); }, [handleInputChange] ); @@ -189,7 +197,7 @@ export const staticValueOperation: OperationDefinition< ( diff --git a/x-pack/plugins/lens/public/xy_visualization/threshold_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/threshold_helpers.tsx new file mode 100644 index 0000000000000..665d431565760 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/threshold_helpers.tsx @@ -0,0 +1,138 @@ +/* + * 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 { layerTypes } from '../../common'; +import { XYLayerConfig } from '../../common/expressions'; +import { DatasourcePublicAPI, FramePublicAPI } from '../types'; +import { isPercentageSeries } from './state_helpers'; +import { XYState } from './types'; +import { checkScaleOperation } from './visualization_helpers'; + +export interface ThresholdBase { + label: 'x' | 'yRight' | 'yLeft'; +} + +export function getGroupsToShow( + thresholdLayers: T[], + state: XYState | undefined, + datasourceLayers: Record +): T[] { + if (!state) { + return []; + } + const dataLayers = state.layers.filter( + ({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA + ); + const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers); + return thresholdLayers.filter(({ label }: T) => groupsAvailable[label]); +} + +export function getGroupsAvailableInData( + dataLayers: XYState['layers'], + datasourceLayers: Record +) { + const hasNumberHistogram = dataLayers.some( + checkScaleOperation('interval', 'number', datasourceLayers) + ); + return dataLayers.reduce( + (memo, dataLayer) => { + if (!memo.x && hasNumberHistogram) { + memo.x = Boolean(dataLayer.xAccessor); + } + if (!memo.yLeft) { + const leftConfigs = dataLayer.yConfig?.filter(({ axisMode }) => axisMode !== 'right'); + const hasMoreConfigsThanAccessor = + dataLayer.accessors.length > (dataLayer.yConfig?.length ?? 0); + + memo.yLeft = Boolean( + dataLayer.accessors.length && + (leftConfigs == null || leftConfigs.length || hasMoreConfigsThanAccessor) + ); + } + if (!memo.yRight) { + memo.yRight = Boolean( + dataLayer.accessors.length && + dataLayer.yConfig?.some(({ axisMode }) => axisMode === 'right') + ); + } + return memo; + }, + { x: false, yLeft: false, yRight: false } + ); +} + +export function getStaticValue( + dataLayers: XYState['layers'], + groupId: 'x' | 'yLeft' | 'yRight', + { activeData }: Pick, + layerHasNumberHistogram: (layer: XYLayerConfig) => boolean +) { + const fallbackValue = 100; + if (!activeData) { + return fallbackValue; + } + + // filter and organize data dimensions into threshold groups + // now pick the columnId in the active data + return ( + computeStaticValueForGroup( + dataLayers, + activeData, + getAccessorCriteriaForGroup(groupId), + (layer) => groupId === 'x' && !layerHasNumberHistogram(layer) + ) || fallbackValue + ); +} + +function getAccessorCriteriaForGroup( + groupId: 'x' | 'yLeft' | 'yRight' +): (layer: XYLayerConfig) => string | undefined { + switch (groupId) { + case 'x': + return ({ xAccessor }) => xAccessor; + case 'yLeft': + return ({ accessors, yConfig }) => { + if (yConfig == null) { + return accessors[0]; + } + return yConfig.find(({ axisMode }) => axisMode == null || axisMode === 'left')?.forAccessor; + }; + case 'yRight': + return ({ yConfig }) => { + return yConfig?.find(({ axisMode }) => axisMode === 'right')?.forAccessor; + }; + } +} + +function computeStaticValueForGroup( + dataLayers: XYState['layers'], + activeData: NonNullable, + getColumnIdForGroup: (layer: XYLayerConfig) => string | undefined, + dropForDateHistogram: (layer: XYLayerConfig) => boolean +) { + const dataLayer = dataLayers.find(getColumnIdForGroup); + + if (dataLayer) { + if (isPercentageSeries(dataLayer?.seriesType)) { + return 0.75; + } + if (dropForDateHistogram(dataLayer)) { + return; + } + const columnId = getColumnIdForGroup(dataLayer); + const tableId = Object.keys(activeData).find((key) => + activeData[key].columns.some(({ id }) => id === columnId) + ); + if (columnId && tableId) { + const columnMax = activeData[tableId].rows.reduce( + (max, row) => Math.max(row[columnId], max), + -Infinity + ); + return Number(((columnMax * 3) / 4).toFixed(2)); + } + } +} diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index bea93127cac74..2daa8634db7fc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -317,6 +317,42 @@ describe('xy_visualization', () => { accessors: [], }); }); + + it('should add a dimension to a threshold layer', () => { + expect( + xyVisualization.setDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'threshold', + layerType: layerTypes.THRESHOLD, + seriesType: 'line', + accessors: [], + }, + ], + }, + layerId: 'threshold', + groupId: 'xThreshold', + columnId: 'newCol', + }).layers[0] + ).toEqual({ + layerId: 'threshold', + layerType: layerTypes.THRESHOLD, + seriesType: 'line', + accessors: ['newCol'], + yConfig: [ + { + axisMode: 'bottom', + forAccessor: 'newCol', + icon: undefined, + lineStyle: 'solid', + lineWidth: 1, + }, + ], + }); + }); }); describe('#removeDimension', () => { @@ -504,6 +540,184 @@ describe('xy_visualization', () => { expect(ops.filter(filterOperations).map((x) => x.dataType)).toEqual(['number']); }); + describe('thresholds', () => { + beforeEach(() => { + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + threshold: mockDatasource.publicAPIMock, + }; + }); + + function getStateWithBaseThreshold(): State { + return { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + splitAccessor: undefined, + xAccessor: undefined, + accessors: ['a'], + }, + { + layerId: 'threshold', + layerType: layerTypes.THRESHOLD, + seriesType: 'line', + accessors: [], + yConfig: [{ axisMode: 'left', forAccessor: 'a' }], + }, + ], + }; + } + + it('should support static value', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].accessors = []; + state.layers[1].yConfig = undefined; + + expect( + xyVisualization.getConfiguration({ + state: getStateWithBaseThreshold(), + frame, + layerId: 'threshold', + }).supportStaticValue + ).toBeTruthy(); + }); + + it('should return no threshold groups for a empty data layer', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].accessors = []; + state.layers[1].yConfig = undefined; + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(0); + }); + + it('should return a group for the vertical left axis', () => { + const options = xyVisualization.getConfiguration({ + state: getStateWithBaseThreshold(), + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(1); + expect(options[0].groupId).toBe('yThresholdLeft'); + }); + + it('should return a group for the vertical right axis', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].yConfig = [{ axisMode: 'right', forAccessor: 'a' }]; + state.layers[1].yConfig![0].axisMode = 'right'; + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(1); + expect(options[0].groupId).toBe('yThresholdRight'); + }); + + it('should discard xAccessor as date histogram when computing groups', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'b'; + state.layers[0].accessors = []; + state.layers[1].yConfig![0].axisMode = 'bottom'; + // set the xAccessor as date_histogram + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'b') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + }; + } + return null; + }); + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(0); + }); + + it('should return groups in a specific order (left, right, bottom)', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'c'; + state.layers[0].accessors = ['a', 'b']; + // invert them on purpose + state.layers[0].yConfig = [ + { axisMode: 'right', forAccessor: 'b' }, + { axisMode: 'left', forAccessor: 'a' }, + ]; + state.layers[1].yConfig = [ + { forAccessor: 'c', axisMode: 'bottom' }, + { forAccessor: 'b', axisMode: 'right' }, + { forAccessor: 'a', axisMode: 'left' }, + ]; + // set the xAccessor as number histogram + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'c') { + return { + dataType: 'number', + isBucketed: true, + scale: 'interval', + label: 'histogram', + }; + } + return null; + }); + + const [left, right, bottom] = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(left.groupId).toBe('yThresholdLeft'); + expect(right.groupId).toBe('yThresholdRight'); + expect(bottom.groupId).toBe('xThreshold'); + }); + + it('should ignore terms operation for xAccessor', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'b'; + state.layers[0].accessors = []; + state.layers[1].yConfig![0].axisMode = 'bottom'; + // set the xAccessor as top values + frame.datasourceLayers.threshold.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'b') { + return { + dataType: 'string', + isBucketed: true, + scale: 'ordinal', + label: 'top values', + }; + } + return null; + }); + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options).toHaveLength(0); + }); + }); + describe('color assignment', () => { function callConfig(layerConfigOverride: Partial) { const baseState = exampleState(); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index b485c6995f271..ab5fcfb3b88f3 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -16,18 +16,11 @@ import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar, DimensionEditor } from './xy_config_panel'; import { LayerHeader } from './xy_config_panel/layer_header'; -import type { - Visualization, - OperationMetadata, - VisualizationType, - AccessorConfig, - DatasourcePublicAPI, - FramePublicAPI, -} from '../types'; -import { State, visualizationTypes, XYState } from './types'; +import type { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types'; +import { State, visualizationTypes } from './types'; import { SeriesType, XYLayerConfig } from '../../common/expressions'; import { LayerType, layerTypes } from '../../common'; -import { isHorizontalChart, isPercentageSeries } from './state_helpers'; +import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; import { LensIconChartMixedXy } from '../assets/chart_mixed_xy'; @@ -36,16 +29,18 @@ import { getAccessorColorConfig, getColorAssignments } from './color_assignment' import { getColumnToLabelMap } from './state_helpers'; import { LensIconChartBarThreshold } from '../assets/chart_bar_threshold'; import { generateId } from '../id_generator'; +import { getGroupsAvailableInData, getGroupsToShow, getStaticValue } from './threshold_helpers'; +import { + checkScaleOperation, + checkXAccessorCompatibility, + getAxisName, +} from './visualization_helpers'; const defaultIcon = LensIconChartBarStacked; const defaultSeriesType = 'bar_stacked'; const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; -interface ThresholdBase { - label: 'x' | 'yRight' | 'yLeft'; -} - function getVisualizationType(state: State): VisualizationType | 'mixed' { if (!state.layers.length) { return ( @@ -215,9 +210,9 @@ export const getXyVisualization = ({ const filledDataLayers = dataLayers.filter( ({ accessors, xAccessor }) => accessors.length || xAccessor ); - const layerHasDateHistogramFn = checkScaleOperation( + const layerHasNumberHistogram = checkScaleOperation( 'interval', - 'date', + 'number', frame?.datasourceLayers || {} ); const thresholdGroups = getGroupsToShow( @@ -242,7 +237,7 @@ export const getXyVisualization = ({ icon: LensIconChartBarThreshold, disabled: !filledDataLayers.length || - (dataLayers.every(layerHasDateHistogramFn) && + (!dataLayers.some(layerHasNumberHistogram) && dataLayers.every(({ accessors }) => !accessors.length)), tooltipContent: filledDataLayers.length ? undefined @@ -259,7 +254,7 @@ export const getXyVisualization = ({ dataLayers, label, { activeData: frame?.activeData }, - layerHasDateHistogramFn + layerHasNumberHistogram ), })) : undefined, @@ -664,40 +659,6 @@ function validateLayersForDimension( }; } -function getAxisName( - axis: 'x' | 'y' | 'yLeft' | 'yRight', - { isHorizontal }: { isHorizontal: boolean } -) { - const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', { - defaultMessage: 'Vertical axis', - }); - const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', { - defaultMessage: 'Horizontal axis', - }); - if (axis === 'x') { - return isHorizontal ? vertical : horizontal; - } - if (axis === 'y') { - return isHorizontal ? horizontal : vertical; - } - const verticalLeft = i18n.translate('xpack.lens.xyChart.verticalLeftAxisLabel', { - defaultMessage: 'Vertical left axis', - }); - const verticalRight = i18n.translate('xpack.lens.xyChart.verticalRightAxisLabel', { - defaultMessage: 'Vertical right axis', - }); - const horizontalTop = i18n.translate('xpack.lens.xyChart.horizontalLeftAxisLabel', { - defaultMessage: 'Horizontal top axis', - }); - const horizontalBottom = i18n.translate('xpack.lens.xyChart.horizontalRightAxisLabel', { - defaultMessage: 'Horizontal bottom axis', - }); - if (axis === 'yLeft') { - return isHorizontal ? horizontalTop : verticalLeft; - } - return isHorizontal ? horizontalBottom : verticalRight; -} - // i18n ids cannot be dynamically generated, hence the function below function getMessageIdsForDimension(dimension: string, layers: number[], isHorizontal: boolean) { const layersList = layers.map((i: number) => i + 1).join(', '); @@ -741,199 +702,8 @@ function newLayerState( }; } -// min requirement for the bug: -// * 2 or more layers -// * at least one with date histogram -// * at least one with interval function -function checkXAccessorCompatibility( - state: XYState, - datasourceLayers: Record -) { - const errors = []; - const hasDateHistogramSet = state.layers.some( - checkScaleOperation('interval', 'date', datasourceLayers) - ); - const hasNumberHistogram = state.layers.some( - checkScaleOperation('interval', 'number', datasourceLayers) - ); - const hasOrdinalAxis = state.layers.some( - checkScaleOperation('ordinal', undefined, datasourceLayers) - ); - if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { - errors.push({ - shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { - defaultMessage: `Wrong data type for {axis}.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', { - defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - }); - } - if (state.layers.length > 1 && (hasDateHistogramSet || hasNumberHistogram) && hasOrdinalAxis) { - errors.push({ - shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { - defaultMessage: `Wrong data type for {axis}.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXOrdinalLong', { - defaultMessage: `Data type mismatch for the {axis}, use a different function.`, - values: { - axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), - }, - }), - }); - } - return errors; -} - -function checkScaleOperation( - scaleType: 'ordinal' | 'interval' | 'ratio', - dataType: 'date' | 'number' | 'string' | undefined, - datasourceLayers: Record -) { - return (layer: XYLayerConfig) => { - const datasourceAPI = datasourceLayers[layer.layerId]; - if (!layer.xAccessor) { - return false; - } - const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor); - return Boolean( - operation && (!dataType || operation.dataType === dataType) && operation.scale === scaleType - ); - }; -} - function getLayersByType(state: State, byType?: string) { return state.layers.filter(({ layerType = layerTypes.DATA }) => byType ? layerType === byType : true ); } - -function getGroupsToShow( - thresholdLayers: T[], - state: State | undefined, - datasourceLayers: Record -): T[] { - if (!state) { - return []; - } - const dataLayers = state.layers.filter( - ({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA - ); - const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers); - return thresholdLayers.filter(({ label }: T) => groupsAvailable[label]); -} - -function getGroupsAvailableInData( - dataLayers: State['layers'], - datasourceLayers: Record -) { - const hasNumberHistogram = dataLayers.some( - checkScaleOperation('interval', 'number', datasourceLayers) - ); - return dataLayers.reduce( - (memo, dataLayer) => { - if (!memo.x && hasNumberHistogram) { - memo.x = Boolean(dataLayer.xAccessor); - } - if (!memo.yLeft) { - const leftConfigs = dataLayer.yConfig?.filter(({ axisMode }) => axisMode !== 'right'); - const hasMoreConfigsThanAccessor = - dataLayer.accessors.length > (dataLayer.yConfig?.length ?? 0); - - memo.yLeft = Boolean( - dataLayer.accessors.length && - (leftConfigs == null || leftConfigs.length || hasMoreConfigsThanAccessor) - ); - } - if (!memo.yRight) { - memo.yRight = Boolean( - dataLayer.accessors.length && - dataLayer.yConfig?.some(({ axisMode }) => axisMode === 'right') - ); - } - return memo; - }, - { x: false, yLeft: false, yRight: false } - ); -} - -function getStaticValue( - dataLayers: State['layers'], - groupId: 'x' | 'yLeft' | 'yRight', - { activeData }: Pick, - layerHasDateHistogramFn: (layer: XYLayerConfig) => boolean -) { - const fallbackValue = 100; - if (!activeData) { - return fallbackValue; - } - - // filter and organize data dimensions into threshold groups - // now pick the columnId in the active data - return ( - computeStaticValueForGroup( - dataLayers, - activeData, - getAccessorCriteriaForGroup(groupId), - (layer) => groupId === 'x' && layerHasDateHistogramFn(layer) - ) || fallbackValue - ); -} - -function getAccessorCriteriaForGroup( - groupId: 'x' | 'yLeft' | 'yRight' -): (layer: XYLayerConfig) => string | undefined { - switch (groupId) { - case 'x': - return ({ xAccessor }) => xAccessor; - case 'yLeft': - return ({ accessors, yConfig }) => { - if (yConfig == null) { - return accessors[0]; - } - return yConfig.find(({ axisMode }) => axisMode == null || axisMode === 'left')?.forAccessor; - }; - case 'yRight': - return ({ yConfig }) => { - return yConfig?.find(({ axisMode }) => axisMode === 'right')?.forAccessor; - }; - } -} - -function computeStaticValueForGroup( - dataLayers: State['layers'], - activeData: NonNullable, - getColumnIdForGroup: (layer: XYLayerConfig) => string | undefined, - dropForDateHistogram: (layer: XYLayerConfig) => boolean -) { - const dataLayer = dataLayers.find(getColumnIdForGroup); - - if (dataLayer) { - if (isPercentageSeries(dataLayer?.seriesType)) { - return 0.75; - } - if (dropForDateHistogram(dataLayer)) { - return; - } - const columnId = getColumnIdForGroup(dataLayer); - const tableId = Object.keys(activeData).find((key) => - activeData[key].columns.some(({ id }) => id === columnId) - ); - if (columnId && tableId) { - const columnMax = activeData[tableId].rows.reduce( - (max, row) => Math.max(row[columnId], max), - -Infinity - ); - return Number(((columnMax * 3) / 4).toFixed(2)); - } - } -} diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx new file mode 100644 index 0000000000000..22c3c7e895323 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -0,0 +1,116 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { DatasourcePublicAPI } from '../types'; +import { XYState } from './types'; +import { isHorizontalChart } from './state_helpers'; +import { XYLayerConfig } from '../../common/expressions'; + +export function getAxisName( + axis: 'x' | 'y' | 'yLeft' | 'yRight', + { isHorizontal }: { isHorizontal: boolean } +) { + const vertical = i18n.translate('xpack.lens.xyChart.verticalAxisLabel', { + defaultMessage: 'Vertical axis', + }); + const horizontal = i18n.translate('xpack.lens.xyChart.horizontalAxisLabel', { + defaultMessage: 'Horizontal axis', + }); + if (axis === 'x') { + return isHorizontal ? vertical : horizontal; + } + if (axis === 'y') { + return isHorizontal ? horizontal : vertical; + } + const verticalLeft = i18n.translate('xpack.lens.xyChart.verticalLeftAxisLabel', { + defaultMessage: 'Vertical left axis', + }); + const verticalRight = i18n.translate('xpack.lens.xyChart.verticalRightAxisLabel', { + defaultMessage: 'Vertical right axis', + }); + const horizontalTop = i18n.translate('xpack.lens.xyChart.horizontalLeftAxisLabel', { + defaultMessage: 'Horizontal top axis', + }); + const horizontalBottom = i18n.translate('xpack.lens.xyChart.horizontalRightAxisLabel', { + defaultMessage: 'Horizontal bottom axis', + }); + if (axis === 'yLeft') { + return isHorizontal ? horizontalTop : verticalLeft; + } + return isHorizontal ? horizontalBottom : verticalRight; +} + +// min requirement for the bug: +// * 2 or more layers +// * at least one with date histogram +// * at least one with interval function +export function checkXAccessorCompatibility( + state: XYState, + datasourceLayers: Record +) { + const errors = []; + const hasDateHistogramSet = state.layers.some( + checkScaleOperation('interval', 'date', datasourceLayers) + ); + const hasNumberHistogram = state.layers.some( + checkScaleOperation('interval', 'number', datasourceLayers) + ); + const hasOrdinalAxis = state.layers.some( + checkScaleOperation('ordinal', undefined, datasourceLayers) + ); + if (state.layers.length > 1 && hasDateHistogramSet && hasNumberHistogram) { + errors.push({ + shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { + defaultMessage: `Wrong data type for {axis}.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXLong', { + defaultMessage: `Data type mismatch for the {axis}. Cannot mix date and number interval types.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + }); + } + if (state.layers.length > 1 && (hasDateHistogramSet || hasNumberHistogram) && hasOrdinalAxis) { + errors.push({ + shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXShort', { + defaultMessage: `Wrong data type for {axis}.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureXOrdinalLong', { + defaultMessage: `Data type mismatch for the {axis}, use a different function.`, + values: { + axis: getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }), + }, + }), + }); + } + return errors; +} + +export function checkScaleOperation( + scaleType: 'ordinal' | 'interval' | 'ratio', + dataType: 'date' | 'number' | 'string' | undefined, + datasourceLayers: Record +) { + return (layer: XYLayerConfig) => { + const datasourceAPI = datasourceLayers[layer.layerId]; + if (!layer.xAccessor) { + return false; + } + const operation = datasourceAPI?.getOperationForColumnId(layer.xAccessor); + return Boolean( + operation && (!dataType || operation.dataType === dataType) && operation.scale === scaleType + ); + }; +} diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 19ecc017c5073..22b979006f946 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -43,6 +43,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./lens_tagging')); loadTestFile(require.resolve('./formula')); loadTestFile(require.resolve('./heatmap')); + loadTestFile(require.resolve('./thresholds')); // has to be last one in the suite because it overrides saved objects loadTestFile(require.resolve('./rollup')); diff --git a/x-pack/test/functional/apps/lens/thresholds.ts b/x-pack/test/functional/apps/lens/thresholds.ts new file mode 100644 index 0000000000000..c3e6cff5f26be --- /dev/null +++ b/x-pack/test/functional/apps/lens/thresholds.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const find = getService('find'); + const retry = getService('retry'); + // const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); + // const elasticChart = getService('elasticChart'); + // const filterBar = getService('filterBar'); + + describe('lens thresholds tests', () => { + it('should show a disabled threshold layer button if no data dimension is defined', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + + await testSubjects.click('lnsLayerAddButton'); + await retry.waitFor('wait for layer popup to appear', async () => + testSubjects.exists(`lnsLayerAddButton-threshold`) + ); + expect( + await (await testSubjects.find(`lnsLayerAddButton-threshold`)).getAttribute('disabled') + ).to.be('true'); + }); + + it('should add a threshold layer with a static value in it', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.createLayer('threshold'); + + expect((await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)).length).to.eql(2); + expect( + await ( + await testSubjects.find('lnsXY_yThresholdLeftPanel > lns-dimensionTrigger') + ).getVisibleText() + ).to.eql('Static value: 100'); + }); + + it('should create a dynamic threshold when dragging a field to a threshold dimension group', async () => { + await PageObjects.lens.dragFieldToDimensionTrigger( + 'bytes', + 'lnsXY_yThresholdLeftPanel > lns-empty-dimension' + ); + + expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yThresholdLeftPanel')).to.eql([ + 'Static value: 100', + 'Median of bytes', + ]); + }); + }); +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 2e1151602f311..995c54c93dce8 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -645,8 +645,21 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont /** * Adds a new layer to the chart, fails if the chart does not support new layers */ - async createLayer() { + async createLayer(layerType: string = 'data') { await testSubjects.click('lnsLayerAddButton'); + const layerCount = (await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)) + .length; + + await retry.waitFor('check for layer type support', async () => { + const fasterChecks = await Promise.all([ + (await find.allByCssSelector(`[data-test-subj^="lns-layerPanel-"]`)).length > layerCount, + testSubjects.exists(`lnsLayerAddButton-${layerType}`), + ]); + return fasterChecks.filter(Boolean).length > 0; + }); + if (await testSubjects.exists(`lnsLayerAddButton-${layerType}`)) { + await testSubjects.click(`lnsLayerAddButton-${layerType}`); + } }, /**