diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts index 9a9273e43f6f1..29b0fb1352e5b 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts @@ -27,12 +27,18 @@ interface AxisConfig { hide?: boolean; } -export type YAxisMode = 'auto' | 'left' | 'right'; +export type YAxisMode = 'auto' | 'left' | 'right' | 'bottom'; +export type LineStyle = 'solid' | 'dashed' | 'dotted'; +export type FillStyle = 'none' | 'above' | 'below'; export interface YConfig { forAccessor: string; axisMode?: YAxisMode; color?: string; + icon?: string; + lineWidth?: number; + lineStyle?: LineStyle; + fill?: FillStyle; } export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & { @@ -161,6 +167,24 @@ export const yAxisConfig: ExpressionFunctionDefinition< types: ['string'], help: 'The color of the series', }, + lineStyle: { + types: ['string'], + options: ['solid', 'dotted', 'dashed'], + help: 'The style of the threshold line', + }, + lineWidth: { + types: ['number'], + help: 'The width of the threshold line', + }, + icon: { + types: ['string'], + help: 'An optional icon used for threshold lines', + }, + fill: { + types: ['string'], + options: ['none', 'above', 'below'], + help: '', + }, }, fn: function fn(input: unknown, args: YConfig) { return { diff --git a/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx b/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx new file mode 100644 index 0000000000000..88e0a46b5538c --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx @@ -0,0 +1,40 @@ +/* + * 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 { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartBarThreshold = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx index 0259acc4dcca1..69e4aa629cec6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { EuiToolTip, EuiButton, @@ -38,12 +38,17 @@ export function AddLayerButton({ }: AddLayerButtonProps) { const [showLayersChoice, toggleLayersChoice] = useState(false); - const hasMultipleLayers = Boolean(visualization.appendLayer && visualizationState); - if (!hasMultipleLayers) { + const supportedLayers = useMemo(() => { + if (!visualization.appendLayer || !visualizationState) { + return null; + } + return visualization.getSupportedLayers?.(visualizationState, layersMeta); + }, [visualization, visualizationState, layersMeta]); + + if (supportedLayers == null) { return null; } - const supportedLayers = visualization.getSupportedLayers?.(visualizationState, layersMeta); - if (supportedLayers?.length === 1) { + if (supportedLayers.length === 1) { return ( 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,164 @@ 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); + expect(mockDatasource.initializeDimension).not.toHaveBeenCalled(); + }); + + it('should not add an initial dimension when initialDimensions are not available for the given layer type', async () => { + const props = getDefaultProps(); + props.activeVisualization.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }, + ], + }, + { + type: layerTypes.THRESHOLD, + label: 'Threshold layer', + }, + ]); + mockDatasource.initializeDimension = jest.fn(); + + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance); + + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + expect(mockDatasource.initializeDimension).not.toHaveBeenCalled(); + }); + + 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(1); + expect(mockDatasource.initializeDimension).toHaveBeenCalledWith(undefined, 'newId', { + columnId: 'myColumn', + dataType: 'number', + groupId: 'testGroup', + label: 'Initial value', + staticValue: 100, + }); + }); + + 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: 'newId', + 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); + + expect(mockDatasource.initializeDimension).toHaveBeenCalledWith('state', 'first', { + groupId: 'a', + columnId: 'newId', + dataType: 'number', + label: 'Initial value', + staticValue: 100, + }); + }); + }); }); 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 f7fe2beefa963..57e4cf5b8dffd 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 @@ -26,8 +26,9 @@ import { useLensSelector, selectVisualization, VisualizationState, + LensAppState, } from '../../../state_management'; -import { AddLayerButton } from './add_layer'; +import { AddLayerButton, getLayerType } from './add_layer'; export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const visualization = useLensSelector(selectVisualization); @@ -177,6 +178,33 @@ export function LayerPanels( layerIds.length ) === 'clear' } + onEmptyDimensionAdd={(columnId, { groupId }) => { + // avoid state update if the datasource does not support initializeDimension + if ( + activeDatasourceId != null && + datasourceMap[activeDatasourceId]?.initializeDimension + ) { + dispatchLens( + updateState({ + subType: 'LAYER_DEFAULT_DIMENSION', + updater: (state) => + addInitialValueIfAvailable({ + ...props, + state, + activeDatasourceId, + layerId, + layerType: getLayerType( + activeVisualization, + state.visualization.state, + layerId + ), + columnId, + groupId, + }), + }) + ); + } + }} onRemoveLayer={() => { dispatchLens( updateState({ @@ -232,21 +260,92 @@ export function LayerPanels( dispatchLens( updateState({ subType: 'ADD_LAYER', - updater: (state) => - appendLayer({ + updater: (state) => { + const newState = appendLayer({ activeVisualization, generateId: () => id, trackUiEvent, activeDatasource: datasourceMap[activeDatasourceId!], state, layerType, - }), + }); + return addInitialValueIfAvailable({ + ...props, + activeDatasourceId: activeDatasourceId!, + state: newState, + layerId: id, + layerType, + }); + }, }) ); - setNextFocusedLayerId(id); }} /> ); } + +function addInitialValueIfAvailable({ + state, + activeVisualization, + framePublicAPI, + layerType, + activeDatasourceId, + datasourceMap, + layerId, + columnId, + groupId, +}: ConfigPanelWrapperProps & { + state: LensAppState; + activeDatasourceId: string; + activeVisualization: Visualization; + layerId: string; + layerType: string; + columnId?: string; + groupId?: string; +}) { + const layerInfo = activeVisualization + .getSupportedLayers(state.visualization.state, framePublicAPI) + .find(({ type }) => type === layerType); + + const activeDatasource = datasourceMap[activeDatasourceId]; + + if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) { + const info = groupId + ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId) + : // pick the first available one if not passed + layerInfo.initialDimensions[0]; + + if (info) { + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [activeDatasourceId]: { + ...state.datasourceStates[activeDatasourceId], + state: activeDatasource.initializeDimension( + state.datasourceStates[activeDatasourceId].state, + layerId, + { + ...info, + columnId: columnId || info.columnId, + } + ), + }, + }, + visualization: { + ...state.visualization, + state: activeVisualization.setDimension({ + groupId: info.groupId, + layerId, + columnId: columnId || info.columnId, + prevState: state.visualization.state, + frame: framePublicAPI, + }), + }, + }; + } + } + return state; +} 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 13b7b8cfecf56..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 @@ -83,6 +83,7 @@ describe('LayerPanel', () => { registerNewLayerRef: jest.fn(), isFullscreen: false, toggleFullscreen: jest.fn(), + onEmptyDimensionAdd: jest.fn(), }; } @@ -920,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_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 520c2bc837c60..8c947d3502f93 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -57,6 +57,7 @@ export function LayerPanel( onRemoveLayer: () => void; registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void; toggleFullscreen: () => void; + onEmptyDimensionAdd: (columnId: string, group: { groupId: string }) => void; } ) { const [activeDimension, setActiveDimension] = useState( @@ -124,7 +125,11 @@ export function LayerPanel( dateRange, }; - const { groups, supportStaticValue } = useMemo( + const { + groups, + supportStaticValue, + supportFieldFormat = true, + } = useMemo( () => activeVisualization.getConfiguration(layerVisualizationConfigProps), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -227,13 +232,25 @@ export function LayerPanel( const isDimensionPanelOpen = Boolean(activeId); const updateDataLayerState = useCallback( - (newState: unknown, { isDimensionComplete = true }: { isDimensionComplete?: boolean } = {}) => { + ( + newState: unknown, + { + isDimensionComplete = true, + // this flag is a hack to force a sync render where it was planned an async/setTimeout state update + // TODO: revisit this once we get rid of updateDatasourceAsync upstream + forceRender = false, + }: { isDimensionComplete?: boolean; forceRender?: boolean } = {} + ) => { if (!activeGroup || !activeId) { return; } if (allAccessors.includes(activeId)) { if (isDimensionComplete) { - updateDatasourceAsync(datasourceId, newState); + if (forceRender) { + updateDatasource(datasourceId, newState); + } else { + updateDatasourceAsync(datasourceId, newState); + } } else { // The datasource can indicate that the previously-valid column is no longer // complete, which clears the visualization. This keeps the flyout open and reuses @@ -263,7 +280,11 @@ export function LayerPanel( ); setActiveDimension({ ...activeDimension, isNew: false }); } else { - updateDatasourceAsync(datasourceId, newState); + if (forceRender) { + updateDatasource(datasourceId, newState); + } else { + updateDatasourceAsync(datasourceId, newState); + } } }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -295,11 +316,10 @@ export function LayerPanel( hasBorder hasShadow > -
+
)} -
+ {groups.map((group, groupIndex) => { const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; @@ -460,6 +480,8 @@ export function LayerPanel( columnId: accessorConfig.columnId, groupId: group.groupId, filterOperations: group.filterOperations, + invalid: group.invalid, + invalidMessage: group.invalidMessage, }} /> @@ -478,6 +500,7 @@ export function LayerPanel( layerDatasource={layerDatasource} layerDatasourceDropProps={layerDatasourceDropProps} onClick={(id) => { + props.onEmptyDimensionAdd(id, group); setActiveDimension({ activeGroup: group, activeId: id, @@ -538,6 +561,8 @@ export function LayerPanel( toggleFullscreen, isFullscreen, setState: updateDataLayerState, + supportStaticValue: Boolean(supportStaticValue), + supportFieldFormat: Boolean(supportFieldFormat), layerType: activeVisualization.getLayerType(layerId, visualizationState), }} /> 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/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx index 467b1ecfe1b5b..fc88ff2af8bbe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx @@ -6,44 +6,23 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle } from '@elastic/eui'; import { NativeRenderer } from '../../../native_renderer'; import { Visualization, VisualizationLayerWidgetProps } from '../../../types'; +import { StaticHeader } from '../../../shared_components'; export function LayerSettings({ - layerId, activeVisualization, layerConfigProps, }: { - layerId: string; activeVisualization: Visualization; layerConfigProps: VisualizationLayerWidgetProps; }) { - const description = activeVisualization.getDescription(layerConfigProps.state); - if (!activeVisualization.renderLayerHeader) { + const description = activeVisualization.getDescription(layerConfigProps.state); if (!description) { return null; } - return ( - - {description.icon && ( - - {' '} - - )} - - -
{description.label}
-
-
-
- ); + return ; } return ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 632989057b488..90fa2ab080dd2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -45,21 +45,22 @@ describe('suggestion helpers', () => { generateSuggestion(), ]); const suggestedState = {}; - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization, - getSuggestions: () => [ - { - score: 0.5, - title: 'Test', - state: suggestedState, - previewIcon: 'empty', - }, - ], - }, + const visualizationMap = { + vis1: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test', + state: suggestedState, + previewIcon: 'empty', + }, + ], }, - activeVisualizationId: 'vis1', + }; + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -74,38 +75,39 @@ describe('suggestion helpers', () => { datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ generateSuggestion(), ]); - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization1, - getSuggestions: () => [ - { - score: 0.5, - title: 'Test', - state: {}, - previewIcon: 'empty', - }, - { - score: 0.5, - title: 'Test2', - state: {}, - previewIcon: 'empty', - }, - ], - }, - vis2: { - ...mockVisualization2, - getSuggestions: () => [ - { - score: 0.5, - title: 'Test3', - state: {}, - previewIcon: 'empty', - }, - ], - }, + const visualizationMap = { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.5, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ], }, - activeVisualizationId: 'vis1', + }; + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -116,11 +118,12 @@ describe('suggestion helpers', () => { it('should call getDatasourceSuggestionsForField when a field is passed', () => { datasourceMap.mock.getDatasourceSuggestionsForField.mockReturnValue([generateSuggestion()]); const droppedField = {}; + const visualizationMap = { + vis1: createMockVisualization(), + }; getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -128,7 +131,8 @@ describe('suggestion helpers', () => { }); expect(datasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith( datasourceStates.mock.state, - droppedField + droppedField, + expect.any(Function) ); }); @@ -148,12 +152,13 @@ describe('suggestion helpers', () => { mock2: createMockDatasource('a'), mock3: createMockDatasource('a'), }; + const visualizationMap = { + vis1: createMockVisualization(), + }; const droppedField = {}; getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap: multiDatasourceMap, datasourceStates: multiDatasourceStates, @@ -161,11 +166,13 @@ describe('suggestion helpers', () => { }); expect(multiDatasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith( multiDatasourceStates.mock.state, - droppedField + droppedField, + expect.any(Function) ); expect(multiDatasourceMap.mock2.getDatasourceSuggestionsForField).toHaveBeenCalledWith( multiDatasourceStates.mock2.state, - droppedField + droppedField, + expect.any(Function) ); expect(multiDatasourceMap.mock3.getDatasourceSuggestionsForField).not.toHaveBeenCalled(); }); @@ -174,11 +181,14 @@ describe('suggestion helpers', () => { datasourceMap.mock.getDatasourceSuggestionsForVisualizeField.mockReturnValue([ generateSuggestion(), ]); + + const visualizationMap = { + vis1: createMockVisualization(), + }; + getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -214,11 +224,13 @@ describe('suggestion helpers', () => { indexPatternId: '1', fieldName: 'test', }; + + const visualizationMap = { + vis1: createMockVisualization(), + }; getSuggestions({ - visualizationMap: { - vis1: createMockVisualization(), - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap: multiDatasourceMap, datasourceStates: multiDatasourceStates, @@ -245,38 +257,39 @@ describe('suggestion helpers', () => { datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ generateSuggestion(), ]); - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization1, - getSuggestions: () => [ - { - score: 0.2, - title: 'Test', - state: {}, - previewIcon: 'empty', - }, - { - score: 0.8, - title: 'Test2', - state: {}, - previewIcon: 'empty', - }, - ], - }, - vis2: { - ...mockVisualization2, - getSuggestions: () => [ - { - score: 0.6, - title: 'Test3', - state: {}, - previewIcon: 'empty', - }, - ], - }, + const visualizationMap = { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { + score: 0.2, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], }, - activeVisualizationId: 'vis1', + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.6, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ], + }, + }; + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -305,12 +318,13 @@ describe('suggestion helpers', () => { { state: {}, table: table1, keptLayerIds: ['first'] }, { state: {}, table: table2, keptLayerIds: ['first'] }, ]); + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -357,18 +371,20 @@ describe('suggestion helpers', () => { previewIcon: 'empty', }, ]); - const suggestions = getSuggestions({ - visualizationMap: { - vis1: { - ...mockVisualization1, - getSuggestions: vis1Suggestions, - }, - vis2: { - ...mockVisualization2, - getSuggestions: vis2Suggestions, - }, + const visualizationMap = { + vis1: { + ...mockVisualization1, + getSuggestions: vis1Suggestions, }, - activeVisualizationId: 'vis1', + vis2: { + ...mockVisualization2, + getSuggestions: vis2Suggestions, + }, + }; + + const suggestions = getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -389,12 +405,15 @@ describe('suggestion helpers', () => { generateSuggestion(0), generateSuggestion(1), ]); + + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; + getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -419,12 +438,13 @@ describe('suggestion helpers', () => { generateSuggestion(0), generateSuggestion(1), ]); + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -451,12 +471,14 @@ describe('suggestion helpers', () => { generateSuggestion(0), generateSuggestion(1), ]); + const visualizationMap = { + vis1: mockVisualization1, + vis2: mockVisualization2, + }; + getSuggestions({ - visualizationMap: { - vis1: mockVisualization1, - vis2: mockVisualization2, - }, - activeVisualizationId: 'vis1', + visualizationMap, + activeVisualization: visualizationMap.vis1, visualizationState: {}, datasourceMap, datasourceStates, @@ -538,7 +560,8 @@ describe('suggestion helpers', () => { humanData: { label: 'myfieldLabel', }, - } + }, + expect.any(Function) ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 2f3fe3795a881..a5c7871f33dfc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -58,7 +58,7 @@ export function getSuggestions({ datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId, + activeVisualization, subVisualizationId, visualizationState, field, @@ -69,7 +69,7 @@ export function getSuggestions({ datasourceMap: DatasourceMap; datasourceStates: DatasourceStates; visualizationMap: VisualizationMap; - activeVisualizationId: string | null; + activeVisualization?: Visualization; subVisualizationId?: string; visualizationState: unknown; field?: unknown; @@ -83,16 +83,12 @@ export function getSuggestions({ const layerTypesMap = datasources.reduce((memo, [datasourceId, datasource]) => { const datasourceState = datasourceStates[datasourceId].state; - if (!activeVisualizationId || !datasourceState || !visualizationMap[activeVisualizationId]) { + if (!activeVisualization || !datasourceState) { return memo; } const layers = datasource.getLayers(datasourceState); for (const layerId of layers) { - const type = getLayerType( - visualizationMap[activeVisualizationId], - visualizationState, - layerId - ); + const type = getLayerType(activeVisualization, visualizationState, layerId); memo[layerId] = type; } return memo; @@ -112,7 +108,11 @@ export function getSuggestions({ visualizeTriggerFieldContext.fieldName ); } else if (field) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field); + dataSourceSuggestions = datasource.getDatasourceSuggestionsForField( + datasourceState, + field, + (layerId) => isLayerSupportedByVisualization(layerId, [layerTypes.DATA]) // a field dragged to workspace should added to data layer + ); } else { dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( datasourceState, @@ -121,7 +121,6 @@ export function getSuggestions({ } return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId })); }); - // Pass all table suggestions to all visualization extensions to get visualization suggestions // and rank them by score return Object.entries(visualizationMap) @@ -139,12 +138,8 @@ export function getSuggestions({ .flatMap((datasourceSuggestion) => { const table = datasourceSuggestion.table; const currentVisualizationState = - visualizationId === activeVisualizationId ? visualizationState : undefined; - const palette = - mainPalette || - (activeVisualizationId && visualizationMap[activeVisualizationId]?.getMainPalette - ? visualizationMap[activeVisualizationId].getMainPalette?.(visualizationState) - : undefined); + visualizationId === activeVisualization?.id ? visualizationState : undefined; + const palette = mainPalette || activeVisualization?.getMainPalette?.(visualizationState); return getVisualizationSuggestions( visualization, @@ -169,14 +164,14 @@ export function getVisualizeFieldSuggestions({ datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId, + activeVisualization, visualizationState, visualizeTriggerFieldContext, }: { datasourceMap: DatasourceMap; datasourceStates: DatasourceStates; visualizationMap: VisualizationMap; - activeVisualizationId: string | null; + activeVisualization: Visualization; subVisualizationId?: string; visualizationState: unknown; visualizeTriggerFieldContext?: VisualizeFieldContext; @@ -185,12 +180,12 @@ export function getVisualizeFieldSuggestions({ datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId, + activeVisualization, visualizationState, visualizeTriggerFieldContext, }); if (suggestions.length) { - return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; + return suggestions.find((s) => s.visualizationId === activeVisualization?.id) || suggestions[0]; } } @@ -263,18 +258,19 @@ export function getTopSuggestionForField( (datasourceLayer) => datasourceLayer.getTableSpec().length > 0 ); - const mainPalette = - visualization.activeId && visualizationMap[visualization.activeId]?.getMainPalette - ? visualizationMap[visualization.activeId].getMainPalette?.(visualization.state) - : undefined; + const activeVisualization = visualization.activeId + ? visualizationMap[visualization.activeId] + : undefined; + + const mainPalette = activeVisualization?.getMainPalette?.(visualization.state); const suggestions = getSuggestions({ datasourceMap: { [datasource.id]: datasource }, datasourceStates, visualizationMap: hasData && visualization.activeId - ? { [visualization.activeId]: visualizationMap[visualization.activeId] } + ? { [visualization.activeId]: activeVisualization! } : visualizationMap, - activeVisualizationId: visualization.activeId, + activeVisualization, visualizationState: visualization.state, field, mainPalette, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 858fcedf215ef..5e5e19ea29e84 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -201,7 +201,9 @@ export function SuggestionPanel({ datasourceMap, datasourceStates: currentDatasourceStates, visualizationMap, - activeVisualizationId: currentVisualization.activeId, + activeVisualization: currentVisualization.activeId + ? visualizationMap[currentVisualization.activeId] + : undefined, visualizationState: currentVisualization.state, activeData, }) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 28c0567d784ea..51d4f2955a52b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -515,11 +515,14 @@ function getTopSuggestion( props.visualizationMap[visualization.activeId].getMainPalette ? props.visualizationMap[visualization.activeId].getMainPalette!(visualization.state) : undefined; + const unfilteredSuggestions = getSuggestions({ datasourceMap: props.datasourceMap, datasourceStates, visualizationMap: { [visualizationId]: newVisualization }, - activeVisualizationId: visualization.activeId, + activeVisualization: visualization.activeId + ? props.visualizationMap[visualization.activeId] + : undefined, visualizationState: visualization.state, subVisualizationId, activeData: props.framePublicAPI.activeData, 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 e386bac026fdc..d25e6754fe03f 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 @@ -11,15 +11,11 @@ import { i18n } from '@kbn/i18n'; import { EuiListGroup, EuiFormRow, - EuiFieldText, EuiSpacer, EuiListGroupItemProps, EuiFormLabel, EuiToolTip, EuiText, - EuiTabs, - EuiTab, - EuiCallOut, } from '@elastic/eui'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; import { OperationSupportMatrix } from './operation_support'; @@ -47,41 +43,29 @@ import { setTimeScaling, TimeScaling } from './time_scaling'; import { defaultFilter, Filtering, setFilter } from './filtering'; import { AdvancedOptions } from './advanced_options'; import { setTimeShift, TimeShift } from './time_shift'; -import { useDebouncedValue } from '../../shared_components'; +import { LayerType } from '../../../common'; +import { + quickFunctionsName, + staticValueOperationName, + isQuickFunction, + getParamEditor, + formulaOperationName, + DimensionEditorTabs, + CalloutWarning, + LabelInput, + getErrorMessage, +} from './dimensions_editor_helpers'; +import type { TemporaryState } from './dimensions_editor_helpers'; const operationPanels = getOperationDisplay(); export interface DimensionEditorProps extends IndexPatternDimensionEditorProps { selectedColumn?: IndexPatternColumn; + layerType: LayerType; operationSupportMatrix: OperationSupportMatrix; currentIndexPattern: IndexPattern; } -const LabelInput = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => { - const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value }); - - return ( - - { - handleInputChange(e.target.value); - }} - placeholder={initialValue} - /> - - ); -}; - export function DimensionEditor(props: DimensionEditorProps) { const { selectedColumn, @@ -96,6 +80,8 @@ export function DimensionEditor(props: DimensionEditorProps) { dimensionGroups, toggleFullscreen, isFullscreen, + supportStaticValue, + supportFieldFormat = true, layerType, } = props; const services = { @@ -110,6 +96,11 @@ export function DimensionEditor(props: DimensionEditorProps) { const selectedOperationDefinition = selectedColumn && operationDefinitionMap[selectedColumn.operationType]; + const [temporaryState, setTemporaryState] = useState('none'); + + const temporaryQuickFunction = Boolean(temporaryState === quickFunctionsName); + const temporaryStaticValue = Boolean(temporaryState === staticValueOperationName); + const updateLayer = useCallback( (newLayer) => setState((prevState) => mergeLayer({ state: prevState, layerId, newLayer })), [layerId, setState] @@ -141,9 +132,64 @@ export function DimensionEditor(props: DimensionEditorProps) { ...incompleteParams } = incompleteInfo || {}; - const ParamEditor = selectedOperationDefinition?.paramEditor; + const isQuickFunctionSelected = Boolean( + supportStaticValue + ? selectedOperationDefinition && isQuickFunction(selectedOperationDefinition.type) + : !selectedOperationDefinition || isQuickFunction(selectedOperationDefinition.type) + ); + const showQuickFunctions = temporaryQuickFunction || isQuickFunctionSelected; + + const showStaticValueFunction = + temporaryStaticValue || + (temporaryState === 'none' && + supportStaticValue && + (!selectedColumn || selectedColumn?.operationType === staticValueOperationName)); + + const addStaticValueColumn = (prevLayer = props.state.layers[props.layerId]) => { + if (selectedColumn?.operationType !== staticValueOperationName) { + trackUiEvent(`indexpattern_dimension_operation_static_value`); + return insertOrReplaceColumn({ + layer: prevLayer, + indexPattern: currentIndexPattern, + columnId, + op: staticValueOperationName, + visualizationGroups: dimensionGroups, + }); + } + return prevLayer; + }; + + // this function intercepts the state update for static value function + // and. if in temporary state, it merges the "add new static value column" state with the incoming + // changes from the static value operation (which has to be a function) + // Note: it forced a rerender at this point to avoid UI glitches in async updates (another hack upstream) + // TODO: revisit this once we get rid of updateDatasourceAsync upstream + const moveDefinetelyToStaticValueAndUpdate = ( + setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + ) => { + if (temporaryStaticValue) { + setTemporaryState('none'); + if (typeof setter === 'function') { + return setState( + (prevState) => { + const layer = setter(addStaticValueColumn(prevState.layers[layerId])); + return mergeLayer({ state: prevState, layerId, newLayer: layer }); + }, + { + isDimensionComplete: true, + forceRender: true, + } + ); + } + } + return setStateWrapper(setter); + }; - const [temporaryQuickFunction, setQuickFunction] = useState(false); + const ParamEditor = getParamEditor( + temporaryStaticValue, + selectedOperationDefinition, + supportStaticValue && !showQuickFunctions + ); const possibleOperations = useMemo(() => { return Object.values(operationDefinitionMap) @@ -245,9 +291,9 @@ export function DimensionEditor(props: DimensionEditorProps) { [`aria-pressed`]: isActive, onClick() { if ( - operationDefinitionMap[operationType].input === 'none' || - operationDefinitionMap[operationType].input === 'managedReference' || - operationDefinitionMap[operationType].input === 'fullReference' + ['none', 'fullReference', 'managedReference'].includes( + operationDefinitionMap[operationType].input + ) ) { // Clear invalid state because we are reseting to a valid column if (selectedColumn?.operationType === operationType) { @@ -264,9 +310,12 @@ export function DimensionEditor(props: DimensionEditorProps) { visualizationGroups: dimensionGroups, targetGroup: props.groupId, }); - if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') { + if ( + temporaryQuickFunction && + isQuickFunction(newLayer.columns[columnId].operationType) + ) { // Only switch the tab once the formula is fully removed - setQuickFunction(false); + setTemporaryState('none'); } setStateWrapper(newLayer); trackUiEvent(`indexpattern_dimension_operation_${operationType}`); @@ -297,9 +346,12 @@ export function DimensionEditor(props: DimensionEditorProps) { }); // ); } - if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') { + if ( + temporaryQuickFunction && + isQuickFunction(newLayer.columns[columnId].operationType) + ) { // Only switch the tab once the formula is fully removed - setQuickFunction(false); + setTemporaryState('none'); } setStateWrapper(newLayer); trackUiEvent(`indexpattern_dimension_operation_${operationType}`); @@ -314,7 +366,7 @@ export function DimensionEditor(props: DimensionEditorProps) { } if (temporaryQuickFunction) { - setQuickFunction(false); + setTemporaryState('none'); } const newLayer = replaceColumn({ layer: props.state.layers[props.layerId], @@ -348,29 +400,10 @@ export function DimensionEditor(props: DimensionEditorProps) { !currentFieldIsInvalid && !incompleteInfo && selectedColumn && - selectedColumn.operationType !== 'formula'; + isQuickFunction(selectedColumn.operationType); const quickFunctions = ( <> - {temporaryQuickFunction && selectedColumn?.operationType === 'formula' && ( - <> - -

- {i18n.translate('xpack.lens.indexPattern.formulaWarningText', { - defaultMessage: 'To overwrite your formula, select a quick function', - })} -

-
- - )}
{i18n.translate('xpack.lens.indexPattern.functionsLabel', { @@ -608,24 +641,28 @@ export function DimensionEditor(props: DimensionEditorProps) { ); - const formulaTab = ParamEditor ? ( - + const customParamEditor = ParamEditor ? ( + <> + + ) : null; + const TabContent = showQuickFunctions ? quickFunctions : customParamEditor; + const onFormatChange = useCallback( (newFormat) => { updateLayer( @@ -640,58 +677,69 @@ export function DimensionEditor(props: DimensionEditorProps) { [columnId, layerId, state.layers, updateLayer] ); + const hasFormula = + !isFullscreen && operationSupportMatrix.operationWithoutField.has(formulaOperationName); + + const hasTabs = hasFormula || supportStaticValue; + return (
- {!isFullscreen && operationSupportMatrix.operationWithoutField.has('formula') ? ( - - { - if (selectedColumn?.operationType === 'formula') { - setQuickFunction(true); + {hasTabs ? ( + { + if (tabClicked === 'quickFunctions') { + if (selectedColumn && !isQuickFunction(selectedColumn.operationType)) { + setTemporaryState(quickFunctionsName); + return; } - }} - > - {i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', { - defaultMessage: 'Quick functions', - })} - - { - if (selectedColumn?.operationType !== 'formula') { - setQuickFunction(false); + } + + if (tabClicked === 'static_value') { + // when coming from a formula, set a temporary state + if (selectedColumn?.operationType === formulaOperationName) { + return setTemporaryState(staticValueOperationName); + } + setTemporaryState('none'); + setStateWrapper(addStaticValueColumn()); + return; + } + + if (tabClicked === 'formula') { + setTemporaryState('none'); + if (selectedColumn?.operationType !== formulaOperationName) { const newLayer = insertOrReplaceColumn({ layer: props.state.layers[props.layerId], indexPattern: currentIndexPattern, columnId, - op: 'formula', + op: formulaOperationName, visualizationGroups: dimensionGroups, }); setStateWrapper(newLayer); trackUiEvent(`indexpattern_dimension_operation_formula`); - return; - } else { - setQuickFunction(false); } - }} - > - {i18n.translate('xpack.lens.indexPattern.formulaLabel', { - defaultMessage: 'Formula', - })} - - + } + }} + /> ) : null} - {isFullscreen - ? formulaTab - : selectedOperationDefinition?.type === 'formula' && !temporaryQuickFunction - ? formulaTab - : quickFunctions} + + {TabContent} - {!isFullscreen && !currentFieldIsInvalid && !temporaryQuickFunction && ( + {!isFullscreen && !currentFieldIsInvalid && temporaryState === 'none' && (
{!incompleteInfo && selectedColumn && ( )} - {!isFullscreen && + {supportFieldFormat && + !isFullscreen && selectedColumn && (selectedColumn.dataType === 'number' || selectedColumn.operationType === 'range') ? ( @@ -735,26 +784,3 @@ export function DimensionEditor(props: DimensionEditorProps) {
); } - -function getErrorMessage( - selectedColumn: IndexPatternColumn | undefined, - incompleteOperation: boolean, - input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, - fieldInvalid: boolean -) { - if (selectedColumn && incompleteOperation) { - if (input === 'field') { - return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { - defaultMessage: 'This field does not work with the selected function.', - }); - } - return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { - defaultMessage: 'To use this function, select a field.', - }); - } - if (fieldInvalid) { - return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { - defaultMessage: 'Invalid field. Check your index pattern or pick another field.', - }); - } -} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 5d56661f15915..d823def1da114 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -52,6 +52,13 @@ jest.mock('lodash', () => { }; }); jest.mock('../../id_generator'); +// Mock the Monaco Editor component +jest.mock('../operations/definitions/formula/editor/formula_editor', () => { + return { + WrappedFormulaEditor: () =>
, + FormulaEditor: () =>
, + }; +}); const fields = [ { @@ -211,6 +218,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dimensionGroups: [], groupId: 'a', isFullscreen: false, + supportStaticValue: false, toggleFullscreen: jest.fn(), }; @@ -402,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', () => { @@ -2217,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/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index f3e51516d161c..ac8296cca968e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -16,7 +16,7 @@ import { IndexPatternColumn } from '../indexpattern'; import { isColumnInvalid } from '../utils'; import { IndexPatternPrivateState } from '../types'; import { DimensionEditor } from './dimension_editor'; -import type { DateRange } from '../../../common'; +import { DateRange, layerTypes } from '../../../common'; import { getOperationSupportMatrix } from './operation_support'; export type IndexPatternDimensionTriggerProps = @@ -49,11 +49,11 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens const layerId = props.layerId; const layer = props.state.layers[layerId]; const currentIndexPattern = props.state.indexPatterns[layer.indexPatternId]; - const { columnId, uniqueLabel } = props; + const { columnId, uniqueLabel, invalid, invalidMessage } = props; const currentColumnHasErrors = useMemo( - () => isColumnInvalid(layer, columnId, currentIndexPattern), - [layer, columnId, currentIndexPattern] + () => invalid || isColumnInvalid(layer, columnId, currentIndexPattern), + [layer, columnId, currentIndexPattern, invalid] ); const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] ?? null; @@ -67,15 +67,17 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens return ( - {i18n.translate('xpack.lens.configure.invalidConfigTooltip', { - defaultMessage: 'Invalid configuration.', - })} -
- {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', { - defaultMessage: 'Click for more details.', - })} -

+ invalidMessage ?? ( +

+ {i18n.translate('xpack.lens.configure.invalidConfigTooltip', { + defaultMessage: 'Invalid configuration.', + })} +
+ {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', { + defaultMessage: 'Click for more details.', + })} +

+ ) } anchorClassName="eui-displayBlock" > @@ -127,6 +129,7 @@ export const IndexPatternDimensionEditorComponent = function IndexPatternDimensi return ( void; +}) => { + const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value }); + + return ( + + { + handleInputChange(e.target.value); + }} + placeholder={initialValue} + /> + + ); +}; + +export function getParamEditor( + temporaryStaticValue: boolean, + selectedOperationDefinition: typeof operationDefinitionMap[string] | undefined, + showDefaultStaticValue: boolean +) { + if (temporaryStaticValue) { + return operationDefinitionMap[staticValueOperationName].paramEditor; + } + if (selectedOperationDefinition?.paramEditor) { + return selectedOperationDefinition.paramEditor; + } + if (showDefaultStaticValue) { + return operationDefinitionMap[staticValueOperationName].paramEditor; + } + return null; +} + +export const CalloutWarning = ({ + currentOperationType, + temporaryStateType, +}: { + currentOperationType: keyof typeof operationDefinitionMap | undefined; + temporaryStateType: TemporaryState; +}) => { + if ( + temporaryStateType === 'none' || + (currentOperationType != null && isQuickFunction(currentOperationType)) + ) { + return null; + } + if ( + currentOperationType === staticValueOperationName && + temporaryStateType === 'quickFunctions' + ) { + return ( + <> + +

+ {i18n.translate('xpack.lens.indexPattern.staticValueWarningText', { + defaultMessage: 'To overwrite your static value, select a quick function', + })} +

+
+ + ); + } + return ( + <> + + {temporaryStateType !== 'quickFunctions' ? ( +

+ {i18n.translate('xpack.lens.indexPattern.formulaWarningStaticValueText', { + defaultMessage: 'To overwrite your formula, change the value in the input field', + })} +

+ ) : ( +

+ {i18n.translate('xpack.lens.indexPattern.formulaWarningText', { + defaultMessage: 'To overwrite your formula, select a quick function', + })} +

+ )} +
+ + ); +}; + +type DimensionEditorTabsType = + | typeof quickFunctionsName + | typeof staticValueOperationName + | typeof formulaOperationName; + +export const DimensionEditorTabs = ({ + tabsEnabled, + tabsState, + onClick, +}: { + tabsEnabled: Record; + tabsState: Record; + onClick: (tabClicked: DimensionEditorTabsType) => void; +}) => { + return ( + + {tabsEnabled.static_value ? ( + onClick(staticValueOperationName)} + > + {i18n.translate('xpack.lens.indexPattern.staticValueLabel', { + defaultMessage: 'Static value', + })} + + ) : null} + onClick(quickFunctionsName)} + > + {i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', { + defaultMessage: 'Quick functions', + })} + + {tabsEnabled.formula ? ( + onClick(formulaOperationName)} + > + {i18n.translate('xpack.lens.indexPattern.formulaLabel', { + defaultMessage: 'Formula', + })} + + ) : null} + + ); +}; + +export function getErrorMessage( + selectedColumn: IndexPatternColumn | undefined, + incompleteOperation: boolean, + input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined, + fieldInvalid: boolean +) { + if (selectedColumn && incompleteOperation) { + if (input === 'field') { + return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', { + defaultMessage: 'This field does not work with the selected function.', + }); + } + return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', { + defaultMessage: 'To use this function, select a field.', + }); + } + if (fieldInvalid) { + return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', { + defaultMessage: 'Invalid field. Check your index pattern or pick another field.', + }); + } +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 26aac5dab31e3..85807721f80f6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -17,6 +17,7 @@ import { OperationMetadata, DropType } from '../../../types'; import { IndexPatternColumn, MedianIndexPatternColumn } from '../../operations'; import { getFieldByNameFactory } from '../../pure_helpers'; import { generateId } from '../../../id_generator'; +import { layerTypes } from '../../../../common'; jest.mock('../../../id_generator'); @@ -263,7 +264,6 @@ describe('IndexPatternDimensionEditorPanel', () => { dateRange: { fromDate: 'now-1d', toDate: 'now' }, columnId: 'col1', layerId: 'first', - layerType: 'data', uniqueLabel: 'stuff', groupId: 'group1', filterOperations: () => true, @@ -287,6 +287,8 @@ describe('IndexPatternDimensionEditorPanel', () => { dimensionGroups: [], isFullscreen: false, toggleFullscreen: () => {}, + supportStaticValue: false, + layerType: layerTypes.DATA, }; jest.clearAllMocks(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index e09c3e904f535..b518f667a0bfb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -121,8 +121,12 @@ function onMoveCompatible( indexPattern, }); - let updatedColumnOrder = getColumnOrder(modifiedLayer); - updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + const updatedColumnOrder = reorderByGroups( + dimensionGroups, + groupId, + getColumnOrder(modifiedLayer), + columnId + ); // Time to replace setState( 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 06c8a50cd2dfa..1dfc7d40f6f3e 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 6a45e3c987f3d..2138b06a4c344 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -44,7 +44,7 @@ import { import { isDraggedField, normalizeOperationDataType } from './utils'; import { LayerPanel } from './layerpanel'; -import { IndexPatternColumn, getErrorMessages } from './operations'; +import { IndexPatternColumn, getErrorMessages, insertNewColumn } from './operations'; import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -192,6 +192,27 @@ 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, + newLayer: insertNewColumn({ + layer: state.layers[layerId], + op: 'static_value', + columnId, + field: undefined, + indexPattern, + visualizationGroups: [], + initialParams: { params: { value: staticValue } }, + targetGroup: groupId, + }), + }); + }, + toExpression: (state, layerId) => toExpression(state, layerId, uiSettings), renderDataPanel( @@ -404,9 +425,14 @@ export function getIndexPatternDatasource({ }, }; }, - getDatasourceSuggestionsForField(state, draggedField) { + getDatasourceSuggestionsForField(state, draggedField, filterLayers) { return isDraggedField(draggedField) - ? getDatasourceSuggestionsForField(state, draggedField.indexPatternId, draggedField.field) + ? getDatasourceSuggestionsForField( + state, + draggedField.indexPatternId, + draggedField.field, + filterLayers + ) : []; }, getDatasourceSuggestionsFromCurrentState, 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/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index b0793bf912bb2..0fe0ef617dc27 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -95,10 +95,14 @@ function buildSuggestion({ export function getDatasourceSuggestionsForField( state: IndexPatternPrivateState, indexPatternId: string, - field: IndexPatternField + field: IndexPatternField, + filterLayers?: (layerId: string) => boolean ): IndexPatternSuggestion[] { const layers = Object.keys(state.layers); - const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); + let layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); + if (filterLayers) { + layerIds = layerIds.filter(filterLayers); + } if (layerIds.length === 0) { // The field we're suggesting on does not match any existing layer. diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx index c2ba893a9b90e..499170349c3d5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx @@ -355,6 +355,33 @@ describe('formula', () => { references: [], }); }); + + it('should move into Formula previous static_value operation', () => { + expect( + formulaOperation.buildColumn({ + previousColumn: { + label: 'Static value: 0', + dataType: 'number', + isBucketed: false, + operationType: 'static_value', + references: [], + params: { + value: '0', + }, + }, + layer, + indexPattern, + }) + ).toEqual({ + label: '0', + dataType: 'number', + operationType: 'formula', + isBucketed: false, + scale: 'ratio', + params: { isFormulaBroken: false, formula: '0' }, + references: [], + }); + }); }); describe('regenerateLayerFromAst()', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts index 589f547434b91..3db9ebc6f969d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts @@ -38,6 +38,11 @@ export function generateFormula( previousFormula: string, operationDefinitionMap: Record | undefined ) { + if (previousColumn.operationType === 'static_value') { + if (previousColumn.params && 'value' in previousColumn.params) { + return String(previousColumn.params.value); // make sure it's a string + } + } if ('references' in previousColumn) { const metric = layer.columns[previousColumn.references[0]]; if (metric && 'sourceField' in metric && metric.dataType === 'number') { 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/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 326b71f72c060..0212c73f46879 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -49,6 +49,7 @@ import { formulaOperation, FormulaIndexPatternColumn, } from './formula'; +import { staticValueOperation, StaticValueIndexPatternColumn } from './static_value'; import { lastValueOperation, LastValueIndexPatternColumn } from './last_value'; import { FrameDatasourceAPI, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; @@ -87,7 +88,8 @@ export type IndexPatternColumn = | DerivativeIndexPatternColumn | MovingAverageIndexPatternColumn | MathIndexPatternColumn - | FormulaIndexPatternColumn; + | FormulaIndexPatternColumn + | StaticValueIndexPatternColumn; export type FieldBasedIndexPatternColumn = Extract; @@ -119,6 +121,7 @@ export { CountIndexPatternColumn } from './count'; export { LastValueIndexPatternColumn } from './last_value'; export { RangeIndexPatternColumn } from './ranges'; export { FormulaIndexPatternColumn, MathIndexPatternColumn } from './formula'; +export { StaticValueIndexPatternColumn } from './static_value'; // List of all operation definitions registered to this data source. // If you want to implement a new operation, add the definition to this array and @@ -147,6 +150,7 @@ const internalOperationDefinitions = [ overallMinOperation, overallMaxOperation, overallAverageOperation, + staticValueOperation, ]; export { termsOperation } from './terms'; @@ -168,6 +172,7 @@ export { overallMinOperation, } from './calculations'; export { formulaOperation } from './formula/formula'; +export { staticValueOperation } from './static_value'; /** * Properties passed to the operation-specific part of the popover editor 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..0a6620eecf308 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx @@ -0,0 +1,404 @@ +/* + * 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.mock.calls[0]).toEqual([expect.any(Function)]); + // check that the result of the setter call is correct + expect(updateLayerSpy.mock.calls[0][0](layer)).toEqual({ + ...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 new file mode 100644 index 0000000000000..a76c5f64d1750 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx @@ -0,0 +1,222 @@ +/* + * 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, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFieldNumber, EuiFormLabel, EuiSpacer } from '@elastic/eui'; +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', +}); + +const defaultValue = 100; + +function isEmptyValue(value: number | string | undefined) { + return value == null || value === ''; +} + +function ofName(value: number | string | undefined) { + if (isEmptyValue(value)) { + return defaultLabel; + } + return i18n.translate('xpack.lens.indexPattern.staticValueLabelWithValue', { + defaultMessage: 'Static value: {value}', + values: { value }, + }); +} + +export interface StaticValueIndexPatternColumn extends ReferenceBasedIndexPatternColumn { + operationType: 'static_value'; + params: { + value?: string; + format?: { + id: string; + params?: { + decimals: number; + }; + }; + }; +} + +export const staticValueOperation: OperationDefinition< + StaticValueIndexPatternColumn, + 'managedReference' +> = { + type: 'static_value', + displayName: defaultLabel, + getDefaultLabel: (column) => ofName(column.params.value), + input: 'managedReference', + hidden: true, + getDisabledStatus(indexPattern: IndexPattern) { + return undefined; + }, + getErrorMessage(layer, columnId) { + const column = layer.columns[columnId] as StaticValueIndexPatternColumn; + + return !isValidNumber(column.params.value) + ? [ + i18n.translate('xpack.lens.indexPattern.staticValueError', { + defaultMessage: 'The static value of {value} is not a valid number', + values: { value: column.params.value }, + }), + ] + : undefined; + }, + getPossibleOperation() { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + toExpression: (layer, columnId) => { + const currentColumn = layer.columns[columnId] as StaticValueIndexPatternColumn; + const params = currentColumn.params; + // TODO: improve this logic + const useDisplayLabel = currentColumn.label !== defaultLabel; + const label = isValidNumber(params.value) + ? useDisplayLabel + ? currentColumn.label + : params?.value ?? defaultLabel + : defaultLabel; + + return [ + { + type: 'function', + function: isValidNumber(params.value) ? 'mathColumn' : 'mapColumn', + arguments: { + id: [columnId], + name: [label || defaultLabel], + expression: [isValidNumber(params.value) ? params.value! : String(defaultValue)], + }, + }, + ]; + }, + buildColumn({ previousColumn, layer, indexPattern }, columnParams, operationDefinitionMap) { + const existingStaticValue = + previousColumn?.params && + 'value' in previousColumn.params && + isValidNumber(previousColumn.params.value) + ? previousColumn.params.value + : undefined; + const previousParams: StaticValueIndexPatternColumn['params'] = { + ...{ value: existingStaticValue }, + ...getFormatFromPreviousColumn(previousColumn), + ...columnParams, + }; + return { + label: ofName(previousParams.value), + dataType: 'number', + operationType: 'static_value', + isBucketed: false, + scale: 'ratio', + params: { ...previousParams, value: previousParams.value ?? String(defaultValue) }, + references: [], + }; + }, + isTransferable: (column) => { + return true; + }, + createCopy(layer, sourceId, targetId, indexPattern, operationDefinitionMap) { + const currentColumn = layer.columns[sourceId] as StaticValueIndexPatternColumn; + return { + ...layer, + columns: { + ...layer.columns, + [targetId]: { ...currentColumn }, + }, + }; + }, + + paramEditor: function StaticValueEditor({ + layer, + updateLayer, + currentColumn, + columnId, + activeData, + layerId, + indexPattern, + }) { + const onChange = useCallback( + (newValue) => { + // even if debounced it's triggering for empty string with the previous valid value + if (currentColumn.params.value === newValue) { + return; + } + // Because of upstream specific UX flows, we need fresh layer state here + // so need to use the updater pattern + updateLayer((newLayer) => { + const newColumn = newLayer.columns[columnId] as StaticValueIndexPatternColumn; + return { + ...newLayer, + columns: { + ...newLayer.columns, + [columnId]: { + ...newColumn, + label: newColumn?.customLabel ? newColumn.label : ofName(newValue), + params: { + ...newColumn.params, + value: newValue, + }, + }, + }, + }; + }); + }, + [columnId, updateLayer, currentColumn?.params?.value] + ); + + // Pick the data from the current activeData (to be used when the current operation is not static_value) + const activeDataValue = + activeData && + activeData[layerId] && + activeData[layerId]?.rows?.length === 1 && + activeData[layerId].rows[0][columnId]; + + const fallbackValue = + currentColumn?.operationType !== 'static_value' && activeDataValue != null + ? activeDataValue + : String(defaultValue); + + const { inputValue, handleInputChange } = useDebouncedValue( + { + value: currentColumn?.params?.value || fallbackValue, + onChange, + }, + { allowFalsyValue: true } + ); + + const onChangeHandler = useCallback( + (e: React.ChangeEvent) => { + const value = e.currentTarget.value; + handleInputChange(isValidNumber(value) ? value : undefined); + }, + [handleInputChange] + ); + + return ( +
+ + {i18n.translate('xpack.lens.indexPattern.staticValue.label', { + defaultMessage: 'Threshold value', + })} + + + +
+ ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 11c8206fee021..baacc7bb64d16 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -184,6 +184,7 @@ export function insertNewColumn({ targetGroup, shouldResetLabel, incompleteParams, + initialParams, }: ColumnChange): IndexPatternLayer { const operationDefinition = operationDefinitionMap[op]; @@ -197,7 +198,7 @@ export function insertNewColumn({ const baseOptions = { indexPattern, - previousColumn: { ...incompleteParams, ...layer.columns[columnId] }, + previousColumn: { ...incompleteParams, ...initialParams, ...layer.columns[columnId] }, }; if (operationDefinition.input === 'none' || operationDefinition.input === 'managedReference') { @@ -396,9 +397,17 @@ export function replaceColumn({ tempLayer = resetIncomplete(tempLayer, columnId); - if (previousDefinition.input === 'managedReference') { + if ( + previousDefinition.input === 'managedReference' && + operationDefinition.input !== previousDefinition.input + ) { // If the transition is incomplete, leave the managed state until it's finished. - tempLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + tempLayer = removeOrphanedColumns( + previousDefinition, + previousColumn, + tempLayer, + indexPattern + ); const hypotheticalLayer = insertNewColumn({ layer: tempLayer, @@ -641,21 +650,31 @@ function removeOrphanedColumns( previousDefinition: | OperationDefinition | OperationDefinition - | OperationDefinition, + | OperationDefinition + | OperationDefinition, previousColumn: IndexPatternColumn, tempLayer: IndexPatternLayer, indexPattern: IndexPattern ) { + let newLayer: IndexPatternLayer = tempLayer; + if (previousDefinition.input === 'managedReference') { + const [columnId] = + Object.entries(tempLayer.columns).find(([_, currColumn]) => currColumn === previousColumn) || + []; + if (columnId != null) { + newLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + } + } if (previousDefinition.input === 'fullReference') { (previousColumn as ReferenceBasedIndexPatternColumn).references.forEach((id: string) => { - tempLayer = deleteColumn({ + newLayer = deleteColumn({ layer: tempLayer, columnId: id, indexPattern, }); }); } - return tempLayer; + return newLayer; } export function canTransition({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index 2ed6e2b3a7bcb..08136ed501cfc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -378,6 +378,10 @@ describe('getOperationTypesForField', () => { "operationType": "formula", "type": "managedReference", }, + Object { + "operationType": "static_value", + "type": "managedReference", + }, ], }, Object { diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index 47a2e00055ce3..402440f3302f6 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -59,9 +59,9 @@ export function mockDatasourceStates() { }; } -export function createMockVisualization(): jest.Mocked { +export function createMockVisualization(id = 'vis1'): jest.Mocked { return { - id: 'TEST_VIS', + id, clearLayer: jest.fn((state, _layerId) => state), removeLayer: jest.fn(), getLayerIds: jest.fn((_state) => ['layer1']), @@ -70,9 +70,9 @@ export function createMockVisualization(): jest.Mocked { visualizationTypes: [ { icon: 'empty', - id: 'TEST_VIS', + id, label: 'TEST', - groupLabel: 'TEST_VISGroup', + groupLabel: `${id}Group`, }, ], getVisualizationTypeId: jest.fn((_state) => 'empty'), @@ -122,7 +122,7 @@ export function createMockDatasource(id: string): DatasourceMock { return { id: 'mockindexpattern', clearLayer: jest.fn((state, _layerId) => state), - getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), + getDatasourceSuggestionsForField: jest.fn((_state, _item, filterFn) => []), getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []), getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), getPersistableState: jest.fn((x) => ({ diff --git a/x-pack/plugins/lens/public/shared_components/debounced_value.ts b/x-pack/plugins/lens/public/shared_components/debounced_value.ts index fa8fc22dedd57..412199a371f1f 100644 --- a/x-pack/plugins/lens/public/shared_components/debounced_value.ts +++ b/x-pack/plugins/lens/public/shared_components/debounced_value.ts @@ -11,6 +11,10 @@ import { debounce } from 'lodash'; /** * Debounces value changes and updates inputValue on root state changes if no debounced changes * are in flight because the user is currently modifying the value. + * + * * allowFalsyValue: update upstream with all falsy values but null or undefined + * + * When testing this function mock the "debounce" function in lodash (see this module test for an example) */ export const useDebouncedValue = ( diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index c200a18a25caf..f947ce699dce4 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -14,3 +14,4 @@ export * from './coloring'; export { useDebouncedValue } from './debounced_value'; export * from './helpers'; export { LegendActionPopover } from './legend_action_popover'; +export * from './static_header'; diff --git a/x-pack/plugins/lens/public/shared_components/static_header.tsx b/x-pack/plugins/lens/public/shared_components/static_header.tsx new file mode 100644 index 0000000000000..2250358234a7f --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/static_header.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, IconType } from '@elastic/eui'; + +export const StaticHeader = ({ label, icon }: { label: string; icon?: IconType }) => { + return ( + + {icon && ( + + {' '} + + )} + + +
{label}
+
+
+
+ ); +}; diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 0f5f1c15d4fa7..7db03a17a3a8f 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -149,7 +149,7 @@ export function loadInitial( datasourceMap, datasourceStates, visualizationMap, - activeVisualizationId: Object.keys(visualizationMap)[0] || null, + activeVisualization: visualizationMap?.[Object.keys(visualizationMap)[0]] || null, visualizationState: null, visualizeTriggerFieldContext: initialContext, }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a4a483fa95d37..cf6634c200d55 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -234,7 +234,11 @@ export interface Datasource { toExpression: (state: T, layerId: string) => ExpressionAstExpression | string | null; - getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; + getDatasourceSuggestionsForField: ( + state: T, + field: unknown, + filterFn: (layerId: string) => boolean + ) => Array>; getDatasourceSuggestionsForVisualizeField: ( state: T, indexPatternId: string, @@ -326,6 +330,8 @@ export type DatasourceDimensionProps = SharedDimensionProps & { onRemove?: (accessor: string) => void; state: T; activeData?: Record; + invalid?: boolean; + invalidMessage?: string; }; // The only way a visualization has to restrict the query building @@ -335,6 +341,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro newState: Parameters>[0], publishToVisualization?: { isDimensionComplete?: boolean; + forceRender?: boolean; } ) => void; core: Pick; @@ -343,6 +350,8 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro toggleFullscreen: () => void; isFullscreen: boolean; layerType: LayerType | undefined; + supportStaticValue: boolean; + supportFieldFormat?: boolean; }; export type DatasourceDimensionTriggerProps = DatasourceDimensionProps; @@ -434,7 +443,7 @@ export interface VisualizationToolbarProps { export type VisualizationDimensionEditorProps = VisualizationConfigProps & { groupId: string; accessor: string; - setState: (newState: T) => void; + setState(newState: T | ((currState: T) => T)): void; panelRef: MutableRefObject; }; @@ -466,13 +475,16 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { // this dimension group in the hierarchy. If not specified, the position of the dimension in the array is used. specified nesting // orders are always higher in the hierarchy than non-specified ones. nestingOrder?: number; + // some type of layers can produce groups even if invalid. Keep this information to visually show the user that. + invalid?: boolean; + invalidMessage?: string; }; interface VisualizationDimensionChangeProps { layerId: string; columnId: string; prevState: T; - frame: Pick; + frame: Pick; } /** @@ -655,6 +667,7 @@ export interface Visualization { getConfiguration: (props: VisualizationConfigProps) => { groups: VisualizationDimensionGroupConfig[]; supportStaticValue?: boolean; + supportFieldFormat?: boolean; }; /** diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts index 95c9140624e63..9c83e2c58146e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts @@ -30,16 +30,17 @@ export function isFormatterCompatible( return formatter1.id === formatter2.id; } -export function getAxesConfiguration( - layers: XYLayerConfig[], - shouldRotate: boolean, - tables?: Record, - formatFactory?: FormatFactory -): GroupsConfiguration { - const series: { auto: FormattedMetric[]; left: FormattedMetric[]; right: FormattedMetric[] } = { +export function groupAxesByType(layers: XYLayerConfig[], tables?: Record) { + const series: { + auto: FormattedMetric[]; + left: FormattedMetric[]; + right: FormattedMetric[]; + bottom: FormattedMetric[]; + } = { auto: [], left: [], right: [], + bottom: [], }; layers?.forEach((layer) => { @@ -89,6 +90,16 @@ export function getAxesConfiguration( series.right.push(currentSeries); } }); + return series; +} + +export function getAxesConfiguration( + layers: XYLayerConfig[], + shouldRotate: boolean, + tables?: Record, + formatFactory?: FormatFactory +): GroupsConfiguration { + const series = groupAxesByType(layers, tables); const axisGroups: GroupsConfiguration = []; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 026d9da71beea..863289c31bba4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -59,6 +59,7 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './get_legend_action'; +import { ThresholdAnnotations } from './expression_thresholds'; declare global { interface Window { @@ -251,6 +252,7 @@ export function XYChart({ const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; return ; } + const thresholdLayers = layers.filter((layer) => layer.layerType === layerTypes.THRESHOLD); // use formatting hint of first x axis column to format ticks const xAxisColumn = data.tables[filteredLayers[0].layerId].columns.find( @@ -832,6 +834,20 @@ export function XYChart({ } }) )} + {thresholdLayers.length ? ( + groupId === 'left')?.formatter, + right: yAxesConfiguration.find(({ groupId }) => groupId === 'right')?.formatter, + bottom: xAxisFormatter, + }} + /> + ) : null} ); } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx new file mode 100644 index 0000000000000..171e2f1cfba9e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx @@ -0,0 +1,193 @@ +/* + * 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 { groupBy } from 'lodash'; +import { EuiIcon } from '@elastic/eui'; +import { RectAnnotation, AnnotationDomainType, LineAnnotation } from '@elastic/charts'; +import type { PaletteRegistry, SeriesLayer } from 'src/plugins/charts/public'; +import type { FieldFormat } from 'src/plugins/field_formats/common'; +import type { LayerArgs } from '../../common/expressions'; +import type { LensMultiTable } from '../../common/types'; +import type { ColorAssignments } from './color_assignment'; + +export const ThresholdAnnotations = ({ + thresholdLayers, + data, + colorAssignments, + formatters, + paletteService, + syncColors, +}: { + thresholdLayers: LayerArgs[]; + data: LensMultiTable; + colorAssignments: ColorAssignments; + formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; + paletteService: PaletteRegistry; + syncColors: boolean; +}) => { + return ( + <> + {thresholdLayers.flatMap((thresholdLayer) => { + if (!thresholdLayer.yConfig) { + return []; + } + const { columnToLabel, palette, yConfig: yConfigs, layerId } = thresholdLayer; + const columnToLabelMap: Record = columnToLabel + ? JSON.parse(columnToLabel) + : {}; + const table = data.tables[layerId]; + const colorAssignment = colorAssignments[palette.name]; + + const row = table.rows[0]; + + const yConfigByValue = yConfigs.sort( + ({ forAccessor: idA }, { forAccessor: idB }) => row[idA] - row[idB] + ); + + const groupedByDirection = groupBy(yConfigByValue, 'fill'); + + return yConfigByValue.flatMap((yConfig, i) => { + // Find the formatter for the given axis + const groupId = + yConfig.axisMode === 'bottom' + ? undefined + : yConfig.axisMode === 'right' + ? 'right' + : 'left'; + + const formatter = formatters[groupId || 'bottom']; + + const seriesLayers: SeriesLayer[] = [ + { + name: columnToLabelMap[yConfig.forAccessor], + totalSeriesAtDepth: colorAssignment.totalSeriesCount, + rankAtDepth: colorAssignment.getRank( + thresholdLayer, + String(yConfig.forAccessor), + String(yConfig.forAccessor) + ), + }, + ]; + const defaultColor = paletteService.get(palette.name).getCategoricalColor( + seriesLayers, + { + maxDepth: 1, + behindText: false, + totalSeries: colorAssignment.totalSeriesCount, + syncColors, + }, + palette.params + ); + + const props = { + groupId, + marker: yConfig.icon ? : undefined, + }; + const annotations = []; + + const dashStyle = + yConfig.lineStyle === 'dashed' + ? [(yConfig.lineWidth || 1) * 3, yConfig.lineWidth || 1] + : yConfig.lineStyle === 'dotted' + ? [yConfig.lineWidth || 1, yConfig.lineWidth || 1] + : undefined; + + const sharedStyle = { + strokeWidth: yConfig.lineWidth || 1, + stroke: (yConfig.color || defaultColor) ?? '#f00', + dash: dashStyle, + }; + + annotations.push( + ({ + dataValue: row[yConfig.forAccessor], + header: columnToLabelMap[yConfig.forAccessor], + details: formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], + }))} + domainType={ + yConfig.axisMode === 'bottom' + ? AnnotationDomainType.XDomain + : AnnotationDomainType.YDomain + } + style={{ + line: { + ...sharedStyle, + opacity: 1, + }, + }} + /> + ); + + if (yConfig.fill && yConfig.fill !== 'none') { + const isFillAbove = yConfig.fill === 'above'; + const indexFromSameType = groupedByDirection[yConfig.fill].findIndex( + ({ forAccessor }) => forAccessor === yConfig.forAccessor + ); + const shouldCheckNextThreshold = + indexFromSameType < groupedByDirection[yConfig.fill].length - 1; + annotations.push( + { + if (yConfig.axisMode === 'bottom') { + return { + coordinates: { + x0: isFillAbove ? row[yConfig.forAccessor] : undefined, + y0: undefined, + x1: isFillAbove + ? shouldCheckNextThreshold + ? row[ + groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor + ] + : undefined + : row[yConfig.forAccessor], + y1: undefined, + }, + header: columnToLabelMap[yConfig.forAccessor], + details: + formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], + }; + } + return { + coordinates: { + x0: undefined, + y0: isFillAbove ? row[yConfig.forAccessor] : undefined, + x1: undefined, + y1: isFillAbove + ? shouldCheckNextThreshold + ? row[ + groupedByDirection[yConfig.fill!][indexFromSameType + 1].forAccessor + ] + : undefined + : row[yConfig.forAccessor], + }, + header: columnToLabelMap[yConfig.forAccessor], + details: + formatter?.convert(row[yConfig.forAccessor]) || row[yConfig.forAccessor], + }; + })} + style={{ + ...sharedStyle, + fill: (yConfig.color || defaultColor) ?? '#f00', + opacity: 0.1, + }} + /> + ); + } + return annotations; + }); + })} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index e3b16f5981f88..4edf7fdf5e512 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -18,6 +18,14 @@ export function isHorizontalSeries(seriesType: SeriesType) { ); } +export function isPercentageSeries(seriesType: SeriesType) { + return ( + seriesType === 'bar_percentage_stacked' || + seriesType === 'bar_horizontal_percentage_stacked' || + seriesType === 'area_percentage_stacked' + ); +} + export function isHorizontalChart(layers: Array<{ seriesType: SeriesType }>) { return layers.every((l) => isHorizontalSeries(l.seriesType)); } 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..ec47350709473 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/threshold_helpers.tsx @@ -0,0 +1,165 @@ +/* + * 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 type { XYLayerConfig, YConfig } from '../../common/expressions'; +import { Datatable } from '../../../../../src/plugins/expressions/public'; +import type { DatasourcePublicAPI, FramePublicAPI } from '../types'; +import { groupAxesByType } from './axes_configuration'; +import { isPercentageSeries } from './state_helpers'; +import type { XYState } from './types'; +import { checkScaleOperation } from './visualization_helpers'; + +export interface ThresholdBase { + label: 'x' | 'yRight' | 'yLeft'; +} + +/** + * Return the threshold layers groups to show based on multiple criteria: + * * what groups are current defined in data layers + * * what existing threshold are currently defined in data thresholds + */ +export function getGroupsToShow( + thresholdLayers: T[], + state: XYState | undefined, + datasourceLayers: Record, + tables: Record | undefined +): Array { + if (!state) { + return []; + } + const dataLayers = state.layers.filter( + ({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA + ); + const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables); + return thresholdLayers + .filter(({ label, config }: T) => groupsAvailable[label] || config?.length) + .map((layer) => ({ ...layer, valid: groupsAvailable[layer.label] })); +} + +/** + * Returns the threshold layers groups to show based on what groups are current defined in data layers. + */ +export function getGroupsRelatedToData( + thresholdLayers: T[], + state: XYState | undefined, + datasourceLayers: Record, + tables: Record | undefined +): T[] { + if (!state) { + return []; + } + const dataLayers = state.layers.filter( + ({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA + ); + const groupsAvailable = getGroupsAvailableInData(dataLayers, datasourceLayers, tables); + return thresholdLayers.filter(({ label }: T) => groupsAvailable[label]); +} +/** + * Returns a dictionary with the groups filled in all the data layers + */ +export function getGroupsAvailableInData( + dataLayers: XYState['layers'], + datasourceLayers: Record, + tables: Record | undefined +) { + const hasNumberHistogram = dataLayers.some( + checkScaleOperation('interval', 'number', datasourceLayers) + ); + const { right, left } = groupAxesByType(dataLayers, tables); + return { + x: dataLayers.some(({ xAccessor }) => xAccessor != null) && hasNumberHistogram, + yLeft: left.length > 0, + yRight: right.length > 0, + }; +} + +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 + const { dataLayer, accessor } = getAccessorCriteriaForGroup(groupId, dataLayers, activeData); + if (groupId === 'x' && dataLayer && !layerHasNumberHistogram(dataLayer)) { + return fallbackValue; + } + return ( + computeStaticValueForGroup( + dataLayer, + accessor, + activeData, + groupId !== 'x' // histogram axis should compute the min based on the current data + ) || fallbackValue + ); +} + +function getAccessorCriteriaForGroup( + groupId: 'x' | 'yLeft' | 'yRight', + dataLayers: XYState['layers'], + activeData: FramePublicAPI['activeData'] +) { + switch (groupId) { + case 'x': + const dataLayer = dataLayers.find(({ xAccessor }) => xAccessor); + return { + dataLayer, + accessor: dataLayer?.xAccessor, + }; + case 'yLeft': + const { left } = groupAxesByType(dataLayers, activeData); + return { + dataLayer: dataLayers.find(({ layerId }) => layerId === left[0]?.layer), + accessor: left[0]?.accessor, + }; + case 'yRight': + const { right } = groupAxesByType(dataLayers, activeData); + return { + dataLayer: dataLayers.find(({ layerId }) => layerId === right[0]?.layer), + accessor: right[0]?.accessor, + }; + } +} + +function computeStaticValueForGroup( + dataLayer: XYLayerConfig | undefined, + accessorId: string | undefined, + activeData: NonNullable, + minZeroBased: boolean +) { + const defaultThresholdFactor = 3 / 4; + + if (dataLayer && accessorId) { + if (isPercentageSeries(dataLayer?.seriesType)) { + return defaultThresholdFactor; + } + const tableId = Object.keys(activeData).find((key) => + activeData[key].columns.some(({ id }) => id === accessorId) + ); + if (tableId) { + const columnMax = activeData[tableId].rows.reduce( + (max, row) => Math.max(row[accessorId], max), + -Infinity + ); + const columnMin = activeData[tableId].rows.reduce( + (max, row) => Math.min(row[accessorId], max), + Infinity + ); + // Custom axis bounds can go below 0, so consider also lower values than 0 + const finalMinValue = minZeroBased ? Math.min(0, columnMin) : columnMin; + const interval = columnMax - finalMinValue; + return Number((finalMinValue + interval * defaultThresholdFactor).toFixed(2)); + } + } +} diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 5290e0298ae5e..aa99aa9b7b316 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -322,6 +322,10 @@ export const buildExpression = ( forAccessor: [yConfig.forAccessor], axisMode: yConfig.axisMode ? [yConfig.axisMode] : [], color: yConfig.color ? [yConfig.color] : [], + lineStyle: yConfig.lineStyle ? [yConfig.lineStyle] : [], + lineWidth: yConfig.lineWidth ? [yConfig.lineWidth] : [], + fill: [yConfig.fill || 'none'], + icon: yConfig.icon ? [yConfig.icon] : [], }, }, ], 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 7aef40b1481dc..8907db4954f99 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -15,6 +15,7 @@ import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; +import { Datatable } from 'src/plugins/expressions'; function exampleState(): State { return { @@ -216,8 +217,8 @@ describe('xy_visualization', () => { }); describe('#getSupportedLayers', () => { - it('should return a single layer type', () => { - expect(xyVisualization.getSupportedLayers()).toHaveLength(1); + it('should return a double layer types', () => { + expect(xyVisualization.getSupportedLayers()).toHaveLength(2); }); it('should return the icon for the visualization type', () => { @@ -317,6 +318,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 +541,300 @@ 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 compute no groups for thresholds when the only data accessor available is a date histogram', () => { + const state = getStateWithBaseThreshold(); + state.layers[0].xAccessor = 'b'; + state.layers[0].accessors = []; + state.layers[1].yConfig = []; // empty the configuration + // 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 mark horizontal group is invalid when xAccessor is changed to a date histogram', () => { + 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[0]).toEqual( + expect.objectContaining({ + invalid: true, + groupId: 'xThreshold', + }) + ); + }); + + 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 = []; // empty the configuration + // 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); + }); + + it('should mark horizontal group is invalid when accessor is changed to a terms operation', () => { + 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: 'string', + isBucketed: true, + scale: 'ordinal', + label: 'top values', + }; + } + return null; + }); + + const options = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'threshold', + }).groups; + + expect(options[0]).toEqual( + expect.objectContaining({ + invalid: true, + groupId: 'xThreshold', + }) + ); + }); + + it('differ vertical axis if the formatters are not compatibles between each other', () => { + const tables: Record = { + first: { + type: 'datatable', + rows: [], + columns: [ + { + id: 'xAccessorId', + name: 'horizontal axis', + meta: { + type: 'date', + params: { params: { id: 'date', params: { pattern: 'HH:mm' } } }, + }, + }, + { + id: 'yAccessorId', + name: 'left axis', + meta: { + type: 'number', + params: { id: 'number' }, + }, + }, + { + id: 'yAccessorId2', + name: 'right axis', + meta: { + type: 'number', + params: { id: 'bytes' }, + }, + }, + ], + }, + }; + + const state = getStateWithBaseThreshold(); + state.layers[0].accessors = ['yAccessorId', 'yAccessorId2']; + state.layers[1].yConfig = []; // empty the configuration + + const options = xyVisualization.getConfiguration({ + state, + frame: { ...frame, activeData: tables }, + layerId: 'threshold', + }).groups; + + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: 'yThresholdLeft' }), + expect.objectContaining({ groupId: 'yThresholdRight' }), + ]) + ); + }); + }); + 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 026c2827cedbd..ed1cc015806c5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { uniq } from 'lodash'; +import { groupBy, uniq } from 'lodash'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; @@ -14,15 +14,10 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { getSuggestions } from './xy_suggestions'; -import { XyToolbar, DimensionEditor, LayerHeader } from './xy_config_panel'; -import type { - Visualization, - OperationMetadata, - VisualizationType, - AccessorConfig, - DatasourcePublicAPI, -} from '../types'; -import { State, visualizationTypes, XYState } from './types'; +import { XyToolbar, DimensionEditor } from './xy_config_panel'; +import { LayerHeader } from './xy_config_panel/layer_header'; +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 } from './state_helpers'; @@ -32,6 +27,19 @@ import { LensIconChartMixedXy } from '../assets/chart_mixed_xy'; import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal'; 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, + getGroupsRelatedToData, + getGroupsToShow, + getStaticValue, +} from './threshold_helpers'; +import { + checkScaleOperation, + checkXAccessorCompatibility, + getAxisName, +} from './visualization_helpers'; const defaultIcon = LensIconChartBarStacked; const defaultSeriesType = 'bar_stacked'; @@ -186,6 +194,39 @@ export const getXyVisualization = ({ }, getSupportedLayers(state, frame) { + const thresholdGroupIds = [ + { + id: 'yThresholdLeft', + label: 'yLeft' as const, + }, + { + id: 'yThresholdRight', + label: 'yRight' as const, + }, + { + id: 'xThreshold', + label: 'x' as const, + }, + ]; + + const dataLayers = + state?.layers.filter(({ layerType = layerTypes.DATA }) => layerType === layerTypes.DATA) || + []; + const filledDataLayers = dataLayers.filter( + ({ accessors, xAccessor }) => accessors.length || xAccessor + ); + const layerHasNumberHistogram = checkScaleOperation( + 'interval', + 'number', + frame?.datasourceLayers || {} + ); + const thresholdGroups = getGroupsRelatedToData( + thresholdGroupIds, + state, + frame?.datasourceLayers || {}, + frame?.activeData + ); + const layers = [ { type: layerTypes.DATA, @@ -194,6 +235,36 @@ export const getXyVisualization = ({ }), icon: LensIconChartMixedXy, }, + { + type: layerTypes.THRESHOLD, + label: i18n.translate('xpack.lens.xyChart.addThresholdLayerLabel', { + defaultMessage: 'Add threshold layer', + }), + icon: LensIconChartBarThreshold, + disabled: + !filledDataLayers.length || + (!dataLayers.some(layerHasNumberHistogram) && + dataLayers.every(({ accessors }) => !accessors.length)), + tooltipContent: filledDataLayers.length + ? undefined + : i18n.translate('xpack.lens.xyChart.addThresholdLayerLabelDisabledHelp', { + defaultMessage: 'Add some data to enable threshold layer', + }), + initialDimensions: state + ? thresholdGroups.map(({ id, label }) => ({ + groupId: id, + columnId: generateId(), + dataType: 'number', + label: getAxisName(label, { isHorizontal: isHorizontalChart(state?.layers || []) }), + staticValue: getStaticValue( + dataLayers, + label, + { activeData: frame?.activeData }, + layerHasNumberHistogram + ), + })) + : undefined, + }, ]; return layers; @@ -233,8 +304,70 @@ export const getXyVisualization = ({ const isDataLayer = !layer.layerType || layer.layerType === layerTypes.DATA; if (!isDataLayer) { + const idToIndex = sortedAccessors.reduce>((memo, id, index) => { + memo[id] = index; + return memo; + }, {}); + const { bottom, left, right } = groupBy( + [...(layer.yConfig || [])].sort( + ({ forAccessor: forA }, { forAccessor: forB }) => idToIndex[forA] - idToIndex[forB] + ), + ({ axisMode }) => { + return axisMode; + } + ); + const groupsToShow = getGroupsToShow( + [ + // When a threshold layer panel is added, a static threshold should automatically be included by default + // in the first available axis, in the following order: vertical left, vertical right, horizontal. + { + config: left, + id: 'yThresholdLeft', + label: 'yLeft', + dataTestSubj: 'lnsXY_yThresholdLeftPanel', + }, + { + config: right, + id: 'yThresholdRight', + label: 'yRight', + dataTestSubj: 'lnsXY_yThresholdRightPanel', + }, + { + config: bottom, + id: 'xThreshold', + label: 'x', + dataTestSubj: 'lnsXY_xThresholdPanel', + }, + ], + state, + frame.datasourceLayers, + frame?.activeData + ); return { - groups: [], + supportFieldFormat: false, + supportStaticValue: true, + // Each thresholds layer panel will have sections for each available axis + // (horizontal axis, vertical axis left, vertical axis right). + // Only axes that support numeric thresholds should be shown + groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({ + groupId: id, + groupLabel: getAxisName(label, { isHorizontal }), + accessors: config.map(({ forAccessor, color }) => ({ + columnId: forAccessor, + color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color, + triggerIcon: 'color', + })), + filterOperations: isNumericMetric, + supportsMoreColumns: true, + required: false, + enableDimensionEditor: true, + dataTestSubj, + invalid: !valid, + invalidMessage: i18n.translate('xpack.lens.configure.invalidThresholdDimension', { + defaultMessage: + 'This threshold is assigned to an axis that no longer exists. You may move this threshold to another available axis or remove it.', + }), + })), }; } @@ -305,6 +438,30 @@ export const getXyVisualization = ({ newLayer.splitAccessor = columnId; } + if (newLayer.layerType === layerTypes.THRESHOLD) { + newLayer.accessors = [...newLayer.accessors.filter((a) => a !== columnId), columnId]; + const hasYConfig = newLayer.yConfig?.some(({ forAccessor }) => forAccessor === columnId); + if (!hasYConfig) { + newLayer.yConfig = [ + ...(newLayer.yConfig || []), + // TODO: move this + // add a default config if none is available + { + forAccessor: columnId, + axisMode: + groupId === 'xThreshold' + ? 'bottom' + : groupId === 'yThresholdRight' + ? 'right' + : 'left', + icon: undefined, + lineStyle: 'solid', + lineWidth: 1, + }, + ]; + } + } + return { ...prevState, layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), @@ -331,7 +488,24 @@ export const getXyVisualization = ({ newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); } - const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + // // check if there's any threshold layer and pull it off if all data layers have no dimensions set + const layersByType = groupBy(newLayers, ({ layerType }) => layerType); + // // check for data layers if they all still have xAccessors + const groupsAvailable = getGroupsAvailableInData( + layersByType[layerTypes.DATA], + frame.datasourceLayers, + frame?.activeData + ); + if ( + (Object.keys(groupsAvailable) as Array<'x' | 'yLeft' | 'yRight'>).every( + (id) => !groupsAvailable[id] + ) + ) { + newLayers = newLayers.filter( + ({ layerType, accessors }) => layerType === layerTypes.DATA || accessors.length + ); + } return { ...prevState, @@ -510,19 +684,6 @@ function validateLayersForDimension( }; } -function getAxisName(axis: 'x' | 'y', { 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; - } - return isHorizontal ? horizontal : vertical; -} - // 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(', '); @@ -566,76 +727,6 @@ 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 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/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.test.tsx similarity index 98% rename from x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.test.tsx index aa287795c8181..ebe0e536a4d77 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { AxisSettingsPopover, AxisSettingsPopoverProps } from './axis_settings_popover'; -import { ToolbarPopover } from '../shared_components'; -import { layerTypes } from '../../common'; +import { ToolbarPopover } from '../../shared_components'; +import { layerTypes } from '../../../common'; describe('Axes Settings', () => { let props: AxisSettingsPopoverProps; diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx similarity index 96% rename from x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx index 2285cd1a7a43a..e0a30bdb2c511 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/axis_settings_popover.tsx @@ -20,15 +20,15 @@ import { EuiFieldNumber, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../common/expressions'; -import { ToolbarPopover, useDebouncedValue } from '../shared_components'; -import { isHorizontalChart } from './state_helpers'; -import { EuiIconAxisBottom } from '../assets/axis_bottom'; -import { EuiIconAxisLeft } from '../assets/axis_left'; -import { EuiIconAxisRight } from '../assets/axis_right'; -import { EuiIconAxisTop } from '../assets/axis_top'; -import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; -import { validateExtent } from './axes_configuration'; +import { XYLayerConfig, AxesSettingsConfig, AxisExtentConfig } from '../../../common/expressions'; +import { ToolbarPopover, useDebouncedValue } from '../../shared_components'; +import { isHorizontalChart } from '../state_helpers'; +import { EuiIconAxisBottom } from '../../assets/axis_bottom'; +import { EuiIconAxisLeft } from '../../assets/axis_left'; +import { EuiIconAxisRight } from '../../assets/axis_right'; +import { EuiIconAxisTop } from '../../assets/axis_top'; +import { ToolbarButtonProps } from '../../../../../../src/plugins/kibana_react/public'; +import { validateExtent } from '../axes_configuration'; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx new file mode 100644 index 0000000000000..5a6458a4654d0 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -0,0 +1,175 @@ +/* + * 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 './xy_config_panel.scss'; +import React, { useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; +import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui'; +import type { PaletteRegistry } from 'src/plugins/charts/public'; +import type { VisualizationDimensionEditorProps } from '../../types'; +import { State } from '../types'; +import { FormatFactory } from '../../../common'; +import { getSeriesColor } from '../state_helpers'; +import { getAccessorColorConfig, getColorAssignments } from '../color_assignment'; +import { getSortedAccessors } from '../to_expression'; +import { updateLayer } from '.'; +import { TooltipWrapper } from '../../shared_components'; + +const tooltipContent = { + auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { + defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.', + }), + custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', { + defaultMessage: 'Clear the custom color to return to “Auto” mode.', + }), + disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', { + defaultMessage: + 'Individual series cannot be custom colored when the layer includes a “Break down by.“', + }), +}; + +export const ColorPicker = ({ + state, + setState, + layerId, + accessor, + frame, + formatFactory, + paletteService, + label, + disableHelpTooltip, +}: VisualizationDimensionEditorProps & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + label?: string; + disableHelpTooltip?: boolean; +}) => { + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + const disabled = Boolean(layer.splitAccessor); + + const overwriteColor = getSeriesColor(layer, accessor); + const currentColor = useMemo(() => { + if (overwriteColor || !frame.activeData) return overwriteColor; + + const datasource = frame.datasourceLayers[layer.layerId]; + const sortedAccessors: string[] = getSortedAccessors(datasource, layer); + + const colorAssignments = getColorAssignments( + state.layers, + { tables: frame.activeData }, + formatFactory + ); + const mappedAccessors = getAccessorColorConfig( + colorAssignments, + frame, + { + ...layer, + accessors: sortedAccessors.filter((sorted) => layer.accessors.includes(sorted)), + }, + paletteService + ); + + return mappedAccessors.find((a) => a.columnId === accessor)?.color || null; + }, [overwriteColor, frame, paletteService, state.layers, accessor, formatFactory, layer]); + + const [color, setColor] = useState(currentColor); + + const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { + setColor(text); + if (output.isValid || text === '') { + updateColorInState(text, output); + } + }; + + const updateColorInState: EuiColorPickerProps['onChange'] = useMemo( + () => + debounce((text, output) => { + const newYConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); + if (existingIndex !== -1) { + if (text === '') { + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined }; + } else { + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex }; + } + } else { + newYConfigs.push({ + forAccessor: accessor, + color: output.hex, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); + }, 256), + [state, setState, layer, accessor, index] + ); + + const inputLabel = + label ?? + i18n.translate('xpack.lens.xyChart.seriesColor.label', { + defaultMessage: 'Series color', + }); + + const colorPicker = ( + + ); + + return ( + + + {inputLabel} + {!disableHelpTooltip && ( + <> + {''} + + + )} + + + } + > + {disabled ? ( + + {colorPicker} + + ) : ( + colorPicker + )} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx similarity index 72% rename from x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index cd90fa52cd402..1427a3d28ea39 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -6,24 +6,15 @@ */ import './xy_config_panel.scss'; -import React, { useMemo, useState, memo, useCallback } from 'react'; +import React, { memo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { Position, ScaleType, VerticalAlignment, HorizontalAlignment } from '@elastic/charts'; -import { debounce } from 'lodash'; import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiFormRow, htmlIdGenerator, - EuiColorPicker, - EuiColorPickerProps, - EuiToolTip, - EuiIcon, - EuiPopover, - EuiSelectable, - EuiText, - EuiPopoverTitle, } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { @@ -31,30 +22,34 @@ import type { VisualizationToolbarProps, VisualizationDimensionEditorProps, FramePublicAPI, -} from '../types'; -import { State, visualizationTypes, XYState } from './types'; -import type { FormatFactory } from '../../common'; +} from '../../types'; +import { State, visualizationTypes, XYState } from '../types'; +import type { FormatFactory } from '../../../common'; import { SeriesType, YAxisMode, AxesSettingsConfig, AxisExtentConfig, -} from '../../common/expressions'; -import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; -import { trackUiEvent } from '../lens_ui_telemetry'; -import { LegendSettingsPopover } from '../shared_components'; +} from '../../../common/expressions'; +import { isHorizontalChart, isHorizontalSeries } from '../state_helpers'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { LegendSettingsPopover } from '../../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; -import { getAxesConfiguration, GroupsConfiguration } from './axes_configuration'; -import { PalettePicker, TooltipWrapper } from '../shared_components'; -import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; -import { getScaleType, getSortedAccessors } from './to_expression'; -import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; -import { ToolbarButton } from '../../../../../src/plugins/kibana_react/public'; +import { getAxesConfiguration, GroupsConfiguration } from '../axes_configuration'; +import { VisualOptionsPopover } from './visual_options_popover'; +import { getScaleType } from '../to_expression'; +import { ColorPicker } from './color_picker'; +import { ThresholdPanel } from './threshold_panel'; +import { PalettePicker, TooltipWrapper } from '../../shared_components'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; -function updateLayer(state: State, layer: UnwrapArray, index: number): State { +export function updateLayer( + state: State, + layer: UnwrapArray, + index: number +): State { const newLayers = [...state.layers]; newLayers[index] = layer; @@ -92,90 +87,6 @@ const legendOptions: Array<{ }, ]; -export function LayerHeader(props: VisualizationLayerWidgetProps) { - const [isPopoverOpen, setPopoverIsOpen] = useState(false); - const { state, layerId } = props; - const horizontalOnly = isHorizontalChart(state.layers); - const index = state.layers.findIndex((l) => l.layerId === layerId); - const layer = state.layers[index]; - if (!layer) { - return null; - } - - const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!; - - const createTrigger = function () { - return ( - setPopoverIsOpen(!isPopoverOpen)} - fullWidth - size="s" - > - <> - - - {currentVisType.fullLabel || currentVisType.label} - - - - ); - }; - - return ( - <> - setPopoverIsOpen(false)} - display="block" - panelPaddingSize="s" - ownFocus - > - - {i18n.translate('xpack.lens.layerPanel.layerVisualizationType', { - defaultMessage: 'Layer visualization type', - })} - -
- - singleSelection="always" - options={visualizationTypes - .filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) - .map((t) => ({ - value: t.id, - key: t.id, - checked: t.id === currentVisType.id ? 'on' : undefined, - prepend: , - label: t.fullLabel || t.label, - 'data-test-subj': `lnsXY_seriesType-${t.id}`, - }))} - onChange={(newOptions) => { - const chosenType = newOptions.find(({ checked }) => checked === 'on'); - if (!chosenType) { - return; - } - const id = chosenType.value!; - trackUiEvent('xy_change_layer_display'); - props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index)); - setPopoverIsOpen(false); - }} - > - {(list) => <>{list}} - -
-
- - ); -} - export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); @@ -622,7 +533,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp ); }); -const idPrefix = htmlIdGenerator()(); +export const idPrefix = htmlIdGenerator()(); export function DimensionEditor( props: VisualizationDimensionEditorProps & { @@ -653,6 +564,10 @@ export function DimensionEditor( ); } + if (layer.layerType === 'threshold') { + return ; + } + return ( <> @@ -728,140 +643,3 @@ export function DimensionEditor( ); } - -const tooltipContent = { - auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { - defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.', - }), - custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', { - defaultMessage: 'Clear the custom color to return to “Auto” mode.', - }), - disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', { - defaultMessage: - 'Individual series cannot be custom colored when the layer includes a “Break down by.“', - }), -}; - -const ColorPicker = ({ - state, - setState, - layerId, - accessor, - frame, - formatFactory, - paletteService, -}: VisualizationDimensionEditorProps & { - formatFactory: FormatFactory; - paletteService: PaletteRegistry; -}) => { - const index = state.layers.findIndex((l) => l.layerId === layerId); - const layer = state.layers[index]; - const disabled = !!layer.splitAccessor; - - const overwriteColor = getSeriesColor(layer, accessor); - const currentColor = useMemo(() => { - if (overwriteColor || !frame.activeData) return overwriteColor; - - const datasource = frame.datasourceLayers[layer.layerId]; - const sortedAccessors: string[] = getSortedAccessors(datasource, layer); - - const colorAssignments = getColorAssignments( - state.layers, - { tables: frame.activeData }, - formatFactory - ); - const mappedAccessors = getAccessorColorConfig( - colorAssignments, - frame, - { - ...layer, - accessors: sortedAccessors.filter((sorted) => layer.accessors.includes(sorted)), - }, - paletteService - ); - - return mappedAccessors.find((a) => a.columnId === accessor)?.color || null; - }, [overwriteColor, frame, paletteService, state.layers, accessor, formatFactory, layer]); - - const [color, setColor] = useState(currentColor); - - const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { - setColor(text); - if (output.isValid || text === '') { - updateColorInState(text, output); - } - }; - - const updateColorInState: EuiColorPickerProps['onChange'] = useMemo( - () => - debounce((text, output) => { - const newYConfigs = [...(layer.yConfig || [])]; - const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); - if (existingIndex !== -1) { - if (text === '') { - newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: undefined }; - } else { - newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], color: output.hex }; - } - } else { - newYConfigs.push({ - forAccessor: accessor, - color: output.hex, - }); - } - setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); - }, 256), - [state, setState, layer, accessor, index] - ); - - const colorPicker = ( - - ); - - return ( - - - {i18n.translate('xpack.lens.xyChart.seriesColor.label', { - defaultMessage: 'Series color', - })}{' '} - - -
- } - > - {disabled ? ( - - {colorPicker} - - ) : ( - colorPicker - )} - - ); -}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx new file mode 100644 index 0000000000000..dde4de0dd4bc3 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx @@ -0,0 +1,115 @@ +/* + * 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 './xy_config_panel.scss'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiIcon, EuiPopover, EuiSelectable, EuiText, EuiPopoverTitle } from '@elastic/eui'; +import type { VisualizationLayerWidgetProps } from '../../types'; +import { State, visualizationTypes } from '../types'; +import { layerTypes } from '../../../common'; +import { SeriesType } from '../../../common/expressions'; +import { isHorizontalChart, isHorizontalSeries } from '../state_helpers'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { StaticHeader } from '../../shared_components'; +import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public'; +import { LensIconChartBarThreshold } from '../../assets/chart_bar_threshold'; +import { updateLayer } from '.'; + +export function LayerHeader(props: VisualizationLayerWidgetProps) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const { state, layerId } = props; + const horizontalOnly = isHorizontalChart(state.layers); + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + if (!layer) { + return null; + } + // if it's a threshold just draw a static text + if (layer.layerType === layerTypes.THRESHOLD) { + return ( + + ); + } + const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!; + + const createTrigger = function () { + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + size="s" + > + <> + + + {currentVisType.fullLabel || currentVisType.label} + + + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > + + {i18n.translate('xpack.lens.layerPanel.layerVisualizationType', { + defaultMessage: 'Layer visualization type', + })} + +
+ + singleSelection="always" + options={visualizationTypes + .filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map((t) => ({ + value: t.id, + key: t.id, + checked: t.id === currentVisType.id ? 'on' : undefined, + prepend: , + label: t.fullLabel || t.label, + 'data-test-subj': `lnsXY_seriesType-${t.id}`, + }))} + onChange={(newOptions) => { + const chosenType = newOptions.find(({ checked }) => checked === 'on'); + if (!chosenType) { + return; + } + const id = chosenType.value!; + trackUiEvent('xy_change_layer_display'); + props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index)); + setPopoverIsOpen(false); + }} + > + {(list) => <>{list}} + +
+
+ + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx new file mode 100644 index 0000000000000..1e5b90e41b623 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx @@ -0,0 +1,326 @@ +/* + * 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 './xy_config_panel.scss'; +import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonGroup, EuiComboBox, EuiFormRow, EuiIcon, EuiRange } from '@elastic/eui'; +import type { PaletteRegistry } from 'src/plugins/charts/public'; +import type { VisualizationDimensionEditorProps } from '../../types'; +import { State } from '../types'; +import { FormatFactory } from '../../../common'; +import { YConfig } from '../../../common/expressions'; +import { LineStyle, FillStyle } from '../../../common/expressions/xy_chart'; + +import { ColorPicker } from './color_picker'; +import { updateLayer, idPrefix } from '.'; +import { useDebouncedValue } from '../../shared_components'; + +const icons = [ + { + value: 'none', + label: i18n.translate('xpack.lens.xyChart.thresholds.noIconLabel', { defaultMessage: 'None' }), + }, + { + value: 'asterisk', + label: i18n.translate('xpack.lens.xyChart.thresholds.asteriskIconLabel', { + defaultMessage: 'Asterisk', + }), + }, + { + value: 'bell', + label: i18n.translate('xpack.lens.xyChart.thresholds.bellIconLabel', { + defaultMessage: 'Bell', + }), + }, + { + value: 'bolt', + label: i18n.translate('xpack.lens.xyChart.thresholds.boltIconLabel', { + defaultMessage: 'Bolt', + }), + }, + { + value: 'bug', + label: i18n.translate('xpack.lens.xyChart.thresholds.bugIconLabel', { + defaultMessage: 'Bug', + }), + }, + { + value: 'editorComment', + label: i18n.translate('xpack.lens.xyChart.thresholds.commentIconLabel', { + defaultMessage: 'Comment', + }), + }, + { + value: 'alert', + label: i18n.translate('xpack.lens.xyChart.thresholds.alertIconLabel', { + defaultMessage: 'Alert', + }), + }, + { + value: 'flag', + label: i18n.translate('xpack.lens.xyChart.thresholds.flagIconLabel', { + defaultMessage: 'Flag', + }), + }, + { + value: 'tag', + label: i18n.translate('xpack.lens.xyChart.thresholds.tagIconLabel', { + defaultMessage: 'Tag', + }), + }, +]; + +const IconView = (props: { value?: string; label: string }) => { + if (!props.value) return null; + return ( + + + {` ${props.label}`} + + ); +}; + +const IconSelect = ({ + value, + onChange, +}: { + value?: string; + onChange: (newIcon: string) => void; +}) => { + const selectedIcon = icons.find((option) => value === option.value) || icons[0]; + + return ( + { + onChange(selection[0].value!); + }} + singleSelection={{ asPlainText: true }} + renderOption={IconView} + compressed + /> + ); +}; + +export const ThresholdPanel = ( + props: VisualizationDimensionEditorProps & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + } +) => { + const { state, setState, layerId, accessor } = props; + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + + const setYConfig = useCallback( + (yConfig: Partial | undefined) => { + if (yConfig == null) { + return; + } + setState((currState) => { + const currLayer = currState.layers[index]; + const newYConfigs = [...(currLayer.yConfig || [])]; + const existingIndex = newYConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYConfigs[existingIndex] = { ...newYConfigs[existingIndex], ...yConfig }; + } else { + newYConfigs.push({ forAccessor: accessor, ...yConfig }); + } + return updateLayer(currState, { ...currLayer, yConfig: newYConfigs }, index); + }); + }, + [accessor, index, setState] + ); + + const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); + + return ( + <> + + + { + const newMode = id.replace(idPrefix, '') as LineStyle; + setYConfig({ forAccessor: accessor, lineStyle: newMode }); + }} + /> + + + { + setYConfig({ forAccessor: accessor, lineWidth: value }); + }} + /> + + + { + const newMode = id.replace(idPrefix, '') as FillStyle; + setYConfig({ forAccessor: accessor, fill: newMode }); + }} + /> + + + { + setYConfig({ forAccessor: accessor, icon: newIcon }); + }} + /> + + + ); +}; + +const minRange = 1; +const maxRange = 10; + +function getSafeValue(value: number | '', prevValue: number, min: number, max: number) { + if (value === '') { + return prevValue; + } + return Math.max(minRange, Math.min(value, maxRange)); +} + +const LineThicknessSlider = ({ + value, + onChange, +}: { + value: number; + onChange: (value: number) => void; +}) => { + const onChangeWrapped = useCallback( + (newValue) => { + if (Number.isInteger(newValue)) { + onChange(getSafeValue(newValue, newValue, minRange, maxRange)); + } + }, + [onChange] + ); + const { inputValue, handleInputChange } = useDebouncedValue( + { value, onChange: onChangeWrapped }, + { allowFalsyValue: true } + ); + + return ( + { + const newValue = e.currentTarget.value; + handleInputChange(newValue === '' ? '' : Number(newValue)); + }} + onBlur={() => { + handleInputChange(getSafeValue(inputValue, value, minRange, maxRange)); + }} + /> + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.test.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.tsx similarity index 95% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.tsx index eb8d35c54a99b..09b381dd03f7a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/fill_opacity_option.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/fill_opacity_option.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiRange } from '@elastic/eui'; -import { useDebouncedValue } from '../../shared_components'; +import { useDebouncedValue } from '../../../shared_components'; export interface FillOpacityOptionProps { /** diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx similarity index 93% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx index 6d0e5c2d55b70..2a19897445e63 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx @@ -7,14 +7,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ToolbarPopover, TooltipWrapper } from '../../shared_components'; +import { ToolbarPopover, TooltipWrapper } from '../../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { LineCurveOption } from './line_curve_option'; import { FillOpacityOption } from './fill_opacity_option'; -import { XYState } from '../types'; -import { hasHistogramSeries } from '../state_helpers'; -import { ValidLayer } from '../../../common/expressions'; -import type { FramePublicAPI } from '../../types'; +import { XYState } from '../../types'; +import { hasHistogramSeries } from '../../state_helpers'; +import { ValidLayer } from '../../../../common/expressions'; +import type { FramePublicAPI } from '../../../types'; function getValueLabelDisableReason({ isAreaPercentage, diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.test.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.tsx similarity index 95% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.tsx index 6080a8c68e57d..96926412afb8a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/line_curve_option.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import type { XYCurveType } from '../../../common/expressions'; +import type { XYCurveType } from '../../../../common/expressions'; export interface LineCurveOptionProps { /** diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_value_option.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_value_option.test.tsx diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx similarity index 97% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx index 3dba8757903e9..b12e2d2f57112 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonGroup, EuiFormRow, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; -import { fittingFunctionDefinitions } from '../../../common/expressions'; -import type { FittingFunction, ValueLabelConfig } from '../../../common/expressions'; +import { fittingFunctionDefinitions } from '../../../../common/expressions'; +import type { FittingFunction, ValueLabelConfig } from '../../../../common/expressions'; export interface MissingValuesOptionProps { valueLabels?: ValueLabelConfig; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx similarity index 96% rename from x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx index cd6a20c37dd38..0136612c46705 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/visual_options_popover.test.tsx @@ -8,14 +8,14 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { Position } from '@elastic/charts'; -import type { FramePublicAPI } from '../../types'; -import { createMockDatasource, createMockFramePublicAPI } from '../../mocks'; -import { State } from '../types'; -import { VisualOptionsPopover } from './visual_options_popover'; -import { ToolbarPopover } from '../../shared_components'; +import type { FramePublicAPI } from '../../../types'; +import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks'; +import { State } from '../../types'; +import { VisualOptionsPopover } from '.'; +import { ToolbarPopover } from '../../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { FillOpacityOption } from './fill_opacity_option'; -import { layerTypes } from '../../../common'; +import { layerTypes } from '../../../../common'; describe('Visual options popover', () => { let frame: FramePublicAPI; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.scss b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.scss similarity index 100% rename from x-pack/plugins/lens/public/xy_visualization/xy_config_panel.scss rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.scss diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx similarity index 98% rename from x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx rename to x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx index 9ca9021382fda..e5b1870c73404 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx @@ -8,15 +8,15 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; import { EuiButtonGroupProps, EuiButtonGroup } from '@elastic/eui'; -import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; +import { LayerContextMenu, XyToolbar, DimensionEditor } from '.'; import { AxisSettingsPopover } from './axis_settings_popover'; -import { FramePublicAPI } from '../types'; -import { State } from './types'; +import { FramePublicAPI } from '../../types'; +import { State } from '../types'; import { Position } from '@elastic/charts'; -import { createMockFramePublicAPI, createMockDatasource } from '../mocks'; +import { createMockFramePublicAPI, createMockDatasource } from '../../mocks'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { EuiColorPicker } from '@elastic/eui'; -import { layerTypes } from '../../common'; +import { layerTypes } from '../../../common'; describe('XY Config panels', () => { let frame: FramePublicAPI; diff --git a/x-pack/test/functional/apps/lens/formula.ts b/x-pack/test/functional/apps/lens/formula.ts index f645b2c64629c..a8d074ad0631b 100644 --- a/x-pack/test/functional/apps/lens/formula.ts +++ b/x-pack/test/functional/apps/lens/formula.ts @@ -236,6 +236,38 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + it('should keep the formula if the user does not fully transition to a static value', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + await PageObjects.lens.createLayer('threshold'); + + await PageObjects.lens.configureDimension( + { + dimension: 'lnsXY_yThresholdLeftPanel > lns-dimensionTrigger', + operation: 'formula', + formula: `count()`, + keepOpen: true, + }, + 1 + ); + + await PageObjects.lens.switchToStaticValue(); + await PageObjects.lens.closeDimensionEditor(); + await PageObjects.common.sleep(1000); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yThresholdLeftPanel', 0)).to.eql( + 'count()' + ); + }); + it('should allow numeric only formulas', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 09bbda595d55c..a6f579a9a4890 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')); loadTestFile(require.resolve('./inspector')); // has to be last one in the suite because it overrides saved objects diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 6d9360ac32b4b..ff5bae8aa7e61 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -180,6 +180,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + it('should not show static value tab for data layers', async () => { + await PageObjects.lens.openDimensionEditor('lnsXY_yDimensionPanel > lns-dimensionTrigger'); + // Quick functions and Formula tabs should be visible + expect(await testSubjects.exists('lens-dimensionTabs-quickFunctions')).to.eql(true); + expect(await testSubjects.exists('lens-dimensionTabs-formula')).to.eql(true); + // Static value tab should not be visible + expect(await testSubjects.exists('lens-dimensionTabs-static_value')).to.eql(false); + + await PageObjects.lens.closeDimensionEditor(); + }); + it('should be able to add very long labels and still be able to remove a dimension', async () => { await PageObjects.lens.openDimensionEditor('lnsXY_yDimensionPanel > lns-dimensionTrigger'); const longLabel = 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..bf6535acc7c8e --- /dev/null +++ b/x-pack/test/functional/apps/lens/thresholds.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 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 testSubjects = getService('testSubjects'); + + 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.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: 4992.44'); + }); + + 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: 4992.44', + '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..e26ea8f598c46 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}`); + } }, /** @@ -1075,6 +1088,10 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await testSubjects.click('lens-dimensionTabs-formula'); }, + async switchToStaticValue() { + await testSubjects.click('lens-dimensionTabs-static_value'); + }, + async toggleFullscreen() { await testSubjects.click('lnsFormula-fullscreen'); },