From 8c0440f29ddc1d6966aa6556e0401e8580e10c6b Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Tue, 14 Jan 2020 12:06:51 -0500 Subject: [PATCH] [Lens] Add clear layer feature (#53627) * [Lens] Add clear layer feature * Move clear / remove layer out of the context menu * Address code review comments * Remove xpack.lens.xyChart.deleteLayer translation * Get rid of unused Lens translations Co-authored-by: Elastic Machine --- .../visualization.test.tsx | 40 ++ .../visualization.tsx | 84 ++-- .../editor_frame/chart_switch.tsx | 1 - .../editor_frame/config_panel_wrapper.tsx | 279 +++++++++++-- .../editor_frame/editor_frame.test.tsx | 88 ++-- .../editor_frame/editor_frame.tsx | 36 +- .../editor_frame/layer_actions.test.ts | 115 ++++++ .../editor_frame/layer_actions.ts | 88 ++++ .../editor_frame/state_management.test.ts | 1 + .../editor_frame/state_management.ts | 16 + .../editor_frame/suggestion_helpers.ts | 3 +- .../editor_frame/suggestion_panel.tsx | 2 +- .../editor_frame/workspace_panel.test.tsx | 2 +- .../editor_frame/workspace_panel.tsx | 7 +- .../lens/public/editor_frame_plugin/mocks.tsx | 9 +- .../indexpattern_plugin/indexpattern.tsx | 24 +- .../metric_config_panel.test.tsx | 1 + .../metric_config_panel.tsx | 51 +-- .../metric_visualization.test.ts | 16 + .../metric_visualization.tsx | 13 +- x-pack/legacy/plugins/lens/public/types.ts | 18 +- .../xy_config_panel.test.tsx | 266 +++--------- .../xy_config_panel.tsx | 381 ++++++------------ .../xy_visualization.test.ts | 48 +++ .../xy_visualization.tsx | 64 ++- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 27 files changed, 1018 insertions(+), 639 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.ts diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx index 25d88fbae5b34..cb9350226575c 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx @@ -72,6 +72,42 @@ describe('Datatable Visualization', () => { }); }); + describe('#getLayerIds', () => { + it('return the layer ids', () => { + const state: DatatableVisualizationState = { + layers: [ + { + layerId: 'baz', + columns: ['a', 'b', 'c'], + }, + ], + }; + expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']); + }); + }); + + describe('#clearLayer', () => { + it('should reset the layer', () => { + (generateId as jest.Mock).mockReturnValueOnce('testid'); + const state: DatatableVisualizationState = { + layers: [ + { + layerId: 'baz', + columns: ['a', 'b', 'c'], + }, + ], + }; + expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({ + layers: [ + { + layerId: 'baz', + columns: ['testid'], + }, + ], + }); + }); + }); + describe('#getSuggestions', () => { function numCol(columnId: string): TableSuggestionColumn { return { @@ -188,6 +224,7 @@ describe('Datatable Visualization', () => { mount( {} }} frame={frame} layer={layer} @@ -224,6 +261,7 @@ describe('Datatable Visualization', () => { frame.datasourceLayers = { a: datasource.publicAPIMock }; const component = mount( {} }} frame={frame} layer={layer} @@ -258,6 +296,7 @@ describe('Datatable Visualization', () => { frame.datasourceLayers = { a: datasource.publicAPIMock }; const component = mount( {} }} frame={frame} layer={layer} @@ -290,6 +329,7 @@ describe('Datatable Visualization', () => { frame.datasourceLayers = { a: datasource.publicAPIMock }; const component = mount( {} }} frame={frame} layer={layer} diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx index f9a7ec419a9b9..79a018635134f 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -6,19 +6,18 @@ import React from 'react'; import { render } from 'react-dom'; -import { EuiForm, EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { MultiColumnEditor } from '../multi_column_editor'; import { SuggestionRequest, Visualization, - VisualizationProps, + VisualizationLayerConfigProps, VisualizationSuggestion, Operation, } from '../types'; import { generateId } from '../id_generator'; -import { NativeRenderer } from '../native_renderer'; import chartTableSVG from '../assets/chart_datatable.svg'; export interface LayerState { @@ -56,7 +55,7 @@ export function DataTableLayer({ state, setState, dragDropContext, -}: { layer: LayerState } & VisualizationProps) { +}: { layer: LayerState } & VisualizationLayerConfigProps) { const datasource = frame.datasourceLayers[layer.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); @@ -64,32 +63,24 @@ export function DataTableLayer({ const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns))); return ( - - + setState(updateColumns(state, layer, columns => [...columns, generateId()]))} + onRemove={column => + setState(updateColumns(state, layer, columns => columns.filter(c => c !== column))) + } + testSubj="datatable_columns" + data-test-subj="datatable_multicolumnEditor" /> - - - - setState(updateColumns(state, layer, columns => [...columns, generateId()]))} - onRemove={column => - setState(updateColumns(state, layer, columns => columns.filter(c => c !== column))) - } - testSubj="datatable_columns" - data-test-subj="datatable_multicolumnEditor" - /> - - + ); } @@ -110,7 +101,17 @@ export const datatableVisualization: Visualization< }, ], - getDescription(state) { + getLayerIds(state) { + return state.layers.map(l => l.layerId); + }, + + clearLayer(state) { + return { + layers: state.layers.map(l => newLayerState(l.layerId)), + }; + }, + + getDescription() { return { icon: chartTableSVG, label: i18n.translate('xpack.lens.datatable.label', { @@ -187,17 +188,18 @@ export const datatableVisualization: Visualization< ]; }, - renderConfigPanel: (domElement, props) => - render( - - - {props.state.layers.map(layer => ( - - ))} - - , - domElement - ), + renderLayerConfigPanel(domElement, props) { + const layer = props.state.layers.find(l => l.layerId === props.layerId); + + if (layer) { + render( + + + , + domElement + ); + } + }, toExpression(state, frame) { const layer = state.layers[0]; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx index dca6b3e7616d6..5e2fced577724 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx @@ -81,7 +81,6 @@ export function ChartSwitch(props: Props) { trackUiEvent(`chart_switch`); switchToSuggestion( - props.framePublicAPI, props.dispatch, { ...selection, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx index 4179a9455eefa..1422ee86be3e9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -4,14 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useContext, memo } from 'react'; +import React, { useMemo, useContext, memo, useState } from 'react'; +import { + EuiPanel, + EuiSpacer, + EuiPopover, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiToolTip, + EuiButton, + EuiForm, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; -import { Visualization, FramePublicAPI, Datasource } from '../../types'; +import { + Visualization, + FramePublicAPI, + Datasource, + VisualizationLayerConfigProps, +} from '../../types'; import { DragContext } from '../../drag_drop'; import { ChartSwitch } from './chart_switch'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { generateId } from '../../id_generator'; +import { removeLayer, appendLayer } from './layer_actions'; interface ConfigPanelWrapperProps { + activeDatasourceId: string; visualizationState: unknown; visualizationMap: Record; activeVisualizationId: string | null; @@ -28,17 +50,8 @@ interface ConfigPanelWrapperProps { } export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { - const context = useContext(DragContext); - const setVisualizationState = useMemo( - () => (newState: unknown) => { - props.dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - newState, - clearStagedPreview: false, - }); - }, - [props.dispatch] - ); + const activeVisualization = props.visualizationMap[props.activeVisualizationId || '']; + const { visualizationState } = props; return ( <> @@ -52,19 +65,235 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config dispatch={props.dispatch} framePublicAPI={props.framePublicAPI} /> - {props.activeVisualizationId && props.visualizationState !== null && ( -
- -
+ {activeVisualization && visualizationState && ( + )} ); }); + +function LayerPanels( + props: ConfigPanelWrapperProps & { + activeDatasourceId: string; + activeVisualization: Visualization; + } +) { + const { + framePublicAPI, + activeVisualization, + visualizationState, + dispatch, + activeDatasourceId, + datasourceMap, + } = props; + const dragDropContext = useContext(DragContext); + const setState = useMemo( + () => (newState: unknown) => { + props.dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, + newState, + clearStagedPreview: false, + }); + }, + [props.dispatch, activeVisualization] + ); + const layerIds = activeVisualization.getLayerIds(visualizationState); + + return ( + + {layerIds.map(layerId => ( + { + dispatch({ + type: 'UPDATE_STATE', + subType: 'REMOVE_OR_CLEAR_LAYER', + updater: state => + removeLayer({ + activeVisualization, + layerId, + trackUiEvent, + datasourceMap, + state, + }), + }); + }} + /> + ))} + {activeVisualization.appendLayer && ( + + + { + dispatch({ + type: 'UPDATE_STATE', + subType: 'ADD_LAYER', + updater: state => + appendLayer({ + activeVisualization, + generateId, + trackUiEvent, + activeDatasource: datasourceMap[activeDatasourceId], + state, + }), + }); + }} + iconType="plusInCircleFilled" + /> + + + )} + + ); +} + +function LayerPanel( + props: ConfigPanelWrapperProps & + VisualizationLayerConfigProps & { + isOnlyLayer: boolean; + activeVisualization: Visualization; + onRemove: () => void; + } +) { + const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemove } = props; + const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; + const layerConfigProps = { + layerId, + dragDropContext: props.dragDropContext, + state: props.visualizationState, + setState: props.setState, + frame: props.framePublicAPI, + }; + + return ( + + + + + + + {datasourcePublicAPI && ( + + + + )} + + + + + + + + + + + { + // If we don't blur the remove / clear button, it remains focused + // which is a strange UX in this case. e.target.blur doesn't work + // due to who knows what, but probably event re-writing. Additionally, + // activeElement does not have blur so, we need to do some casting + safeguards. + const el = (document.activeElement as unknown) as { blur: () => void }; + + if (el && el.blur) { + el.blur(); + } + + onRemove(); + }} + > + {isOnlyLayer + ? i18n.translate('xpack.lens.resetLayer', { + defaultMessage: 'Reset layer', + }) + : i18n.translate('xpack.lens.deleteLayer', { + defaultMessage: 'Delete layer', + })} + + + + + ); +} + +function LayerSettings({ + layerId, + activeVisualization, + layerConfigProps, +}: { + layerId: string; + activeVisualization: Visualization; + layerConfigProps: VisualizationLayerConfigProps; +}) { + const [isOpen, setIsOpen] = useState(false); + + if (!activeVisualization.renderLayerContextMenu) { + return null; + } + + return ( + setIsOpen(!isOpen)} + data-test-subj="lns_layer_settings" + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="leftUp" + > + + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index cf711eea29b96..c9b9a43376651 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -9,7 +9,7 @@ import { ReactWrapper } from 'enzyme'; import { EuiPanel, EuiToolTip } from '@elastic/eui'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EditorFrame } from './editor_frame'; -import { Visualization, DatasourcePublicAPI, DatasourceSuggestion } from '../../types'; +import { DatasourcePublicAPI, DatasourceSuggestion, Visualization } from '../../types'; import { act } from 'react-dom/test-utils'; import { coreMock } from 'src/core/public/mocks'; import { @@ -24,7 +24,11 @@ import { FrameLayout } from './frame_layout'; // calling this function will wait for all pending Promises from mock // datasources to be processed by its callers. -const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); +async function waitForPromises(n = 3) { + for (let i = 0; i < n; ++i) { + await Promise.resolve(); + } +} function generateSuggestion(state = {}): DatasourceSuggestion { return { @@ -88,6 +92,9 @@ describe('editor_frame', () => { ], }; + mockVisualization.getLayerIds.mockReturnValue(['first']); + mockVisualization2.getLayerIds.mockReturnValue(['second']); + mockDatasource = createMockDatasource(); mockDatasource2 = createMockDatasource(); @@ -202,7 +209,7 @@ describe('editor_frame', () => { ); }); - expect(mockVisualization.renderConfigPanel).not.toHaveBeenCalled(); + expect(mockVisualization.renderLayerConfigPanel).not.toHaveBeenCalled(); expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); }); @@ -294,6 +301,7 @@ describe('editor_frame', () => { it('should remove layer on active datasource on frame api call', async () => { const initialState = { datasource2: '' }; + mockDatasource.getLayers.mockReturnValue(['first']); mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState)); mockDatasource2.getLayers.mockReturnValue(['abc', 'def']); mockDatasource2.removeLayer.mockReturnValue({ removed: true }); @@ -361,7 +369,7 @@ describe('editor_frame', () => { it('should initialize visualization state and render config panel', async () => { const initialState = {}; - + mockDatasource.getLayers.mockReturnValue(['first']); mount( { await waitForPromises(); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: initialState }) ); @@ -390,6 +398,7 @@ describe('editor_frame', () => { it('should render the resulting expression using the expression renderer', async () => { mockDatasource.getLayers.mockReturnValue(['first']); + const instance = mount( { /> ); - await waitForPromises(); await waitForPromises(); instance.update(); @@ -601,6 +609,7 @@ describe('editor_frame', () => { describe('state update', () => { it('should re-render config panel after state update', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); mount( { await waitForPromises(); const updatedState = {}; - const setVisualizationState = (mockVisualization.renderConfigPanel as jest.Mock).mock + const setVisualizationState = (mockVisualization.renderLayerConfigPanel as jest.Mock).mock .calls[0][1].setState; act(() => { setVisualizationState(updatedState); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); - expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ state: updatedState, @@ -635,6 +644,7 @@ describe('editor_frame', () => { }); it('should re-render data panel after state update', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); mount( { await waitForPromises(); - const updatedPublicAPI = {}; - mockDatasource.getPublicAPI.mockReturnValue( - (updatedPublicAPI as unknown) as DatasourcePublicAPI - ); + const updatedPublicAPI: DatasourcePublicAPI = { + renderLayerPanel: jest.fn(), + renderDimensionPanel: jest.fn(), + getOperationForColumnId: jest.fn(), + getTableSpec: jest.fn(), + }; + mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] .setState; @@ -700,8 +713,8 @@ describe('editor_frame', () => { setDatasourceState({}); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); - expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ frame: expect.objectContaining({ @@ -754,10 +767,10 @@ describe('editor_frame', () => { await waitForPromises(); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalled(); const datasourceLayers = - mockVisualization.renderConfigPanel.mock.calls[0][1].frame.datasourceLayers; + mockVisualization.renderLayerConfigPanel.mock.calls[0][1].frame.datasourceLayers; expect(datasourceLayers.first).toBe(mockDatasource.publicAPIMock); expect(datasourceLayers.second).toBe(mockDatasource2.publicAPIMock); expect(datasourceLayers.third).toBe(mockDatasource2.publicAPIMock); @@ -919,7 +932,7 @@ describe('editor_frame', () => { } beforeEach(async () => { - mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getLayers.mockReturnValue(['first', 'second']); mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { state: {}, @@ -1018,7 +1031,7 @@ describe('editor_frame', () => { expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.initialize).toHaveBeenCalledWith(expect.anything(), initialState); - expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: { initial: true } }) ); @@ -1032,9 +1045,11 @@ describe('editor_frame', () => { expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.initialize).toHaveBeenCalledWith( - expect.objectContaining({ datasourceLayers: { first: mockDatasource.publicAPIMock } }) + expect.objectContaining({ + datasourceLayers: expect.objectContaining({ first: mockDatasource.publicAPIMock }), + }) ); - expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: { initial: true } }) ); @@ -1102,6 +1117,7 @@ describe('editor_frame', () => { }); it('should display top 5 suggestions in descending order', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); const instance = mount( { }); it('should switch to suggested visualization', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const newDatasourceState = {}; const suggestionVisState = {}; const instance = mount( @@ -1228,8 +1245,8 @@ describe('editor_frame', () => { .simulate('click'); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(1); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(1); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1244,6 +1261,7 @@ describe('editor_frame', () => { }); it('should switch to best suggested visualization on field drop', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); const suggestionVisState = {}; const instance = mount( { .simulate('drop'); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1302,6 +1320,7 @@ describe('editor_frame', () => { }); it('should use the currently selected visualization if possible on field drop', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const suggestionVisState = {}; const instance = mount( { }); }); - expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1375,10 +1394,12 @@ describe('editor_frame', () => { }); it('should use the highest priority suggestion available', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const suggestionVisState = {}; const mockVisualization3 = { ...createMockVisualization(), id: 'testVis3', + getLayerIds: () => ['third'], visualizationTypes: [ { icon: 'empty', @@ -1460,7 +1481,7 @@ describe('editor_frame', () => { }); }); - expect(mockVisualization3.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization3.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1633,13 +1654,16 @@ describe('editor_frame', () => { await waitForPromises(); expect(onChange).toHaveBeenCalledTimes(2); - (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: () => ({ - newState: true, - }), - datasourceId: 'testDatasource', + act(() => { + (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater: () => ({ + newState: true, + }), + datasourceId: 'testDatasource', + }); }); + await waitForPromises(); expect(onChange).toHaveBeenCalledTimes(3); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx index a2745818e19bb..3284f69b503c5 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -52,6 +52,8 @@ export interface EditorFrameProps { export function EditorFrame(props: EditorFrameProps) { const [state, dispatch] = useReducer(reducer, props, getInitialState); const { onError } = props; + const activeVisualization = + state.visualization.activeId && props.visualizationMap[state.visualization.activeId]; const allLoaded = Object.values(state.datasourceStates).every( ({ isLoading }) => typeof isLoading === 'boolean' && !isLoading @@ -125,7 +127,20 @@ export function EditorFrame(props: EditorFrameProps) { return newLayerId; }, - removeLayers: (layerIds: string[]) => { + + removeLayers(layerIds: string[]) { + if (activeVisualization && activeVisualization.removeLayer && state.visualization.state) { + dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, + newState: layerIds.reduce( + (acc, layerId) => + activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc, + state.visualization.state + ), + }); + } + layerIds.forEach(layerId => { const layerDatasourceId = Object.entries(props.datasourceMap).find( ([datasourceId, datasource]) => @@ -158,16 +173,15 @@ export function EditorFrame(props: EditorFrameProps) { // Initialize visualization as soon as all datasources are ready useEffect(() => { - if (allLoaded && state.visualization.state === null && state.visualization.activeId !== null) { - const initialVisualizationState = props.visualizationMap[ - state.visualization.activeId - ].initialize(framePublicAPI); + if (allLoaded && state.visualization.state === null && activeVisualization) { + const initialVisualizationState = activeVisualization.initialize(framePublicAPI); dispatch({ type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, newState: initialVisualizationState, }); } - }, [allLoaded, state.visualization.activeId, state.visualization.state]); + }, [allLoaded, activeVisualization, state.visualization.state]); // The frame needs to call onChange every time its internal state changes useEffect(() => { @@ -176,11 +190,7 @@ export function EditorFrame(props: EditorFrameProps) { ? props.datasourceMap[state.activeDatasourceId] : undefined; - const visualization = state.visualization.activeId - ? props.visualizationMap[state.visualization.activeId] - : undefined; - - if (!activeDatasource || !visualization) { + if (!activeDatasource || !activeVisualization) { return; } @@ -208,13 +218,14 @@ export function EditorFrame(props: EditorFrameProps) { }), {} ), - visualization, + visualization: activeVisualization, state, framePublicAPI, }); props.onChange({ filterableIndexPatterns: indexPatterns, doc }); }, [ + activeVisualization, state.datasourceStates, state.visualization, props.query, @@ -248,6 +259,7 @@ export function EditorFrame(props: EditorFrameProps) { configPanel={ allLoaded && ( ({ + 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], + }; + + return { + state: { + activeDatasourceId: 'ds1', + datasourceStates: { + ds1: { + isLoading: false, + state: initialLayerIds.slice(0, 1), + }, + ds2: { + isLoading: false, + state: initialLayerIds.slice(1), + }, + }, + title: 'foo', + visualization: { + activeId: 'vis1', + state: initialLayerIds, + }, + }, + activeVisualization, + datasourceMap: { + ds1: testDatasource('ds1'), + ds2: testDatasource('ds2'), + }, + trackUiEvent, + }; +} + +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(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(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, + }); + + expect(newState.visualization.state).toEqual(['layer1', 'layer2', 'foo']); + expect(newState.datasourceStates.ds1.state).toEqual(['layer1', 'foo']); + expect(newState.datasourceStates.ds2.state).toEqual(['layer2']); + expect(trackUiEvent).toHaveBeenCalledWith('layer_added'); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.ts new file mode 100644 index 0000000000000..e0562e8ca8e11 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { EditorFrameState } from './state_management'; +import { Datasource, Visualization } from '../../types'; + +interface RemoveLayerOptions { + trackUiEvent: (name: string) => void; + state: EditorFrameState; + layerId: string; + activeVisualization: Pick; + datasourceMap: Record>; +} + +interface AppendLayerOptions { + trackUiEvent: (name: string) => void; + state: EditorFrameState; + generateId: () => string; + activeDatasource: Pick; + activeVisualization: Pick; +} + +export function removeLayer(opts: RemoveLayerOptions): EditorFrameState { + 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), + }, + }; +} + +export function appendLayer({ + trackUiEvent, + activeVisualization, + state, + generateId, + activeDatasource, +}: AppendLayerOptions): EditorFrameState { + 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), + }, + }; +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts index 5168059a33258..4aaf2a3ee9e81 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -119,6 +119,7 @@ describe('editor_frame state management', () => { }, { type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: 'testVis', newState: newVisState, } ); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts index 78a9a13f48d6a..7d763bcac2cc9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -31,6 +31,13 @@ export type Action = type: 'UPDATE_TITLE'; title: string; } + | { + type: 'UPDATE_STATE'; + // Just for diagnostics, so we can determine what action + // caused this update. + subType: string; + updater: (prevState: EditorFrameState) => EditorFrameState; + } | { type: 'UPDATE_DATASOURCE_STATE'; updater: unknown | ((prevState: unknown) => unknown); @@ -39,6 +46,7 @@ export type Action = } | { type: 'UPDATE_VISUALIZATION_STATE'; + visualizationId: string; newState: unknown; clearStagedPreview?: boolean; } @@ -128,6 +136,8 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta return action.state; case 'UPDATE_TITLE': return { ...state, title: action.title }; + case 'UPDATE_STATE': + return action.updater(state); case 'UPDATE_LAYER': return { ...state, @@ -249,6 +259,12 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta 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 !== action.visualizationId) { + return state; + } return { ...state, visualization: { diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts index 173f64c6292a8..eabcdfa7a24ab 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts @@ -10,7 +10,6 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Visualization, Datasource, - FramePublicAPI, TableChangeType, TableSuggestion, DatasourceSuggestion, @@ -130,7 +129,6 @@ function getVisualizationSuggestions( } export function switchToSuggestion( - frame: FramePublicAPI, dispatch: (action: Action) => void, suggestion: Pick< Suggestion, @@ -145,5 +143,6 @@ export function switchToSuggestion( datasourceState: suggestion.datasourceState, datasourceId: suggestion.datasourceId!, }; + dispatch(action); } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx index 2408d004689c9..46e226afe9c59 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx @@ -320,7 +320,7 @@ export function SuggestionPanel({ } else { trackSuggestionEvent(`position_${index}_of_${suggestions.length}`); setLastSelectedSuggestion(index); - switchToSuggestion(frame, dispatch, suggestion); + switchToSuggestion(dispatch, suggestion); } }} selected={index === lastSelectedSuggestion} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index fb3fe770b315b..74dacd50d7a15 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { ExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public'; -import { Visualization, FramePublicAPI, TableSuggestion } from '../../types'; +import { FramePublicAPI, TableSuggestion, Visualization } from '../../types'; import { createMockVisualization, createMockDatasource, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index 05dcafcaeba31..1058ccd81d669 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -126,12 +126,7 @@ export function InnerWorkspacePanel({ if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); trackUiEvent(expression ? 'drop_non_empty' : 'drop_empty'); - switchToSuggestion( - framePublicAPI, - dispatch, - suggestionForDraggedField, - 'SWITCH_VISUALIZATION' - ); + switchToSuggestion(dispatch, suggestionForDraggedField, 'SWITCH_VISUALIZATION'); } } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx index 5df6cc8106d6a..7257647d5953e 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -14,12 +14,14 @@ import { import { embeddablePluginMock } from '../../../../../../src/plugins/embeddable/public/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; -import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types'; +import { DatasourcePublicAPI, FramePublicAPI, Datasource, Visualization } from '../types'; import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin'; export function createMockVisualization(): jest.Mocked { return { id: 'TEST_VIS', + clearLayer: jest.fn((state, _layerId) => state), + getLayerIds: jest.fn(_state => ['layer1']), visualizationTypes: [ { icon: 'empty', @@ -32,7 +34,7 @@ export function createMockVisualization(): jest.Mocked { getPersistableState: jest.fn(_state => _state), getSuggestions: jest.fn(_options => []), initialize: jest.fn((_frame, _state?) => ({})), - renderConfigPanel: jest.fn(), + renderLayerConfigPanel: jest.fn(), toExpression: jest.fn((_state, _frame) => null), toPreviewExpression: jest.fn((_state, _frame) => null), }; @@ -52,7 +54,8 @@ export function createMockDatasource(): DatasourceMock { return { id: 'mockindexpattern', - getDatasourceSuggestionsForField: jest.fn((_state, item) => []), + clearLayer: jest.fn((state, _layerId) => state), + getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), getPersistableState: jest.fn(), getPublicAPI: jest.fn().mockReturnValue(publicAPIMock), diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index b58a2d8ca52c7..2426d7fc14b5d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -132,11 +132,7 @@ export function getIndexPatternDatasource({ ...state, layers: { ...state.layers, - [newLayerId]: { - indexPatternId: state.currentIndexPatternId, - columns: {}, - columnOrder: [], - }, + [newLayerId]: blankLayer(state.currentIndexPatternId), }, }; }, @@ -151,6 +147,16 @@ export function getIndexPatternDatasource({ }; }, + clearLayer(state: IndexPatternPrivateState, layerId: string) { + return { + ...state, + layers: { + ...state.layers, + [layerId]: blankLayer(state.currentIndexPatternId), + }, + }; + }, + getLayers(state: IndexPatternPrivateState) { return Object.keys(state.layers); }, @@ -280,3 +286,11 @@ export function getIndexPatternDatasource({ return indexPatternDatasource; } + +function blankLayer(indexPatternId: string) { + return { + indexPatternId, + columns: {}, + columnOrder: [], + }; +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx index ff2e55ac83dcc..a66239e5d30f6 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx @@ -38,6 +38,7 @@ describe('MetricConfigPanel', () => { const state = testState(); const component = mount( !op.isBucketed && op.dataType === 'number'; -export function MetricConfigPanel(props: VisualizationProps) { - const { state, frame } = props; - const [datasource] = Object.values(frame.datasourceLayers); - const [layerId] = Object.keys(frame.datasourceLayers); +export function MetricConfigPanel(props: VisualizationLayerConfigProps) { + const { state, frame, layerId } = props; + const datasource = frame.datasourceLayers[layerId]; return ( - - - - - - - - - + + + ); } diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts index a95b5a2b27631..c131612399cca 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts @@ -50,6 +50,22 @@ describe('metric_visualization', () => { }); }); + describe('#getLayerIds', () => { + it('returns the layer id', () => { + expect(metricVisualization.getLayerIds(exampleState())).toEqual(['l1']); + }); + }); + + describe('#clearLayer', () => { + it('returns a clean layer', () => { + (generateId as jest.Mock).mockReturnValueOnce('test-id1'); + expect(metricVisualization.clearLayer(exampleState(), 'l1')).toEqual({ + accessor: 'test-id1', + layerId: 'l1', + }); + }); + }); + describe('#getPersistableState', () => { it('persists the state as given', () => { expect(metricVisualization.getPersistableState(exampleState())).toEqual(exampleState()); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx index 00e945c0ce6e5..6714c05787837 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx @@ -54,6 +54,17 @@ export const metricVisualization: Visualization = { }, ], + clearLayer(state) { + return { + ...state, + accessor: generateId(), + }; + }, + + getLayerIds(state) { + return [state.layerId]; + }, + getDescription() { return { icon: chartMetricSVG, @@ -76,7 +87,7 @@ export const metricVisualization: Visualization = { getPersistableState: state => state, - renderConfigPanel: (domElement, props) => + renderLayerConfigPanel: (domElement, props) => render( diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index f83157b2a8000..923e0aff5ae0e 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -135,6 +135,7 @@ export interface Datasource { insertLayer: (state: T, newLayerId: string) => T; removeLayer: (state: T, layerId: string) => T; + clearLayer: (state: T, layerId: string) => T; getLayers: (state: T) => string[]; renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; @@ -237,7 +238,8 @@ export interface LensMultiTable { }; } -export interface VisualizationProps { +export interface VisualizationLayerConfigProps { + layerId: string; dragDropContext: DragContextState; frame: FramePublicAPI; state: T; @@ -325,6 +327,18 @@ export interface Visualization { visualizationTypes: VisualizationType[]; + getLayerIds: (state: T) => string[]; + + clearLayer: (state: T, layerId: string) => T; + + removeLayer?: (state: T, layerId: string) => T; + + appendLayer?: (state: T, layerId: string) => T; + + getLayerContextMenuIcon?: (opts: { state: T; layerId: string }) => IconType | undefined; + + renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerConfigProps) => void; + getDescription: ( state: T ) => { @@ -339,7 +353,7 @@ export interface Visualization { getPersistableState: (state: T) => P; - renderConfigPanel: (domElement: Element, props: VisualizationProps) => void; + renderLayerConfigPanel: (domElement: Element, props: VisualizationLayerConfigProps) => void; toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx index 5cdf1031a22b0..6ed827bc71c68 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -8,9 +8,9 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps } from '@elastic/eui'; -import { XYConfigPanel } from './xy_config_panel'; +import { XYConfigPanel, LayerContextMenu } from './xy_config_panel'; import { DatasourceDimensionPanelProps, Operation, FramePublicAPI } from '../types'; -import { State, XYState } from './types'; +import { State } from './types'; import { Position } from '@elastic/charts'; import { NativeRendererProps } from '../native_renderer'; import { generateId } from '../id_generator'; @@ -46,15 +46,6 @@ describe('XYConfigPanel', () => { .props(); } - function openComponentPopover(component: ReactWrapper, layerId: string) { - component - .find(`[data-test-subj="lnsXY_layer_${layerId}"]`) - .first() - .find(`[data-test-subj="lnsXY_layer_advanced"]`) - .first() - .simulate('click'); - } - beforeEach(() => { frame = createMockFramePublicAPI(); frame.datasourceLayers = { @@ -67,55 +58,55 @@ describe('XYConfigPanel', () => { test.skip('allows toggling the y axis gridlines', () => {}); test.skip('allows toggling the x axis gridlines', () => {}); - test('enables stacked chart types even when there is no split series', () => { - const state = testState(); - const component = mount( - - ); - - openComponentPopover(component, 'first'); - - const options = component - .find('[data-test-subj="lnsXY_seriesType"]') - .first() - .prop('options') as EuiButtonGroupProps['options']; + describe('LayerContextMenu', () => { + test('enables stacked chart types even when there is no split series', () => { + const state = testState(); + const component = mount( + + ); - expect(options!.map(({ id }) => id)).toEqual([ - 'bar', - 'bar_stacked', - 'line', - 'area', - 'area_stacked', - ]); + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') + .first() + .prop('options') as EuiButtonGroupProps['options']; - expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); - }); + expect(options!.map(({ id }) => id)).toEqual([ + 'bar', + 'bar_stacked', + 'line', + 'area', + 'area_stacked', + ]); - test('shows only horizontal bar options when in horizontal mode', () => { - const state = testState(); - const component = mount( - - ); + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + }); - openComponentPopover(component, 'first'); + test('shows only horizontal bar options when in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); - const options = component - .find('[data-test-subj="lnsXY_seriesType"]') - .first() - .prop('options') as EuiButtonGroupProps['options']; + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') + .first() + .prop('options') as EuiButtonGroupProps['options']; - expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']); - expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']); + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + }); }); test('the x dimension panel accepts only bucketed operations', () => { @@ -123,6 +114,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( { const state = testState(); const component = mount( { const state = testState(); const component = mount( { /> ); - openComponentPopover(component, 'first'); - const onRemove = component .find('[data-test-subj="lensXY_yDimensionPanel"]') .first() @@ -223,6 +215,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( { ], }); }); - - describe('layers', () => { - it('adds layers', () => { - frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); - (generateId as jest.Mock).mockReturnValue('accessor'); - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); - - component - .find('[data-test-subj="lnsXY_layer_add"]') - .first() - .simulate('click'); - - expect(frame.addNewLayer).toHaveBeenCalled(); - expect(setState).toHaveBeenCalledTimes(1); - expect(generateId).toHaveBeenCalledTimes(4); - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [ - ...state.layers, - expect.objectContaining({ - layerId: 'newLayerId', - xAccessor: 'accessor', - accessors: ['accessor'], - splitAccessor: 'accessor', - }), - ], - }); - }); - - it('should use series type of existing layers if they all have the same', () => { - frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); - frame.datasourceLayers.second = createMockDatasource().publicAPIMock; - (generateId as jest.Mock).mockReturnValue('accessor'); - const setState = jest.fn(); - const state: XYState = { - ...testState(), - preferredSeriesType: 'bar', - layers: [ - { - seriesType: 'line', - layerId: 'first', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - { - seriesType: 'line', - layerId: 'second', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - ], - }; - const component = mount( - - ); - - component - .find('[data-test-subj="lnsXY_layer_add"]') - .first() - .simulate('click'); - - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [ - ...state.layers, - expect.objectContaining({ - seriesType: 'line', - }), - ], - }); - }); - - it('should use preffered series type if there are already various different layers', () => { - frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); - frame.datasourceLayers.second = createMockDatasource().publicAPIMock; - (generateId as jest.Mock).mockReturnValue('accessor'); - const setState = jest.fn(); - const state: XYState = { - ...testState(), - preferredSeriesType: 'bar', - layers: [ - { - seriesType: 'area', - layerId: 'first', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - { - seriesType: 'line', - layerId: 'second', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - ], - }; - const component = mount( - - ); - - component - .find('[data-test-subj="lnsXY_layer_add"]') - .first() - .simulate('click'); - - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [ - ...state.layers, - expect.objectContaining({ - seriesType: 'bar', - }), - ], - }); - }); - - it('removes layers', () => { - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); - - openComponentPopover(component, 'first'); - - component - .find('[data-test-subj="lnsXY_layer_remove"]') - .first() - .simulate('click'); - - expect(frame.removeLayers).toHaveBeenCalled(); - expect(setState).toHaveBeenCalledTimes(1); - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [], - }); - }); - }); }); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx index f59b1520dbbb4..dbcfa24395001 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -5,25 +5,11 @@ */ import _ from 'lodash'; -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiButton, - EuiButtonGroup, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiPanel, - EuiButtonIcon, - EuiPopover, - EuiSpacer, - EuiButtonEmpty, - EuiPopoverFooter, - EuiToolTip, -} from '@elastic/eui'; -import { State, SeriesType, LayerConfig, visualizationTypes } from './types'; -import { VisualizationProps, OperationMetadata } from '../types'; +import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; +import { State, SeriesType, visualizationTypes } from './types'; +import { VisualizationLayerConfigProps, OperationMetadata } from '../types'; import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from '../multi_column_editor'; import { generateId } from '../id_generator'; @@ -45,253 +31,140 @@ function updateLayer(state: State, layer: UnwrapArray, index: n }; } -function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { - return { - layerId, - seriesType, - xAccessor: generateId(), - accessors: [generateId()], - splitAccessor: generateId(), - }; -} +export function LayerContextMenu(props: VisualizationLayerConfigProps) { + const { state, layerId } = props; + const horizontalOnly = isHorizontalChart(state.layers); + const index = state.layers.findIndex(l => l.layerId === layerId); + const layer = state.layers[index]; -function LayerSettings({ - layer, - horizontalOnly, - setSeriesType, - removeLayer, -}: { - layer: LayerConfig; - horizontalOnly: boolean; - setSeriesType: (seriesType: SeriesType) => void; - removeLayer: () => void; -}) { - const [isOpen, setIsOpen] = useState(false); - const { icon } = visualizationTypes.find(c => c.id === layer.seriesType)!; + if (!layer) { + return null; + } return ( - setIsOpen(!isOpen)} - data-test-subj="lnsXY_layer_advanced" - /> - } - isOpen={isOpen} - closePopover={() => setIsOpen(false)} - anchorPosition="leftUp" + - - isHorizontalSeries(t.id as SeriesType) === horizontalOnly) - .map(t => ({ - id: t.id, - label: t.label, - iconType: t.icon || 'empty', - }))} - idSelected={layer.seriesType} - onChange={seriesType => { - trackUiEvent('xy_change_layer_display'); - setSeriesType(seriesType as SeriesType); - }} - isIconOnly - buttonSize="compressed" - /> - - - - {i18n.translate('xpack.lens.xyChart.deleteLayer', { - defaultMessage: 'Delete layer', - })} - - - + name="chartType" + className="eui-displayInlineBlock" + data-test-subj="lnsXY_seriesType" + options={visualizationTypes + .filter(t => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map(t => ({ + id: t.id, + label: t.label, + iconType: t.icon || 'empty', + }))} + idSelected={layer.seriesType} + onChange={seriesType => { + trackUiEvent('xy_change_layer_display'); + props.setState( + updateLayer(state, { ...layer, seriesType: seriesType as SeriesType }, index) + ); + }} + isIconOnly + buttonSize="compressed" + /> + ); } -export function XYConfigPanel(props: VisualizationProps) { - const { state, setState, frame } = props; - const horizontalOnly = isHorizontalChart(state.layers); - - return ( - - {state.layers.map((layer, index) => ( - - - - - setState(updateLayer(state, { ...layer, seriesType }, index)) - } - removeLayer={() => { - trackUiEvent('xy_layer_removed'); - frame.removeLayers([layer.layerId]); - setState({ ...state, layers: state.layers.filter(l => l !== layer) }); - }} - /> - - - - - - +export function XYConfigPanel(props: VisualizationLayerConfigProps) { + const { state, setState, frame, layerId } = props; + const index = props.state.layers.findIndex(l => l.layerId === layerId); - + if (index < 0) { + return null; + } - - - - - - setState( - updateLayer( - state, - { - ...layer, - accessors: [...layer.accessors, generateId()], - }, - index - ) - ) - } - onRemove={accessor => - setState( - updateLayer( - state, - { - ...layer, - accessors: layer.accessors.filter(col => col !== accessor), - }, - index - ) - ) - } - filterOperations={isNumericMetric} - data-test-subj="lensXY_yDimensionPanel" - testSubj="lensXY_yDimensionPanel" - layerId={layer.layerId} - /> - - - - - - ))} + const layer = props.state.layers[index]; - - - { - trackUiEvent('xy_layer_added'); - const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType)); - setState({ - ...state, - layers: [ - ...state.layers, - newLayerState( - usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, - frame.addNewLayer() - ), - ], - }); - }} - iconType="plusInCircleFilled" - /> - - - + return ( + <> + + + + + + setState( + updateLayer( + state, + { + ...layer, + accessors: [...layer.accessors, generateId()], + }, + index + ) + ) + } + onRemove={accessor => + setState( + updateLayer( + state, + { + ...layer, + accessors: layer.accessors.filter(col => col !== accessor), + }, + index + ) + ) + } + filterOperations={isNumericMetric} + data-test-subj="lensXY_yDimensionPanel" + testSubj="lensXY_yDimensionPanel" + layerId={layer.layerId} + /> + + + + + ); } diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index db28e76f82946..89794ec74eaec 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -137,6 +137,54 @@ describe('xy_visualization', () => { }); }); + describe('#removeLayer', () => { + it('removes the specified layer', () => { + const prevState: State = { + ...exampleState(), + layers: [ + ...exampleState().layers, + { + layerId: 'second', + seriesType: 'area', + splitAccessor: 'e', + xAccessor: 'f', + accessors: ['g', 'h'], + }, + ], + }; + + expect(xyVisualization.removeLayer!(prevState, 'second')).toEqual(exampleState()); + }); + }); + + describe('#appendLayer', () => { + it('adds a layer', () => { + const layers = xyVisualization.appendLayer!(exampleState(), 'foo').layers; + expect(layers.length).toEqual(exampleState().layers.length + 1); + expect(layers[layers.length - 1]).toMatchObject({ layerId: 'foo' }); + }); + }); + + describe('#clearLayer', () => { + it('clears the specified layer', () => { + (generateId as jest.Mock).mockReturnValue('test_empty_id'); + const layer = xyVisualization.clearLayer(exampleState(), 'first').layers[0]; + expect(layer).toMatchObject({ + accessors: ['test_empty_id'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'test_empty_id', + xAccessor: 'test_empty_id', + }); + }); + }); + + describe('#getLayerIds', () => { + it('returns layerids', () => { + expect(xyVisualization.getLayerIds(exampleState())).toEqual(['first']); + }); + }); + describe('#toExpression', () => { let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 5ba77cb39d5f8..75d6fcc7d160b 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -11,9 +11,9 @@ import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; -import { XYConfigPanel } from './xy_config_panel'; +import { XYConfigPanel, LayerContextMenu } from './xy_config_panel'; import { Visualization } from '../types'; -import { State, PersistableState, SeriesType, visualizationTypes } from './types'; +import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types'; import { toExpression, toPreviewExpression } from './to_expression'; import { generateId } from '../id_generator'; import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; @@ -67,6 +67,40 @@ export const xyVisualization: Visualization = { visualizationTypes, + getLayerIds(state) { + return state.layers.map(l => l.layerId); + }, + + removeLayer(state, layerId) { + return { + ...state, + layers: state.layers.filter(l => l.layerId !== layerId), + }; + }, + + appendLayer(state, layerId) { + const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType)); + return { + ...state, + layers: [ + ...state.layers, + newLayerState( + usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, + layerId + ), + ], + }; + }, + + clearLayer(state, layerId) { + return { + ...state, + layers: state.layers.map(l => + l.layerId !== layerId ? l : newLayerState(state.preferredSeriesType, layerId) + ), + }; + }, + getDescription(state) { const { icon, label } = getDescription(state); const chartLabel = i18n.translate('xpack.lens.xyVisualization.chartLabel', { @@ -113,7 +147,7 @@ export const xyVisualization: Visualization = { getPersistableState: state => state, - renderConfigPanel: (domElement, props) => + renderLayerConfigPanel: (domElement, props) => render( @@ -121,6 +155,30 @@ export const xyVisualization: Visualization = { domElement ), + getLayerContextMenuIcon({ state, layerId }) { + const layer = state.layers.find(l => l.layerId === layerId); + return visualizationTypes.find(t => t.id === layer?.seriesType)?.icon; + }, + + renderLayerContextMenu(domElement, props) { + render( + + + , + domElement + ); + }, + toExpression, toPreviewExpression, }; + +function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { + return { + layerId, + seriesType, + xAccessor: generateId(), + accessors: [generateId()], + splitAccessor: generateId(), + }; +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ca49aea3ee885..36c3d4a6c1805 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6935,10 +6935,8 @@ "xpack.lens.xyChart.addLayerTooltip": "複数のレイヤーを使用すると、グラフタイプを組み合わせたり、別のインデックスパターンを可視化したりすることができます。", "xpack.lens.xyChart.chartTypeLabel": "チャートタイプ", "xpack.lens.xyChart.chartTypeLegend": "チャートタイプ", - "xpack.lens.xyChart.deleteLayer": "レイヤーを削除", "xpack.lens.xyChart.help": "X/Y チャート", "xpack.lens.xyChart.isVisible.help": "判例の表示・非表示を指定します。", - "xpack.lens.xyChart.layerSettings": "レイヤー設定を編集", "xpack.lens.xyChart.legend.help": "チャートの凡例を構成します。", "xpack.lens.xyChart.nestUnderRoot": "データセット全体", "xpack.lens.xyChart.position.help": "凡例の配置を指定します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ba6ff117c688d..a0c514a89feb0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6934,10 +6934,8 @@ "xpack.lens.xyChart.addLayerTooltip": "使用多个图层以组合图表类型或可视化不同的索引模式。", "xpack.lens.xyChart.chartTypeLabel": "图表类型", "xpack.lens.xyChart.chartTypeLegend": "图表类型", - "xpack.lens.xyChart.deleteLayer": "删除图层", "xpack.lens.xyChart.help": "X/Y 图表", "xpack.lens.xyChart.isVisible.help": "指定图例是否可见。", - "xpack.lens.xyChart.layerSettings": "编辑图层设置", "xpack.lens.xyChart.legend.help": "配置图表图例。", "xpack.lens.xyChart.nestUnderRoot": "整个数据集", "xpack.lens.xyChart.position.help": "指定图例位置。",