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 34f9f67f8b928..8802f06e00133 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 @@ -77,10 +77,15 @@ export function ChartSwitch(props: Props) { const commitSelection = (selection: VisualizationSelection) => { setFlyoutOpen(false); - switchToSuggestion(props.framePublicAPI, props.dispatch, { - ...selection, - visualizationState: selection.getVisualizationState(), - }); + switchToSuggestion( + props.framePublicAPI, + props.dispatch, + { + ...selection, + visualizationState: selection.getVisualizationState(), + }, + 'SWITCH_VISUALIZATION' + ); }; function getSelection( 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 67175a19237f5..4179a9455eefa 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 @@ -34,6 +34,7 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config props.dispatch({ type: 'UPDATE_VISUALIZATION_STATE', newState, + clearStagedPreview: false, }); }, [props.dispatch] diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx index ea4c909d75cbe..0edf79417882d 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx @@ -32,6 +32,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { type: 'UPDATE_DATASOURCE_STATE', updater, datasourceId: props.activeDatasource!, + clearStagedPreview: true, }); }, [props.dispatch, props.activeDatasource] 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 23ae8d4ad08e4..8023946d42127 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 @@ -1153,7 +1153,14 @@ describe('editor_frame', () => { .find('[data-test-subj="lnsSuggestion"]') .find(EuiPanel) .map(el => el.parents(EuiToolTip).prop('content')) - ).toEqual(['Suggestion1', 'Suggestion2', 'Suggestion3', 'Suggestion4', 'Suggestion5']); + ).toEqual([ + 'Current', + 'Suggestion1', + 'Suggestion2', + 'Suggestion3', + 'Suggestion4', + 'Suggestion5', + ]); }); it('should switch to suggested visualization', async () => { @@ -1196,7 +1203,7 @@ describe('editor_frame', () => { act(() => { instance .find('[data-test-subj="lnsSuggestion"]') - .first() + .at(2) .simulate('click'); }); 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 9d658cd2967d7..c0a2990015138 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 @@ -84,6 +84,7 @@ export function EditorFrame(props: EditorFrameProps) { type: 'UPDATE_DATASOURCE_STATE', datasourceId: id, updater: newState, + clearStagedPreview: true, }); }, layer @@ -265,6 +266,7 @@ export function EditorFrame(props: EditorFrameProps) { visualizationMap={props.visualizationMap} dispatch={dispatch} ExpressionRenderer={props.ExpressionRenderer} + stagedPreview={state.stagedPreview} /> ) } 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 27f315463f175..5104ace7c79a3 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 @@ -8,14 +8,18 @@ import { i18n } from '@kbn/i18n'; import { EditorFrameProps } from '../editor_frame'; import { Document } from '../../persistence/saved_object_store'; -export interface EditorFrameState { - persistedId?: string; - title: string; +export interface PreviewState { visualization: { activeId: string | null; state: unknown; }; datasourceStates: Record; +} + +export interface EditorFrameState extends PreviewState { + persistedId?: string; + title: string; + stagedPreview?: PreviewState; activeDatasourceId: string | null; } @@ -32,10 +36,12 @@ export type Action = type: 'UPDATE_DATASOURCE_STATE'; updater: unknown | ((prevState: unknown) => unknown); datasourceId: string; + clearStagedPreview?: boolean; } | { type: 'UPDATE_VISUALIZATION_STATE'; newState: unknown; + clearStagedPreview?: boolean; } | { type: 'UPDATE_LAYER'; @@ -59,6 +65,19 @@ export type Action = datasourceState: unknown; datasourceId: string; } + | { + type: 'SELECT_SUGGESTION'; + newVisualizationId: string; + initialState: unknown; + datasourceState: unknown; + datasourceId: string; + } + | { + type: 'ROLLBACK_SUGGESTION'; + } + | { + type: 'SUBMIT_SUGGESTION'; + } | { type: 'SWITCH_DATASOURCE'; newDatasourceId: string; @@ -176,6 +195,41 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta activeId: action.newVisualizationId, state: action.initialState, }, + stagedPreview: undefined, + }; + case 'SELECT_SUGGESTION': + return { + ...state, + datasourceStates: + 'datasourceId' in action && action.datasourceId + ? { + ...state.datasourceStates, + [action.datasourceId]: { + ...state.datasourceStates[action.datasourceId], + state: action.datasourceState, + }, + } + : state.datasourceStates, + visualization: { + ...state.visualization, + activeId: action.newVisualizationId, + state: action.initialState, + }, + stagedPreview: state.stagedPreview || { + datasourceStates: state.datasourceStates, + visualization: state.visualization, + }, + }; + case 'ROLLBACK_SUGGESTION': + return { + ...state, + ...(state.stagedPreview || {}), + stagedPreview: undefined, + }; + case 'SUBMIT_SUGGESTION': + return { + ...state, + stagedPreview: undefined, }; case 'UPDATE_DATASOURCE_STATE': return { @@ -190,6 +244,7 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta isLoading: false, }, }, + stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview, }; case 'UPDATE_VISUALIZATION_STATE': if (!state.visualization.activeId) { @@ -201,6 +256,7 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta ...state.visualization, state: action.newState, }, + stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview, }; default: return state; 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 270c279375088..29db5f37a6436 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 @@ -144,14 +144,15 @@ export function switchToSuggestion( suggestion: Pick< Suggestion, 'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' | 'keptLayerIds' - > + >, + type: 'SWITCH_VISUALIZATION' | 'SELECT_SUGGESTION' = 'SELECT_SUGGESTION' ) { const action: Action = { - type: 'SWITCH_VISUALIZATION', + type, newVisualizationId: suggestion.visualizationId, initialState: suggestion.visualizationState, datasourceState: suggestion.datasourceState, - datasourceId: suggestion.datasourceId, + datasourceId: suggestion.datasourceId!, }; dispatch(action); const layerIds = Object.keys(frame.datasourceLayers).filter(id => { diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss index c966bfcb80668..ee15fa64540f9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss @@ -10,25 +10,20 @@ } .lnsSuggestionsPanel__title { - margin: $euiSizeS 0 $euiSizeXS; + margin-left: $euiSizeXS / 2; } .lnsSuggestionsPanel__suggestions { @include euiScrollBar; @include lnsOverflowShadowHorizontal; padding-top: $euiSizeXS; - overflow-x: auto; + overflow-x: scroll; overflow-y: hidden; display: flex; // Padding / negative margins to make room for overflow shadow padding-left: $euiSizeXS; margin-left: -$euiSizeXS; - - // Add margin to the next of the same type - > * + * { - margin-left: $euiSizeS; - } } // These sizes also match canvas' page thumbnails for consistency @@ -39,8 +34,13 @@ $lnsSuggestionWidth: 150px; flex: 0 0 auto; width: $lnsSuggestionWidth !important; height: $lnsSuggestionHeight; - // Allows the scrollbar to stay flush to window - margin-bottom: $euiSize; + margin-right: $euiSizeS; + margin-left: $euiSizeXS / 2; + margin-bottom: $euiSizeXS / 2; +} + +.lnsSuggestionPanel__button-isSelected { + @include euiFocusRing; } .lnsSidebar__suggestionIcon { @@ -58,3 +58,15 @@ $lnsSuggestionWidth: 150px; pointer-events: none; margin: 0 $euiSizeS; } + +.lnsSuggestionChartWrapper--withLabel { + height: $lnsSuggestionHeight - $euiSizeL; +} + +.lnsSuggestionPanel__buttonLabel { + @include euiFontSizeXS; + display: block; + font-weight: $euiFontWeightBold; + text-align: center; + flex-grow: 0; +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx index 7302ea379eba8..0b172ff6df7af 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx @@ -14,14 +14,16 @@ import { DatasourceMock, createMockFramePublicAPI, } from '../mocks'; +import { act } from 'react-dom/test-utils'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; -import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; +import { InnerSuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; import { getSuggestions, Suggestion } from './suggestion_helpers'; -import { fromExpression } from '@kbn/interpreter/target/common'; import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; jest.mock('./suggestion_helpers'); +const getSuggestionsMock = getSuggestions as jest.Mock; + describe('suggestion_panel', () => { let mockVisualization: Visualization; let mockDatasource: DatasourceMock; @@ -40,7 +42,7 @@ describe('suggestion_panel', () => { expressionRendererMock = createExpressionRendererMock(); dispatchMock = jest.fn(); - (getSuggestions as jest.Mock).mockReturnValue([ + getSuggestionsMock.mockReturnValue([ { datasourceState: {}, previewIcon: 'empty', @@ -75,6 +77,7 @@ describe('suggestion_panel', () => { activeVisualizationId: 'vis', visualizationMap: { vis: mockVisualization, + vis2: createMockVisualization(), }, visualizationState: {}, dispatch: dispatchMock, @@ -84,27 +87,131 @@ describe('suggestion_panel', () => { }); it('should list passed in suggestions', () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper .find('[data-test-subj="lnsSuggestion"]') .find(EuiPanel) .map(el => el.parents(EuiToolTip).prop('content')) - ).toEqual(['Suggestion1', 'Suggestion2']); + ).toEqual(['Current', 'Suggestion1', 'Suggestion2']); + }); + + describe('uncommitted suggestions', () => { + let suggestionState: Pick< + SuggestionPanelProps, + 'datasourceStates' | 'activeVisualizationId' | 'visualizationState' + >; + let stagedPreview: SuggestionPanelProps['stagedPreview']; + beforeEach(() => { + suggestionState = { + datasourceStates: { + mock: { + isLoading: false, + state: {}, + }, + }, + activeVisualizationId: 'vis2', + visualizationState: {}, + }; + + stagedPreview = { + datasourceStates: defaultProps.datasourceStates, + visualization: { + state: defaultProps.visualizationState, + activeId: defaultProps.activeVisualizationId, + }, + }; + }); + + it('should not update suggestions if current state is moved to staged preview', () => { + const wrapper = mount(); + getSuggestionsMock.mockClear(); + wrapper.setProps({ + stagedPreview, + ...suggestionState, + }); + wrapper.update(); + expect(getSuggestionsMock).not.toHaveBeenCalled(); + }); + + it('should update suggestions if staged preview is removed', () => { + const wrapper = mount(); + getSuggestionsMock.mockClear(); + wrapper.setProps({ + stagedPreview, + ...suggestionState, + }); + wrapper.update(); + wrapper.setProps({ + stagedPreview: undefined, + ...suggestionState, + }); + wrapper.update(); + expect(getSuggestionsMock).toHaveBeenCalledTimes(1); + }); + + it('should highlight currently active suggestion', () => { + const wrapper = mount(); + + act(() => { + wrapper + .find('[data-test-subj="lnsSuggestion"]') + .at(2) + .simulate('click'); + }); + + wrapper.update(); + + expect( + wrapper + .find('[data-test-subj="lnsSuggestion"]') + .at(2) + .prop('className') + ).toContain('lnsSuggestionPanel__button-isSelected'); + }); + + it('should rollback suggestion if current panel is clicked', () => { + const wrapper = mount(); + + act(() => { + wrapper + .find('[data-test-subj="lnsSuggestion"]') + .at(2) + .simulate('click'); + }); + + wrapper.update(); + + act(() => { + wrapper + .find('[data-test-subj="lnsSuggestion"]') + .at(0) + .simulate('click'); + }); + + wrapper.update(); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'ROLLBACK_SUGGESTION', + }); + }); }); it('should dispatch visualization switch action if suggestion is clicked', () => { - const wrapper = mount(); + const wrapper = mount(); - wrapper - .find('[data-test-subj="lnsSuggestion"]') - .first() - .simulate('click'); + act(() => { + wrapper + .find('button[data-test-subj="lnsSuggestion"]') + .at(1) + .simulate('click'); + }); + wrapper.update(); expect(dispatchMock).toHaveBeenCalledWith( expect.objectContaining({ - type: 'SWITCH_VISUALIZATION', + type: 'SELECT_SUGGESTION', initialState: suggestion1State, }) ); @@ -113,12 +220,29 @@ describe('suggestion_panel', () => { it('should remove unused layers if suggestion is clicked', () => { defaultProps.frame.datasourceLayers.a = mockDatasource.publicAPIMock; defaultProps.frame.datasourceLayers.b = mockDatasource.publicAPIMock; - const wrapper = mount(); + const wrapper = mount( + + ); + + act(() => { + wrapper + .find('button[data-test-subj="lnsSuggestion"]') + .at(1) + .simulate('click'); + }); - wrapper - .find('[data-test-subj="lnsSuggestion"]') - .first() - .simulate('click'); + wrapper.update(); + + act(() => { + wrapper + .find('[data-test-subj="lensSubmitSuggestion"]') + .first() + .simulate('click'); + }); expect(defaultProps.frame.removeLayers).toHaveBeenCalledWith(['b']); }); @@ -141,18 +265,18 @@ describe('suggestion_panel', () => { visualizationState: suggestion2State, visualizationId: 'vis', title: 'Suggestion2', - previewExpression: 'test | expression', }, ] as Suggestion[]); + (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce(undefined); + (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression'); mockDatasource.toExpression.mockReturnValue('datasource_expression'); - mount(); + mount(); expect(expressionRendererMock).toHaveBeenCalledTimes(1); - const passedExpression = fromExpression( - (expressionRendererMock as jest.Mock).mock.calls[0][0].expression - ); + const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression; + expect(passedExpression).toMatchInlineSnapshot(` Object { "chain": Array [ @@ -163,6 +287,7 @@ describe('suggestion_panel', () => { }, Object { "arguments": Object { + "filters": Array [], "query": Array [ "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", ], @@ -212,7 +337,7 @@ describe('suggestion_panel', () => { it('should render render icon if there is no preview expression', () => { mockDatasource.getLayers.mockReturnValue(['first']); - (getSuggestions as jest.Mock).mockReturnValue([ + getSuggestionsMock.mockReturnValue([ { datasourceState: {}, previewIcon: 'visTable', @@ -232,9 +357,15 @@ describe('suggestion_panel', () => { }, ] as Suggestion[]); + (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce(undefined); + (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression'); + + // this call will go to the currently active visualization + (mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('current | preview'); + mockDatasource.toExpression.mockReturnValue('datasource_expression'); - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find(EuiIcon)).toHaveLength(1); expect(wrapper.find(EuiIcon).prop('type')).toEqual('visTable'); 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 ad073913930cc..cff7eed0d8703 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 @@ -4,14 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import _ from 'lodash'; +import React, { useState, useEffect, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip, EuiToolTip } from '@elastic/eui'; -import { toExpression, Ast } from '@kbn/interpreter/common'; +import { + EuiIcon, + EuiTitle, + EuiPanel, + EuiIconTip, + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; -import { Action } from './state_management'; -import { Datasource, Visualization, FramePublicAPI } from '../../types'; -import { getSuggestions, Suggestion, switchToSuggestion } from './suggestion_helpers'; +import classNames from 'classnames'; +import { Action, PreviewState } from './state_management'; +import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types'; +import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; @@ -38,45 +49,48 @@ export interface SuggestionPanelProps { dispatch: (action: Action) => void; ExpressionRenderer: ExpressionRenderer; frame: FramePublicAPI; + stagedPreview?: PreviewState; } const SuggestionPreview = ({ - suggestion, - dispatch, - frame, - previewExpression, + preview, ExpressionRenderer: ExpressionRendererComponent, + selected, + onSelect, + showTitleAsLabel, }: { - suggestion: Suggestion; - dispatch: (action: Action) => void; - frame: FramePublicAPI; + onSelect: () => void; + preview: { + expression?: string | Ast; + icon: string; + title: string; + }; ExpressionRenderer: ExpressionRenderer; - previewExpression?: string; + selected: boolean; + showTitleAsLabel?: boolean; }) => { const [expressionError, setExpressionError] = useState(false); useEffect(() => { setExpressionError(false); - }, [previewExpression]); - - const clickHandler = () => { - switchToSuggestion(frame, dispatch, suggestion); - }; + }, [preview.expression]); return ( - + {expressionError ? (
- ) : previewExpression ? ( + ) : preview.expression ? ( { // eslint-disable-next-line no-console console.error(`Failed to render preview: `, e); @@ -96,18 +112,23 @@ const SuggestionPreview = ({ }} /> ) : ( -
- -
+ + + + )} + {showTitleAsLabel && ( + {preview.title} )}
); }; -export const SuggestionPanel = debouncedComponent(InnerSuggestionPanel, 2000); +// TODO this little debounce value is just here to showcase the feature better, +// will be fixed in suggestion performance PR +export const SuggestionPanel = debouncedComponent(InnerSuggestionPanel, 200); -function InnerSuggestionPanel({ +export function InnerSuggestionPanel({ activeDatasourceId, datasourceMap, datasourceStates, @@ -117,70 +138,243 @@ function InnerSuggestionPanel({ dispatch, frame, ExpressionRenderer: ExpressionRendererComponent, + stagedPreview, }: SuggestionPanelProps) { - if (!activeDatasourceId) { - return null; - } + const currentDatasourceStates = stagedPreview ? stagedPreview.datasourceStates : datasourceStates; + const currentVisualizationState = stagedPreview + ? stagedPreview.visualization.state + : visualizationState; + const currentVisualizationId = stagedPreview + ? stagedPreview.visualization.activeId + : activeVisualizationId; + + const { suggestions, currentStateExpression } = useMemo(() => { + const newSuggestions = getSuggestions({ + datasourceMap, + datasourceStates: currentDatasourceStates, + visualizationMap, + activeVisualizationId: currentVisualizationId, + visualizationState: currentVisualizationState, + }) + .map(suggestion => ({ + ...suggestion, + previewExpression: preparePreviewExpression( + suggestion, + visualizationMap[suggestion.visualizationId], + datasourceMap, + currentDatasourceStates, + frame + ), + })) + .filter(suggestion => !suggestion.hide) + .slice(0, MAX_SUGGESTIONS_DISPLAYED); - const suggestions = getSuggestions({ + const newStateExpression = + currentVisualizationState && currentVisualizationId + ? preparePreviewExpression( + { visualizationState: currentVisualizationState }, + visualizationMap[currentVisualizationId], + datasourceMap, + currentDatasourceStates, + frame + ) + : undefined; + + return { suggestions: newSuggestions, currentStateExpression: newStateExpression }; + }, [ + currentDatasourceStates, + currentVisualizationState, + currentVisualizationId, datasourceMap, - datasourceStates, visualizationMap, - activeVisualizationId, - visualizationState, - }) - .filter(suggestion => !suggestion.hide) - .slice(0, MAX_SUGGESTIONS_DISPLAYED); + ]); + + const [lastSelectedSuggestion, setLastSelectedSuggestion] = useState(-1); + + useEffect(() => { + // if the staged preview is overwritten by a suggestion, + // reset the selected index to "current visualization" because + // we are not in transient suggestion state anymore + if (!stagedPreview && lastSelectedSuggestion !== -1) { + setLastSelectedSuggestion(-1); + } + }, [stagedPreview]); + + if (!activeDatasourceId) { + return null; + } if (suggestions.length === 0) { return null; } + function rollbackToCurrentVisualization() { + if (lastSelectedSuggestion !== -1) { + setLastSelectedSuggestion(-1); + dispatch({ + type: 'ROLLBACK_SUGGESTION', + }); + } + } + + const expressionContext = { + query: frame.query, + timeRange: { + from: frame.dateRange.fromDate, + to: frame.dateRange.toDate, + }, + }; + return (
- -

- -

-
+ + + +

+ +

+
+
+ {stagedPreview && ( + + { + dispatch({ + type: 'SUBMIT_SUGGESTION', + }); + }} + > + {i18n.translate('xpack.lens.sugegstion.confirmSuggestionLabel', { + defaultMessage: 'Confirm and reload suggestions', + })} + + + )} +
+
- {suggestions.map((suggestion: Suggestion) => ( + {currentVisualizationId && ( - ))} + )} + {suggestions.map((suggestion, index) => { + return ( + { + if (lastSelectedSuggestion === index) { + rollbackToCurrentVisualization(); + } else { + setLastSelectedSuggestion(index); + switchToSuggestion(frame, dispatch, suggestion); + } + }} + selected={index === lastSelectedSuggestion} + /> + ); + })}
); } +interface VisualizableState { + visualizationState: unknown; + datasourceState?: unknown; + datasourceId?: string; + keptLayerIds?: string[]; +} + +function getPreviewExpression( + visualizableState: VisualizableState, + visualization: Visualization, + datasources: Record, + frame: FramePublicAPI +) { + if (!visualization.toPreviewExpression) { + return null; + } + + const suggestionFrameApi: FramePublicAPI = { + ...frame, + datasourceLayers: { ...frame.datasourceLayers }, + }; + + // use current frame api and patch apis for changed datasource layers + if ( + visualizableState.keptLayerIds && + visualizableState.datasourceId && + visualizableState.datasourceState + ) { + const datasource = datasources[visualizableState.datasourceId]; + const datasourceState = visualizableState.datasourceState; + const updatedLayerApis: Record = _.pick( + frame.datasourceLayers, + visualizableState.keptLayerIds + ); + const changedLayers = datasource.getLayers(visualizableState.datasourceState); + changedLayers.forEach(layerId => { + if (updatedLayerApis[layerId]) { + updatedLayerApis[layerId] = datasource.getPublicAPI(datasourceState, () => {}, layerId); + } + }); + } + + return visualization.toPreviewExpression( + visualizableState.visualizationState, + suggestionFrameApi + ); +} + function preparePreviewExpression( - expression: string | Ast, + visualizableState: VisualizableState, + visualization: Visualization, datasourceMap: Record>, datasourceStates: Record, - framePublicAPI: FramePublicAPI, - suggestionDatasourceId?: string, - suggestionDatasourceState?: unknown + framePublicAPI: FramePublicAPI ) { + const suggestionDatasourceId = visualizableState.datasourceId; + const suggestionDatasourceState = visualizableState.datasourceState; + + const expression = getPreviewExpression( + visualizableState, + visualization, + datasourceMap, + framePublicAPI + ); + + if (!expression) { + return; + } + const expressionWithDatasource = prependDatasourceExpression( expression, datasourceMap, @@ -195,15 +389,5 @@ function preparePreviewExpression( : datasourceStates ); - const expressionContext = { - query: framePublicAPI.query, - timeRange: { - from: framePublicAPI.dateRange.fromDate, - to: framePublicAPI.dateRange.toDate, - }, - }; - - return expressionWithDatasource - ? toExpression(prependKibanaContext(expressionWithDatasource, expressionContext)) - : undefined; + return expressionWithDatasource; } 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 81777f3593dc0..02214adbe0092 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 @@ -76,7 +76,12 @@ export function InnerWorkspacePanel({ function onDrop() { if (suggestionForDraggedField) { - switchToSuggestion(framePublicAPI, dispatch, suggestionForDraggedField); + switchToSuggestion( + framePublicAPI, + 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 02e93e4631284..97e1fe8393fc3 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 @@ -30,6 +30,7 @@ export function createMockVisualization(): jest.Mocked { initialize: jest.fn((_frame, _state?) => ({})), renderConfigPanel: jest.fn(), toExpression: jest.fn((_state, _frame) => null), + toPreviewExpression: jest.fn((_state, _frame) => null), }; } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx index c7e33640dee87..57003f84bc06c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx @@ -674,7 +674,7 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions).toHaveLength(0); }); - it('appends a terms column after the last existing bucket column on string field', () => { + it('prepends a terms column on string field', () => { const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { field: { name: 'dest', type: 'string', aggregatable: true, searchable: true }, indexPatternId: '1', @@ -686,7 +686,7 @@ describe('IndexPattern Data Source suggestions', () => { layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['col1', 'newId', 'col2'], + columnOrder: ['newId', 'col1', 'col2'], columns: { ...initialState.layers.currentLayer.columns, newId: expect.objectContaining({ diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts index fb2bfd1783864..76a1034d9a3e6 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts @@ -204,7 +204,7 @@ function addFieldAsBucketOperation( }; let updatedColumnOrder: string[] = []; if (applicableBucketOperation === 'terms') { - updatedColumnOrder = [...buckets, newColumnId, ...metrics]; + updatedColumnOrder = [newColumnId, ...buckets, ...metrics]; } else { const oldDateHistogramColumn = layer.columnOrder.find( columnId => layer.columns[columnId].operationType === 'date_histogram' @@ -392,16 +392,21 @@ function createChangedNestingSuggestion(state: IndexPatternPrivateState, layerId state, layerId, updatedLayer, - label: i18n.translate('xpack.lens.indexpattern.suggestions.nestingChangeLabel', { - defaultMessage: 'Nest within {operation}', - values: { - operation: layer.columns[secondBucket].label, - }, - }), + label: getNestedTitle([layer.columns[secondBucket], layer.columns[firstBucket]]), changeType: 'extended', }); } +function getNestedTitle([outerBucket, innerBucket]: IndexPatternColumn[]) { + return i18n.translate('xpack.lens.indexpattern.suggestions.nestingChangeLabel', { + defaultMessage: '{innerOperation} per each {outerOperation}', + values: { + innerOperation: innerBucket.label, + outerOperation: hasField(outerBucket) ? outerBucket.sourceField : outerBucket.label, + }, + }); +} + function createAlternativeMetricSuggestions( indexPattern: IndexPattern, layerId: string, diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx index 722be9048e775..aed8893620402 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx @@ -49,7 +49,7 @@ describe('AutoScale', () => { ) ).toMatchInlineSnapshot(`
{ maxWidth: '100%', maxHeight: '100%', overflow: 'hidden', + lineHeight: 1.5, }} >
{ expect(rest).toHaveLength(0); expect(suggestion).toMatchInlineSnapshot(` Object { - "previewExpression": Object { - "chain": Array [ - Object { - "arguments": Object { - "accessor": Array [ - "bytes", - ], - "mode": Array [ - "reduced", - ], - "title": Array [ - "", - ], - }, - "function": "lens_metric_chart", - "type": "function", - }, - ], - "type": "expression", - }, "previewIcon": "visMetric", "score": 0.5, "state": Object { diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts index 9cf5133527183..6d4ed21c0983b 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts @@ -41,20 +41,6 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion { title, score: 0.5, previewIcon: 'visMetric', - previewExpression: { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_metric_chart', - arguments: { - title: [''], - accessor: [col.columnId], - mode: ['reduced'], - }, - }, - ], - }, state: { layerId: table.layerId, accessor: col.columnId, 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 b6de912089c4b..a95b5a2b27631 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 @@ -38,11 +38,11 @@ describe('metric_visualization', () => { expect(initialState.accessor).toBeDefined(); expect(initialState).toMatchInlineSnapshot(` - Object { - "accessor": "test-id1", - "layerId": "l42", - } - `); + Object { + "accessor": "test-id1", + "layerId": "l42", + } + `); }); it('loads from persisted state', () => { @@ -83,6 +83,9 @@ describe('metric_visualization', () => { "accessor": Array [ "a", ], + "mode": Array [ + "full", + ], "title": Array [ "shazm", ], 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 f178b2bd4fe5e..d17c77e64c21a 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 @@ -8,12 +8,37 @@ import React from 'react'; import { render } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { Ast } from '@kbn/interpreter/target/common'; import { getSuggestions } from './metric_suggestions'; import { MetricConfigPanel } from './metric_config_panel'; -import { Visualization } from '../types'; +import { Visualization, FramePublicAPI } from '../types'; import { State, PersistableState } from './types'; import { generateId } from '../id_generator'; +const toExpression = ( + state: State, + frame: FramePublicAPI, + mode: 'reduced' | 'full' = 'full' +): Ast => { + const [datasource] = Object.values(frame.datasourceLayers); + const operation = datasource && datasource.getOperationForColumnId(state.accessor); + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_metric_chart', + arguments: { + title: [(operation && operation.label) || ''], + accessor: [state.accessor], + mode: [mode], + }, + }, + ], + }; +}; + export const metricVisualization: Visualization = { id: 'lnsMetric', @@ -57,22 +82,7 @@ export const metricVisualization: Visualization = { domElement ), - toExpression(state, frame) { - const [datasource] = Object.values(frame.datasourceLayers); - const operation = datasource && datasource.getOperationForColumnId(state.accessor); - - return { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_metric_chart', - arguments: { - title: [(operation && operation.label) || ''], - accessor: [state.accessor], - }, - }, - ], - }; - }, + toExpression, + toPreviewExpression: (state: State, frame: FramePublicAPI) => + toExpression(state, frame, 'reduced'), }; diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index 2b2c46bf5cd2a..7945d439f75cd 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -270,11 +270,6 @@ export interface VisualizationSuggestion { * The new state of the visualization if this suggestion is applied. */ state: T; - /** - * The expression of the preview of the chart rendered if the suggestion is advertised to the user. - * If there is no expression provided, the preview icon is used. - */ - previewExpression?: Ast | string; /** * An EUI icon type shown instead of the preview expression. */ @@ -323,6 +318,12 @@ export interface Visualization { toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; + /** + * Epression to render a preview version of the chart in very constraint space. + * If there is no expression provided, the preview icon is used. + */ + toPreviewExpression?: (state: T, frame: FramePublicAPI) => Ast | string | null; + // The frame will call this function on all visualizations when the table changes, or when // rendering additional ways of using the data getSuggestions: (context: SuggestionRequest) => Array>; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss index ec94ebdf235b0..81675e01d9dad 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss @@ -1,3 +1,11 @@ .lnsChart { height: 100%; } + +.lnsChart__empty { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts index bbb27bae778b2..0bc316c96287d 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts @@ -76,6 +76,21 @@ export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => ); }; +export function toPreviewExpression(state: State, frame: FramePublicAPI) { + return toExpression( + { + ...state, + layers: state.layers.map(layer => ({ ...layer, hide: true })), + // hide legend for preview + legend: { + ...state.legend, + isVisible: false, + }, + }, + frame + ); +} + export function getScaleType(metadata: OperationMetadata | null, defaultScale: ScaleType) { if (!metadata) { return defaultScale; 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 77853c38d075b..c212815ca4258 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 @@ -19,7 +19,7 @@ import { } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, IconType } from '@elastic/eui'; +import { EuiIcon, EuiText, IconType } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; @@ -129,19 +129,17 @@ export function XYChart({ if (Object.values(data.tables).every(table => table.rows.length === 0)) { const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; return ( - - + +

- - - - - - - +

+

+ +

+
); } diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts index ececea6a1d99f..41abf99e987a5 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -13,7 +13,6 @@ import { } from '../types'; import { State, XYState } from './types'; import { generateId } from '../id_generator'; -import { Ast } from '@kbn/interpreter/target/common'; jest.mock('../id_generator'); @@ -65,6 +64,10 @@ describe('xy_suggestions', () => { })); } + beforeEach(() => { + jest.resetAllMocks(); + }); + test('ignores invalid combinations', () => { const unknownCol = () => { const str = strCol('foo'); @@ -114,17 +117,17 @@ describe('xy_suggestions', () => { expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "area", - "splitAccessor": "aaa", - "x": "date", - "y": Array [ - "bytes", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "area_stacked", + "splitAccessor": "aaa", + "x": "date", + "y": Array [ + "bytes", + ], + }, + ] + `); }); test('does not suggest multiple splits', () => { @@ -158,18 +161,18 @@ describe('xy_suggestions', () => { expect(rest).toHaveLength(0); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "area", - "splitAccessor": "product", - "x": "date", - "y": Array [ - "price", - "quantity", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "area_stacked", + "splitAccessor": "product", + "x": "date", + "y": Array [ + "price", + "quantity", + ], + }, + ] + `); }); test('uses datasource provided title if available', () => { @@ -254,7 +257,7 @@ describe('xy_suggestions', () => { state: currentState, }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(1); expect(suggestion.state).toEqual({ ...currentState, preferredSeriesType: 'area', @@ -290,7 +293,7 @@ describe('xy_suggestions', () => { state: currentState, }); - expect(rest).toHaveLength(0); + expect(rest).toHaveLength(1); expect(suggestion.state).toEqual({ ...currentState, isHorizontal: true, @@ -298,6 +301,85 @@ describe('xy_suggestions', () => { expect(suggestion.title).toEqual('Flip'); }); + test('suggests a stacked chart for unchanged table and unstacked chart', () => { + (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); + (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); + const currentState: XYState = { + isHorizontal: false, + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + accessors: ['price', 'quantity'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'dummyCol', + xAccessor: 'product', + }, + ], + }; + const suggestion = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + })[1]; + + expect(suggestion.state).toEqual({ + ...currentState, + preferredSeriesType: 'bar_stacked', + layers: [ + { + ...currentState.layers[0], + seriesType: 'bar_stacked', + }, + ], + }); + expect(suggestion.title).toEqual('Stacked'); + }); + + test('keeps column to dimension mappings on extended tables', () => { + (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); + const currentState: XYState = { + isHorizontal: false, + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + accessors: ['price', 'quantity'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'dummyCol', + xAccessor: 'product', + }, + ], + }; + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), strCol('product'), strCol('category')], + layerId: 'first', + changeType: 'extended', + }, + state: currentState, + }); + + expect(rest).toHaveLength(0); + expect(suggestion.state).toEqual({ + ...currentState, + layers: [ + { + ...currentState.layers[0], + xAccessor: 'product', + splitAccessor: 'category', + }, + ], + }); + }); + test('handles two numeric values', () => { (generateId as jest.Mock).mockReturnValueOnce('ddd'); const [suggestion] = getSuggestions({ @@ -310,17 +392,17 @@ describe('xy_suggestions', () => { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "bar", - "splitAccessor": "ddd", - "x": "quantity", - "y": Array [ - "price", - ], - }, - ] - `); + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": "ddd", + "x": "quantity", + "y": Array [ + "price", + ], + }, + ] + `); }); test('handles unbucketed suggestions', () => { @@ -345,36 +427,16 @@ describe('xy_suggestions', () => { }); expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` - Array [ - Object { - "seriesType": "bar", - "splitAccessor": "eee", - "x": "mybool", - "y": Array [ - "num votes", - ], - }, - ] - `); - }); - - test('adds a preview expression with disabled axes and legend', () => { - const [suggestion] = getSuggestions({ - table: { - isMultiRow: true, - columns: [numCol('bytes'), dateCol('date')], - layerId: 'first', - changeType: 'unchanged', - }, - }); - - const expression = suggestion.previewExpression! as Ast; - - expect( - (expression.chain[0].arguments.legend[0] as Ast).chain[0].arguments.isVisible[0] - ).toBeFalsy(); - expect( - (expression.chain[0].arguments.layers[0] as Ast).chain[0].arguments.hide[0] - ).toBeTruthy(); + Array [ + Object { + "seriesType": "bar_stacked", + "splitAccessor": "eee", + "x": "mybool", + "y": Array [ + "num votes", + ], + }, + ] + `); }); }); 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 3ffd15067e73c..e447c9d7eb366 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 @@ -13,12 +13,10 @@ import { VisualizationSuggestion, TableSuggestionColumn, TableSuggestion, - OperationMetadata, TableChangeType, } from '../types'; import { State, SeriesType, XYState } from './types'; import { generateId } from '../id_generator'; -import { buildExpression } from './to_expression'; const columnSortOrder = { date: 0, @@ -63,27 +61,24 @@ export function getSuggestions({ return []; } - const suggestion = getSuggestionForColumns(table, state); + const suggestions = getSuggestionForColumns(table, state); - if (suggestion) { - return [suggestion]; + if (suggestions && suggestions instanceof Array) { + return suggestions; } - return []; + return suggestions ? [suggestions] : []; } function getSuggestionForColumns( table: TableSuggestion, currentState?: State -): VisualizationSuggestion | undefined { - const [buckets, values] = partition( - prioritizeColumns(table.columns), - col => col.operation.isBucketed - ); +): VisualizationSuggestion | Array> | undefined { + const [buckets, values] = partition(table.columns, col => col.operation.isBucketed); if (buckets.length === 1 || buckets.length === 2) { - const [x, splitBy] = buckets; - return getSuggestion( + const [x, splitBy] = getBucketMappings(table, currentState); + return getSuggestionsForLayer( table.layerId, table.changeType, x, @@ -93,8 +88,8 @@ function getSuggestionForColumns( table.label ); } else if (buckets.length === 0) { - const [x, ...yValues] = values; - return getSuggestion( + const [x, ...yValues] = prioritizeColumns(values); + return getSuggestionsForLayer( table.layerId, table.changeType, x, @@ -106,6 +101,40 @@ function getSuggestionForColumns( } } +function getBucketMappings(table: TableSuggestion, currentState?: State) { + const currentLayer = + currentState && currentState.layers.find(({ layerId }) => layerId === table.layerId); + + const buckets = table.columns.filter(col => col.operation.isBucketed); + // reverse the buckets before prioritization to always use the most inner + // bucket of the highest-prioritized group as x value (don't use nested + // buckets as split series) + const prioritizedBuckets = prioritizeColumns(buckets.reverse()); + + if (!currentLayer || table.changeType === 'initial') { + return prioritizedBuckets; + } + + // if existing table is just modified, try to map buckets to the current dimensions + const currentXColumnIndex = prioritizedBuckets.findIndex( + ({ columnId }) => columnId === currentLayer.xAccessor + ); + if (currentXColumnIndex) { + const [x] = prioritizedBuckets.splice(currentXColumnIndex, 1); + prioritizedBuckets.unshift(x); + } + + const currentSplitColumnIndex = prioritizedBuckets.findIndex( + ({ columnId }) => columnId === currentLayer.splitAccessor + ); + if (currentSplitColumnIndex) { + const [splitBy] = prioritizedBuckets.splice(currentSplitColumnIndex, 1); + prioritizedBuckets.push(splitBy); + } + + return prioritizedBuckets; +} + // This shuffles columns around so that the left-most column defualts to: // date, string, boolean, then number, in that priority. We then use this // order to pluck out the x column, and the split / stack column. @@ -115,7 +144,7 @@ function prioritizeColumns(columns: TableSuggestionColumn[]) { ); } -function getSuggestion( +function getSuggestionsForLayer( layerId: string, changeType: TableChangeType, xValue: TableSuggestionColumn, @@ -123,7 +152,7 @@ function getSuggestion( splitBy?: TableSuggestionColumn, currentState?: State, tableLabel?: string -): VisualizationSuggestion { +): VisualizationSuggestion | Array> { const title = getSuggestionTitle(yValues, xValue, tableLabel); const seriesType: SeriesType = getSeriesType(currentState, layerId, xValue, changeType); const isHorizontal = currentState ? currentState.isHorizontal : false; @@ -146,30 +175,68 @@ function getSuggestion( return buildSuggestion(options); } + const sameStateSuggestions: Array> = []; + // if current state is using the same data, suggest same chart with different presentational configuration if (xValue.operation.scale === 'ordinal') { // flip between horizontal/vertical for ordinal scales - return buildSuggestion({ - ...options, - title: i18n.translate('xpack.lens.xySuggestions.flipTitle', { defaultMessage: 'Flip' }), - isHorizontal: !options.isHorizontal, - }); + sameStateSuggestions.push( + buildSuggestion({ + ...options, + title: i18n.translate('xpack.lens.xySuggestions.flipTitle', { defaultMessage: 'Flip' }), + isHorizontal: !options.isHorizontal, + }) + ); + } else { + // change chart type for interval or ratio scales on x axis + const newSeriesType = flipSeriesType(seriesType); + sameStateSuggestions.push( + buildSuggestion({ + ...options, + seriesType: newSeriesType, + title: newSeriesType.startsWith('area') + ? i18n.translate('xpack.lens.xySuggestions.areaChartTitle', { + defaultMessage: 'Area chart', + }) + : i18n.translate('xpack.lens.xySuggestions.barChartTitle', { + defaultMessage: 'Bar chart', + }), + }) + ); } - // change chart type for interval or ratio scales on x axis - const newSeriesType = flipSeriesType(seriesType); - return buildSuggestion({ - ...options, - seriesType: newSeriesType, - title: newSeriesType.startsWith('area') - ? i18n.translate('xpack.lens.xySuggestions.areaChartTitle', { - defaultMessage: 'Area chart', - }) - : i18n.translate('xpack.lens.xySuggestions.barChartTitle', { - defaultMessage: 'Bar chart', - }), - }); + // flip between stacked/unstacked + sameStateSuggestions.push( + buildSuggestion({ + ...options, + seriesType: toggleStackSeriesType(seriesType), + title: seriesType.endsWith('stacked') + ? i18n.translate('xpack.lens.xySuggestions.unstackedChartTitle', { + defaultMessage: 'Unstacked', + }) + : i18n.translate('xpack.lens.xySuggestions.stackedChartTitle', { + defaultMessage: 'Stacked', + }), + }) + ); + + return sameStateSuggestions; +} + +function toggleStackSeriesType(oldSeriesType: SeriesType) { + switch (oldSeriesType) { + case 'area': + return 'area_stacked'; + case 'area_stacked': + return 'area'; + case 'bar': + return 'bar_stacked'; + case 'bar_stacked': + return 'bar'; + default: + return oldSeriesType; + } } function flipSeriesType(oldSeriesType: SeriesType) { @@ -193,7 +260,7 @@ function getSeriesType( xValue: TableSuggestionColumn, changeType: TableChangeType ): SeriesType { - const defaultType = xValue.operation.dataType === 'date' ? 'area' : 'bar'; + const defaultType = xValue.operation.dataType === 'date' ? 'area_stacked' : 'bar_stacked'; if (changeType === 'initial') { return defaultType; } else { @@ -258,7 +325,7 @@ function buildSuggestion({ xValue: TableSuggestionColumn; splitBy: TableSuggestionColumn | undefined; layerId: string; - changeType: string; + changeType: TableChangeType; }) { const newLayer = { ...(getExistingLayer(currentState, layerId) || {}), @@ -281,54 +348,25 @@ function buildSuggestion({ return { title, - // chart with multiple y values and split series will have a score of 1, single y value and no split series reduce score - score: ((yValues.length > 1 ? 2 : 1) + (splitBy ? 1 : 0)) / 3, + score: getScore(yValues, splitBy, changeType), // don't advertise chart of same type but with less data hide: currentState && changeType === 'reduced', state, previewIcon: getIconForSeries(seriesType), - previewExpression: buildPreviewExpression(state, layerId, xValue, yValues, splitBy), }; } -function buildPreviewExpression( - state: XYState, - layerId: string, - xValue: TableSuggestionColumn, +function getScore( yValues: TableSuggestionColumn[], - splitBy: TableSuggestionColumn | undefined + splitBy: TableSuggestionColumn | undefined, + changeType: TableChangeType ) { - return buildExpression( - { - ...state, - // only show changed layer in preview and hide axes - layers: state.layers - .filter(layer => layer.layerId === layerId) - .map(layer => ({ ...layer, hide: true })), - // hide legend for preview - legend: { - ...state.legend, - isVisible: false, - }, - }, - { [layerId]: collectColumnMetaData(xValue, yValues, splitBy) } - ); + // Unchanged table suggestions half the score because the underlying data doesn't change + const changeFactor = changeType === 'unchanged' ? 0.5 : 1; + // chart with multiple y values and split series will have a score of 1, single y value and no split series reduce score + return (((yValues.length > 1 ? 2 : 1) + (splitBy ? 1 : 0)) / 3) * changeFactor; } function getExistingLayer(currentState: XYState | undefined, layerId: string) { return currentState && currentState.layers.find(layer => layer.layerId === layerId); } - -function collectColumnMetaData( - xValue: TableSuggestionColumn, - yValues: TableSuggestionColumn[], - splitBy: TableSuggestionColumn | undefined -) { - const metadata: Record = {}; - [xValue, ...yValues, splitBy].forEach(col => { - if (col) { - metadata[col.columnId] = col.operation; - } - }); - return metadata; -} 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 15a34abf12651..14c35c21c7333 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 @@ -14,7 +14,7 @@ import { getSuggestions } from './xy_suggestions'; import { XYConfigPanel } from './xy_config_panel'; import { Visualization } from '../types'; import { State, PersistableState, SeriesType, visualizationTypes } from './types'; -import { toExpression } from './to_expression'; +import { toExpression, toPreviewExpression } from './to_expression'; import { generateId } from '../id_generator'; const defaultIcon = 'visBarVertical'; @@ -104,4 +104,5 @@ export const xyVisualization: Visualization = { ), toExpression, + toPreviewExpression, };