diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index a559c0a94465b..12cc6f3fb0f47 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -29,8 +29,8 @@ export class AppPlugin { const editorFrame = editorFrameSetup(); editorFrame.registerDatasource('indexpattern', indexPattern); - editorFrame.registerVisualization('xy', xyVisualization); - editorFrame.registerVisualization('datatable', datatableVisualization); + editorFrame.registerVisualization(xyVisualization); + editorFrame.registerVisualization(datatableVisualization); this.instance = editorFrame.createInstance({}); 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 65b52b8d7fa87..f94e46aa39ca7 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 @@ -20,7 +20,7 @@ jest.mock('../id_generator'); function mockFrame(): FramePublicAPI { return { addNewLayer: () => 'aaa', - removeLayer: () => {}, + removeLayers: () => {}, datasourceLayers: {}, }; } @@ -77,7 +77,7 @@ describe('Datatable Visualization', () => { dragDropContext={{ dragging: undefined, setDragging: () => {} }} frame={{ addNewLayer: jest.fn(), - removeLayer: jest.fn(), + removeLayers: jest.fn(), datasourceLayers: { a: datasource.publicAPIMock }, }} layer={layer} @@ -115,7 +115,7 @@ describe('Datatable Visualization', () => { dragDropContext={{ dragging: undefined, setDragging: () => {} }} frame={{ addNewLayer: jest.fn(), - removeLayer: jest.fn(), + removeLayers: jest.fn(), datasourceLayers: { a: datasource.publicAPIMock }, }} layer={layer} @@ -151,7 +151,7 @@ describe('Datatable Visualization', () => { dragDropContext={{ dragging: undefined, setDragging: () => {} }} frame={{ addNewLayer: jest.fn(), - removeLayer: jest.fn(), + removeLayers: jest.fn(), datasourceLayers: { a: datasource.publicAPIMock }, }} 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 fd960e9f08218..b81f3bc0a44f9 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 @@ -91,6 +91,29 @@ export const datatableVisualization: Visualization< DatatableVisualizationState, DatatableVisualizationState > = { + id: 'lnsDatatable', + + visualizationTypes: [ + { + id: 'lnsDatatable', + icon: 'visTable', + label: i18n.translate('xpack.lens.datatable.label', { + defaultMessage: 'Datatable', + }), + }, + ], + + getDescription(state) { + return { + icon: 'empty', + label: i18n.translate('xpack.lens.datatable.label', { + defaultMessage: 'Datatable', + }), + }; + }, + + switchVisualizationType: (_, state) => state, + initialize(frame, state) { const layerId = Object.keys(frame.datasourceLayers)[0] || frame.addNewLayer(); return ( @@ -137,7 +160,7 @@ export const datatableVisualization: Visualization< {props.state.layers.map(layer => ( - + ))} , diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.test.tsx new file mode 100644 index 0000000000000..1d5fb3563d744 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.test.tsx @@ -0,0 +1,339 @@ +/* + * 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 React from 'react'; +import { createMockVisualization, createMockFramePublicAPI } from '../mocks'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; +import { ChartSwitch } from './chart_switch'; +import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types'; +import { EuiKeyPadMenuItemButton } from '@elastic/eui'; + +describe('chart_switch', () => { + function generateVisualization(id: string): jest.Mocked { + return { + ...createMockVisualization(), + id, + visualizationTypes: [ + { + icon: 'empty', + id: `sub${id}`, + label: `Label ${id}`, + }, + ], + initialize: jest.fn((_frame, state?: unknown) => { + return state || `${id} initial state`; + }), + getSuggestions: jest.fn(options => { + return [ + { + score: 1, + title: '', + state: `suggestion ${id}`, + datasourceSuggestionId: options.tables[0].datasourceSuggestionId, + previewIcon: 'empty', + }, + ]; + }), + }; + } + + function mockVisualizations() { + return { + visA: generateVisualization('visA'), + visB: generateVisualization('visB'), + visC: { + ...generateVisualization('visC'), + visualizationTypes: [ + { + icon: 'empty', + id: 'subvisC1', + label: 'C1', + }, + { + icon: 'empty', + id: 'subvisC2', + label: 'C2', + }, + ], + }, + }; + } + + function mockFrame(layers: string[]) { + return { + ...createMockFramePublicAPI(), + datasourceLayers: layers.reduce( + (acc, layerId) => ({ + ...acc, + [layerId]: ({ + getTableSpec: jest.fn(() => { + return [{ columnId: 2 }]; + }), + getOperationForColumnId() { + return {}; + }, + } as unknown) as DatasourcePublicAPI, + }), + {} as Record + ), + } as FramePublicAPI; + } + + function showFlyout(component: ReactWrapper) { + component + .find('[data-test-subj="lnsChartSwitchPopover"]') + .first() + .simulate('click'); + } + + function switchTo(subType: string, component: ReactWrapper) { + showFlyout(component); + component + .find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`) + .first() + .simulate('click'); + } + + function getMenuItem(subType: string, component: ReactWrapper) { + showFlyout(component); + return component + .find(EuiKeyPadMenuItemButton) + .find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`) + .first(); + } + + it('should use suggested state if there is a suggestion from the target visualization', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(dispatch).toHaveBeenCalledWith({ + initialState: 'suggestion visB', + newVisualizationId: 'visB', + type: 'SWITCH_VISUALIZATION', + }); + }); + + it('should use initial state if there is no suggestion from the target visualization', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a']); + (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); + + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(dispatch).toHaveBeenCalledWith({ + initialState: 'visB initial state', + newVisualizationId: 'visB', + type: 'SWITCH_VISUALIZATION', + }); + }); + + it('should indicate data loss if not all layers will be used', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const frame = mockFrame(['a', 'b']); + + const component = mount( + + ); + + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('bolt'); + }); + + it('should indicate data loss if no data will be used', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a']); + + const component = mount( + + ); + + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('bolt'); + }); + + it('should not indicate data loss if there is no data', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a']); + (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); + + const component = mount( + + ); + + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toBeUndefined(); + }); + + it('should not indicate data loss if visualization is not changed', () => { + const dispatch = jest.fn(); + const removeLayers = jest.fn(); + const frame = { + ...mockFrame(['a', 'b', 'c']), + removeLayers, + }; + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn(() => 'therebedragons'); + + visualizations.visC.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).toBeUndefined(); + }); + + it('should remove unused layers', () => { + const removeLayers = jest.fn(); + const frame = { + ...mockFrame(['a', 'b', 'c']), + removeLayers, + }; + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(removeLayers).toHaveBeenCalledTimes(1); + expect(removeLayers).toHaveBeenCalledWith(['b', 'c']); + }); + + it('should not remove layers if the visualization is not changing', () => { + const dispatch = jest.fn(); + const removeLayers = jest.fn(); + const frame = { + ...mockFrame(['a', 'b', 'c']), + removeLayers, + }; + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn(() => 'therebedragons'); + + visualizations.visC.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + switchTo('subvisC2', component); + expect(removeLayers).not.toHaveBeenCalled(); + expect(switchVisualizationType).toHaveBeenCalledWith('subvisC2', 'therebegriffins'); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UPDATE_VISUALIZATION_STATE', + newState: 'therebedragons', + }); + }); + + it('should ensure the new visualization has the proper subtype', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn( + (visualizationType, state) => `${state} ${visualizationType}` + ); + + visualizations.visB.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(dispatch).toHaveBeenCalledWith({ + initialState: 'suggestion visB subvisB', + newVisualizationId: 'visB', + type: 'SWITCH_VISUALIZATION', + }); + }); + + it('should show all visualization types', () => { + const component = mount( + + ); + + showFlyout(component); + + const allDisplayed = ['subvisA', 'subvisB', 'subvisC1', 'subvisC2'].every( + subType => component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0 + ); + + expect(allDisplayed).toBeTruthy(); + }); +}); 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 new file mode 100644 index 0000000000000..08d5e8650c374 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx @@ -0,0 +1,243 @@ +/* + * 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 React, { useState, useMemo } from 'react'; +import { + EuiIcon, + EuiPopover, + EuiButton, + EuiPopoverTitle, + EuiKeyPadMenu, + EuiKeyPadMenuItemButton, +} from '@elastic/eui'; +import { flatten } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { Visualization, FramePublicAPI } from '../../types'; +import { Action } from './state_management'; + +interface VisualizationSelection { + visualizationId: string; + subVisualizationId: string; + getVisualizationState: () => unknown; + keptLayerId: string; + dataLoss: 'nothing' | 'layers' | 'everything'; +} + +interface Props { + dispatch: (action: Action) => void; + visualizationMap: Record; + visualizationId: string | null; + visualizationState: unknown; + framePublicAPI: FramePublicAPI; +} + +function dropUnusedLayers(frame: FramePublicAPI, layerId: string) { + // Remove any layers that are not used by the new visualization. If we don't do this, + // we get orphaned objects, and weird edge cases such as prompting the user that + // layers are going to be dropped, when the user is unaware of any extraneous layers. + const layerIds = Object.keys(frame.datasourceLayers).filter(id => { + return id !== layerId; + }); + frame.removeLayers(layerIds); +} + +function VisualizationSummary(props: Props) { + const visualization = props.visualizationMap[props.visualizationId || '']; + + if (!visualization) { + return ( + <> + {i18n.translate('xpack.lens.configPanel.chooseVisualization', { + defaultMessage: 'Choose a visualization', + })} + + ); + } + + const description = visualization.getDescription(props.visualizationState); + + return ( + <> + {description.icon && } + {description.label} + + ); +} + +export function ChartSwitch(props: Props) { + const [flyoutOpen, setFlyoutOpen] = useState(false); + + const commitSelection = (selection: VisualizationSelection) => { + setFlyoutOpen(false); + + if (selection.dataLoss === 'everything' || selection.dataLoss === 'layers') { + dropUnusedLayers(props.framePublicAPI, selection.keptLayerId); + } + + if (selection.visualizationId !== props.visualizationId) { + props.dispatch({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: selection.visualizationId, + initialState: selection.getVisualizationState(), + }); + } else { + props.dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + newState: selection.getVisualizationState(), + }); + } + }; + + function getSelection( + visualizationId: string, + subVisualizationId: string + ): VisualizationSelection { + const newVisualization = props.visualizationMap[visualizationId]; + const switchVisType = + props.visualizationMap[visualizationId].switchVisualizationType || + ((_type: string, initialState: unknown) => initialState); + if (props.visualizationId === visualizationId) { + return { + visualizationId, + subVisualizationId, + dataLoss: 'nothing', + keptLayerId: '', + getVisualizationState: () => switchVisType(subVisualizationId, props.visualizationState), + }; + } + + const layers = Object.entries(props.framePublicAPI.datasourceLayers); + const containsData = layers.some( + ([_layerId, datasource]) => datasource.getTableSpec().length > 0 + ); + + // get top ranked suggestion for all layers of current state + const topSuggestion = newVisualization + .getSuggestions({ + tables: layers.map(([layerId, datasource], index) => ({ + datasourceSuggestionId: index, + isMultiRow: true, + columns: datasource.getTableSpec().map(col => ({ + ...col, + operation: datasource.getOperationForColumnId(col.columnId)!, + })), + layerId, + })), + }) + .map(suggestion => ({ suggestion, layerId: layers[suggestion.datasourceSuggestionId][0] })) + .sort( + ({ suggestion: { score: scoreA } }, { suggestion: { score: scoreB } }) => scoreB - scoreA + )[0]; + + let dataLoss: VisualizationSelection['dataLoss'] = 'nothing'; + + if (!topSuggestion && containsData) { + dataLoss = 'everything'; + } else if (layers.length > 1 && containsData) { + dataLoss = 'layers'; + } + + return { + visualizationId, + subVisualizationId, + dataLoss, + getVisualizationState: topSuggestion + ? () => + switchVisType( + subVisualizationId, + newVisualization.initialize(props.framePublicAPI, topSuggestion.suggestion.state) + ) + : () => { + return switchVisType( + subVisualizationId, + newVisualization.initialize(props.framePublicAPI) + ); + }, + keptLayerId: topSuggestion ? topSuggestion.layerId : '', + }; + } + + const visualizationTypes = useMemo( + () => + flyoutOpen && + flatten( + Object.values(props.visualizationMap).map(v => + v.visualizationTypes.map(t => ({ + visualizationId: v.id, + ...t, + })) + ) + ).map(visualizationType => ({ + ...visualizationType, + selection: getSelection(visualizationType.visualizationId, visualizationType.id), + })), + [ + flyoutOpen, + props.visualizationMap, + props.framePublicAPI, + props.visualizationId, + props.visualizationState, + ] + ); + + return ( + <> + setFlyoutOpen(!flyoutOpen)} + data-test-subj="lnsChartSwitchPopover" + > + + + } + isOpen={flyoutOpen} + closePopover={() => setFlyoutOpen(false)} + anchorPosition="leftUp" + > + + {i18n.translate('xpack.lens.configPanel.chooseVisualization', { + defaultMessage: 'Choose a visualization', + })} + + + {(visualizationTypes || []).map(v => ( + {v.label}} + role="menuitem" + data-test-subj={`lnsChartSwitchPopover_${v.id}`} + onClick={() => commitSelection(v.selection)} + betaBadgeLabel={ + v.selection.dataLoss !== 'nothing' + ? i18n.translate('xpack.lens.chartSwitch.dataLossLabel', { + defaultMessage: 'Data loss', + }) + : undefined + } + betaBadgeTooltipContent={ + v.selection.dataLoss !== 'nothing' + ? i18n.translate('xpack.lens.chartSwitch.dataLossDescription', { + defaultMessage: 'Switching to this chart will lose some of the configuration', + }) + : undefined + } + betaBadgeIconType={v.selection.dataLoss !== 'nothing' ? 'bolt' : undefined} + > + + + ))} + + + + ); +} 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 eb269909a7d6a..7c7fc0906717b 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 @@ -5,11 +5,11 @@ */ import React, { useMemo, useContext, memo } from 'react'; -import { EuiSelect } from '@elastic/eui'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; -import { Visualization, FramePublicAPI, VisualizationSuggestion } from '../../types'; +import { Visualization, FramePublicAPI } from '../../types'; import { DragContext } from '../../drag_drop'; +import { ChartSwitch } from './chart_switch'; interface ConfigPanelWrapperProps { visualizationState: unknown; @@ -19,36 +19,6 @@ interface ConfigPanelWrapperProps { framePublicAPI: FramePublicAPI; } -function getSuggestedVisualizationState(frame: FramePublicAPI, visualization: Visualization) { - const datasources = Object.entries(frame.datasourceLayers); - - let results: VisualizationSuggestion[] = []; - - datasources.forEach(([layerId, datasource]) => { - const suggestions = visualization.getSuggestions({ - tables: [ - { - datasourceSuggestionId: 0, - isMultiRow: true, - columns: datasource.getTableSpec().map(col => ({ - ...col, - operation: datasource.getOperationForColumnId(col.columnId)!, - })), - layerId, - }, - ], - }); - - results = results.concat(suggestions); - }); - - if (!results.length) { - return visualization.initialize(frame); - } - - return visualization.initialize(frame, results[0].state); -} - export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const context = useContext(DragContext); const setVisualizationState = useMemo( @@ -63,24 +33,13 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config return ( <> - ({ - value: visualizationId, - text: visualizationId, - }))} - value={props.activeVisualizationId || undefined} - onChange={e => { - const newState = getSuggestedVisualizationState( - props.framePublicAPI, - props.visualizationMap[e.target.value] - ); - props.dispatch({ - type: 'SWITCH_VISUALIZATION', - newVisualizationId: e.target.value, - initialState: newState, - }); - }} + {props.activeVisualizationId && props.visualizationState !== null && (
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 e95e6c982a50c..24cb07e6d62f7 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 @@ -53,8 +53,28 @@ describe('editor_frame', () => { }; beforeEach(() => { - mockVisualization = createMockVisualization(); - mockVisualization2 = createMockVisualization(); + mockVisualization = { + ...createMockVisualization(), + id: 'testVis', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis', + label: 'TEST1', + }, + ], + }; + mockVisualization2 = { + ...createMockVisualization(), + id: 'testVis2', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis2', + label: 'TEST2', + }, + ], + }; mockDatasource = createMockDatasource(); mockDatasource2 = createMockDatasource(); @@ -223,7 +243,7 @@ describe('editor_frame', () => { expect(mockVisualization.initialize).toHaveBeenCalledWith({ datasourceLayers: {}, addNewLayer: expect.any(Function), - removeLayer: expect.any(Function), + removeLayers: expect.any(Function), }); }); @@ -255,6 +275,37 @@ describe('editor_frame', () => { expect(mockDatasource2.insertLayer).toHaveBeenCalledWith(initialState, expect.anything()); }); + it('should remove layer on active datasource on frame api call', async () => { + const initialState = { datasource2: '' }; + mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState)); + mockDatasource2.getLayers.mockReturnValue(['abc', 'def']); + mockDatasource2.removeLayer.mockReturnValue({ removed: true }); + act(() => { + mount( + + ); + }); + + await waitForPromises(); + + mockVisualization.initialize.mock.calls[0][0].removeLayers(['abc', 'def']); + + expect(mockDatasource2.removeLayer).toHaveBeenCalledWith(initialState, 'abc'); + expect(mockDatasource2.removeLayer).toHaveBeenCalledWith({ removed: true }, 'def'); + }); + it('should render data panel after initialization is complete', async () => { const initialState = {}; let databaseInitialized: ({}) => void; @@ -795,8 +846,27 @@ describe('editor_frame', () => { describe('switching', () => { let instance: ReactWrapper; + function switchTo(subType: string) { + act(() => { + instance + .find('[data-test-subj="lnsChartSwitchPopover"]') + .last() + .simulate('click'); + }); + + instance.update(); + + act(() => { + instance + .find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`) + .last() + .simulate('click'); + }); + } + beforeEach(async () => { mockDatasource.getLayers.mockReturnValue(['first']); + instance = mount( { }); it('should initialize other visualization on switch', async () => { - act(() => { - instance - .find('select[data-test-subj="visualization-switch"]') - .simulate('change', { target: { value: 'testVis2' } }); - }); + switchTo('testVis2'); expect(mockVisualization2.initialize).toHaveBeenCalled(); }); @@ -883,11 +949,7 @@ describe('editor_frame', () => { }, ]); - act(() => { - instance - .find('select[data-test-subj="visualization-switch"]') - .simulate('change', { target: { value: 'testVis2' } }); - }); + switchTo('testVis2'); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.initialize).toHaveBeenCalledWith(expect.anything(), initialState); @@ -900,11 +962,7 @@ describe('editor_frame', () => { it('should fall back when switching visualizations if the visualization has no suggested use', async () => { mockVisualization2.initialize.mockReturnValueOnce({ initial: true }); - act(() => { - instance - .find('select[data-test-subj="visualization-switch"]') - .simulate('change', { target: { value: 'testVis2' } }); - }); + switchTo('testVis2'); expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); 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 12ba3ff6e3192..09b2884b29b92 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 @@ -90,32 +90,32 @@ export function EditorFrame(props: EditorFrameProps) { const framePublicAPI: FramePublicAPI = { datasourceLayers, - addNewLayer: () => { - const newLayerId = generateId(); - const newState = props.datasourceMap[state.activeDatasourceId!].insertLayer( - state.datasourceStates[state.activeDatasourceId!].state, - newLayerId - ); + addNewLayer() { + const newLayerId = generateId(); dispatch({ - type: 'UPDATE_DATASOURCE_STATE', + type: 'UPDATE_LAYER', datasourceId: state.activeDatasourceId!, - newState, + layerId: newLayerId, + updater: props.datasourceMap[state.activeDatasourceId!].insertLayer, }); return newLayerId; }, - removeLayer: (layerId: string) => { - const newState = props.datasourceMap[state.activeDatasourceId!].removeLayer( - state.datasourceStates[state.activeDatasourceId!].state, - layerId - ); - - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - datasourceId: state.activeDatasourceId!, - newState, + removeLayers: (layerIds: string[]) => { + layerIds.forEach(layerId => { + const layerDatasourceId = Object.entries(props.datasourceMap).find( + ([datasourceId, datasource]) => + state.datasourceStates[datasourceId] && + datasource.getLayers(state.datasourceStates[datasourceId].state).includes(layerId) + )![0]; + dispatch({ + type: 'UPDATE_LAYER', + layerId, + datasourceId: layerDatasourceId, + updater: props.datasourceMap[layerDatasourceId].removeLayer, + }); }); }, }; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts index fe829c70cc063..67e5ef1f192ed 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts @@ -35,7 +35,7 @@ describe('save editor frame state', () => { activeDatasourceId: 'indexpattern', framePublicAPI: { addNewLayer: jest.fn(), - removeLayer: jest.fn(), + removeLayers: jest.fn(), datasourceLayers: { first: mockDatasource.publicAPIMock, }, 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 e02614ffb4be5..eccca61814303 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 @@ -153,6 +153,36 @@ describe('editor_frame state management', () => { expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); }); + it('should update the datasource state with passed in reducer', () => { + const layerReducer = jest.fn((_state, layerId) => ({ inserted: layerId })); + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + saving: false, + title: 'bbb', + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_LAYER', + layerId: 'abc', + updater: layerReducer, + datasourceId: 'testDatasource', + } + ); + + expect(newState.datasourceStates.testDatasource.state).toEqual({ inserted: 'abc' }); + expect(layerReducer).toHaveBeenCalledTimes(1); + }); + it('should should switch active visualization', () => { const testVisState = {}; const 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 004bbc45194a1..bfa52341b9495 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 @@ -46,6 +46,12 @@ export type Action = type: 'UPDATE_VISUALIZATION_STATE'; newState: unknown; } + | { + type: 'UPDATE_LAYER'; + layerId: string; + datasourceId: string; + updater: (state: unknown, layerId: string) => unknown; + } | { type: 'VISUALIZATION_LOADED'; doc: Document; @@ -97,6 +103,20 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta return { ...state, persistedId: action.id }; case 'UPDATE_TITLE': return { ...state, title: action.title }; + case 'UPDATE_LAYER': + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [action.datasourceId]: { + ...state.datasourceStates[action.datasourceId], + state: action.updater( + state.datasourceStates[action.datasourceId].state, + action.layerId + ), + }, + }, + }; case 'VISUALIZATION_LOADED': return { ...state, 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 ad30f84165d2c..cdd4f1bf07898 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 @@ -11,6 +11,16 @@ import { EditorFrameSetupPlugins } from './plugin'; export function createMockVisualization(): jest.Mocked { return { + id: 'TEST_VIS', + visualizationTypes: [ + { + icon: 'empty', + id: 'TEST_VIS', + label: 'TEST', + }, + ], + getDescription: jest.fn(_state => ({ label: '' })), + switchVisualizationType: jest.fn((_, x) => x), getPersistableState: jest.fn(_state => _state), getSuggestions: jest.fn(_options => []), initialize: jest.fn((_frame, _state?) => ({})), @@ -59,7 +69,7 @@ export function createMockFramePublicAPI(): FrameMock { return { datasourceLayers: {}, addNewLayer: jest.fn(() => ''), - removeLayer: jest.fn(), + removeLayers: jest.fn(), }; } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx index fac75fe421d59..99beee31463d5 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -146,8 +146,8 @@ export class EditorFramePlugin { registerDatasource: (name, datasource) => { this.datasources[name] = datasource as Datasource; }, - registerVisualization: (name, visualization) => { - this.visualizations[name] = visualization as Visualization; + registerVisualization: visualization => { + this.visualizations[visualization.id] = visualization as Visualization; }, }; } 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 5a43ebe8918a8..779d06a0a0bdb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -249,6 +249,7 @@ export function getIndexPatternDatasource({ removeLayer(state: IndexPatternPrivateState, layerId: string) { const newLayers = { ...state.layers }; delete newLayers[layerId]; + return { ...state, layers: newLayers, @@ -287,11 +288,9 @@ export function getIndexPatternDatasource({ return state.layers[layerId].columnOrder.map(colId => ({ columnId: colId })); }, getOperationForColumnId: (columnId: string) => { - const layer = Object.values(state.layers).find(l => - l.columnOrder.find(id => id === columnId) - ); + const layer = state.layers[layerId]; - if (layer) { + if (layer && layer.columns[columnId]) { return columnToOperation(layer.columns[columnId]); } return null; diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 39c0db880a8f3..387acd6612810 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -5,6 +5,7 @@ */ import { Ast } from '@kbn/interpreter/common'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { DragContextState } from './drag_drop'; // eslint-disable-next-line @@ -21,7 +22,7 @@ export interface EditorFrameSetup { createInstance: (options: EditorFrameOptions) => EditorFrameInstance; // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation registerDatasource: (name: string, datasource: Datasource) => void; - registerVisualization: (name: string, visualization: Visualization) => void; + registerVisualization: (visualization: Visualization) => void; } // Hints the default nesting to the data source. 0 is the highest priority @@ -186,10 +187,29 @@ export interface FramePublicAPI { datasourceLayers: Record; // Adds a new layer. This has a side effect of updating the datasource state addNewLayer: () => string; - removeLayer: (layerId: string) => void; + removeLayers: (layerIds: string[]) => void; +} + +export interface VisualizationType { + id: string; + icon?: EuiIconType | string; + label: string; } export interface Visualization { + id: string; + + visualizationTypes: VisualizationType[]; + + getDescription: ( + state: T + ) => { + icon?: EuiIconType | string; + label: string; + }; + + switchVisualizationType?: (visualizationTypeId: string, state: T) => T; + // For initializing from saved object initialize: (frame: FramePublicAPI, state?: P) => T; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts index c5650fde3ddac..2287ec3c3d1ef 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -5,10 +5,12 @@ */ import { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; import { ExpressionFunction, ArgumentType, } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { VisualizationType } from '..'; export interface LegendConfig { isVisible: boolean; @@ -177,6 +179,7 @@ export interface XYArgs { // Persisted parts of the state export interface XYState { + preferredSeriesType: SeriesType; legend: LegendConfig; layers: LayerConfig[]; isHorizontal: boolean; @@ -184,3 +187,41 @@ export interface XYState { export type State = XYState; export type PersistableState = XYState; + +export const visualizationTypes: VisualizationType[] = [ + { + id: 'bar', + icon: 'visBarVertical', + label: i18n.translate('xpack.lens.xyVisualization.barLabel', { + defaultMessage: 'Bar', + }), + }, + { + id: 'bar_stacked', + icon: 'visBarVertical', + label: i18n.translate('xpack.lens.xyVisualization.stackedBarLabel', { + defaultMessage: 'Stacked Bar', + }), + }, + { + id: 'line', + icon: 'visLine', + label: i18n.translate('xpack.lens.xyVisualization.lineLabel', { + defaultMessage: 'Line', + }), + }, + { + id: 'area', + icon: 'visArea', + label: i18n.translate('xpack.lens.xyVisualization.areaLabel', { + defaultMessage: 'Area', + }), + }, + { + id: 'area_stacked', + icon: 'visArea', + label: i18n.translate('xpack.lens.xyVisualization.stackedAreaLabel', { + defaultMessage: 'Stacked Area', + }), + }, +]; 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 4d772ca9a48ab..64ceddac2021d 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 @@ -10,7 +10,7 @@ import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps } from '@elastic/eui'; import { XYConfigPanel } from './xy_config_panel'; import { DatasourceDimensionPanelProps, Operation, FramePublicAPI } from '../types'; -import { State } from './types'; +import { State, XYState } from './types'; import { Position } from '@elastic/charts'; import { NativeRendererProps } from '../native_renderer'; import { generateId } from '../id_generator'; @@ -27,6 +27,7 @@ describe('XYConfigPanel', () => { function testState(): State { return { legend: { isVisible: true, position: Position.Right }, + preferredSeriesType: 'bar', isHorizontal: false, layers: [ { @@ -119,11 +120,11 @@ describe('XYConfigPanel', () => { .prop('options') as EuiButtonGroupProps['options']; expect(options.map(({ id }) => id)).toEqual([ + 'bar', + 'bar_stacked', 'line', 'area', - 'bar', 'area_stacked', - 'bar_stacked', ]); expect(options.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); @@ -294,6 +295,109 @@ describe('XYConfigPanel', () => { ], }); }); + + 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', + title: 'X', + accessors: ['bar'], + }, + { + seriesType: 'line', + layerId: 'second', + splitAccessor: 'baz', + xAccessor: 'foo', + title: 'Y', + 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', + title: 'X', + accessors: ['bar'], + }, + { + seriesType: 'line', + layerId: 'second', + splitAccessor: 'baz', + xAccessor: 'foo', + title: 'Y', + 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(); @@ -313,7 +417,7 @@ describe('XYConfigPanel', () => { .first() .simulate('click'); - expect(frame.removeLayer).toHaveBeenCalled(); + 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 f9d6cc01a29ca..03892abc84c3e 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import _ from 'lodash'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -15,56 +16,16 @@ import { EuiForm, EuiFormRow, EuiPanel, - EuiPopover, - IconType, EuiButtonIcon, - EuiIcon, + EuiPopover, EuiSwitch, } from '@elastic/eui'; -import { State, SeriesType, LayerConfig } from './types'; +import { State, SeriesType, LayerConfig, visualizationTypes } from './types'; import { VisualizationProps, OperationMetadata } from '../types'; import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from '../multi_column_editor'; import { generateId } from '../id_generator'; -export const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ - { - id: 'line', - label: i18n.translate('xpack.lens.xyVisualization.lineChartLabel', { - defaultMessage: 'Line', - }), - iconType: 'visLine', - }, - { - id: 'area', - label: i18n.translate('xpack.lens.xyVisualization.areaChartLabel', { - defaultMessage: 'Area', - }), - iconType: 'visArea', - }, - { - id: 'bar', - label: i18n.translate('xpack.lens.xyVisualization.barChartLabel', { - defaultMessage: 'Bar', - }), - iconType: 'visBarVertical', - }, - { - id: 'area_stacked', - label: i18n.translate('xpack.lens.xyVisualization.stackedAreaChartLabel', { - defaultMessage: 'Stacked Area', - }), - iconType: 'visArea', - }, - { - id: 'bar_stacked', - label: i18n.translate('xpack.lens.xyVisualization.stackedBarChartLabel', { - defaultMessage: 'Stacked Bar', - }), - iconType: 'visBarVertical', - }, -]; - const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; @@ -80,39 +41,95 @@ function updateLayer(state: State, layer: UnwrapArray, index: n }; } -function newLayerState(layerId: string): LayerConfig { +function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { return { layerId, + seriesType, xAccessor: generateId(), - seriesType: 'bar_stacked', accessors: [generateId()], title: '', splitAccessor: generateId(), }; } -interface LocalState { - isChartOptionsOpen: boolean; - openLayerId: string | null; +function LayerSettings({ + layer, + setSeriesType, + removeLayer, +}: { + layer: LayerConfig; + setSeriesType: (seriesType: SeriesType) => void; + removeLayer: () => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const { icon } = visualizationTypes.find(c => c.id === layer.seriesType)!; + + return ( + setIsOpen(!isOpen)} + data-test-subj="lnsXY_layer_advanced" + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="leftUp" + > + + ({ + ...t, + iconType: t.icon || 'empty', + }))} + idSelected={layer.seriesType} + onChange={seriesType => setSeriesType(seriesType as SeriesType)} + isIconOnly + /> + + + + {i18n.translate('xpack.lens.xyChart.removeLayer', { + defaultMessage: 'Remove layer', + })} + + + + ); } export function XYConfigPanel(props: VisualizationProps) { const { state, setState, frame } = props; - - const [localState, setLocalState] = useState({ - isChartOptionsOpen: false, - openLayerId: null, - } as LocalState); + const [isChartOptionsOpen, setIsChartOptionsOpen] = useState(false); return ( { - setLocalState({ ...localState, isChartOptionsOpen: false }); - }} + isOpen={isChartOptionsOpen} + closePopover={() => setIsChartOptionsOpen(false)} button={ <> @@ -120,9 +137,7 @@ export function XYConfigPanel(props: VisualizationProps) { iconType="gear" size="s" data-test-subj="lnsXY_chart_settings" - onClick={() => { - setLocalState({ ...localState, isChartOptionsOpen: true }); - }} + onClick={() => setIsChartOptionsOpen(true)} > ) { - { - setLocalState({ ...localState, openLayerId: null }); - }} - button={ - { - setLocalState({ ...localState, openLayerId: layer.layerId }); - }} - /> - } - > - <> - - { - setState( - updateLayer( - state, - { ...layer, seriesType: seriesType as SeriesType }, - index - ) - ); - }} - isIconOnly - /> - - - { - frame.removeLayer(layer.layerId); - setState({ ...state, layers: state.layers.filter(l => l !== layer) }); - }} - > - - - - - - - - icon.id === layer.seriesType)!.iconType} - /> + + + setState(updateLayer(state, { ...layer, seriesType }, index)) + } + removeLayer={() => { + frame.removeLayers([layer.layerId]); + setState({ ...state, layers: state.layers.filter(l => l !== layer) }); + }} + /> + @@ -238,7 +195,6 @@ export function XYConfigPanel(props: VisualizationProps) { /> - ) { }} /> - ) { }} /> - ) { size="s" data-test-subj={`lnsXY_layer_add`} onClick={() => { + const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType)); setState({ ...state, - layers: [...state.layers, newLayerState(frame.addNewLayer())], + layers: [ + ...state.layers, + newLayerState( + usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, + frame.addNewLayer() + ), + ], }); }} iconType="plusInCircle" diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index de122bcb0dd24..e3b0232382bd8 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -22,9 +22,8 @@ import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, IconType } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { LensMultiTable } from '../types'; -import { XYArgs, SeriesType } from './types'; +import { XYArgs, SeriesType, visualizationTypes } from './types'; import { RenderFunction } from '../interpreter_types'; -import { chartTypeIcons } from './xy_config_panel'; export interface XYChartProps { data: LensMultiTable; @@ -97,7 +96,7 @@ export const xyChartRenderer: RenderFunction = { }; function getIconForSeriesType(seriesType: SeriesType): IconType { - return chartTypeIcons.find(chartTypeIcon => chartTypeIcon.id === seriesType)!.iconType; + return visualizationTypes.find(c => c.id === seriesType)!.icon || 'empty'; } export function XYChart({ data, args }: XYChartProps) { diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts index b997745eb8fff..a7eb5f30b7128 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -12,7 +12,7 @@ import { TableSuggestionColumn, TableSuggestion, } from '../types'; -import { State } from './types'; +import { State, SeriesType } from './types'; import { generateId } from '../id_generator'; import { buildExpression } from './to_expression'; @@ -102,15 +102,18 @@ function getSuggestion( // TODO: Localize the title, label, etc const preposition = isDate ? 'over' : 'of'; const title = `${yTitle} ${preposition} ${xTitle}`; + const seriesType: SeriesType = + (currentState && currentState.preferredSeriesType) || (splitBy && isDate ? 'line' : 'bar'); const state: State = { isHorizontal: false, legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, + preferredSeriesType: seriesType, layers: [ ...(currentState ? currentState.layers.filter(layer => layer.layerId !== layerId) : []), { layerId, + seriesType, xAccessor: xValue.columnId, - seriesType: splitBy && isDate ? 'line' : 'bar', splitAccessor: splitBy ? splitBy.columnId : generateId(), accessors: yValues.map(col => col.columnId), title: yTitle, 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 d8854764ff898..1ee0e86f5b6b9 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 @@ -18,6 +18,7 @@ function exampleState(): State { return { isHorizontal: false, legend: { position: Position.Bottom, isVisible: true }, + preferredSeriesType: 'bar', layers: [ { layerId: 'first', @@ -67,6 +68,7 @@ describe('xy_visualization', () => { "isVisible": true, "position": "right", }, + "preferredSeriesType": "bar", "title": "Empty XY Chart", } `); 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 437c993a5dae4..f0b3c1a79ed2b 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 @@ -5,17 +5,70 @@ */ import React from 'react'; +import _ from 'lodash'; import { render } from 'react-dom'; 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 { Visualization } from '../types'; -import { State, PersistableState } from './types'; +import { State, PersistableState, SeriesType, visualizationTypes } from './types'; import { toExpression } from './to_expression'; import { generateId } from '../id_generator'; +const defaultIcon = 'visBarVertical'; +const defaultSeriesType = 'bar'; + +function getDescription(state?: State) { + if (!state) { + return { + icon: defaultIcon, + label: i18n.translate('xpack.lens.xyVisualization.xyLabel', { + defaultMessage: 'XY Chart', + }), + }; + } + + if (!state.layers.length) { + return visualizationTypes.find(v => v.id === state.preferredSeriesType)!; + } + + const visualizationType = visualizationTypes.find(t => t.id === state.layers[0].seriesType)!; + const seriesTypes = _.unique(state.layers.map(l => l.seriesType)); + + return { + icon: visualizationType.icon, + label: + seriesTypes.length === 1 + ? visualizationType.label + : i18n.translate('xpack.lens.xyVisualization.mixedLabel', { + defaultMessage: 'Mixed XY Chart', + }), + }; +} + export const xyVisualization: Visualization = { + id: 'lnsXY', + + visualizationTypes, + + getDescription(state) { + const { icon, label } = getDescription(state); + return { + icon: icon || defaultIcon, + label, + }; + }, + + switchVisualizationType(seriesType: string, state: State) { + return { + ...state, + preferredSeriesType: seriesType as SeriesType, + layers: state.layers.map(layer => ({ ...layer, seriesType: seriesType as SeriesType })), + }; + }, + getSuggestions, initialize(frame, state) { @@ -24,12 +77,13 @@ export const xyVisualization: Visualization = { title: 'Empty XY Chart', isHorizontal: false, legend: { isVisible: true, position: Position.Right }, + preferredSeriesType: defaultSeriesType, layers: [ { layerId: frame.addNewLayer(), accessors: [generateId()], position: Position.Top, - seriesType: 'bar', + seriesType: defaultSeriesType, showGridlines: false, splitAccessor: generateId(), title: '',