[Lens] Transient suggestions (#44234)

* [lens] Initial Commit (#35627)

* [visualization editor] Initial Commit

* [lens] Add more complete initial state

* [lens] Fix type issues

* [lens] Remove feature control

* [lens] Bring back feature control and add tests

* [lens] Update plugin structure and naming per comments

* replace any usage by safe casting

* [lens] Respond to review comments

* [lens] Remove unused EditorFrameState type

* [lens] Initial state for IndexPatternDatasource (#36052)

* [lens] Add first tests to indexpattern data source

* Respond to review comments

* Fix type definitions

* [lens] Editor frame initializes datasources and visualizations (#36060)

* [lens] Editor frame initializes datasources and visualizations

* Respond to review comments

* Fix build issues

* Fix state management issue

* [lens][draft] Lens/drag drop (#36268)

Add basic drag / drop component to Lens

* remove local package (#36456)

* [lens] Native renderer (#36165)

* Add nativerenderer component

* Use native renderer in app and editor frame

* [Lens] No explicit any (#36515)

* [Lens] Implement basic editor frame state handling (#36443)

* [lens] Load index patterns and render in data panel (#36463)

* [lens] Editor frame initializes datasources and visualizations

* Respond to review comments

* Fix build issues

* remove local package

* [lens] Load index patterns into data source

* Redo types for Index Pattern Datasource

* Fix one more type

* Respond to review comments

* [draft] Lens/line chart renderer (#36827)

Expression logic for the Lens xy chart.

* [lens] Index pattern data panel (initial) (#37015)

* [lens] Index pattern switcher

* Respond to review comments

* [Lens] Editor state 2 (#36513)

* [lens] Dimension panel that generates columns (#37117)

* [lens] Dimension panel that generates columns

* Update from review comments

* [lens] Generate esdocs queries from index pattern (#37361)

* [lens] Generate esdocs queries from index pattern

* Remove unused code

* Update yarn.lock from yarn kbn bootstrap

* [Lens] Add basic Lens xy chart suggestions (#37030)

Basic xy chart suggestions

* [Lens] Expression rendering (#37648)

* [Lens] Expression handling (#37876)

* [Lens] Lens/xy config panel (#37391)

Basic xy chart configuration panel

* [Lens] Xy expression building (#37967)

* [Lens] Initialize visualization with datasource api (#38142)

* [lens] Dimension panel lets users select operations and fields individually (#37573)

* [lens] Dimension panel lets users select operations and fields individually

* Split files and add tests

* Fix dimension labeling and add clear button

* Support more aggregations, aggregation nesting, rollups, and clearing

* Fix esaggs expression

* Increase top-level test coverage of dimension panel

* Update from review comments

* [Lens] Rename columns (#38278)

* [Lens] Lens/index pattern drag drop (#37711)

* Basic xy chart suggestions

* Re-apply XY config panel after force merge

* Initial integration of lens drag and drop

* Tweak naming, remove irellevant comment

* Tweaks per Wylie's feedback

* Add xy chart internationalization
Tweak types per Joe's feedback

* Update xy chart i18n implementation

* Fix i18n id

* Add drop tests to the lens index pattern

* improve tests

* [lens] Only allow aggregated dimensions (#38820)

* [lens] Only allow aggregated dimensions

* [lens] Index pattern suggest on drop

* Fully remove value

* Revert "[lens] Index pattern suggest on drop"

This reverts commit 604c6ed68c.

* Fix type errors

* [lens] Suggest on drop (#38848)

* [lens] Index pattern suggest on drop

* Add test for suggestion without date field

* fix merge

* [Lens] Parameter configurations and new dimension config flow (#38863)

* fix eslint failure

* [lens] Fix build by updating saved objects and i18n (#39391)

* [lens] Update location of saved objects code

* Update internatationalization

* Remove added file

* Lens basic metric visualization

* [lens] Fix arguments to esaggs using booleans (#39462)

* [lens] Datatable visualization plugin (#39390)

* [lens] Datatable visualization plugin

* Fix merge issues and add tests

* Update from review

* Fix file locations

* Fix merge issues, localize expression help text

* Add auto-scaling to the lens metric visualization

* Fix unit tests broken by autoscale

* Move autoscale to the new Lens folder

* [lens] Use first suggestion when switching visualizations (#39377)

* [lens] Label each Y axis with its operation label (#39461)

* [lens] Label each Y axis with its operation label

* Remove comment

* Add link to chart issue

* [Lens] Suggestion preview rendering (#39576)

* [Lens] Popover configs (#39565)

* [Lens] Basic layouting (#39587)

* remove datasource public API in suggestions (#39772)

* [Lens] Basic save / load (#39257)

Add basic routing, save, and load to Lens

* [lens] Fix lint error

* [lens] Use node scripts/eslint.js --fix to fix errors

* [lens] Include link to lens from Visualize (#40542)

* [lens] Support stacking in xy visualization (#40546)

* [lens] Support stacking in xy visualization

* Use chart type switcher for stacked and horizontal xy charts

* Clean up remaining isStacked code

* Fix type error

* [Lens] Add xy split series support (#39726)

* Add split series to lens xy chart

* [lens] Lens Filter Ratio (#40196)

* WIP filter ratio

* Fix test issues

* Pass dependencies through plugin like new platform

* Pass props into filter ratio popover editor

* Provide mocks to filter_ratio popover test

* Add another test

* Clean up to prepare for review

* Clean up unnecessary changes

* Respond to review comments

* Fix tests

* [Lens] Terms order direction (#39884)

* fix types

* [Lens] Data panel styling and optimizations (#40787)

Style the data panel (mostly Joe Reuter's doing). Optimize a bunch of the Lens stack.

* Add metric preview icon

* Fix metric vis tests

* Fix metric plugin imports

* Use the operation label as the metric title

* [Lens] Optimize dimension panel flow (#41114)

* [Lens] re-introduce no-explicit-any (#41454)

* [Lens] No results marker (#41450)

* [lens] Support layers for visualizing results of multiple queries (#41290)

* [lens] WIP add support for layers

* [lens] WIP switch to nested tables

* Get basic layering to work

* Load multiple tables and render in one chart

* Fix priority ordering

* Reduce quantity of linting errors

* Ensure that new xy layer state has a split column

* Make the "add" y / split the trailing accessor

* Various fixes for datasource public API and implementation

* Unify datasource deletion and accessor removal

* Fix broken scss

* Fix xy visualization TypeScript errors?

* Build basic suggestions

* Restore save/load and fix typescript bugs

* simplify init routine

* fix save tests

* fix persistence tests

* fix state management tests

* Ensure the data table is aligned to the top

* Add layer support to Lens datatable

* Give xy chart a default layer initially

* Allow deletion of layers in xy charts

* xy: Make split accessor singular
Remove commented code blocks

* Change expression type for lens_merge_tables

* Fix XY chart rendering expression

* Fix type errors relating to `layerId` in table suggestions

* Pass around tables for suggestions with associated layerIds

* fix tests in workspace panel

* fix editor_frame tests

* Fix xy tests, skip inapplicable tests
that will be implemented in a separate PR

* add some tests for multiple datasources and layers

* Suggest that split series comes before X axis in XY chart

* Get datatable suggestion working

* Adjust how xy axis labels are computed

* Datasource suggestions should handle layers and have tests

* Fix linting in XY chart and remove commented code

* Update snapshots from earlier change

* Fix linting errors

* More cleanup

* Remove commented code

* Test the multi-column editor

* XY Visualization does not need to track datasourceId

* Fix various comments

* Remove unused xy prop
Add datasource header to datatable config

* Use operation labels for XY chart

* Adding and removing layers is reflected in the datasource

* rewrote datasource state init

* clean up editor_frame frame api implementation

* clean up editor frame

* [Lens] Embeddable (#41361)

* [lens] Move XY chart config into popover and fix layering (#41927)

* [lens] Move XY chart config into popover and fix layering

* Fix tests

* Update style

* Change wrapping of layer settings popover

* [Lens] Fix bugs in date_histogram and filter ratio (#42046)

* [Lens] Performance improvements (#41784)

* fix type error

* switch default size of terms operation to 3 (#42334)

* [lens] Improve suggestions for split series (#42052)

* [lens] Add chart switcher (#42093)

* solve merge conflicts

* fix test case

* [Lens] Allow only current visualization on field drop in workspace (#42344)

* [Lens] Remove indexpattern id on column (#42429)

* [lens] Implement app-level filtering and time picker (#42031)

* [lens] Implement app-level filtering and time picker

* More integration with filter bar

* Clean up test code and type errors

* Add frame level tests for syncing with app

* Add test coverage for app logic

* Simplify state management from app down

* Fix import errors

* Clarify whether properties are ids or titles for index pattern

* pass new saved object by ref

* add dirty state checking

* Fix tests

* [Lens] Add some tests around document handling in dimension panel (#42670)

* [Lens] Terms operation boolean support (#42817)

* [lens] Minor UX/UI improvements in Lens (#42852)

* Make dimension popover toggle when clicking button

* Without suggestions hide suggestion panel

* Add missing translations (#42921)

* [Lens] Config panel design (#42980)

* Fix up design of config panel

Does not include config popover

* Add metric suggestions, fix tests

* Remove a couple of non-null assertions (#43013)

* Remove a couple of non-null assertions

* Remove orphaned import

* [Lens] Switch indexpattern manually (#42599)

* [Lens] Update frame to put suggestions at the bottom (#42997)

* Back out suggestion changes, in lieu
of Joe's work

* fix type errors

* switch indexpattern on layer if there is only a single empty one (#43079)

* [Lens] Suggest reduced versions of current data table (#42537)

* [Lens] Field formatter support (#38874)

* Fix bugs

* Fix metric autoscale logic

* Register metric as an embeddable

* Fix metric autoscale flicker

* Render mini metric in suggestions panel

* Cache the metric filterOperations function

* fix auto scaling edge cases

* [Lens] Add bucket nesting editor to indexpattern (#42869)

* Modify auto-scale to handle resize events

* use format hints in metric vis

* start cleaning up suggestions

* [Lens] Remove unnecessary fields and indexing from mappings (#43285)

* Tweak metric to only scale down so far, and
scale both the number and the label.

* Fix lens metric tests

* [Lens] Xy scale type (#42142)

* start adding more suggestions

* remove unused imports

* work on suggestions

* work more on suggestions

* work more on suggestions

* work more on suggestions

* [lens] Allow updater function to be used for updating state (#43373)

* [Lens] Lens metric visualization (#39364)

* clean up tests and add new ones

* remove isMetric

* area as default on time dimension

* fix bug in area chart for time

* Fix axis rotation (#43792)

* remove title form layer

* [Lens] Auto date histogram (#43775)

* Add auto date histogram

* Improve documentation and cleanup

* Add tests

* Change test name

* handle state in app

* fix isMetric usages

* fix integration tests

* fix type errors

* fix date handling on submit

* add new suggestion types

* fix test

* do not suggest single tables

* remove unused import

* [Lens] Fix query bar integration (#43865)

* switch order of appending new string column

* resolve merge conflicts

* [Lens] Clean up operations code (#43784)

* fix merge conflicts

* poc implementation

* highlight currently active suggestion and provide button to submit current choice

* [Lens] Functional tests (#44279)

Foundational layer for lens functional tests. Lens-specific page objects are not in this PR.

* [Lens] Add Lens visualizations to Visualize list (#43398)

* [Lens] Suggestion improvements (#43688)

* fix bugs

* [lens] Calculate existence of fields in datasource (#44422)

* [lens] Calculate existence of fields in datasource

* Fix route registration

* Add page object and use existence in functional test

* Simplify layout of filters for index pattern

* Respond to review feedback

* Update class names

* Use new URL constant

* Fix usage of base path

* Fix lint errors

* [Lens ] Preview metric (#43755)

* format filter ratio as percentage (#44625)

* [Lens] Remove datasource suggestion id (#44495)

* [Lens] Make breadcrumbs look and feel like Visualize (#44258)

* [lens] Fix breakage from app-arch movements (#44720)

* Design cleanup

* PR review comments

* fix tests

* small cleanup

* remove unused import

* [lens] Fix type error in test from merge

* [lens] Fix registration of embeddable (#45171)

* keep references stable if table is just extended and add tests

* changed label for stack/unstack

* fix test

* [Lens] Functional tests (#44814)

Basic functional tests for Lens, by no means comprehensive. This is more of a smokescreen test of some normal use cases.

* [lens] Add Lens to CODEOWNERS (#45296)

* [lens] Fix visualization alias registration

* [lens] Fix usage of EUI after typescript upgrade (#45404)

* [lens] Fix usage of EUI after typescript upgrade

* Use local fix instead of workaround

* fix bug and address reviews

* Fix frame tests
This commit is contained in:
Joe Reuter 2019-09-19 00:28:26 +02:00 committed by Wylie Conlon
parent e507264ad8
commit 921e356a4b
27 changed files with 871 additions and 357 deletions

View file

@ -77,10 +77,15 @@ export function ChartSwitch(props: Props) {
const commitSelection = (selection: VisualizationSelection) => { const commitSelection = (selection: VisualizationSelection) => {
setFlyoutOpen(false); setFlyoutOpen(false);
switchToSuggestion(props.framePublicAPI, props.dispatch, { switchToSuggestion(
...selection, props.framePublicAPI,
visualizationState: selection.getVisualizationState(), props.dispatch,
}); {
...selection,
visualizationState: selection.getVisualizationState(),
},
'SWITCH_VISUALIZATION'
);
}; };
function getSelection( function getSelection(

View file

@ -34,6 +34,7 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config
props.dispatch({ props.dispatch({
type: 'UPDATE_VISUALIZATION_STATE', type: 'UPDATE_VISUALIZATION_STATE',
newState, newState,
clearStagedPreview: false,
}); });
}, },
[props.dispatch] [props.dispatch]

View file

@ -32,6 +32,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
type: 'UPDATE_DATASOURCE_STATE', type: 'UPDATE_DATASOURCE_STATE',
updater, updater,
datasourceId: props.activeDatasource!, datasourceId: props.activeDatasource!,
clearStagedPreview: true,
}); });
}, },
[props.dispatch, props.activeDatasource] [props.dispatch, props.activeDatasource]

View file

@ -1153,7 +1153,14 @@ describe('editor_frame', () => {
.find('[data-test-subj="lnsSuggestion"]') .find('[data-test-subj="lnsSuggestion"]')
.find(EuiPanel) .find(EuiPanel)
.map(el => el.parents(EuiToolTip).prop('content')) .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 () => { it('should switch to suggested visualization', async () => {
@ -1196,7 +1203,7 @@ describe('editor_frame', () => {
act(() => { act(() => {
instance instance
.find('[data-test-subj="lnsSuggestion"]') .find('[data-test-subj="lnsSuggestion"]')
.first() .at(2)
.simulate('click'); .simulate('click');
}); });

View file

@ -84,6 +84,7 @@ export function EditorFrame(props: EditorFrameProps) {
type: 'UPDATE_DATASOURCE_STATE', type: 'UPDATE_DATASOURCE_STATE',
datasourceId: id, datasourceId: id,
updater: newState, updater: newState,
clearStagedPreview: true,
}); });
}, },
layer layer
@ -265,6 +266,7 @@ export function EditorFrame(props: EditorFrameProps) {
visualizationMap={props.visualizationMap} visualizationMap={props.visualizationMap}
dispatch={dispatch} dispatch={dispatch}
ExpressionRenderer={props.ExpressionRenderer} ExpressionRenderer={props.ExpressionRenderer}
stagedPreview={state.stagedPreview}
/> />
) )
} }

View file

@ -8,14 +8,18 @@ import { i18n } from '@kbn/i18n';
import { EditorFrameProps } from '../editor_frame'; import { EditorFrameProps } from '../editor_frame';
import { Document } from '../../persistence/saved_object_store'; import { Document } from '../../persistence/saved_object_store';
export interface EditorFrameState { export interface PreviewState {
persistedId?: string;
title: string;
visualization: { visualization: {
activeId: string | null; activeId: string | null;
state: unknown; state: unknown;
}; };
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>; datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
}
export interface EditorFrameState extends PreviewState {
persistedId?: string;
title: string;
stagedPreview?: PreviewState;
activeDatasourceId: string | null; activeDatasourceId: string | null;
} }
@ -32,10 +36,12 @@ export type Action =
type: 'UPDATE_DATASOURCE_STATE'; type: 'UPDATE_DATASOURCE_STATE';
updater: unknown | ((prevState: unknown) => unknown); updater: unknown | ((prevState: unknown) => unknown);
datasourceId: string; datasourceId: string;
clearStagedPreview?: boolean;
} }
| { | {
type: 'UPDATE_VISUALIZATION_STATE'; type: 'UPDATE_VISUALIZATION_STATE';
newState: unknown; newState: unknown;
clearStagedPreview?: boolean;
} }
| { | {
type: 'UPDATE_LAYER'; type: 'UPDATE_LAYER';
@ -59,6 +65,19 @@ export type Action =
datasourceState: unknown; datasourceState: unknown;
datasourceId: string; datasourceId: string;
} }
| {
type: 'SELECT_SUGGESTION';
newVisualizationId: string;
initialState: unknown;
datasourceState: unknown;
datasourceId: string;
}
| {
type: 'ROLLBACK_SUGGESTION';
}
| {
type: 'SUBMIT_SUGGESTION';
}
| { | {
type: 'SWITCH_DATASOURCE'; type: 'SWITCH_DATASOURCE';
newDatasourceId: string; newDatasourceId: string;
@ -176,6 +195,41 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
activeId: action.newVisualizationId, activeId: action.newVisualizationId,
state: action.initialState, 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': case 'UPDATE_DATASOURCE_STATE':
return { return {
@ -190,6 +244,7 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
isLoading: false, isLoading: false,
}, },
}, },
stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview,
}; };
case 'UPDATE_VISUALIZATION_STATE': case 'UPDATE_VISUALIZATION_STATE':
if (!state.visualization.activeId) { if (!state.visualization.activeId) {
@ -201,6 +256,7 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
...state.visualization, ...state.visualization,
state: action.newState, state: action.newState,
}, },
stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview,
}; };
default: default:
return state; return state;

View file

@ -144,14 +144,15 @@ export function switchToSuggestion(
suggestion: Pick< suggestion: Pick<
Suggestion, Suggestion,
'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' | 'keptLayerIds' 'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' | 'keptLayerIds'
> >,
type: 'SWITCH_VISUALIZATION' | 'SELECT_SUGGESTION' = 'SELECT_SUGGESTION'
) { ) {
const action: Action = { const action: Action = {
type: 'SWITCH_VISUALIZATION', type,
newVisualizationId: suggestion.visualizationId, newVisualizationId: suggestion.visualizationId,
initialState: suggestion.visualizationState, initialState: suggestion.visualizationState,
datasourceState: suggestion.datasourceState, datasourceState: suggestion.datasourceState,
datasourceId: suggestion.datasourceId, datasourceId: suggestion.datasourceId!,
}; };
dispatch(action); dispatch(action);
const layerIds = Object.keys(frame.datasourceLayers).filter(id => { const layerIds = Object.keys(frame.datasourceLayers).filter(id => {

View file

@ -10,25 +10,20 @@
} }
.lnsSuggestionsPanel__title { .lnsSuggestionsPanel__title {
margin: $euiSizeS 0 $euiSizeXS; margin-left: $euiSizeXS / 2;
} }
.lnsSuggestionsPanel__suggestions { .lnsSuggestionsPanel__suggestions {
@include euiScrollBar; @include euiScrollBar;
@include lnsOverflowShadowHorizontal; @include lnsOverflowShadowHorizontal;
padding-top: $euiSizeXS; padding-top: $euiSizeXS;
overflow-x: auto; overflow-x: scroll;
overflow-y: hidden; overflow-y: hidden;
display: flex; display: flex;
// Padding / negative margins to make room for overflow shadow // Padding / negative margins to make room for overflow shadow
padding-left: $euiSizeXS; padding-left: $euiSizeXS;
margin-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 // These sizes also match canvas' page thumbnails for consistency
@ -39,8 +34,13 @@ $lnsSuggestionWidth: 150px;
flex: 0 0 auto; flex: 0 0 auto;
width: $lnsSuggestionWidth !important; width: $lnsSuggestionWidth !important;
height: $lnsSuggestionHeight; height: $lnsSuggestionHeight;
// Allows the scrollbar to stay flush to window margin-right: $euiSizeS;
margin-bottom: $euiSize; margin-left: $euiSizeXS / 2;
margin-bottom: $euiSizeXS / 2;
}
.lnsSuggestionPanel__button-isSelected {
@include euiFocusRing;
} }
.lnsSidebar__suggestionIcon { .lnsSidebar__suggestionIcon {
@ -58,3 +58,15 @@ $lnsSuggestionWidth: 150px;
pointer-events: none; pointer-events: none;
margin: 0 $euiSizeS; margin: 0 $euiSizeS;
} }
.lnsSuggestionChartWrapper--withLabel {
height: $lnsSuggestionHeight - $euiSizeL;
}
.lnsSuggestionPanel__buttonLabel {
@include euiFontSizeXS;
display: block;
font-weight: $euiFontWeightBold;
text-align: center;
flex-grow: 0;
}

View file

@ -14,14 +14,16 @@ import {
DatasourceMock, DatasourceMock,
createMockFramePublicAPI, createMockFramePublicAPI,
} from '../mocks'; } from '../mocks';
import { act } from 'react-dom/test-utils';
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; 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 { getSuggestions, Suggestion } from './suggestion_helpers';
import { fromExpression } from '@kbn/interpreter/target/common';
import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui';
jest.mock('./suggestion_helpers'); jest.mock('./suggestion_helpers');
const getSuggestionsMock = getSuggestions as jest.Mock;
describe('suggestion_panel', () => { describe('suggestion_panel', () => {
let mockVisualization: Visualization; let mockVisualization: Visualization;
let mockDatasource: DatasourceMock; let mockDatasource: DatasourceMock;
@ -40,7 +42,7 @@ describe('suggestion_panel', () => {
expressionRendererMock = createExpressionRendererMock(); expressionRendererMock = createExpressionRendererMock();
dispatchMock = jest.fn(); dispatchMock = jest.fn();
(getSuggestions as jest.Mock).mockReturnValue([ getSuggestionsMock.mockReturnValue([
{ {
datasourceState: {}, datasourceState: {},
previewIcon: 'empty', previewIcon: 'empty',
@ -75,6 +77,7 @@ describe('suggestion_panel', () => {
activeVisualizationId: 'vis', activeVisualizationId: 'vis',
visualizationMap: { visualizationMap: {
vis: mockVisualization, vis: mockVisualization,
vis2: createMockVisualization(),
}, },
visualizationState: {}, visualizationState: {},
dispatch: dispatchMock, dispatch: dispatchMock,
@ -84,27 +87,131 @@ describe('suggestion_panel', () => {
}); });
it('should list passed in suggestions', () => { it('should list passed in suggestions', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />); const wrapper = mount(<InnerSuggestionPanel {...defaultProps} />);
expect( expect(
wrapper wrapper
.find('[data-test-subj="lnsSuggestion"]') .find('[data-test-subj="lnsSuggestion"]')
.find(EuiPanel) .find(EuiPanel)
.map(el => el.parents(EuiToolTip).prop('content')) .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(<InnerSuggestionPanel {...defaultProps} />);
getSuggestionsMock.mockClear();
wrapper.setProps({
stagedPreview,
...suggestionState,
});
wrapper.update();
expect(getSuggestionsMock).not.toHaveBeenCalled();
});
it('should update suggestions if staged preview is removed', () => {
const wrapper = mount(<InnerSuggestionPanel {...defaultProps} />);
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(<InnerSuggestionPanel {...defaultProps} />);
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(<InnerSuggestionPanel {...defaultProps} />);
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', () => { it('should dispatch visualization switch action if suggestion is clicked', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />); const wrapper = mount(<InnerSuggestionPanel {...defaultProps} />);
wrapper act(() => {
.find('[data-test-subj="lnsSuggestion"]') wrapper
.first() .find('button[data-test-subj="lnsSuggestion"]')
.simulate('click'); .at(1)
.simulate('click');
});
wrapper.update();
expect(dispatchMock).toHaveBeenCalledWith( expect(dispatchMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
type: 'SWITCH_VISUALIZATION', type: 'SELECT_SUGGESTION',
initialState: suggestion1State, initialState: suggestion1State,
}) })
); );
@ -113,12 +220,29 @@ describe('suggestion_panel', () => {
it('should remove unused layers if suggestion is clicked', () => { it('should remove unused layers if suggestion is clicked', () => {
defaultProps.frame.datasourceLayers.a = mockDatasource.publicAPIMock; defaultProps.frame.datasourceLayers.a = mockDatasource.publicAPIMock;
defaultProps.frame.datasourceLayers.b = mockDatasource.publicAPIMock; defaultProps.frame.datasourceLayers.b = mockDatasource.publicAPIMock;
const wrapper = mount(<SuggestionPanel {...defaultProps} activeVisualizationId="vis2" />); const wrapper = mount(
<InnerSuggestionPanel
{...defaultProps}
stagedPreview={{ visualization: { state: {}, activeId: 'vis' }, datasourceStates: {} }}
activeVisualizationId="vis2"
/>
);
wrapper act(() => {
.find('[data-test-subj="lnsSuggestion"]') wrapper
.first() .find('button[data-test-subj="lnsSuggestion"]')
.simulate('click'); .at(1)
.simulate('click');
});
wrapper.update();
act(() => {
wrapper
.find('[data-test-subj="lensSubmitSuggestion"]')
.first()
.simulate('click');
});
expect(defaultProps.frame.removeLayers).toHaveBeenCalledWith(['b']); expect(defaultProps.frame.removeLayers).toHaveBeenCalledWith(['b']);
}); });
@ -141,18 +265,18 @@ describe('suggestion_panel', () => {
visualizationState: suggestion2State, visualizationState: suggestion2State,
visualizationId: 'vis', visualizationId: 'vis',
title: 'Suggestion2', title: 'Suggestion2',
previewExpression: 'test | expression',
}, },
] as Suggestion[]); ] as Suggestion[]);
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce(undefined);
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression');
mockDatasource.toExpression.mockReturnValue('datasource_expression'); mockDatasource.toExpression.mockReturnValue('datasource_expression');
mount(<SuggestionPanel {...defaultProps} />); mount(<InnerSuggestionPanel {...defaultProps} />);
expect(expressionRendererMock).toHaveBeenCalledTimes(1); expect(expressionRendererMock).toHaveBeenCalledTimes(1);
const passedExpression = fromExpression( const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression;
(expressionRendererMock as jest.Mock).mock.calls[0][0].expression
);
expect(passedExpression).toMatchInlineSnapshot(` expect(passedExpression).toMatchInlineSnapshot(`
Object { Object {
"chain": Array [ "chain": Array [
@ -163,6 +287,7 @@ describe('suggestion_panel', () => {
}, },
Object { Object {
"arguments": Object { "arguments": Object {
"filters": Array [],
"query": Array [ "query": Array [
"{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}",
], ],
@ -212,7 +337,7 @@ describe('suggestion_panel', () => {
it('should render render icon if there is no preview expression', () => { it('should render render icon if there is no preview expression', () => {
mockDatasource.getLayers.mockReturnValue(['first']); mockDatasource.getLayers.mockReturnValue(['first']);
(getSuggestions as jest.Mock).mockReturnValue([ getSuggestionsMock.mockReturnValue([
{ {
datasourceState: {}, datasourceState: {},
previewIcon: 'visTable', previewIcon: 'visTable',
@ -232,9 +357,15 @@ describe('suggestion_panel', () => {
}, },
] as Suggestion[]); ] 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'); mockDatasource.toExpression.mockReturnValue('datasource_expression');
const wrapper = mount(<SuggestionPanel {...defaultProps} />); const wrapper = mount(<InnerSuggestionPanel {...defaultProps} />);
expect(wrapper.find(EuiIcon)).toHaveLength(1); expect(wrapper.find(EuiIcon)).toHaveLength(1);
expect(wrapper.find(EuiIcon).prop('type')).toEqual('visTable'); expect(wrapper.find(EuiIcon).prop('type')).toEqual('visTable');

View file

@ -4,14 +4,25 @@
* you may not use this file except in compliance with the Elastic License. * 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 { FormattedMessage } from '@kbn/i18n/react';
import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip, EuiToolTip } from '@elastic/eui'; import {
import { toExpression, Ast } from '@kbn/interpreter/common'; EuiIcon,
EuiTitle,
EuiPanel,
EuiIconTip,
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
} from '@elastic/eui';
import { Ast } from '@kbn/interpreter/common';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { Action } from './state_management'; import classNames from 'classnames';
import { Datasource, Visualization, FramePublicAPI } from '../../types'; import { Action, PreviewState } from './state_management';
import { getSuggestions, Suggestion, switchToSuggestion } from './suggestion_helpers'; import { Datasource, Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types';
import { getSuggestions, switchToSuggestion } from './suggestion_helpers';
import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public';
import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers';
import { debouncedComponent } from '../../debounced_component'; import { debouncedComponent } from '../../debounced_component';
@ -38,45 +49,48 @@ export interface SuggestionPanelProps {
dispatch: (action: Action) => void; dispatch: (action: Action) => void;
ExpressionRenderer: ExpressionRenderer; ExpressionRenderer: ExpressionRenderer;
frame: FramePublicAPI; frame: FramePublicAPI;
stagedPreview?: PreviewState;
} }
const SuggestionPreview = ({ const SuggestionPreview = ({
suggestion, preview,
dispatch,
frame,
previewExpression,
ExpressionRenderer: ExpressionRendererComponent, ExpressionRenderer: ExpressionRendererComponent,
selected,
onSelect,
showTitleAsLabel,
}: { }: {
suggestion: Suggestion; onSelect: () => void;
dispatch: (action: Action) => void; preview: {
frame: FramePublicAPI; expression?: string | Ast;
icon: string;
title: string;
};
ExpressionRenderer: ExpressionRenderer; ExpressionRenderer: ExpressionRenderer;
previewExpression?: string; selected: boolean;
showTitleAsLabel?: boolean;
}) => { }) => {
const [expressionError, setExpressionError] = useState<boolean>(false); const [expressionError, setExpressionError] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
setExpressionError(false); setExpressionError(false);
}, [previewExpression]); }, [preview.expression]);
const clickHandler = () => {
switchToSuggestion(frame, dispatch, suggestion);
};
return ( return (
<EuiToolTip content={suggestion.title}> <EuiToolTip content={preview.title}>
<EuiPanelFixed <EuiPanelFixed
className="lnsSuggestionPanel__button" className={classNames('lnsSuggestionPanel__button', {
'lnsSuggestionPanel__button-isSelected': selected,
})}
paddingSize="none" paddingSize="none"
data-test-subj="lnsSuggestion" data-test-subj="lnsSuggestion"
onClick={clickHandler} onClick={onSelect}
> >
{expressionError ? ( {expressionError ? (
<div className="lnsSidebar__suggestionIcon"> <div className="lnsSidebar__suggestionIcon">
<EuiIconTip <EuiIconTip
size="xxl" size="xl"
color="danger" color="danger"
type="cross" type="alert"
aria-label={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', { aria-label={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed', defaultMessage: 'Preview rendering failed',
})} })}
@ -85,10 +99,12 @@ const SuggestionPreview = ({
})} })}
/> />
</div> </div>
) : previewExpression ? ( ) : preview.expression ? (
<ExpressionRendererComponent <ExpressionRendererComponent
className="lnsSuggestionChartWrapper" className={classNames('lnsSuggestionChartWrapper', {
expression={previewExpression} 'lnsSuggestionChartWrapper--withLabel': showTitleAsLabel,
})}
expression={preview.expression}
onRenderFailure={(e: unknown) => { onRenderFailure={(e: unknown) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`Failed to render preview: `, e); console.error(`Failed to render preview: `, e);
@ -96,18 +112,23 @@ const SuggestionPreview = ({
}} }}
/> />
) : ( ) : (
<div className="lnsSidebar__suggestionIcon"> <span className="lnsSidebar__suggestionIcon">
<EuiIcon size="xxl" type={suggestion.previewIcon} /> <EuiIcon size="xxl" type={preview.icon} />
</div> </span>
)}
{showTitleAsLabel && (
<span className="lnsSuggestionPanel__buttonLabel">{preview.title}</span>
)} )}
</EuiPanelFixed> </EuiPanelFixed>
</EuiToolTip> </EuiToolTip>
); );
}; };
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, activeDatasourceId,
datasourceMap, datasourceMap,
datasourceStates, datasourceStates,
@ -117,70 +138,243 @@ function InnerSuggestionPanel({
dispatch, dispatch,
frame, frame,
ExpressionRenderer: ExpressionRendererComponent, ExpressionRenderer: ExpressionRendererComponent,
stagedPreview,
}: SuggestionPanelProps) { }: SuggestionPanelProps) {
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 newStateExpression =
currentVisualizationState && currentVisualizationId
? preparePreviewExpression(
{ visualizationState: currentVisualizationState },
visualizationMap[currentVisualizationId],
datasourceMap,
currentDatasourceStates,
frame
)
: undefined;
return { suggestions: newSuggestions, currentStateExpression: newStateExpression };
}, [
currentDatasourceStates,
currentVisualizationState,
currentVisualizationId,
datasourceMap,
visualizationMap,
]);
const [lastSelectedSuggestion, setLastSelectedSuggestion] = useState<number>(-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) { if (!activeDatasourceId) {
return null; return null;
} }
const suggestions = getSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId,
visualizationState,
})
.filter(suggestion => !suggestion.hide)
.slice(0, MAX_SUGGESTIONS_DISPLAYED);
if (suggestions.length === 0) { if (suggestions.length === 0) {
return null; 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 ( return (
<div className="lnsSuggestionsPanel"> <div className="lnsSuggestionsPanel">
<EuiTitle className="lnsSuggestionsPanel__title" size="xxs"> <EuiFlexGroup alignItems="center">
<h3> <EuiFlexItem>
<FormattedMessage <EuiTitle className="lnsSuggestionsPanel__title" size="xxs">
id="xpack.lens.editorFrame.suggestionPanelTitle" <h3>
defaultMessage="Suggestions" <FormattedMessage
/> id="xpack.lens.editorFrame.suggestionPanelTitle"
</h3> defaultMessage="Suggestions"
</EuiTitle> />
</h3>
</EuiTitle>
</EuiFlexItem>
{stagedPreview && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="lensSubmitSuggestion"
size="xs"
onClick={() => {
dispatch({
type: 'SUBMIT_SUGGESTION',
});
}}
>
{i18n.translate('xpack.lens.sugegstion.confirmSuggestionLabel', {
defaultMessage: 'Confirm and reload suggestions',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
<div className="lnsSuggestionsPanel__suggestions"> <div className="lnsSuggestionsPanel__suggestions">
{suggestions.map((suggestion: Suggestion) => ( {currentVisualizationId && (
<SuggestionPreview <SuggestionPreview
suggestion={suggestion} preview={{
dispatch={dispatch} expression: currentStateExpression
frame={frame} ? prependKibanaContext(currentStateExpression, expressionContext)
: undefined,
icon:
visualizationMap[currentVisualizationId].getDescription(currentVisualizationState)
.icon || 'empty',
title: i18n.translate('xpack.lens.suggestions.currentVisLabel', {
defaultMessage: 'Current',
}),
}}
ExpressionRenderer={ExpressionRendererComponent} ExpressionRenderer={ExpressionRendererComponent}
previewExpression={ onSelect={rollbackToCurrentVisualization}
suggestion.previewExpression selected={lastSelectedSuggestion === -1}
? preparePreviewExpression( showTitleAsLabel
suggestion.previewExpression,
datasourceMap,
datasourceStates,
frame,
suggestion.datasourceId,
suggestion.datasourceState
)
: undefined
}
key={`${suggestion.visualizationId}-${suggestion.title}`}
/> />
))} )}
{suggestions.map((suggestion, index) => {
return (
<SuggestionPreview
preview={{
expression: suggestion.previewExpression
? prependKibanaContext(suggestion.previewExpression, expressionContext)
: undefined,
icon: suggestion.previewIcon,
title: suggestion.title,
}}
ExpressionRenderer={ExpressionRendererComponent}
key={index}
onSelect={() => {
if (lastSelectedSuggestion === index) {
rollbackToCurrentVisualization();
} else {
setLastSelectedSuggestion(index);
switchToSuggestion(frame, dispatch, suggestion);
}
}}
selected={index === lastSelectedSuggestion}
/>
);
})}
</div> </div>
</div> </div>
); );
} }
interface VisualizableState {
visualizationState: unknown;
datasourceState?: unknown;
datasourceId?: string;
keptLayerIds?: string[];
}
function getPreviewExpression(
visualizableState: VisualizableState,
visualization: Visualization,
datasources: Record<string, Datasource>,
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<string, DatasourcePublicAPI> = _.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( function preparePreviewExpression(
expression: string | Ast, visualizableState: VisualizableState,
visualization: Visualization,
datasourceMap: Record<string, Datasource<unknown, unknown>>, datasourceMap: Record<string, Datasource<unknown, unknown>>,
datasourceStates: Record<string, { isLoading: boolean; state: unknown }>, datasourceStates: Record<string, { isLoading: boolean; state: unknown }>,
framePublicAPI: FramePublicAPI, framePublicAPI: FramePublicAPI
suggestionDatasourceId?: string,
suggestionDatasourceState?: unknown
) { ) {
const suggestionDatasourceId = visualizableState.datasourceId;
const suggestionDatasourceState = visualizableState.datasourceState;
const expression = getPreviewExpression(
visualizableState,
visualization,
datasourceMap,
framePublicAPI
);
if (!expression) {
return;
}
const expressionWithDatasource = prependDatasourceExpression( const expressionWithDatasource = prependDatasourceExpression(
expression, expression,
datasourceMap, datasourceMap,
@ -195,15 +389,5 @@ function preparePreviewExpression(
: datasourceStates : datasourceStates
); );
const expressionContext = { return expressionWithDatasource;
query: framePublicAPI.query,
timeRange: {
from: framePublicAPI.dateRange.fromDate,
to: framePublicAPI.dateRange.toDate,
},
};
return expressionWithDatasource
? toExpression(prependKibanaContext(expressionWithDatasource, expressionContext))
: undefined;
} }

View file

@ -76,7 +76,12 @@ export function InnerWorkspacePanel({
function onDrop() { function onDrop() {
if (suggestionForDraggedField) { if (suggestionForDraggedField) {
switchToSuggestion(framePublicAPI, dispatch, suggestionForDraggedField); switchToSuggestion(
framePublicAPI,
dispatch,
suggestionForDraggedField,
'SWITCH_VISUALIZATION'
);
} }
} }

View file

@ -30,6 +30,7 @@ export function createMockVisualization(): jest.Mocked<Visualization> {
initialize: jest.fn((_frame, _state?) => ({})), initialize: jest.fn((_frame, _state?) => ({})),
renderConfigPanel: jest.fn(), renderConfigPanel: jest.fn(),
toExpression: jest.fn((_state, _frame) => null), toExpression: jest.fn((_state, _frame) => null),
toPreviewExpression: jest.fn((_state, _frame) => null),
}; };
} }

View file

@ -674,7 +674,7 @@ describe('IndexPattern Data Source suggestions', () => {
expect(suggestions).toHaveLength(0); 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, { const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, {
field: { name: 'dest', type: 'string', aggregatable: true, searchable: true }, field: { name: 'dest', type: 'string', aggregatable: true, searchable: true },
indexPatternId: '1', indexPatternId: '1',
@ -686,7 +686,7 @@ describe('IndexPattern Data Source suggestions', () => {
layers: { layers: {
previousLayer: initialState.layers.previousLayer, previousLayer: initialState.layers.previousLayer,
currentLayer: expect.objectContaining({ currentLayer: expect.objectContaining({
columnOrder: ['col1', 'newId', 'col2'], columnOrder: ['newId', 'col1', 'col2'],
columns: { columns: {
...initialState.layers.currentLayer.columns, ...initialState.layers.currentLayer.columns,
newId: expect.objectContaining({ newId: expect.objectContaining({

View file

@ -204,7 +204,7 @@ function addFieldAsBucketOperation(
}; };
let updatedColumnOrder: string[] = []; let updatedColumnOrder: string[] = [];
if (applicableBucketOperation === 'terms') { if (applicableBucketOperation === 'terms') {
updatedColumnOrder = [...buckets, newColumnId, ...metrics]; updatedColumnOrder = [newColumnId, ...buckets, ...metrics];
} else { } else {
const oldDateHistogramColumn = layer.columnOrder.find( const oldDateHistogramColumn = layer.columnOrder.find(
columnId => layer.columns[columnId].operationType === 'date_histogram' columnId => layer.columns[columnId].operationType === 'date_histogram'
@ -392,16 +392,21 @@ function createChangedNestingSuggestion(state: IndexPatternPrivateState, layerId
state, state,
layerId, layerId,
updatedLayer, updatedLayer,
label: i18n.translate('xpack.lens.indexpattern.suggestions.nestingChangeLabel', { label: getNestedTitle([layer.columns[secondBucket], layer.columns[firstBucket]]),
defaultMessage: 'Nest within {operation}',
values: {
operation: layer.columns[secondBucket].label,
},
}),
changeType: 'extended', 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( function createAlternativeMetricSuggestions(
indexPattern: IndexPattern, indexPattern: IndexPattern,
layerId: string, layerId: string,

View file

@ -49,7 +49,7 @@ describe('AutoScale', () => {
) )
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
<div <div
style="display:flex;justify-content:center;align-items:center;max-width:100%;max-height:100%;overflow:hidden" style="display:flex;justify-content:center;align-items:center;max-width:100%;max-height:100%;overflow:hidden;line-height:1.5"
> >
<div <div
style="transform:scale(0)" style="transform:scale(0)"

View file

@ -76,6 +76,7 @@ export class AutoScale extends React.Component<Props, State> {
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
overflow: 'hidden', overflow: 'hidden',
lineHeight: 1.5,
}} }}
> >
<div <div

View file

@ -97,26 +97,6 @@ describe('metric_suggestions', () => {
expect(rest).toHaveLength(0); expect(rest).toHaveLength(0);
expect(suggestion).toMatchInlineSnapshot(` expect(suggestion).toMatchInlineSnapshot(`
Object { 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", "previewIcon": "visMetric",
"score": 0.5, "score": 0.5,
"state": Object { "state": Object {

View file

@ -41,20 +41,6 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion<State> {
title, title,
score: 0.5, score: 0.5,
previewIcon: 'visMetric', previewIcon: 'visMetric',
previewExpression: {
type: 'expression',
chain: [
{
type: 'function',
function: 'lens_metric_chart',
arguments: {
title: [''],
accessor: [col.columnId],
mode: ['reduced'],
},
},
],
},
state: { state: {
layerId: table.layerId, layerId: table.layerId,
accessor: col.columnId, accessor: col.columnId,

View file

@ -38,11 +38,11 @@ describe('metric_visualization', () => {
expect(initialState.accessor).toBeDefined(); expect(initialState.accessor).toBeDefined();
expect(initialState).toMatchInlineSnapshot(` expect(initialState).toMatchInlineSnapshot(`
Object { Object {
"accessor": "test-id1", "accessor": "test-id1",
"layerId": "l42", "layerId": "l42",
} }
`); `);
}); });
it('loads from persisted state', () => { it('loads from persisted state', () => {
@ -83,6 +83,9 @@ describe('metric_visualization', () => {
"accessor": Array [ "accessor": Array [
"a", "a",
], ],
"mode": Array [
"full",
],
"title": Array [ "title": Array [
"shazm", "shazm",
], ],

View file

@ -8,12 +8,37 @@ import React from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { I18nProvider } from '@kbn/i18n/react'; import { I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { Ast } from '@kbn/interpreter/target/common';
import { getSuggestions } from './metric_suggestions'; import { getSuggestions } from './metric_suggestions';
import { MetricConfigPanel } from './metric_config_panel'; import { MetricConfigPanel } from './metric_config_panel';
import { Visualization } from '../types'; import { Visualization, FramePublicAPI } from '../types';
import { State, PersistableState } from './types'; import { State, PersistableState } from './types';
import { generateId } from '../id_generator'; 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<State, PersistableState> = { export const metricVisualization: Visualization<State, PersistableState> = {
id: 'lnsMetric', id: 'lnsMetric',
@ -57,22 +82,7 @@ export const metricVisualization: Visualization<State, PersistableState> = {
domElement domElement
), ),
toExpression(state, frame) { toExpression,
const [datasource] = Object.values(frame.datasourceLayers); toPreviewExpression: (state: State, frame: FramePublicAPI) =>
const operation = datasource && datasource.getOperationForColumnId(state.accessor); toExpression(state, frame, 'reduced'),
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'lens_metric_chart',
arguments: {
title: [(operation && operation.label) || ''],
accessor: [state.accessor],
},
},
],
};
},
}; };

View file

@ -270,11 +270,6 @@ export interface VisualizationSuggestion<T = unknown> {
* The new state of the visualization if this suggestion is applied. * The new state of the visualization if this suggestion is applied.
*/ */
state: T; 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. * An EUI icon type shown instead of the preview expression.
*/ */
@ -323,6 +318,12 @@ export interface Visualization<T = unknown, P = unknown> {
toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; 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 // The frame will call this function on all visualizations when the table changes, or when
// rendering additional ways of using the data // rendering additional ways of using the data
getSuggestions: (context: SuggestionRequest<T>) => Array<VisualizationSuggestion<T>>; getSuggestions: (context: SuggestionRequest<T>) => Array<VisualizationSuggestion<T>>;

View file

@ -1,3 +1,11 @@
.lnsChart { .lnsChart {
height: 100%; height: 100%;
} }
.lnsChart__empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

View file

@ -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) { export function getScaleType(metadata: OperationMetadata | null, defaultScale: ScaleType) {
if (!metadata) { if (!metadata) {
return defaultScale; return defaultScale;

View file

@ -19,7 +19,7 @@ import {
} from '@elastic/charts'; } from '@elastic/charts';
import { I18nProvider } from '@kbn/i18n/react'; import { I18nProvider } from '@kbn/i18n/react';
import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; 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 { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; 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)) { if (Object.values(data.tables).every(table => table.rows.length === 0)) {
const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar';
return ( return (
<EuiFlexGroup gutterSize="s" direction="column" alignItems="center" justifyContent="center"> <EuiText className="lnsChart__empty" textAlign="center" color="subdued" size="xs">
<EuiFlexItem> <p>
<EuiIcon type={icon} color="subdued" size="l" /> <EuiIcon type={icon} color="subdued" size="l" />
</EuiFlexItem> </p>
<EuiFlexItem> <p>
<EuiText color="subdued" size="xs"> <FormattedMessage
<FormattedMessage id="xpack.lens.xyVisualization.noDataLabel"
id="xpack.lens.xyVisualization.noDataLabel" defaultMessage="No results found"
defaultMessage="No results found" />
/> </p>
</EuiText> </EuiText>
</EuiFlexItem>
</EuiFlexGroup>
); );
} }

View file

@ -13,7 +13,6 @@ import {
} from '../types'; } from '../types';
import { State, XYState } from './types'; import { State, XYState } from './types';
import { generateId } from '../id_generator'; import { generateId } from '../id_generator';
import { Ast } from '@kbn/interpreter/target/common';
jest.mock('../id_generator'); jest.mock('../id_generator');
@ -65,6 +64,10 @@ describe('xy_suggestions', () => {
})); }));
} }
beforeEach(() => {
jest.resetAllMocks();
});
test('ignores invalid combinations', () => { test('ignores invalid combinations', () => {
const unknownCol = () => { const unknownCol = () => {
const str = strCol('foo'); const str = strCol('foo');
@ -114,17 +117,17 @@ describe('xy_suggestions', () => {
expect(rest).toHaveLength(0); expect(rest).toHaveLength(0);
expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(`
Array [ Array [
Object { Object {
"seriesType": "area", "seriesType": "area_stacked",
"splitAccessor": "aaa", "splitAccessor": "aaa",
"x": "date", "x": "date",
"y": Array [ "y": Array [
"bytes", "bytes",
], ],
}, },
] ]
`); `);
}); });
test('does not suggest multiple splits', () => { test('does not suggest multiple splits', () => {
@ -158,18 +161,18 @@ describe('xy_suggestions', () => {
expect(rest).toHaveLength(0); expect(rest).toHaveLength(0);
expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(`
Array [ Array [
Object { Object {
"seriesType": "area", "seriesType": "area_stacked",
"splitAccessor": "product", "splitAccessor": "product",
"x": "date", "x": "date",
"y": Array [ "y": Array [
"price", "price",
"quantity", "quantity",
], ],
}, },
] ]
`); `);
}); });
test('uses datasource provided title if available', () => { test('uses datasource provided title if available', () => {
@ -254,7 +257,7 @@ describe('xy_suggestions', () => {
state: currentState, state: currentState,
}); });
expect(rest).toHaveLength(0); expect(rest).toHaveLength(1);
expect(suggestion.state).toEqual({ expect(suggestion.state).toEqual({
...currentState, ...currentState,
preferredSeriesType: 'area', preferredSeriesType: 'area',
@ -290,7 +293,7 @@ describe('xy_suggestions', () => {
state: currentState, state: currentState,
}); });
expect(rest).toHaveLength(0); expect(rest).toHaveLength(1);
expect(suggestion.state).toEqual({ expect(suggestion.state).toEqual({
...currentState, ...currentState,
isHorizontal: true, isHorizontal: true,
@ -298,6 +301,85 @@ describe('xy_suggestions', () => {
expect(suggestion.title).toEqual('Flip'); 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', () => { test('handles two numeric values', () => {
(generateId as jest.Mock).mockReturnValueOnce('ddd'); (generateId as jest.Mock).mockReturnValueOnce('ddd');
const [suggestion] = getSuggestions({ const [suggestion] = getSuggestions({
@ -310,17 +392,17 @@ describe('xy_suggestions', () => {
}); });
expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(`
Array [ Array [
Object { Object {
"seriesType": "bar", "seriesType": "bar_stacked",
"splitAccessor": "ddd", "splitAccessor": "ddd",
"x": "quantity", "x": "quantity",
"y": Array [ "y": Array [
"price", "price",
], ],
}, },
] ]
`); `);
}); });
test('handles unbucketed suggestions', () => { test('handles unbucketed suggestions', () => {
@ -345,36 +427,16 @@ describe('xy_suggestions', () => {
}); });
expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(`
Array [ Array [
Object { Object {
"seriesType": "bar", "seriesType": "bar_stacked",
"splitAccessor": "eee", "splitAccessor": "eee",
"x": "mybool", "x": "mybool",
"y": Array [ "y": Array [
"num votes", "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();
}); });
}); });

View file

@ -13,12 +13,10 @@ import {
VisualizationSuggestion, VisualizationSuggestion,
TableSuggestionColumn, TableSuggestionColumn,
TableSuggestion, TableSuggestion,
OperationMetadata,
TableChangeType, TableChangeType,
} from '../types'; } from '../types';
import { State, SeriesType, XYState } from './types'; import { State, SeriesType, XYState } from './types';
import { generateId } from '../id_generator'; import { generateId } from '../id_generator';
import { buildExpression } from './to_expression';
const columnSortOrder = { const columnSortOrder = {
date: 0, date: 0,
@ -63,27 +61,24 @@ export function getSuggestions({
return []; return [];
} }
const suggestion = getSuggestionForColumns(table, state); const suggestions = getSuggestionForColumns(table, state);
if (suggestion) { if (suggestions && suggestions instanceof Array) {
return [suggestion]; return suggestions;
} }
return []; return suggestions ? [suggestions] : [];
} }
function getSuggestionForColumns( function getSuggestionForColumns(
table: TableSuggestion, table: TableSuggestion,
currentState?: State currentState?: State
): VisualizationSuggestion<State> | undefined { ): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> | undefined {
const [buckets, values] = partition( const [buckets, values] = partition(table.columns, col => col.operation.isBucketed);
prioritizeColumns(table.columns),
col => col.operation.isBucketed
);
if (buckets.length === 1 || buckets.length === 2) { if (buckets.length === 1 || buckets.length === 2) {
const [x, splitBy] = buckets; const [x, splitBy] = getBucketMappings(table, currentState);
return getSuggestion( return getSuggestionsForLayer(
table.layerId, table.layerId,
table.changeType, table.changeType,
x, x,
@ -93,8 +88,8 @@ function getSuggestionForColumns(
table.label table.label
); );
} else if (buckets.length === 0) { } else if (buckets.length === 0) {
const [x, ...yValues] = values; const [x, ...yValues] = prioritizeColumns(values);
return getSuggestion( return getSuggestionsForLayer(
table.layerId, table.layerId,
table.changeType, table.changeType,
x, 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: // This shuffles columns around so that the left-most column defualts to:
// date, string, boolean, then number, in that priority. We then use this // date, string, boolean, then number, in that priority. We then use this
// order to pluck out the x column, and the split / stack column. // 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, layerId: string,
changeType: TableChangeType, changeType: TableChangeType,
xValue: TableSuggestionColumn, xValue: TableSuggestionColumn,
@ -123,7 +152,7 @@ function getSuggestion(
splitBy?: TableSuggestionColumn, splitBy?: TableSuggestionColumn,
currentState?: State, currentState?: State,
tableLabel?: string tableLabel?: string
): VisualizationSuggestion<State> { ): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> {
const title = getSuggestionTitle(yValues, xValue, tableLabel); const title = getSuggestionTitle(yValues, xValue, tableLabel);
const seriesType: SeriesType = getSeriesType(currentState, layerId, xValue, changeType); const seriesType: SeriesType = getSeriesType(currentState, layerId, xValue, changeType);
const isHorizontal = currentState ? currentState.isHorizontal : false; const isHorizontal = currentState ? currentState.isHorizontal : false;
@ -146,30 +175,68 @@ function getSuggestion(
return buildSuggestion(options); return buildSuggestion(options);
} }
const sameStateSuggestions: Array<VisualizationSuggestion<State>> = [];
// if current state is using the same data, suggest same chart with different presentational configuration // if current state is using the same data, suggest same chart with different presentational configuration
if (xValue.operation.scale === 'ordinal') { if (xValue.operation.scale === 'ordinal') {
// flip between horizontal/vertical for ordinal scales // flip between horizontal/vertical for ordinal scales
return buildSuggestion({ sameStateSuggestions.push(
...options, buildSuggestion({
title: i18n.translate('xpack.lens.xySuggestions.flipTitle', { defaultMessage: 'Flip' }), ...options,
isHorizontal: !options.isHorizontal, 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 // flip between stacked/unstacked
const newSeriesType = flipSeriesType(seriesType); sameStateSuggestions.push(
return buildSuggestion({ buildSuggestion({
...options, ...options,
seriesType: newSeriesType, seriesType: toggleStackSeriesType(seriesType),
title: newSeriesType.startsWith('area') title: seriesType.endsWith('stacked')
? i18n.translate('xpack.lens.xySuggestions.areaChartTitle', { ? i18n.translate('xpack.lens.xySuggestions.unstackedChartTitle', {
defaultMessage: 'Area chart', defaultMessage: 'Unstacked',
}) })
: i18n.translate('xpack.lens.xySuggestions.barChartTitle', { : i18n.translate('xpack.lens.xySuggestions.stackedChartTitle', {
defaultMessage: 'Bar chart', 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) { function flipSeriesType(oldSeriesType: SeriesType) {
@ -193,7 +260,7 @@ function getSeriesType(
xValue: TableSuggestionColumn, xValue: TableSuggestionColumn,
changeType: TableChangeType changeType: TableChangeType
): SeriesType { ): SeriesType {
const defaultType = xValue.operation.dataType === 'date' ? 'area' : 'bar'; const defaultType = xValue.operation.dataType === 'date' ? 'area_stacked' : 'bar_stacked';
if (changeType === 'initial') { if (changeType === 'initial') {
return defaultType; return defaultType;
} else { } else {
@ -258,7 +325,7 @@ function buildSuggestion({
xValue: TableSuggestionColumn; xValue: TableSuggestionColumn;
splitBy: TableSuggestionColumn | undefined; splitBy: TableSuggestionColumn | undefined;
layerId: string; layerId: string;
changeType: string; changeType: TableChangeType;
}) { }) {
const newLayer = { const newLayer = {
...(getExistingLayer(currentState, layerId) || {}), ...(getExistingLayer(currentState, layerId) || {}),
@ -281,54 +348,25 @@ function buildSuggestion({
return { return {
title, 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: getScore(yValues, splitBy, changeType),
score: ((yValues.length > 1 ? 2 : 1) + (splitBy ? 1 : 0)) / 3,
// don't advertise chart of same type but with less data // don't advertise chart of same type but with less data
hide: currentState && changeType === 'reduced', hide: currentState && changeType === 'reduced',
state, state,
previewIcon: getIconForSeries(seriesType), previewIcon: getIconForSeries(seriesType),
previewExpression: buildPreviewExpression(state, layerId, xValue, yValues, splitBy),
}; };
} }
function buildPreviewExpression( function getScore(
state: XYState,
layerId: string,
xValue: TableSuggestionColumn,
yValues: TableSuggestionColumn[], yValues: TableSuggestionColumn[],
splitBy: TableSuggestionColumn | undefined splitBy: TableSuggestionColumn | undefined,
changeType: TableChangeType
) { ) {
return buildExpression( // Unchanged table suggestions half the score because the underlying data doesn't change
{ const changeFactor = changeType === 'unchanged' ? 0.5 : 1;
...state, // chart with multiple y values and split series will have a score of 1, single y value and no split series reduce score
// only show changed layer in preview and hide axes return (((yValues.length > 1 ? 2 : 1) + (splitBy ? 1 : 0)) / 3) * changeFactor;
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) }
);
} }
function getExistingLayer(currentState: XYState | undefined, layerId: string) { function getExistingLayer(currentState: XYState | undefined, layerId: string) {
return currentState && currentState.layers.find(layer => layer.layerId === layerId); return currentState && currentState.layers.find(layer => layer.layerId === layerId);
} }
function collectColumnMetaData(
xValue: TableSuggestionColumn,
yValues: TableSuggestionColumn[],
splitBy: TableSuggestionColumn | undefined
) {
const metadata: Record<string, OperationMetadata> = {};
[xValue, ...yValues, splitBy].forEach(col => {
if (col) {
metadata[col.columnId] = col.operation;
}
});
return metadata;
}

View file

@ -14,7 +14,7 @@ import { getSuggestions } from './xy_suggestions';
import { XYConfigPanel } from './xy_config_panel'; import { XYConfigPanel } from './xy_config_panel';
import { Visualization } from '../types'; import { Visualization } from '../types';
import { State, PersistableState, SeriesType, visualizationTypes } 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'; import { generateId } from '../id_generator';
const defaultIcon = 'visBarVertical'; const defaultIcon = 'visBarVertical';
@ -104,4 +104,5 @@ export const xyVisualization: Visualization<State, PersistableState> = {
), ),
toExpression, toExpression,
toPreviewExpression,
}; };