diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index a8436edb63f63..cd26cd3197587 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -7,7 +7,13 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { createMockFramePublicAPI, visualizationMap, datasourceMap } from '../../../mocks'; +import { + createMockFramePublicAPI, + mockVisualizationMap, + mockDatasourceMap, + mockStoreDeps, + MountStoreProps, +} from '../../../mocks'; import { Visualization } from '../../../types'; import { LayerPanels } from './config_panel'; import { LayerPanel } from './layer_panel'; @@ -16,6 +22,7 @@ import { generateId } from '../../../id_generator'; import { mountWithProvider } from '../../../mocks'; import { layerTypes } from '../../../../common'; import { ReactWrapper } from 'enzyme'; +import { addLayer } from '../../../state_management'; jest.mock('../../../id_generator'); @@ -39,8 +46,40 @@ afterEach(() => { describe('ConfigPanel', () => { const frame = createMockFramePublicAPI(); - - function getDefaultProps() { + function prepareAndMountComponent( + props: ReturnType, + customStoreProps?: Partial + ) { + (generateId as jest.Mock).mockReturnValue(`newId`); + return mountWithProvider( + , + { + preloadedState: { + datasourceStates: { + testDatasource: { + isLoading: false, + state: 'state', + }, + }, + activeDatasourceId: 'testDatasource', + }, + storeDeps: mockStoreDeps({ + datasourceMap: props.datasourceMap, + visualizationMap: props.visualizationMap, + }), + ...customStoreProps, + }, + { + attachTo: container, + } + ); + } + function getDefaultProps( + { datasourceMap = mockDatasourceMap(), visualizationMap = mockVisualizationMap() } = { + datasourceMap: mockDatasourceMap(), + visualizationMap: mockVisualizationMap(), + } + ) { frame.datasourceLayers = { first: datasourceMap.testDatasource.publicAPIMock, }; @@ -75,22 +114,13 @@ describe('ConfigPanel', () => { it('should fail to render layerPanels if the public API is out of date', async () => { const props = getDefaultProps(); props.framePublicAPI.datasourceLayers = {}; - const { instance } = await mountWithProvider(); + const { instance } = await prepareAndMountComponent(props); expect(instance.find(LayerPanel).exists()).toBe(false); }); it('allow datasources and visualizations to use setters', async () => { const props = getDefaultProps(); - const { instance, lensStore } = await mountWithProvider(, { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: 'state', - }, - }, - }, - }); + const { instance, lensStore } = await prepareAndMountComponent(props); const { updateDatasource, updateAll } = instance.find(LayerPanel).props(); const updater = () => 'updated'; @@ -116,22 +146,7 @@ describe('ConfigPanel', () => { describe('focus behavior when adding or removing layers', () => { it('should focus the only layer when resetting the layer', async () => { - const { instance } = await mountWithProvider( - , - { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: 'state', - }, - }, - }, - }, - { - attachTo: container, - } - ); + const { instance } = await prepareAndMountComponent(getDefaultProps()); const firstLayerFocusable = instance .find(LayerPanel) .first() @@ -146,29 +161,15 @@ describe('ConfigPanel', () => { }); it('should focus the second layer when removing the first layer', async () => { - const defaultProps = getDefaultProps(); + const datasourceMap = mockDatasourceMap(); + const defaultProps = getDefaultProps({ datasourceMap }); // overwriting datasourceLayers to test two layers frame.datasourceLayers = { first: datasourceMap.testDatasource.publicAPIMock, second: datasourceMap.testDatasource.publicAPIMock, }; - const { instance } = await mountWithProvider( - , - { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: 'state', - }, - }, - }, - }, - { - attachTo: container, - } - ); + const { instance } = await prepareAndMountComponent(defaultProps); const secondLayerFocusable = instance .find(LayerPanel) .at(1) @@ -183,28 +184,14 @@ describe('ConfigPanel', () => { }); it('should focus the first layer when removing the second layer', async () => { - const defaultProps = getDefaultProps(); + const datasourceMap = mockDatasourceMap(); + const defaultProps = getDefaultProps({ datasourceMap }); // overwriting datasourceLayers to test two layers frame.datasourceLayers = { first: datasourceMap.testDatasource.publicAPIMock, second: datasourceMap.testDatasource.publicAPIMock, }; - const { instance } = await mountWithProvider( - , - { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: 'state', - }, - }, - }, - }, - { - attachTo: container, - } - ); + const { instance } = await prepareAndMountComponent(defaultProps); const firstLayerFocusable = instance .find(LayerPanel) .first() @@ -219,31 +206,22 @@ describe('ConfigPanel', () => { }); it('should focus the added layer', async () => { - (generateId as jest.Mock).mockReturnValue(`second`); + const datasourceMap = mockDatasourceMap(); + frame.datasourceLayers = { + first: datasourceMap.testDatasource.publicAPIMock, + newId: datasourceMap.testDatasource.publicAPIMock, + }; - const { instance } = await mountWithProvider( - , + const defaultProps = getDefaultProps({ datasourceMap }); + + const { instance } = await prepareAndMountComponent(defaultProps, { + dispatch: jest.fn((x) => { + if (x.type === addLayer.type) { + frame.datasourceLayers.newId = datasourceMap.testDatasource.publicAPIMock; + } + }), + }); - { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: 'state', - }, - }, - activeDatasourceId: 'testDatasource', - }, - dispatch: jest.fn((x) => { - if (x.payload.subType === 'ADD_LAYER') { - frame.datasourceLayers.second = datasourceMap.testDatasource.publicAPIMock; - } - }), - }, - { - attachTo: container, - } - ); act(() => { instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); @@ -253,26 +231,6 @@ describe('ConfigPanel', () => { }); describe('initial default value', () => { - function prepareAndMountComponent(props: ReturnType) { - (generateId as jest.Mock).mockReturnValue(`newId`); - return mountWithProvider( - , - { - preloadedState: { - datasourceStates: { - testDatasource: { - isLoading: false, - state: 'state', - }, - }, - activeDatasourceId: 'testDatasource', - }, - }, - { - attachTo: container, - } - ); - } function clickToAddLayer(instance: ReactWrapper) { act(() => { instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); @@ -297,8 +255,10 @@ describe('ConfigPanel', () => { } it('should not add an initial dimension when not specified', async () => { - const props = getDefaultProps(); - props.activeVisualization.getSupportedLayers = jest.fn(() => [ + const datasourceMap = mockDatasourceMap(); + const visualizationMap = mockVisualizationMap(); + + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ { type: layerTypes.DATA, label: 'Data Layer' }, { type: layerTypes.REFERENCELINE, @@ -306,6 +266,7 @@ describe('ConfigPanel', () => { }, ]); datasourceMap.testDatasource.initializeDimension = jest.fn(); + const props = getDefaultProps({ datasourceMap, visualizationMap }); const { instance, lensStore } = await prepareAndMountComponent(props); await clickToAddLayer(instance); @@ -315,8 +276,11 @@ describe('ConfigPanel', () => { }); 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(() => [ + const datasourceMap = mockDatasourceMap(); + const visualizationMap = mockVisualizationMap(); + datasourceMap.testDatasource.initializeDimension = jest.fn(); + + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ { type: layerTypes.DATA, label: 'Data Layer', @@ -335,8 +299,7 @@ describe('ConfigPanel', () => { label: 'Reference layer', }, ]); - datasourceMap.testDatasource.initializeDimension = jest.fn(); - + const props = getDefaultProps({ datasourceMap, visualizationMap }); const { instance, lensStore } = await prepareAndMountComponent(props); await clickToAddLayer(instance); @@ -345,8 +308,9 @@ describe('ConfigPanel', () => { }); it('should use group initial dimension value when adding a new layer if available', async () => { - const props = getDefaultProps(); - props.activeVisualization.getSupportedLayers = jest.fn(() => [ + const datasourceMap = mockDatasourceMap(); + const visualizationMap = mockVisualizationMap(); + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ { type: layerTypes.DATA, label: 'Data Layer' }, { type: layerTypes.REFERENCELINE, @@ -363,6 +327,7 @@ describe('ConfigPanel', () => { }, ]); datasourceMap.testDatasource.initializeDimension = jest.fn(); + const props = getDefaultProps({ datasourceMap, visualizationMap }); const { instance, lensStore } = await prepareAndMountComponent(props); await clickToAddLayer(instance); @@ -378,8 +343,10 @@ describe('ConfigPanel', () => { }); it('should add an initial dimension value when clicking on the empty dimension button', async () => { - const props = getDefaultProps(); - props.activeVisualization.getSupportedLayers = jest.fn(() => [ + const datasourceMap = mockDatasourceMap(); + + const visualizationMap = mockVisualizationMap(); + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ { type: layerTypes.DATA, label: 'Data Layer', @@ -395,7 +362,7 @@ describe('ConfigPanel', () => { }, ]); datasourceMap.testDatasource.initializeDimension = jest.fn(); - + const props = getDefaultProps({ visualizationMap, datasourceMap }); const { instance, lensStore } = await prepareAndMountComponent(props); await clickToAddDimension(instance); 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 0b6223ac87ce2..d3574abe4f57a 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 @@ -7,26 +7,26 @@ import React, { useMemo, memo } from 'react'; import { EuiForm } from '@elastic/eui'; -import { mapValues } from 'lodash'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; -import { appendLayer } from './layer_actions'; import { ConfigPanelWrapperProps } from './types'; import { useFocusUpdate } from './use_focus_update'; import { + setLayerDefaultDimension, useLensDispatch, + removeOrClearLayer, + addLayer, updateState, updateDatasourceState, updateVisualizationState, setToggleFullscreen, useLensSelector, selectVisualization, - VisualizationState, - LensAppState, } from '../../../state_management'; -import { AddLayerButton, getLayerType } from './add_layer'; +import { AddLayerButton } from './add_layer'; +import { getRemoveOperation } from '../../../utils'; export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const visualization = useLensSelector(selectVisualization); @@ -39,18 +39,6 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config ) : null; }); -function getRemoveOperation( - activeVisualization: Visualization, - visualizationState: VisualizationState['state'], - layerId: string, - layerCount: number -) { - if (activeVisualization.getRemoveOperation) { - return activeVisualization.getRemoveOperation(visualizationState, layerId); - } - // fallback to generic count check - return layerCount === 1 ? 'clear' : 'remove'; -} export function LayerPanels( props: ConfigPanelWrapperProps & { activeVisualization: Visualization; @@ -73,8 +61,7 @@ export function LayerPanels( dispatchLens( updateVisualizationState({ visualizationId: activeVisualization.id, - updater: newState, - clearStagedPreview: false, + newState, }) ); }, @@ -110,7 +97,6 @@ export function LayerPanels( setTimeout(() => { dispatchLens( updateState({ - subType: 'UPDATE_ALL_STATES', updater: (prevState) => { const updatedDatasourceState = typeof newDatasourceState === 'function' @@ -133,7 +119,6 @@ export function LayerPanels( ...prevState.visualization, state: updatedVisualizationState, }, - stagedPreview: undefined, }; }, }) @@ -183,66 +168,22 @@ export function LayerPanels( 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, - }), + setLayerDefaultDimension({ + layerId, + columnId, + groupId, }) ); } }} onRemoveLayer={() => { dispatchLens( - updateState({ - subType: 'REMOVE_OR_CLEAR_LAYER', - updater: (state) => { - const isOnlyLayer = - getRemoveOperation( - activeVisualization, - state.visualization.state, - layerId, - layerIds.length - ) === 'clear'; - - return { - ...state, - datasourceStates: mapValues( - state.datasourceStates, - (datasourceState, datasourceId) => { - const datasource = datasourceMap[datasourceId!]; - return { - ...datasourceState, - state: isOnlyLayer - ? datasource.clearLayer(datasourceState.state, layerId) - : datasource.removeLayer(datasourceState.state, layerId), - }; - } - ), - visualization: { - ...state.visualization, - state: - isOnlyLayer || !activeVisualization.removeLayer - ? activeVisualization.clearLayer(state.visualization.state, layerId) - : activeVisualization.removeLayer(state.visualization.state, layerId), - }, - stagedPreview: undefined, - }; - }, + removeOrClearLayer({ + visualizationId: activeVisualization.id, + layerId, + layerIds, }) ); - removeLayerRef(layerId); }} toggleFullscreen={toggleFullscreen} @@ -254,96 +195,12 @@ export function LayerPanels( visualizationState={visualization.state} layersMeta={props.framePublicAPI} onAddLayerClick={(layerType) => { - const id = generateId(); - dispatchLens( - updateState({ - subType: 'ADD_LAYER', - 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); + const layerId = generateId(); + dispatchLens(addLayer({ layerId, layerType })); + trackUiEvent('layer_added'); + setNextFocusedLayerId(layerId); }} /> ); } - -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_actions.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts deleted file mode 100644 index 44cefb0bf8ec4..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { layerTypes } from '../../../../common'; -import { LensAppState } from '../../../state_management/types'; -import { removeLayer, appendLayer } from './layer_actions'; - -function createTestArgs(initialLayerIds: string[]) { - const trackUiEvent = jest.fn(); - const testDatasource = (datasourceId: string) => ({ - id: datasourceId, - clearLayer: (layerIds: unknown, layerId: string) => - (layerIds as string[]).map((id: string) => - id === layerId ? `${datasourceId}_clear_${layerId}` : id - ), - removeLayer: (layerIds: unknown, layerId: string) => - (layerIds as string[]).filter((id: string) => id !== layerId), - insertLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId], - }); - - const activeVisualization = { - clearLayer: (layerIds: unknown, layerId: string) => - (layerIds as string[]).map((id: string) => (id === layerId ? `vis_clear_${layerId}` : id)), - removeLayer: (layerIds: unknown, layerId: string) => - (layerIds as string[]).filter((id: string) => id !== layerId), - getLayerIds: (layerIds: unknown) => layerIds as string[], - appendLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId], - }; - - const datasourceStates = { - ds1: { - isLoading: false, - state: initialLayerIds.slice(0, 1), - }, - ds2: { - isLoading: false, - state: initialLayerIds.slice(1), - }, - }; - - return { - state: { - activeDatasourceId: 'ds1', - datasourceStates, - title: 'foo', - visualization: { - activeId: 'testVis', - state: initialLayerIds, - }, - } as unknown as LensAppState, - activeVisualization, - datasourceMap: { - ds1: testDatasource('ds1'), - ds2: testDatasource('ds2'), - }, - trackUiEvent, - stagedPreview: { - visualization: { - activeId: 'testVis', - state: initialLayerIds, - }, - datasourceStates, - }, - }; -} - -describe('removeLayer', () => { - it('should clear the layer if it is the only layer', () => { - const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs(['layer1']); - const newState = removeLayer({ - activeVisualization, - datasourceMap, - layerId: 'layer1', - state, - trackUiEvent, - }); - - expect(newState.visualization.state).toEqual(['vis_clear_layer1']); - expect(newState.datasourceStates.ds1.state).toEqual(['ds1_clear_layer1']); - expect(newState.datasourceStates.ds2.state).toEqual([]); - expect(newState.stagedPreview).not.toBeDefined(); - expect(trackUiEvent).toHaveBeenCalledWith('layer_cleared'); - }); - - it('should remove the layer if it is not the only layer', () => { - const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs([ - 'layer1', - 'layer2', - ]); - const newState = removeLayer({ - activeVisualization, - datasourceMap, - layerId: 'layer1', - state, - trackUiEvent, - }); - - expect(newState.visualization.state).toEqual(['layer2']); - expect(newState.datasourceStates.ds1.state).toEqual([]); - expect(newState.datasourceStates.ds2.state).toEqual(['layer2']); - expect(newState.stagedPreview).not.toBeDefined(); - expect(trackUiEvent).toHaveBeenCalledWith('layer_removed'); - }); -}); - -describe('appendLayer', () => { - it('should add the layer to the datasource and visualization', () => { - const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs([ - 'layer1', - 'layer2', - ]); - const newState = appendLayer({ - activeDatasource: datasourceMap.ds1, - activeVisualization, - generateId: () => 'foo', - state, - trackUiEvent, - layerType: layerTypes.DATA, - }); - - expect(newState.visualization.state).toEqual(['layer1', 'layer2', 'foo']); - expect(newState.datasourceStates.ds1.state).toEqual(['layer1', 'foo']); - expect(newState.datasourceStates.ds2.state).toEqual(['layer2']); - expect(newState.stagedPreview).not.toBeDefined(); - expect(trackUiEvent).toHaveBeenCalledWith('layer_added'); - }); -}); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts deleted file mode 100644 index c0f0847e8ff5c..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { mapValues } from 'lodash'; -import type { LayerType } from '../../../../common'; -import { LensAppState } from '../../../state_management'; - -import { Datasource, Visualization } from '../../../types'; - -interface RemoveLayerOptions { - trackUiEvent: (name: string) => void; - state: LensAppState; - layerId: string; - activeVisualization: Pick; - datasourceMap: Record>; -} - -interface AppendLayerOptions { - trackUiEvent: (name: string) => void; - state: LensAppState; - generateId: () => string; - activeDatasource: Pick; - activeVisualization: Pick; - layerType: LayerType; -} - -export function removeLayer(opts: RemoveLayerOptions): LensAppState { - const { state, trackUiEvent: trackUiEvent, activeVisualization, layerId, datasourceMap } = opts; - const isOnlyLayer = activeVisualization - .getLayerIds(state.visualization.state) - .every((id) => id === opts.layerId); - - trackUiEvent(isOnlyLayer ? 'layer_cleared' : 'layer_removed'); - - return { - ...state, - datasourceStates: mapValues(state.datasourceStates, (datasourceState, datasourceId) => { - const datasource = datasourceMap[datasourceId!]; - return { - ...datasourceState, - state: isOnlyLayer - ? datasource.clearLayer(datasourceState.state, layerId) - : datasource.removeLayer(datasourceState.state, layerId), - }; - }), - visualization: { - ...state.visualization, - state: - isOnlyLayer || !activeVisualization.removeLayer - ? activeVisualization.clearLayer(state.visualization.state, layerId) - : activeVisualization.removeLayer(state.visualization.state, layerId), - }, - stagedPreview: undefined, - }; -} - -export function appendLayer({ - trackUiEvent, - activeVisualization, - state, - generateId, - activeDatasource, - layerType, -}: AppendLayerOptions): LensAppState { - trackUiEvent('layer_added'); - - if (!activeVisualization.appendLayer) { - return state; - } - - const layerId = generateId(); - - return { - ...state, - datasourceStates: { - ...state.datasourceStates, - [activeDatasource.id]: { - ...state.datasourceStates[activeDatasource.id], - state: activeDatasource.insertLayer( - state.datasourceStates[activeDatasource.id].state, - layerId - ), - }, - }, - visualization: { - ...state.visualization, - state: activeVisualization.appendLayer(state.visualization.state, layerId, layerType), - }, - stagedPreview: undefined, - }; -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index d289b69f4105e..37191ffa89fdc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -38,6 +38,7 @@ import { createMockDatasource, DatasourceMock, createExpressionRendererMock, + mockStoreDeps, } from '../../mocks'; import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks'; import { ReactExpressionRendererType } from 'src/plugins/expressions/public'; @@ -144,21 +145,19 @@ describe('editor_frame', () => { ExpressionRenderer: expressionRendererMock, }; - const lensStore = ( - await mountWithProvider(, { - preloadedState: { - activeDatasourceId: 'testDatasource', - datasourceStates: { - testDatasource: { - isLoading: true, - state: { - internalState1: '', - }, + const { lensStore } = await mountWithProvider(, { + preloadedState: { + activeDatasourceId: 'testDatasource', + datasourceStates: { + testDatasource: { + isLoading: true, + state: { + internalState1: '', }, }, }, - }) - ).lensStore; + }, + }); expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); lensStore.dispatch( setState({ @@ -553,6 +552,7 @@ describe('editor_frame', () => { } beforeEach(async () => { + mockVisualization2.initialize.mockReturnValue({ initial: true }); mockDatasource.getLayers.mockReturnValue(['first', 'second']); mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { @@ -567,20 +567,27 @@ describe('editor_frame', () => { }, ]); + const visualizationMap = { + testVis: mockVisualization, + testVis2: mockVisualization2, + }; + + const datasourceMap = { + testDatasource: mockDatasource, + testDatasource2: mockDatasource2, + }; + const props = { ...getDefaultProps(), - visualizationMap: { - testVis: mockVisualization, - testVis2: mockVisualization2, - }, - datasourceMap: { - testDatasource: mockDatasource, - testDatasource2: mockDatasource2, - }, - + visualizationMap, + datasourceMap, ExpressionRenderer: expressionRendererMock, }; - instance = (await mountWithProvider()).instance; + instance = ( + await mountWithProvider(, { + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), + }) + ).instance; // necessary to flush elements to dom synchronously instance.update(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx index 7cb97882a5e03..c325e6d516c8b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx @@ -9,8 +9,9 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { createMockVisualization, + mockStoreDeps, createMockFramePublicAPI, - createMockDatasource, + mockDatasourceMap, mockDatasourceStates, } from '../../../mocks'; import { mountWithProvider } from '../../../mocks'; @@ -71,7 +72,7 @@ describe('chart_switch', () => { * - Allows a switch to subvisC3 * - Allows a switch to subvisC1 */ - function mockVisualizations() { + function mockVisualizationMap() { return { visA: generateVisualization('visA'), visB: generateVisualization('visB'), @@ -143,27 +144,6 @@ describe('chart_switch', () => { } as FramePublicAPI; } - function mockDatasourceMap() { - const datasource = createMockDatasource('testDatasource'); - datasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ - { - state: {}, - table: { - columns: [], - isMultiRow: true, - layerId: 'a', - changeType: 'unchanged', - }, - keptLayerIds: ['a'], - }, - ]); - - datasource.getLayers.mockReturnValue(['a']); - return { - testDatasource: datasource, - }; - } - function showFlyout(instance: ReactWrapper) { instance.find('[data-test-subj="lnsChartSwitchPopover"]').first().simulate('click'); } @@ -178,10 +158,10 @@ describe('chart_switch', () => { return instance.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).first(); } it('should use suggested state if there is a suggestion from the target visualization', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const { instance, lensStore } = await mountWithProvider( , @@ -212,19 +192,20 @@ describe('chart_switch', () => { }); it('should use initial state if there is no suggestion from the target visualization', async () => { - const visualizations = mockVisualizations(); - visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const visualizationMap = mockVisualizationMap(); + visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a']); (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); const datasourceMap = mockDatasourceMap(); const datasourceStates = mockDatasourceStates(); const { instance, lensStore } = await mountWithProvider( , { + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), preloadedState: { datasourceStates, activeDatasourceId: 'testDatasource', @@ -249,18 +230,14 @@ describe('chart_switch', () => { }, }); expect(lensStore.dispatch).toHaveBeenCalledWith({ - type: 'lens/updateLayer', - payload: expect.objectContaining({ - datasourceId: 'testDatasource', - layerId: 'a', - }), + type: 'lens/removeLayers', + payload: { layerIds: ['a'], visualizationId: 'visA' }, }); }); it('should indicate data loss if not all columns will be used', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const frame = mockFrame(['a']); - const datasourceMap = mockDatasourceMap(); datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { @@ -299,9 +276,9 @@ describe('chart_switch', () => { const { instance } = await mountWithProvider( , { preloadedState: { @@ -322,12 +299,11 @@ describe('chart_switch', () => { }); it('should indicate data loss if not all layers will be used', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const frame = mockFrame(['a', 'b']); - const { instance } = await mountWithProvider( , @@ -350,9 +326,8 @@ describe('chart_switch', () => { }); it('should support multi-layer suggestions without data loss', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const frame = mockFrame(['a', 'b']); - const datasourceMap = mockDatasourceMap(); datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { @@ -378,7 +353,7 @@ describe('chart_switch', () => { const { instance } = await mountWithProvider( , @@ -398,13 +373,13 @@ describe('chart_switch', () => { }); it('should indicate data loss if no data will be used', async () => { - const visualizations = mockVisualizations(); - visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const visualizationMap = mockVisualizationMap(); + visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a']); const { instance } = await mountWithProvider( , @@ -427,14 +402,14 @@ describe('chart_switch', () => { }); it('should not indicate data loss if there is no data', async () => { - const visualizations = mockVisualizations(); - visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const visualizationMap = mockVisualizationMap(); + visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a']); (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); const { instance } = await mountWithProvider( , @@ -456,20 +431,19 @@ describe('chart_switch', () => { it('should not show a warning when the subvisualization is the same', async () => { const frame = mockFrame(['a', 'b', 'c']); - const visualizations = mockVisualizations(); - visualizations.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); + const visualizationMap = mockVisualizationMap(); + visualizationMap.visC.getVisualizationTypeId.mockReturnValue('subvisC2'); const switchVisualizationType = jest.fn(() => ({ type: 'subvisC1' })); - visualizations.visC.switchVisualizationType = switchVisualizationType; + visualizationMap.visC.switchVisualizationType = switchVisualizationType; - const datasourceMap = mockDatasourceMap(); const datasourceStates = mockDatasourceStates(); const { instance } = await mountWithProvider( , { preloadedState: { @@ -491,8 +465,8 @@ describe('chart_switch', () => { }); it('should get suggestions when switching subvisualization', async () => { - const visualizations = mockVisualizations(); - visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const visualizationMap = mockVisualizationMap(); + visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a', 'b', 'c']); const datasourceMap = mockDatasourceMap(); datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']); @@ -500,11 +474,12 @@ describe('chart_switch', () => { const { instance, lensStore } = await mountWithProvider( , { + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), preloadedState: { datasourceStates, visualization: { @@ -519,7 +494,7 @@ describe('chart_switch', () => { expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a'); expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'b'); expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'c'); - expect(visualizations.visB.getSuggestions).toHaveBeenCalledWith( + expect(visualizationMap.visB.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ keptLayerIds: ['a'], }) @@ -540,19 +515,18 @@ describe('chart_switch', () => { }); it('should query main palette from active chart and pass into suggestions', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const mockPalette: PaletteOutput = { type: 'palette', name: 'mock' }; - visualizations.visA.getMainPalette = jest.fn(() => mockPalette); - visualizations.visB.getSuggestions.mockReturnValueOnce([]); + visualizationMap.visA.getMainPalette = jest.fn(() => mockPalette); + visualizationMap.visB.getSuggestions.mockReturnValueOnce([]); const frame = mockFrame(['a', 'b', 'c']); const currentVisState = {}; - const datasourceMap = mockDatasourceMap(); datasourceMap.testDatasource.getLayers.mockReturnValue(['a', 'b', 'c']); const { instance } = await mountWithProvider( , @@ -563,14 +537,15 @@ describe('chart_switch', () => { state: currentVisState, }, }, + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), } ); switchTo('visB', instance); - expect(visualizations.visA.getMainPalette).toHaveBeenCalledWith(currentVisState); + expect(visualizationMap.visA.getMainPalette).toHaveBeenCalledWith(currentVisState); - expect(visualizations.visB.getSuggestions).toHaveBeenCalledWith( + expect(visualizationMap.visB.getSuggestions).toHaveBeenCalledWith( expect.objectContaining({ keptLayerIds: ['a'], mainPalette: mockPalette, @@ -580,14 +555,14 @@ describe('chart_switch', () => { it('should not remove layers when switching between subtypes', async () => { const frame = mockFrame(['a', 'b', 'c']); - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const switchVisualizationType = jest.fn(() => 'switched'); - visualizations.visC.switchVisualizationType = switchVisualizationType; + visualizationMap.visC.switchVisualizationType = switchVisualizationType; const datasourceMap = mockDatasourceMap(); const { instance, lensStore } = await mountWithProvider( , @@ -598,6 +573,7 @@ describe('chart_switch', () => { state: { type: 'subvisC1' }, }, }, + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), } ); @@ -622,13 +598,13 @@ describe('chart_switch', () => { it('should not remove layers and initialize with existing state when switching between subtypes without data', async () => { const frame = mockFrame(['a']); frame.datasourceLayers.a.getTableSpec = jest.fn().mockReturnValue([]); - const visualizations = mockVisualizations(); - visualizations.visC.getSuggestions = jest.fn().mockReturnValue([]); - visualizations.visC.switchVisualizationType = jest.fn(() => 'switched'); + const visualizationMap = mockVisualizationMap(); + visualizationMap.visC.getSuggestions = jest.fn().mockReturnValue([]); + visualizationMap.visC.switchVisualizationType = jest.fn(() => 'switched'); const datasourceMap = mockDatasourceMap(); const { instance } = await mountWithProvider( , @@ -639,21 +615,21 @@ describe('chart_switch', () => { state: { type: 'subvisC1' }, }, }, + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), } ); switchTo('subvisC3', instance); - expect(visualizations.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC3', { + expect(visualizationMap.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC3', { type: 'subvisC1', }); expect(datasourceMap.testDatasource.removeLayer).not.toHaveBeenCalled(); }); it('should switch to the updated datasource state', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const frame = mockFrame(['a', 'b']); - const datasourceMap = mockDatasourceMap(); datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { @@ -684,14 +660,14 @@ describe('chart_switch', () => { keptLayerIds: [], }, ]); - const { instance, lensStore } = await mountWithProvider( , { + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), preloadedState: { visualization: { activeId: 'visA', @@ -718,20 +694,21 @@ describe('chart_switch', () => { }); it('should ensure the new visualization has the proper subtype', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const switchVisualizationType = jest.fn( (visualizationType, state) => `${state} ${visualizationType}` ); - visualizations.visB.switchVisualizationType = switchVisualizationType; - + visualizationMap.visB.switchVisualizationType = switchVisualizationType; + const datasourceMap = mockDatasourceMap(); const { instance, lensStore } = await mountWithProvider( , { + storeDeps: mockStoreDeps({ datasourceMap, visualizationMap }), preloadedState: { visualization: { activeId: 'visA', @@ -758,16 +735,16 @@ describe('chart_switch', () => { }); it('should use the suggestion that matches the subtype', async () => { - const visualizations = mockVisualizations(); + const visualizationMap = mockVisualizationMap(); const switchVisualizationType = jest.fn(); - visualizations.visC.switchVisualizationType = switchVisualizationType; - + visualizationMap.visC.switchVisualizationType = switchVisualizationType; + const datasourceMap = mockDatasourceMap(); const { instance } = await mountWithProvider( , { preloadedState: { @@ -787,11 +764,12 @@ describe('chart_switch', () => { }); it('should show all visualization types', async () => { + const datasourceMap = mockDatasourceMap(); const { instance } = await mountWithProvider( , { preloadedState: { 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 a5ba12941cf7f..427306cb54fb9 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 @@ -31,8 +31,8 @@ import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_he import { trackUiEvent } from '../../../lens_ui_telemetry'; import { ToolbarButton } from '../../../../../../../src/plugins/kibana_react/public'; import { - updateLayer, - updateVisualizationState, + insertLayer, + removeLayers, useLensDispatch, useLensSelector, VisualizationState, @@ -120,41 +120,6 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { const visualization = useLensSelector(selectVisualization); const datasourceStates = useLensSelector(selectDatasourceStates); - function removeLayers(layerIds: string[]) { - const activeVisualization = - visualization.activeId && props.visualizationMap[visualization.activeId]; - if (activeVisualization && activeVisualization.removeLayer && visualization.state) { - dispatchLens( - updateVisualizationState({ - visualizationId: activeVisualization.id, - updater: layerIds.reduce( - (acc, layerId) => - activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc, - visualization.state - ), - }) - ); - } - layerIds.forEach((layerId) => { - const [layerDatasourceId] = - Object.entries(props.datasourceMap).find(([datasourceId, datasource]) => { - return ( - datasourceStates[datasourceId] && - datasource.getLayers(datasourceStates[datasourceId].state).includes(layerId) - ); - }) ?? []; - if (layerDatasourceId) { - dispatchLens( - updateLayer({ - layerId, - datasourceId: layerDatasourceId, - updater: props.datasourceMap[layerDatasourceId].removeLayer, - }) - ); - } - }); - } - const commitSelection = (selection: VisualizationSelection) => { setFlyoutOpen(false); @@ -173,7 +138,12 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { (!selection.datasourceId && !selection.sameDatasources) || selection.dataLoss === 'everything' ) { - removeLayers(Object.keys(props.framePublicAPI.datasourceLayers)); + dispatchLens( + removeLayers({ + visualizationId: visualization.activeId, + layerIds: Object.keys(props.framePublicAPI.datasourceLayers), + }) + ); } }; @@ -231,13 +201,11 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { function addNewLayer() { const newLayerId = generateId(); dispatchLens( - updateLayer({ + insertLayer({ datasourceId: activeDatasourceId!, layerId: newLayerId, - updater: props.datasourceMap[activeDatasourceId!].insertLayer, }) ); - return newLayerId; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index a03c9292d2da8..4b98b2842a01b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -52,7 +52,7 @@ import { DefaultInspectorAdapters } from '../../../../../../../src/plugins/expre import { onActiveDataChange, useLensDispatch, - updateVisualizationState, + editVisualizationAction, updateDatasourceState, setSaveable, useLensSelector, @@ -246,9 +246,9 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ } if (isLensEditEvent(event) && activeVisualization?.onEditAction) { dispatchLens( - updateVisualizationState({ + editVisualizationAction({ visualizationId: activeVisualization.id, - updater: (oldState: unknown) => activeVisualization.onEditAction!(oldState, event), + event, }) ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index d8959e714d16e..f13ecb78593d9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -54,8 +54,7 @@ export function WorkspacePanelWrapper({ dispatchLens( updateVisualizationState({ visualizationId: activeVisualization.id, - updater: newState, - clearStagedPreview: false, + newState, }) ); }, diff --git a/x-pack/plugins/lens/public/state_management/index.ts b/x-pack/plugins/lens/public/state_management/index.ts index 0aa7185931c5a..5f3b60d95d77d 100644 --- a/x-pack/plugins/lens/public/state_management/index.ts +++ b/x-pack/plugins/lens/public/state_management/index.ts @@ -25,13 +25,18 @@ export const { updateState, updateDatasourceState, updateVisualizationState, - updateLayer, + insertLayer, switchVisualization, rollbackSuggestion, submitSuggestion, switchDatasource, setToggleFullscreen, initEmpty, + editVisualizationAction, + removeLayers, + removeOrClearLayer, + addLayer, + setLayerDefaultDimension, } = lensActions; export const makeConfigureStore = ( diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts index 7d88e6ceb616c..85061f36ce35e 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts @@ -13,8 +13,13 @@ import { updateState, updateDatasourceState, updateVisualizationState, + removeOrClearLayer, + addLayer, + LensRootStore, } from '.'; -import { makeLensStore, defaultState } from '../mocks'; +import { layerTypes } from '../../common'; +import { makeLensStore, defaultState, mockStoreDeps } from '../mocks'; +import { DatasourceMap, VisualizationMap } from '../types'; describe('lensSlice', () => { const { store } = makeLensStore({}); @@ -31,7 +36,7 @@ describe('lensSlice', () => { it('updateState: updates state with updater', () => { const customUpdater = jest.fn((state) => ({ ...state, query: customQuery })); - store.dispatch(updateState({ subType: 'UPDATE', updater: customUpdater })); + store.dispatch(updateState({ updater: customUpdater })); const changedState = store.getState().lens; expect(changedState).toEqual({ ...defaultState, query: customQuery }); }); @@ -40,7 +45,7 @@ describe('lensSlice', () => { store.dispatch( updateVisualizationState({ visualizationId: 'testVis', - updater: newVisState, + newState: newVisState, }) ); @@ -117,7 +122,7 @@ describe('lensSlice', () => { expect(store.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(true); }); - it('not initialize already initialized datasource on switch', () => { + it('should not initialize already initialized datasource on switch', () => { const datasource2State = {}; const { store: customStore } = makeLensStore({ preloadedState: { @@ -146,5 +151,109 @@ describe('lensSlice', () => { datasource2State ); }); + + describe('adding or removing layer', () => { + const testDatasource = (datasourceId: string) => { + return { + id: datasourceId, + getPublicAPI: () => ({ + datasourceId: 'testDatasource', + getOperationForColumnId: jest.fn(), + getTableSpec: jest.fn(), + }), + getLayers: () => ['layer1'], + clearLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).map((id: string) => + id === layerId ? `${datasourceId}_clear_${layerId}` : id + ), + removeLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).filter((id: string) => id !== layerId), + insertLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId], + }; + }; + const datasourceStates = { + testDatasource: { + isLoading: false, + state: ['layer1'], + }, + testDatasource2: { + isLoading: false, + state: ['layer2'], + }, + }; + const datasourceMap = { + testDatasource: testDatasource('testDatasource'), + testDatasource2: testDatasource('testDatasource2'), + }; + const visualizationMap = { + testVis: { + clearLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).map((id: string) => + id === layerId ? `vis_clear_${layerId}` : id + ), + removeLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).filter((id: string) => id !== layerId), + getLayerIds: (layerIds: unknown) => layerIds as string[], + appendLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId], + getSupportedLayers: jest.fn(() => [{ type: layerTypes.DATA, label: 'Data Layer' }]), + }, + }; + + let customStore: LensRootStore; + beforeEach(() => { + customStore = makeLensStore({ + preloadedState: { + activeDatasourceId: 'testDatasource', + datasourceStates, + visualization: { + activeId: 'testVis', + state: ['layer1', 'layer2'], + }, + stagedPreview: { + visualization: { + activeId: 'testVis', + state: ['layer1', 'layer2'], + }, + datasourceStates, + }, + }, + storeDeps: mockStoreDeps({ + visualizationMap: visualizationMap as unknown as VisualizationMap, + datasourceMap: datasourceMap as unknown as DatasourceMap, + }), + }).store; + }); + + it('addLayer: should add the layer to the datasource and visualization', () => { + customStore.dispatch( + addLayer({ + layerId: 'foo', + layerType: layerTypes.DATA, + }) + ); + const state = customStore.getState().lens; + + expect(state.visualization.state).toEqual(['layer1', 'layer2', 'foo']); + expect(state.datasourceStates.testDatasource.state).toEqual(['layer1', 'foo']); + expect(state.datasourceStates.testDatasource2.state).toEqual(['layer2']); + expect(state.stagedPreview).not.toBeDefined(); + }); + + it('removeLayer: should remove the layer if it is not the only layer', () => { + customStore.dispatch( + removeOrClearLayer({ + visualizationId: 'testVis', + layerId: 'layer1', + layerIds: ['layer1', 'layer2'], + }) + ); + const state = customStore.getState().lens; + + expect(state.visualization.state).toEqual(['layer2']); + expect(state.datasourceStates.testDatasource.state).toEqual([]); + expect(state.datasourceStates.testDatasource2.state).toEqual(['layer2']); + expect(state.stagedPreview).not.toBeDefined(); + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index df178cadf6c30..af9897581fcf4 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -7,16 +7,22 @@ import { createAction, createReducer, current, PayloadAction } from '@reduxjs/toolkit'; import { VisualizeFieldContext } from 'src/plugins/ui_actions/public'; +import { mapValues } from 'lodash'; import { History } from 'history'; import { LensEmbeddableInput } from '..'; +import { getDatasourceLayers } from '../editor_frame_service/editor_frame'; import { TableInspectorAdapter } from '../editor_frame_service/types'; -import { getInitialDatasourceId, getResolvedDateRange } from '../utils'; -import { LensAppState, LensStoreDeps } from './types'; +import { getInitialDatasourceId, getResolvedDateRange, getRemoveOperation } from '../utils'; +import { LensAppState, LensStoreDeps, VisualizationState } from './types'; +import { Datasource, Visualization } from '../types'; import { generateId } from '../id_generator'; +import type { LayerType } from '../../common/types'; +import { getLayerType } from '../editor_frame_service/editor_frame/config_panel/add_layer'; import { getVisualizeFieldSuggestions, Suggestion, } from '../editor_frame_service/editor_frame/suggestion_helpers'; +import { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types'; export const initialState: LensAppState = { persistedDoc: undefined, @@ -80,7 +86,6 @@ export const setState = createAction>('lens/setState'); export const onActiveDataChange = createAction('lens/onActiveDataChange'); export const setSaveable = createAction('lens/setSaveable'); export const updateState = createAction<{ - subType: string; updater: (prevState: LensAppState) => LensAppState; }>('lens/updateState'); export const updateDatasourceState = createAction<{ @@ -90,15 +95,13 @@ export const updateDatasourceState = createAction<{ }>('lens/updateDatasourceState'); export const updateVisualizationState = createAction<{ visualizationId: string; - updater: unknown; - clearStagedPreview?: boolean; + newState: unknown; }>('lens/updateVisualizationState'); -export const updateLayer = createAction<{ +export const insertLayer = createAction<{ layerId: string; datasourceId: string; - updater: (state: unknown, layerId: string) => unknown; -}>('lens/updateLayer'); +}>('lens/insertLayer'); export const switchVisualization = createAction<{ suggestion: { @@ -133,6 +136,29 @@ export const initEmpty = createAction( return { payload: { layerId: generateId(), newState, initialContext } }; } ); +export const editVisualizationAction = createAction<{ + visualizationId: string; + event: LensEditEvent; +}>('lens/editVisualizationAction'); +export const removeLayers = createAction<{ + visualizationId: VisualizationState['activeId']; + layerIds: string[]; +}>('lens/removeLayers'); +export const removeOrClearLayer = createAction<{ + visualizationId: string; + layerId: string; + layerIds: string[]; +}>('lens/removeOrClearLayer'); +export const addLayer = createAction<{ + layerId: string; + layerType: LayerType; +}>('lens/addLayer'); + +export const setLayerDefaultDimension = createAction<{ + layerId: string; + columnId: string; + groupId: string; +}>('lens/setLayerDefaultDimension'); export const lensActions = { setState, @@ -141,7 +167,7 @@ export const lensActions = { updateState, updateDatasourceState, updateVisualizationState, - updateLayer, + insertLayer, switchVisualization, rollbackSuggestion, setToggleFullscreen, @@ -150,6 +176,11 @@ export const lensActions = { navigateAway, loadInitial, initEmpty, + editVisualizationAction, + removeLayers, + removeOrClearLayer, + addLayer, + setLayerDefaultDimension, }; export const makeLensReducer = (storeDeps: LensStoreDeps) => { @@ -175,14 +206,58 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }, [updateState.type]: ( state, - action: { + { + payload: { updater }, + }: { payload: { - subType: string; updater: (prevState: LensAppState) => LensAppState; }; } ) => { - return action.payload.updater(current(state) as LensAppState); + const newState = updater(current(state) as LensAppState); + return { + ...newState, + stagedPreview: undefined, + }; + }, + [removeOrClearLayer.type]: ( + state, + { + payload: { visualizationId, layerId, layerIds }, + }: { + payload: { + visualizationId: string; + layerId: string; + layerIds: string[]; + }; + } + ) => { + const activeVisualization = visualizationMap[visualizationId]; + const isOnlyLayer = + getRemoveOperation( + activeVisualization, + state.visualization.state, + layerId, + layerIds.length + ) === 'clear'; + + state.datasourceStates = mapValues( + state.datasourceStates, + (datasourceState, datasourceId) => { + const datasource = datasourceMap[datasourceId!]; + return { + ...datasourceState, + state: isOnlyLayer + ? datasource.clearLayer(datasourceState.state, layerId) + : datasource.removeLayer(datasourceState.state, layerId), + }; + } + ); + state.stagedPreview = undefined; + state.visualization.state = + isOnlyLayer || !activeVisualization.removeLayer + ? activeVisualization.clearLayer(state.visualization.state, layerId) + : activeVisualization.removeLayer(state.visualization.state, layerId); }, [updateDatasourceState.type]: ( state, @@ -218,8 +293,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }: { payload: { visualizationId: string; - updater: unknown | ((state: unknown) => unknown); - clearStagedPreview?: boolean; + newState: unknown; }; } ) => { @@ -236,37 +310,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { ...state, visualization: { ...state.visualization, - state: - typeof payload.updater === 'function' - ? payload.updater(current(state.visualization.state)) - : payload.updater, - }, - stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview, - }; - }, - [updateLayer.type]: ( - state, - { - payload, - }: { - payload: { - layerId: string; - datasourceId: string; - updater: (state: unknown, layerId: string) => unknown; - }; - } - ) => { - return { - ...state, - datasourceStates: { - ...state.datasourceStates, - [payload.datasourceId]: { - ...state.datasourceStates[payload.datasourceId], - state: payload.updater( - current(state).datasourceStates[payload.datasourceId].state, - payload.layerId - ), - }, + state: payload.newState, }, }; }, @@ -433,5 +477,248 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { } return newState; }, + [editVisualizationAction.type]: ( + state, + { + payload, + }: { + payload: { + visualizationId: string; + event: LensEditEvent; + }; + } + ) => { + if (!state.visualization.activeId) { + throw new Error('Invariant: visualization state got updated without active visualization'); + } + // This is a safeguard that prevents us from accidentally updating the + // wrong visualization. This occurs in some cases due to the uncoordinated + // way we manage state across plugins. + if (state.visualization.activeId !== payload.visualizationId) { + return state; + } + const activeVisualization = visualizationMap[payload.visualizationId]; + if (activeVisualization?.onEditAction) { + state.visualization.state = activeVisualization.onEditAction( + state.visualization.state, + payload.event + ); + } + }, + [insertLayer.type]: ( + state, + { + payload, + }: { + payload: { + layerId: string; + datasourceId: string; + }; + } + ) => { + const updater = datasourceMap[payload.datasourceId].insertLayer; + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [payload.datasourceId]: { + ...state.datasourceStates[payload.datasourceId], + state: updater( + current(state).datasourceStates[payload.datasourceId].state, + payload.layerId + ), + }, + }, + }; + }, + [removeLayers.type]: ( + state, + { + payload: { visualizationId, layerIds }, + }: { + payload: { + visualizationId: VisualizationState['activeId']; + layerIds: string[]; + }; + } + ) => { + if (!state.visualization.activeId) { + throw new Error('Invariant: visualization state got updated without active visualization'); + } + + const activeVisualization = visualizationId && visualizationMap[visualizationId]; + + // This is a safeguard that prevents us from accidentally updating the + // wrong visualization. This occurs in some cases due to the uncoordinated + // way we manage state across plugins. + if ( + state.visualization.activeId === visualizationId && + activeVisualization && + activeVisualization.removeLayer && + state.visualization.state + ) { + const updater = layerIds.reduce( + (acc, layerId) => + activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc, + state.visualization.state + ); + + state.visualization.state = + typeof updater === 'function' ? updater(current(state.visualization.state)) : updater; + } + layerIds.forEach((layerId) => { + const [layerDatasourceId] = + Object.entries(datasourceMap).find(([datasourceId, datasource]) => { + return ( + state.datasourceStates[datasourceId] && + datasource.getLayers(state.datasourceStates[datasourceId].state).includes(layerId) + ); + }) ?? []; + if (layerDatasourceId) { + state.datasourceStates[layerDatasourceId].state = datasourceMap[ + layerDatasourceId + ].removeLayer(current(state).datasourceStates[layerDatasourceId].state, layerId); + } + }); + }, + + [addLayer.type]: ( + state, + { + payload: { layerId, layerType }, + }: { + payload: { + layerId: string; + layerType: LayerType; + }; + } + ) => { + if (!state.activeDatasourceId || !state.visualization.activeId) { + return state; + } + + const activeDatasource = datasourceMap[state.activeDatasourceId]; + const activeVisualization = visualizationMap[state.visualization.activeId]; + + const datasourceState = activeDatasource.insertLayer( + state.datasourceStates[state.activeDatasourceId].state, + layerId + ); + + const visualizationState = activeVisualization.appendLayer!( + state.visualization.state, + layerId, + layerType + ); + + const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({ + datasourceState, + visualizationState, + framePublicAPI: { + // any better idea to avoid `as`? + activeData: state.activeData as TableInspectorAdapter, + datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), + }, + activeVisualization, + activeDatasource, + layerId, + layerType, + }); + + state.visualization.state = activeVisualizationState; + state.datasourceStates[state.activeDatasourceId].state = activeDatasourceState; + state.stagedPreview = undefined; + }, + [setLayerDefaultDimension.type]: ( + state, + { + payload: { layerId, columnId, groupId }, + }: { + payload: { + layerId: string; + columnId: string; + groupId: string; + }; + } + ) => { + if (!state.activeDatasourceId || !state.visualization.activeId) { + return state; + } + + const activeDatasource = datasourceMap[state.activeDatasourceId]; + const activeVisualization = visualizationMap[state.visualization.activeId]; + const layerType = getLayerType(activeVisualization, state.visualization.state, layerId); + const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({ + datasourceState: state.datasourceStates[state.activeDatasourceId].state, + visualizationState: state.visualization.state, + framePublicAPI: { + // any better idea to avoid `as`? + activeData: state.activeData as TableInspectorAdapter, + datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), + }, + activeVisualization, + activeDatasource, + layerId, + layerType, + columnId, + groupId, + }); + + state.visualization.state = activeVisualizationState; + state.datasourceStates[state.activeDatasourceId].state = activeDatasourceState; + }, }); }; + +function addInitialValueIfAvailable({ + visualizationState, + datasourceState, + activeVisualization, + activeDatasource, + framePublicAPI, + layerType, + layerId, + columnId, + groupId, +}: { + framePublicAPI: FramePublicAPI; + visualizationState: unknown; + datasourceState: unknown; + activeDatasource: Datasource; + activeVisualization: Visualization; + layerId: string; + layerType: string; + columnId?: string; + groupId?: string; +}) { + const layerInfo = activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType); + + 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 { + activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { + ...info, + columnId: columnId || info.columnId, + }), + activeVisualizationState: activeVisualization.setDimension({ + groupId: info.groupId, + layerId, + columnId: columnId || info.columnId, + prevState: visualizationState, + frame: framePublicAPI, + }), + }; + } + } + return { + activeDatasourceState: datasourceState, + activeVisualizationState: visualizationState, + }; +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index e207f2938dd3c..975e44f703959 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -776,7 +776,7 @@ export interface LensBrushEvent { } // Use same technique as TriggerContext -interface LensEditContextMapping { +export interface LensEditContextMapping { [LENS_EDIT_SORT_ACTION]: LensSortActionData; [LENS_EDIT_RESIZE_ACTION]: LensResizeActionData; [LENS_TOGGLE_ACTION]: LensToggleActionData; diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 993be9a06a2d9..921cc8fb364a2 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -16,8 +16,8 @@ import type { import type { IUiSettingsClient } from 'kibana/public'; import type { SavedObjectReference } from 'kibana/public'; import type { Document } from './persistence/saved_object_store'; -import type { Datasource, DatasourceMap } from './types'; -import type { DatasourceStates } from './state_management'; +import type { Datasource, DatasourceMap, Visualization } from './types'; +import type { DatasourceStates, VisualizationState } from './state_management'; export function getVisualizeGeoFieldMessage(fieldType: string) { return i18n.translate('xpack.lens.visualizeGeoFieldMessage', { @@ -94,3 +94,16 @@ export async function getIndexPatternsObjects( // return also the rejected ids in case we want to show something later on return { indexPatterns: fullfilled.map((response) => response.value), rejectedIds }; } + +export function getRemoveOperation( + activeVisualization: Visualization, + visualizationState: VisualizationState['state'], + layerId: string, + layerCount: number +) { + if (activeVisualization.getRemoveOperation) { + return activeVisualization.getRemoveOperation(visualizationState, layerId); + } + // fallback to generic count check + return layerCount === 1 ? 'clear' : 'remove'; +}