From 717eaff2e9aab6c757905231c15111aa0c15e3de Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Thu, 6 Jun 2019 09:58:04 -0400 Subject: [PATCH] [Lens] Lens/xy config panel (#37391) Basic xy chart configuration panel --- .../editor_frame/editor_frame.test.tsx | 3 - .../lens/public/editor_frame_plugin/mocks.tsx | 1 + .../indexpattern_plugin/indexpattern.tsx | 5 + .../native_renderer/native_renderer.test.tsx | 60 +-- .../native_renderer/native_renderer.tsx | 57 +-- x-pack/plugins/lens/public/types.ts | 1 + .../public/xy_visualization_plugin/plugin.tsx | 3 +- .../public/xy_visualization_plugin/types.ts | 146 +++++++ .../xy_config_panel.test.tsx | 375 ++++++++++++++++++ .../xy_config_panel.tsx | 313 +++++++++++++++ .../xy_expression.test.tsx | 13 +- .../xy_visualization_plugin/xy_expression.tsx | 151 +------ .../xy_suggestions.test.ts | 4 +- .../xy_visualization_plugin/xy_suggestions.ts | 10 +- .../xy_visualization.test.ts | 24 +- .../xy_visualization.tsx | 60 +-- 16 files changed, 902 insertions(+), 324 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/types.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index d18bb564522e2..a2d4918d6a4bb 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -252,9 +252,6 @@ Object { state: updatedState, }) ); - - // don't re-render datasource when visulization changes - expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(1); }); it('should re-render data panel after state update', async () => { diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx index a472a56bec386..5d2d9e5bc5309 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -27,6 +27,7 @@ export function createMockDatasource(): DatasourceMock { const publicAPIMock: jest.Mocked = { getTableSpec: jest.fn(() => []), getOperationForColumnId: jest.fn(), + generateColumnId: jest.fn(), renderDimensionPanel: jest.fn(), removeColumnInTableSpec: jest.fn(), moveColumnTo: jest.fn(), diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 3ca66e2ab0db4..22614914d4dda 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -10,6 +10,7 @@ import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; import { Datasource, DataType } from '..'; +import uuid from 'uuid'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps } from '../types'; import { getIndexPatterns } from './loader'; @@ -232,6 +233,10 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To isBucketed, }; }, + generateColumnId: () => { + // TODO: Come up with a more compact form of generating unique column ids + return uuid.v4(); + }, renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { render( diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx index af5196165f9a5..9b29174a06d69 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx @@ -39,59 +39,7 @@ describe('native_renderer', () => { expect(renderSpy).toHaveBeenCalledWith(containerElement, testProps); }); - it('should not render again if props do not change', () => { - const renderSpy = jest.fn(); - const testProps = { a: 'abc' }; - - renderAndTriggerHooks( - , - mountpoint - ); - renderAndTriggerHooks( - , - mountpoint - ); - expect(renderSpy).toHaveBeenCalledTimes(1); - }); - - it('should not render again if props do not change shallowly', () => { - const renderSpy = jest.fn(); - const testProps = { a: 'abc' }; - - renderAndTriggerHooks( - , - mountpoint - ); - renderAndTriggerHooks( - , - mountpoint - ); - expect(renderSpy).toHaveBeenCalledTimes(1); - }); - - it('should not render again for unchanged callback functions', () => { - const renderSpy = jest.fn(); - const testCallback = () => {}; - const testState = { a: 'abc' }; - - render( - , - mountpoint - ); - render( - , - mountpoint - ); - expect(renderSpy).toHaveBeenCalledTimes(1); - }); - - it('should render again once if props change', () => { + it('should render again if props change', () => { const renderSpy = jest.fn(); const testProps = { a: 'abc' }; @@ -107,12 +55,12 @@ describe('native_renderer', () => { , mountpoint ); - expect(renderSpy).toHaveBeenCalledTimes(2); + expect(renderSpy).toHaveBeenCalledTimes(3); const containerElement = mountpoint.firstElementChild; expect(renderSpy).lastCalledWith(containerElement, { a: 'def' }); }); - it('should render again once if props is just a string', () => { + it('should render again if props is just a string', () => { const renderSpy = jest.fn(); const testProps = 'abc'; @@ -122,7 +70,7 @@ describe('native_renderer', () => { ); renderAndTriggerHooks(, mountpoint); renderAndTriggerHooks(, mountpoint); - expect(renderSpy).toHaveBeenCalledTimes(2); + expect(renderSpy).toHaveBeenCalledTimes(3); const containerElement = mountpoint.firstElementChild; expect(renderSpy).lastCalledWith(containerElement, 'def'); }); diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx index f0eb4b829c153..3bc042660e646 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx @@ -4,77 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; export interface NativeRendererProps { render: (domElement: Element, props: T) => void; nativeProps: T; tag?: string; - children?: never; -} - -function is(x: unknown, y: unknown) { - return (x === y && (x !== 0 || 1 / (x as number) === 1 / (y as number))) || (x !== x && y !== y); -} - -function isShallowDifferent(objA: T, objB: T): boolean { - if (is(objA, objB)) { - return false; - } - - if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { - return true; - } - - const keysA = Object.keys(objA) as Array; - const keysB = Object.keys(objB) as Array; - - if (keysA.length !== keysB.length) { - return true; - } - - for (let i = 0; i < keysA.length; i++) { - if (!window.hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { - return true; - } - } - - return false; } /** * A component which takes care of providing a mountpoint for a generic render * function which takes an html element and an optional props object. - * It also takes care of calling render again if the props object changes. * By default the mountpoint element will be a div, this can be changed with the * `tag` prop. * * @param props */ export function NativeRenderer({ render, nativeProps, tag }: NativeRendererProps) { - const elementRef = useRef(null); - const propsRef = useRef(null); - - function renderAndUpdate(element: Element) { - elementRef.current = element; - propsRef.current = nativeProps; - render(element, nativeProps); - } - - useEffect( - () => { - if (elementRef.current && isShallowDifferent(propsRef.current, nativeProps)) { - renderAndUpdate(elementRef.current); - } - }, - [nativeProps] - ); - return React.createElement(tag || 'div', { - ref: element => { - if (element && element !== elementRef.current) { - renderAndUpdate(element); - } - }, + ref: el => el && render(el, nativeProps), }); } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index d6b0071fe803f..07b12cef50726 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -68,6 +68,7 @@ export interface Datasource { export interface DatasourcePublicAPI { getTableSpec: () => TableSpec; getOperationForColumnId: (columnId: string) => Operation | null; + generateColumnId: () => string; // Render can be called many times renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => void; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx index 55b5aa049077b..78e3b1964b99c 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -14,7 +14,8 @@ import { // @ts-ignore untyped dependency } from '../../../../../src/legacy/core_plugins/interpreter/public/registries'; import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; -import { legendConfig, xConfig, yConfig, xyChart, xyChartRenderer } from './xy_expression'; +import { xyChart, xyChartRenderer } from './xy_expression'; +import { legendConfig, xConfig, yConfig } from './types'; // TODO these are intermediary types because interpreter is not typed yet // They can get replaced by references to the real interfaces as soon as they diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts new file mode 100644 index 0000000000000..7ffe0669d35e9 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Position } from '@elastic/charts'; +import { + ExpressionFunction, + ArgumentType, +} from '../../../../../src/legacy/core_plugins/interpreter/public'; + +export interface LegendConfig { + isVisible: boolean; + position: Position; +} + +type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; + +export const legendConfig: ExpressionFunction< + 'lens_xy_legendConfig', + null, + LegendConfig, + LegendConfigResult +> = { + name: 'lens_xy_legendConfig', + aliases: [], + type: 'lens_xy_legendConfig', + help: `Configure the xy chart's legend`, + context: { + types: ['null'], + }, + args: { + isVisible: { + types: ['boolean'], + help: 'Specifies whether or not the legend is visible.', + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: 'Specifies the legend position.', + }, + }, + fn: function fn(_context: unknown, args: LegendConfig) { + return { + type: 'lens_xy_legendConfig', + ...args, + }; + }, +}; + +interface AxisConfig { + title: string; + showGridlines: boolean; + position: Position; +} + +const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { + title: { + types: ['string'], + help: 'The axis title', + }, + showGridlines: { + types: ['boolean'], + help: 'Show / hide axis grid lines.', + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: 'The position of the axis', + }, +}; + +export interface YConfig extends AxisConfig { + accessors: string[]; +} + +type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; + +export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { + name: 'lens_xy_yConfig', + aliases: [], + type: 'lens_xy_yConfig', + help: `Configure the xy chart's y axis`, + context: { + types: ['null'], + }, + args: { + ...axisConfig, + accessors: { + types: ['string'], + help: 'The columns to display on the y axis.', + multi: true, + }, + }, + fn: function fn(_context: unknown, args: YConfig) { + return { + type: 'lens_xy_yConfig', + ...args, + }; + }, +}; + +export interface XConfig extends AxisConfig { + accessor: string; +} + +type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; + +export const xConfig: ExpressionFunction<'lens_xy_xConfig', null, XConfig, XConfigResult> = { + name: 'lens_xy_xConfig', + aliases: [], + type: 'lens_xy_xConfig', + help: `Configure the xy chart's x axis`, + context: { + types: ['null'], + }, + args: { + ...axisConfig, + accessor: { + types: ['string'], + help: 'The column to display on the x axis.', + }, + }, + fn: function fn(_context: unknown, args: XConfig) { + return { + type: 'lens_xy_xConfig', + ...args, + }; + }, +}; + +export type SeriesType = 'bar' | 'horizontal_bar' | 'line' | 'area'; + +export interface XYArgs { + seriesType: SeriesType; + title: string; + legend: LegendConfig; + y: YConfig; + x: XConfig; + splitSeriesAccessors: string[]; + stackAccessors: string[]; +} + +export type State = XYArgs; +export type PersistableState = XYArgs; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx new file mode 100644 index 0000000000000..cf5762c58fc84 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -0,0 +1,375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { XYConfigPanel } from './xy_config_panel'; +import { DatasourcePublicAPI, DatasourceDimensionPanelProps, Operation } from '../types'; +import { State, SeriesType } from './types'; +import { Position } from '@elastic/charts'; +import { NativeRendererProps } from '../native_renderer'; + +describe('XYConfigPanel', () => { + function mockDatasource(): DatasourcePublicAPI { + return { + duplicateColumn: () => [], + getOperationForColumnId: () => null, + generateColumnId: () => 'TESTID', + getTableSpec: () => [], + moveColumnTo: () => {}, + removeColumnInTableSpec: () => [], + renderDimensionPanel: () => {}, + }; + } + + function testState(): State { + return { + legend: { isVisible: true, position: Position.Right }, + seriesType: 'bar', + splitSeriesAccessors: [], + stackAccessors: [], + title: 'Test Chart', + x: { + accessor: 'foo', + position: Position.Bottom, + showGridlines: true, + title: 'X', + }, + y: { + accessors: ['bar'], + position: Position.Left, + showGridlines: true, + title: 'Y', + }, + }; + } + + function testSubj(component: ReactWrapper, subj: string) { + return component + .find(`[data-test-subj="${subj}"]`) + .first() + .props(); + } + + test('toggles axis position when going from horizontal bar to any other type', () => { + const changeSeriesType = (fromSeriesType: SeriesType, toSeriesType: SeriesType) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_seriesType').onChange as Function)(toSeriesType); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(changeSeriesType('line', 'horizontal_bar')).toMatchObject({ + seriesType: 'horizontal_bar', + x: { position: Position.Left }, + y: { position: Position.Bottom }, + }); + expect(changeSeriesType('horizontal_bar', 'bar')).toMatchObject({ + seriesType: 'bar', + x: { position: Position.Bottom }, + y: { position: Position.Left }, + }); + expect(changeSeriesType('horizontal_bar', 'line')).toMatchObject({ + seriesType: 'line', + x: { position: Position.Bottom }, + y: { position: Position.Left }, + }); + expect(changeSeriesType('horizontal_bar', 'area')).toMatchObject({ + seriesType: 'area', + x: { position: Position.Bottom }, + y: { position: Position.Left }, + }); + }); + + test('allows toggling of legend visibility', () => { + const toggleIsVisible = (isVisible: boolean) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_legendIsVisible').onChange as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(toggleIsVisible(false)).toMatchObject({ + legend: { isVisible: true }, + }); + expect(toggleIsVisible(true)).toMatchObject({ + legend: { isVisible: false }, + }); + }); + + test('allows editing the chart title', () => { + const testSetTitle = (title: string) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_title').onChange as Function)({ target: { value: title } }); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testSetTitle('Hoi')).toMatchObject({ + title: 'Hoi', + }); + expect(testSetTitle('There!')).toMatchObject({ + title: 'There!', + }); + }); + + test('allows changing legend position', () => { + const testLegendPosition = (position: Position) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_legendPosition').onChange as Function)(position); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testLegendPosition(Position.Bottom)).toMatchObject({ + legend: { position: Position.Bottom }, + }); + expect(testLegendPosition(Position.Top)).toMatchObject({ + legend: { position: Position.Top }, + }); + expect(testLegendPosition(Position.Left)).toMatchObject({ + legend: { position: Position.Left }, + }); + expect(testLegendPosition(Position.Right)).toMatchObject({ + legend: { position: Position.Right }, + }); + }); + + test('allows editing the x axis title', () => { + const testSetTitle = (title: string) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_xTitle').onChange as Function)({ target: { value: title } }); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testSetTitle('Hoi')).toMatchObject({ + x: { title: 'Hoi' }, + }); + expect(testSetTitle('There!')).toMatchObject({ + x: { title: 'There!' }, + }); + }); + + test('the x dimension panel accepts any operations', () => { + const datasource = { + ...mockDatasource(), + renderDimensionPanel: jest.fn(), + }; + const state = testState(); + const component = mount( + + ); + + const panel = testSubj(component, 'lnsXY_xDimensionPanel'); + const nativeProps = (panel as NativeRendererProps).nativeProps; + const { columnId, filterOperations } = nativeProps; + const exampleOperation: Operation = { + dataType: 'number', + id: 'foo', + isBucketed: false, + label: 'bar', + }; + const ops: Operation[] = [ + { ...exampleOperation, dataType: 'number' }, + { ...exampleOperation, dataType: 'string' }, + { ...exampleOperation, dataType: 'boolean' }, + { ...exampleOperation, dataType: 'date' }, + ]; + expect(columnId).toEqual('shazm'); + expect(ops.filter(filterOperations)).toEqual(ops); + }); + + test('allows toggling the x axis gridlines', () => { + const toggleXGridlines = (showGridlines: boolean) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_xShowGridlines').onChange as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(toggleXGridlines(true)).toMatchObject({ + x: { showGridlines: false }, + }); + expect(toggleXGridlines(false)).toMatchObject({ + x: { showGridlines: true }, + }); + }); + + test('allows editing the y axis title', () => { + const testSetTitle = (title: string) => { + const setState = jest.fn(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_yTitle').onChange as Function)({ target: { value: title } }); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(testSetTitle('Hoi')).toMatchObject({ + y: { title: 'Hoi' }, + }); + expect(testSetTitle('There!')).toMatchObject({ + y: { title: 'There!' }, + }); + }); + + test('the y dimension panel accepts numeric operations', () => { + const datasource = { + ...mockDatasource(), + renderDimensionPanel: jest.fn(), + }; + const state = testState(); + const component = mount( + + ); + + const panel = testSubj(component, 'lnsXY_yDimensionPanel_a'); + const nativeProps = (panel as NativeRendererProps).nativeProps; + const { filterOperations } = nativeProps; + const exampleOperation: Operation = { + dataType: 'number', + id: 'foo', + isBucketed: false, + label: 'bar', + }; + const ops: Operation[] = [ + { ...exampleOperation, dataType: 'number' }, + { ...exampleOperation, dataType: 'string' }, + { ...exampleOperation, dataType: 'boolean' }, + { ...exampleOperation, dataType: 'date' }, + ]; + expect(ops.filter(filterOperations).map(x => x.dataType)).toEqual(['number']); + }); + + test('allows removal of y dimensions', () => { + const removeColumnInTableSpec = jest.fn(); + const datasource = { + ...mockDatasource(), + removeColumnInTableSpec, + }; + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_yDimensionPanel_remove_b').onClick as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + y: { accessors: ['a', 'c'] }, + }); + expect(removeColumnInTableSpec).toHaveBeenCalledTimes(1); + expect(removeColumnInTableSpec).toHaveBeenCalledWith('b'); + }); + + test('allows adding y dimensions', () => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + 'zed' }} + setState={setState} + state={{ ...state, y: { ...state.y, accessors: ['a', 'b', 'c'] } }} + /> + ); + + (testSubj(component, 'lnsXY_yDimensionPanel_add').onClick as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + y: { accessors: ['a', 'b', 'c', 'zed'] }, + }); + }); + + test('allows toggling the y axis gridlines', () => { + const toggleYGridlines = (showGridlines: boolean) => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + (testSubj(component, 'lnsXY_yShowGridlines').onChange as Function)(); + + expect(setState).toHaveBeenCalledTimes(1); + return setState.mock.calls[0][0]; + }; + + expect(toggleYGridlines(true)).toMatchObject({ + y: { showGridlines: false }, + }); + expect(toggleYGridlines(false)).toMatchObject({ + y: { showGridlines: true }, + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx new file mode 100644 index 0000000000000..017fd2ccec4b3 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; +import { + EuiFieldText, + EuiButtonGroup, + EuiForm, + EuiFormRow, + EuiSwitch, + EuiButtonIcon, + EuiButton, + IconType, +} from '@elastic/eui'; +import { State, SeriesType } from './types'; +import { VisualizationProps, Operation } from '../types'; +import { NativeRenderer } from '../native_renderer'; + +const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ + { + id: 'line', + label: 'Line', + iconType: 'visLine', + }, + { + id: 'area', + label: 'Area', + iconType: 'visArea', + }, + { + id: 'bar', + label: 'Bar', + iconType: 'visBarVertical', + }, + { + id: 'horizontal_bar', + label: 'Horizontal Bar', + iconType: 'visBarHorizontal', + }, +]; + +const positionIcons = [ + { + id: Position.Left, + label: 'Left', + iconType: 'arrowLeft', + }, + { + id: Position.Top, + label: 'Top', + iconType: 'arrowUp', + }, + { + id: Position.Bottom, + label: 'Bottom', + iconType: 'arrowDown', + }, + { + id: Position.Right, + label: 'Right', + iconType: 'arrowRight', + }, +]; + +export function XYConfigPanel(props: VisualizationProps) { + const { state, datasource, setState } = props; + + return ( + + + { + const isHorizontal = seriesType === 'horizontal_bar'; + setState({ + ...state, + seriesType: seriesType as SeriesType, + x: { + ...state.x, + position: isHorizontal ? Position.Left : Position.Bottom, + }, + y: { + ...state.y, + position: isHorizontal ? Position.Bottom : Position.Left, + }, + }); + }} + isIconOnly + /> + + + + setState({ ...state, title: e.target.value })} + aria-label={i18n.translate('xpack.lens.xyChart.chartTitleAriaLabel', { + defaultMessage: 'Title', + })} + /> + + + + + setState({ + ...state, + legend: { ...state.legend, isVisible: !state.legend.isVisible }, + }) + } + /> + + + {state.legend.isVisible && ( + + + setState({ ...state, legend: { ...state.legend, position: position as Position } }) + } + isIconOnly + /> + + )} + + + <> + + setState({ ...state, x: { ...state.x, title: e.target.value } })} + aria-label={i18n.translate('xpack.lens.xyChart.xTitleAriaLabel', { + defaultMessage: 'Title', + })} + /> + + + + true, + }} + /> + + + + + setState({ ...state, x: { ...state.x, showGridlines: !state.x.showGridlines } }) + } + /> + + + + + + <> + + setState({ ...state, y: { ...state.y, title: e.target.value } })} + aria-label={i18n.translate('xpack.lens.xyChart.yTitleAriaLabel', { + defaultMessage: 'Title', + })} + /> + + + + <> + {state.y.accessors.map(accessor => ( +
+ + !op.isBucketed && op.dataType === 'number', + }} + /> + { + datasource.removeColumnInTableSpec(accessor); + setState({ + ...state, + y: { + ...state.y, + accessors: state.y.accessors.filter(col => col !== accessor), + }, + }); + }} + aria-label={i18n.translate('xpack.lens.xyChart.yRemoveAriaLabel', { + defaultMessage: 'Remove', + })} + /> +
+ ))} + + setState({ + ...state, + y: { + ...state.y, + accessors: [...state.y.accessors, datasource.generateColumnId()], + }, + }) + } + iconType="plusInCircle" + /> + +
+ + + + setState({ ...state, y: { ...state.y, showGridlines: !state.y.showGridlines } }) + } + /> + + +
+
+ ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx index eb7292f4515ee..3b6f05f86d6d5 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -5,20 +5,11 @@ */ import { Position } from '@elastic/charts'; -import { - legendConfig, - LegendConfig, - xConfig, - XConfig, - YConfig, - yConfig, - XYArgs, - xyChart, - XYChart, -} from './xy_expression'; +import { xyChart, XYChart } from './xy_expression'; import { KibanaDatatable } from '../types'; import React from 'react'; import { shallow } from 'enzyme'; +import { XYArgs, LegendConfig, legendConfig, XConfig, xConfig, YConfig, yConfig } from './types'; function sampleArgs() { const data: KibanaDatatable = { diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index 144083035bdad..9b2b9290b54f1 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -7,7 +7,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { - Position, Chart, Settings, Axis, @@ -17,152 +16,11 @@ import { AreaSeries, BarSeries, } from '@elastic/charts'; -import { - ExpressionFunction, - ArgumentType, -} from '../../../../../src/legacy/core_plugins/interpreter/public'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { XYArgs } from './types'; import { KibanaDatatable } from '../types'; import { RenderFunction } from './plugin'; -/** - * This file contains TypeScript type definitions and their equivalent expression - * definitions, for configuring and rendering an XY chart. The XY chart serves - * triple duty as a bar, line, or area chart. - * - * The xy_chart expression function serves mostly as a passthrough to the xy_chart_renderer - * which does the heavy-lifting. - */ - -export interface LegendConfig { - isVisible: boolean; - position: Position; -} - -type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; - -export const legendConfig: ExpressionFunction< - 'lens_xy_legendConfig', - null, - LegendConfig, - LegendConfigResult -> = { - name: 'lens_xy_legendConfig', - aliases: [], - type: 'lens_xy_legendConfig', - help: `Configure the xy chart's legend`, - context: { - types: ['null'], - }, - args: { - isVisible: { - types: ['boolean'], - help: 'Specifies whether or not the legend is visible.', - }, - position: { - types: ['string'], - options: [Position.Top, Position.Right, Position.Bottom, Position.Left], - help: 'Specifies the legend position.', - }, - }, - fn: function fn(_context: unknown, args: LegendConfig) { - return { - type: 'lens_xy_legendConfig', - ...args, - }; - }, -}; - -interface AxisConfig { - title: string; - showGridlines: boolean; - position: Position; -} - -const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { - title: { - types: ['string'], - help: 'The axis title', - }, - showGridlines: { - types: ['boolean'], - help: 'Show / hide axis grid lines.', - }, - position: { - types: ['string'], - options: [Position.Top, Position.Right, Position.Bottom, Position.Left], - help: 'The position of the axis', - }, -}; - -export interface YConfig extends AxisConfig { - accessors: string[]; -} - -type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; - -export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConfigResult> = { - name: 'lens_xy_yConfig', - aliases: [], - type: 'lens_xy_yConfig', - help: `Configure the xy chart's y axis`, - context: { - types: ['null'], - }, - args: { - ...axisConfig, - accessors: { - types: ['string'], - help: 'The columns to display on the y axis.', - multi: true, - }, - }, - fn: function fn(_context: unknown, args: YConfig) { - return { - type: 'lens_xy_yConfig', - ...args, - }; - }, -}; - -export interface XConfig extends AxisConfig { - accessor: string; -} - -type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; - -export const xConfig: ExpressionFunction<'lens_xy_xConfig', null, XConfig, XConfigResult> = { - name: 'lens_xy_xConfig', - aliases: [], - type: 'lens_xy_xConfig', - help: `Configure the xy chart's x axis`, - context: { - types: ['null'], - }, - args: { - ...axisConfig, - accessor: { - types: ['string'], - help: 'The column to display on the x axis.', - }, - }, - fn: function fn(_context: unknown, args: XConfig) { - return { - type: 'lens_xy_xConfig', - ...args, - }; - }, -}; - -export interface XYArgs { - seriesType: 'bar' | 'line' | 'area'; - title: string; - legend: LegendConfig; - y: YConfig; - x: XConfig; - splitSeriesAccessors: string[]; - stackAccessors: string[]; -} - export interface XYChartProps { data: KibanaDatatable; args: XYArgs; @@ -227,6 +85,11 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArg // TODO the typings currently don't support custom type args. As soon as they do, this can be removed } as unknown) as ExpressionFunction<'lens_xy_chart', KibanaDatatable, XYArgs, XYRender>; +export interface XYChartProps { + data: KibanaDatatable; + args: XYArgs; +} + export const xyChartRenderer: RenderFunction = { name: 'lens_xy_chart_renderer', displayName: 'XY Chart', diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts index bc68cf206d573..83665dd8a6fe4 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -6,7 +6,7 @@ import { getSuggestions } from './xy_suggestions'; import { TableColumn, VisualizationSuggestion } from '../types'; -import { XYArgs } from './xy_expression'; +import { State } from './types'; describe('xy_suggestions', () => { function numCol(columnId: string): TableColumn { @@ -47,7 +47,7 @@ describe('xy_suggestions', () => { // Helper that plucks out the important part of a suggestion for // most test assertions - function suggestionSubset(suggestion: VisualizationSuggestion) { + function suggestionSubset(suggestion: VisualizationSuggestion) { const { seriesType, splitSeriesAccessors, stackAccessors, x, y } = suggestion.state; return { diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts index f1b49a2318424..28ef677e49644 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -6,8 +6,8 @@ import { partition } from 'lodash'; import { Position } from '@elastic/charts'; -import { XYArgs } from './xy_expression'; import { SuggestionRequest, VisualizationSuggestion, TableColumn, TableSuggestion } from '../types'; +import { State } from './types'; const columnSortOrder = { date: 0, @@ -22,8 +22,8 @@ const columnSortOrder = { * @param opts */ export function getSuggestions( - opts: SuggestionRequest -): Array> { + opts: SuggestionRequest +): Array> { return opts.tables .filter( ({ isMultiRow, columns }) => @@ -38,7 +38,7 @@ export function getSuggestions( .map(table => getSuggestionForColumns(table)); } -function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { +function getSuggestionForColumns(table: TableSuggestion): VisualizationSuggestion { const [buckets, values] = partition( prioritizeColumns(table.columns), col => col.operation.isBucketed @@ -67,7 +67,7 @@ function getSuggestion( xValue: TableColumn, yValues: TableColumn[], splitBy?: TableColumn -): VisualizationSuggestion { +): VisualizationSuggestion { const yTitle = yValues.map(col => col.operation.label).join(' & '); const xTitle = xValue.operation.label; const isDate = xValue.operation.dataType === 'date'; diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index 51996de5e0352..6fa876bed0663 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { xyVisualization, State } from './xy_visualization'; +import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; +import { State } from './types'; function exampleState(): State { return { @@ -32,7 +33,18 @@ function exampleState(): State { describe('IndexPattern Data Source', () => { describe('#initialize', () => { it('loads default state', () => { - expect(xyVisualization.initialize()).toMatchInlineSnapshot(` + const initialState = xyVisualization.initialize(); + + expect(initialState.x.accessor).toBeDefined(); + expect(initialState.y.accessors[0]).toBeDefined(); + expect(initialState.x.accessor).not.toEqual(initialState.y.accessors[0]); + + // These change with each generation, so we'll ignore them + // in our match snapshot test. + delete initialState.x.accessor; + delete initialState.y.accessors; + + expect(initialState).toMatchInlineSnapshot(` Object { "legend": Object { "isVisible": true, @@ -41,18 +53,16 @@ Object { "seriesType": "line", "splitSeriesAccessors": Array [], "stackAccessors": Array [], - "title": "Empty line chart", + "title": "Empty XY Chart", "x": Object { - "accessor": "", "position": "bottom", "showGridlines": false, - "title": "Uknown", + "title": "X", }, "y": Object { - "accessors": Array [], "position": "left", "showGridlines": false, - "title": "Uknown", + "title": "Y", }, } `); diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index beefaaaa7b45d..58cc7422631cb 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -7,13 +7,12 @@ import React from 'react'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; -import { Visualization, Operation } from '../types'; +import uuid from 'uuid'; +import { I18nProvider } from '@kbn/i18n/react'; import { getSuggestions } from './xy_suggestions'; -import { XYArgs } from './xy_expression'; -import { NativeRenderer } from '../native_renderer'; - -export type State = XYArgs; -export type PersistableState = XYArgs; +import { XYConfigPanel } from './xy_config_panel'; +import { Visualization } from '../types'; +import { State, PersistableState } from './types'; export const xyVisualization: Visualization = { getSuggestions, @@ -21,55 +20,36 @@ export const xyVisualization: Visualization = { initialize(state) { return ( state || { - title: 'Empty line chart', - legend: { isVisible: true, position: Position.Right }, seriesType: 'line', - splitSeriesAccessors: [], - stackAccessors: [], + title: 'Empty XY Chart', + legend: { isVisible: true, position: Position.Right }, x: { - accessor: '', + accessor: uuid.v4(), position: Position.Bottom, showGridlines: false, - title: 'Uknown', + title: 'X', }, y: { - accessors: [], + accessors: [uuid.v4()], position: Position.Left, showGridlines: false, - title: 'Uknown', + title: 'Y', }, + splitSeriesAccessors: [], + stackAccessors: [], } ); }, - getPersistableState(state) { - return state; - }, + getPersistableState: state => state, - renderConfigPanel: (domElement, props) => { + renderConfigPanel: (domElement, props) => render( -
- XY Visualization - true, - suggestedOrder: 1, - }} - render={props.datasource.renderDimensionPanel} - /> - true, - suggestedOrder: 2, - }} - render={props.datasource.renderDimensionPanel} - /> -
, + + + , domElement - ); - }, + ), - toExpression: state => null, + toExpression: () => '', };