[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) => {
setFlyoutOpen(false);
switchToSuggestion(props.framePublicAPI, props.dispatch, {
...selection,
visualizationState: selection.getVisualizationState(),
});
switchToSuggestion(
props.framePublicAPI,
props.dispatch,
{
...selection,
visualizationState: selection.getVisualizationState(),
},
'SWITCH_VISUALIZATION'
);
};
function getSelection(

View file

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

View file

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

View file

@ -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');
});

View file

@ -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}
/>
)
}

View file

@ -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<string, { state: unknown; isLoading: boolean }>;
}
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;

View file

@ -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 => {

View file

@ -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;
}

View file

@ -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(<SuggestionPanel {...defaultProps} />);
const wrapper = mount(<InnerSuggestionPanel {...defaultProps} />);
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(<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', () => {
const wrapper = mount(<SuggestionPanel {...defaultProps} />);
const wrapper = mount(<InnerSuggestionPanel {...defaultProps} />);
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(<SuggestionPanel {...defaultProps} activeVisualizationId="vis2" />);
const wrapper = mount(
<InnerSuggestionPanel
{...defaultProps}
stagedPreview={{ visualization: { state: {}, activeId: 'vis' }, datasourceStates: {} }}
activeVisualizationId="vis2"
/>
);
wrapper
.find('[data-test-subj="lnsSuggestion"]')
.first()
.simulate('click');
act(() => {
wrapper
.find('button[data-test-subj="lnsSuggestion"]')
.at(1)
.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(<SuggestionPanel {...defaultProps} />);
mount(<InnerSuggestionPanel {...defaultProps} />);
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(<SuggestionPanel {...defaultProps} />);
const wrapper = mount(<InnerSuggestionPanel {...defaultProps} />);
expect(wrapper.find(EuiIcon)).toHaveLength(1);
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.
*/
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<boolean>(false);
useEffect(() => {
setExpressionError(false);
}, [previewExpression]);
const clickHandler = () => {
switchToSuggestion(frame, dispatch, suggestion);
};
}, [preview.expression]);
return (
<EuiToolTip content={suggestion.title}>
<EuiToolTip content={preview.title}>
<EuiPanelFixed
className="lnsSuggestionPanel__button"
className={classNames('lnsSuggestionPanel__button', {
'lnsSuggestionPanel__button-isSelected': selected,
})}
paddingSize="none"
data-test-subj="lnsSuggestion"
onClick={clickHandler}
onClick={onSelect}
>
{expressionError ? (
<div className="lnsSidebar__suggestionIcon">
<EuiIconTip
size="xxl"
size="xl"
color="danger"
type="cross"
type="alert"
aria-label={i18n.translate('xpack.lens.editorFrame.previewErrorLabel', {
defaultMessage: 'Preview rendering failed',
})}
@ -85,10 +99,12 @@ const SuggestionPreview = ({
})}
/>
</div>
) : previewExpression ? (
) : preview.expression ? (
<ExpressionRendererComponent
className="lnsSuggestionChartWrapper"
expression={previewExpression}
className={classNames('lnsSuggestionChartWrapper', {
'lnsSuggestionChartWrapper--withLabel': showTitleAsLabel,
})}
expression={preview.expression}
onRenderFailure={(e: unknown) => {
// eslint-disable-next-line no-console
console.error(`Failed to render preview: `, e);
@ -96,18 +112,23 @@ const SuggestionPreview = ({
}}
/>
) : (
<div className="lnsSidebar__suggestionIcon">
<EuiIcon size="xxl" type={suggestion.previewIcon} />
</div>
<span className="lnsSidebar__suggestionIcon">
<EuiIcon size="xxl" type={preview.icon} />
</span>
)}
{showTitleAsLabel && (
<span className="lnsSuggestionPanel__buttonLabel">{preview.title}</span>
)}
</EuiPanelFixed>
</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,
datasourceMap,
datasourceStates,
@ -117,70 +138,243 @@ function InnerSuggestionPanel({
dispatch,
frame,
ExpressionRenderer: ExpressionRendererComponent,
stagedPreview,
}: 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) {
return null;
}
const suggestions = getSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
activeVisualizationId,
visualizationState,
})
.filter(suggestion => !suggestion.hide)
.slice(0, MAX_SUGGESTIONS_DISPLAYED);
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 (
<div className="lnsSuggestionsPanel">
<EuiTitle className="lnsSuggestionsPanel__title" size="xxs">
<h3>
<FormattedMessage
id="xpack.lens.editorFrame.suggestionPanelTitle"
defaultMessage="Suggestions"
/>
</h3>
</EuiTitle>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiTitle className="lnsSuggestionsPanel__title" size="xxs">
<h3>
<FormattedMessage
id="xpack.lens.editorFrame.suggestionPanelTitle"
defaultMessage="Suggestions"
/>
</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">
{suggestions.map((suggestion: Suggestion) => (
{currentVisualizationId && (
<SuggestionPreview
suggestion={suggestion}
dispatch={dispatch}
frame={frame}
preview={{
expression: currentStateExpression
? prependKibanaContext(currentStateExpression, expressionContext)
: undefined,
icon:
visualizationMap[currentVisualizationId].getDescription(currentVisualizationState)
.icon || 'empty',
title: i18n.translate('xpack.lens.suggestions.currentVisLabel', {
defaultMessage: 'Current',
}),
}}
ExpressionRenderer={ExpressionRendererComponent}
previewExpression={
suggestion.previewExpression
? preparePreviewExpression(
suggestion.previewExpression,
datasourceMap,
datasourceStates,
frame,
suggestion.datasourceId,
suggestion.datasourceState
)
: undefined
}
key={`${suggestion.visualizationId}-${suggestion.title}`}
onSelect={rollbackToCurrentVisualization}
selected={lastSelectedSuggestion === -1}
showTitleAsLabel
/>
))}
)}
{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>
);
}
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(
expression: string | Ast,
visualizableState: VisualizableState,
visualization: Visualization,
datasourceMap: Record<string, Datasource<unknown, unknown>>,
datasourceStates: Record<string, { isLoading: boolean; state: unknown }>,
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;
}

View file

@ -76,7 +76,12 @@ export function InnerWorkspacePanel({
function onDrop() {
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?) => ({})),
renderConfigPanel: jest.fn(),
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);
});
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({

View file

@ -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,

View file

@ -49,7 +49,7 @@ describe('AutoScale', () => {
)
).toMatchInlineSnapshot(`
<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
style="transform:scale(0)"

View file

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

View file

@ -97,26 +97,6 @@ describe('metric_suggestions', () => {
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 {

View file

@ -41,20 +41,6 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion<State> {
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,

View file

@ -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",
],

View file

@ -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<State, PersistableState> = {
id: 'lnsMetric',
@ -57,22 +82,7 @@ export const metricVisualization: Visualization<State, PersistableState> = {
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'),
};

View file

@ -270,11 +270,6 @@ export interface VisualizationSuggestion<T = unknown> {
* 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<T = unknown, P = unknown> {
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<T>) => Array<VisualizationSuggestion<T>>;

View file

@ -1,3 +1,11 @@
.lnsChart {
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) {
if (!metadata) {
return defaultScale;

View file

@ -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 (
<EuiFlexGroup gutterSize="s" direction="column" alignItems="center" justifyContent="center">
<EuiFlexItem>
<EuiText className="lnsChart__empty" textAlign="center" color="subdued" size="xs">
<p>
<EuiIcon type={icon} color="subdued" size="l" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="xs">
<FormattedMessage
id="xpack.lens.xyVisualization.noDataLabel"
defaultMessage="No results found"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</p>
<p>
<FormattedMessage
id="xpack.lens.xyVisualization.noDataLabel"
defaultMessage="No results found"
/>
</p>
</EuiText>
);
}

View file

@ -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",
],
},
]
`);
});
});

View file

@ -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<State> | undefined {
const [buckets, values] = partition(
prioritizeColumns(table.columns),
col => col.operation.isBucketed
);
): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> | 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<State> {
): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> {
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<VisualizationSuggestion<State>> = [];
// 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<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 { 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<State, PersistableState> = {
),
toExpression,
toPreviewExpression,
};