diff --git a/x-pack/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/plugins/lens/public/app_plugin/plugin.tsx index 857cee9adbc64..9cdfa74a9148e 100644 --- a/x-pack/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/app_plugin/plugin.tsx @@ -7,6 +7,10 @@ import React from 'react'; import { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; +import { + datatableVisualizationSetup, + datatableVisualizationStop, +} from '../datatable_visualization_plugin'; import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin'; import { App } from './app'; import { EditorFrameInstance } from '../types'; @@ -20,11 +24,13 @@ export class AppPlugin { // TODO: These plugins should not be called from the top level, but since this is the // entry point to the app we have no choice until the new platform is ready const indexPattern = indexPatternDatasourceSetup(); + const datatableVisualization = datatableVisualizationSetup(); const xyVisualization = xyVisualizationSetup(); const editorFrame = editorFrameSetup(); editorFrame.registerDatasource('indexpattern', indexPattern); editorFrame.registerVisualization('xy', xyVisualization); + editorFrame.registerVisualization('datatable', datatableVisualization); this.instance = editorFrame.createInstance({}); @@ -38,6 +44,7 @@ export class AppPlugin { // TODO this will be handled by the plugin platform itself indexPatternDatasourceStop(); + datatableVisualizationStop(); xyVisualizationStop(); editorFrameStop(); } diff --git a/x-pack/plugins/lens/public/datatable_visualization_plugin/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization_plugin/expression.tsx new file mode 100644 index 0000000000000..44799bc7a403b --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -0,0 +1,141 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable } from '@elastic/eui'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from '../types'; +import { RenderFunction } from '../interpreter_types'; + +export interface DatatableColumns { + columnIds: string[]; + labels: string[]; +} + +interface Args { + columns: DatatableColumns; +} + +export interface DatatableProps { + data: KibanaDatatable; + args: Args; +} + +export interface DatatableRender { + type: 'render'; + as: 'lens_datatable_renderer'; + value: DatatableProps; +} + +export const datatable: ExpressionFunction< + 'lens_datatable', + KibanaDatatable, + Args, + DatatableRender +> = ({ + name: 'lens_datatable', + type: 'render', + help: i18n.translate('xpack.lens.datatable.expressionHelpLabel', { + defaultMessage: 'Datatable renderer', + }), + args: { + title: { + types: ['string'], + help: i18n.translate('xpack.lens.datatable.titleLabel', { + defaultMessage: 'Title', + }), + }, + columns: { + types: ['lens_datatable_columns'], + help: '', + }, + }, + context: { + types: ['kibana_datatable'], + }, + fn(data: KibanaDatatable, args: Args) { + return { + type: 'render', + as: 'lens_datatable_renderer', + value: { + data, + args, + }, + }; + }, + // TODO the typings currently don't support custom type args. As soon as they do, this can be removed +} as unknown) as ExpressionFunction<'lens_datatable', KibanaDatatable, Args, DatatableRender>; + +type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' }; + +export const datatableColumns: ExpressionFunction< + 'lens_datatable_columns', + null, + DatatableColumns, + DatatableColumnsResult +> = { + name: 'lens_datatable_columns', + aliases: [], + type: 'lens_datatable_columns', + help: '', + context: { + types: ['null'], + }, + args: { + columnIds: { + types: ['string'], + multi: true, + help: '', + }, + labels: { + types: ['string'], + multi: true, + help: '', + }, + }, + fn: function fn(_context: unknown, args: DatatableColumns) { + return { + type: 'lens_datatable_columns', + ...args, + }; + }, +}; + +export interface DatatableProps { + data: KibanaDatatable; + args: Args; +} + +export const datatableRenderer: RenderFunction = { + name: 'lens_datatable_renderer', + displayName: i18n.translate('xpack.lens.datatable.visualizationName', { + defaultMessage: 'Datatable', + }), + help: '', + validate: () => {}, + reuseDomNode: true, + render: async (domNode: Element, config: DatatableProps, _handlers: unknown) => { + ReactDOM.render(, domNode); + }, +}; + +function DatatableComponent(props: DatatableProps) { + return ( + { + return { + field: props.args.columns.columnIds[index], + name: props.args.columns.labels[index], + }; + }) + .filter(({ field }) => !!field)} + items={props.data.rows} + /> + ); +} diff --git a/x-pack/plugins/lens/public/datatable_visualization_plugin/index.ts b/x-pack/plugins/lens/public/datatable_visualization_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization_plugin/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export * from './plugin'; diff --git a/x-pack/plugins/lens/public/datatable_visualization_plugin/plugin.tsx b/x-pack/plugins/lens/public/datatable_visualization_plugin/plugin.tsx new file mode 100644 index 0000000000000..298325dac9a6a --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Registry } from '@kbn/interpreter/target/common'; +import { CoreSetup } from 'src/core/public'; +import { datatableVisualization } from './visualization'; + +import { + renderersRegistry, + functionsRegistry, + // @ts-ignore untyped dependency +} from '../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { InterpreterSetup, RenderFunction } from '../interpreter_types'; +import { datatable, datatableColumns, datatableRenderer } from './expression'; + +export interface DatatableVisualizationPluginSetupPlugins { + interpreter: InterpreterSetup; +} + +class DatatableVisualizationPlugin { + constructor() {} + + setup(_core: CoreSetup | null, { interpreter }: DatatableVisualizationPluginSetupPlugins) { + interpreter.functionsRegistry.register(() => datatableColumns); + interpreter.functionsRegistry.register(() => datatable); + interpreter.renderersRegistry.register(() => datatableRenderer as RenderFunction); + + return datatableVisualization; + } + + stop() {} +} + +const plugin = new DatatableVisualizationPlugin(); + +export const datatableVisualizationSetup = () => + plugin.setup(null, { + interpreter: { + renderersRegistry, + functionsRegistry, + }, + }); +export const datatableVisualizationStop = () => plugin.stop(); diff --git a/x-pack/plugins/lens/public/datatable_visualization_plugin/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization_plugin/visualization.tsx new file mode 100644 index 0000000000000..9ffb877d67cb8 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -0,0 +1,215 @@ +/* + * 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 { render } from 'react-dom'; +import { + EuiButtonIcon, + EuiForm, + EuiFieldText, + EuiFormRow, + EuiButton, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { + SuggestionRequest, + Visualization, + VisualizationProps, + VisualizationSuggestion, +} from '../types'; +import { NativeRenderer } from '../native_renderer'; + +export interface DatatableVisualizationState { + columns: Array<{ + id: string; + label: string; + }>; +} + +export function DatatableConfigPanel(props: VisualizationProps) { + const { state, datasource, setState } = props; + + return ( +
+ + {state.columns.map(({ id, label }, index) => { + const operation = datasource.getOperationForColumnId(id); + return ( + <> + + { + const newColumns = [...state.columns]; + newColumns[index] = { ...newColumns[index], label: e.target.value }; + setState({ + ...state, + columns: newColumns, + }); + }} + placeholder={ + operation + ? operation.label + : i18n.translate('xpack.lens.datatable.columnTitlePlaceholder', { + defaultMessage: 'Title', + }) + } + aria-label={i18n.translate('xpack.lens.datatable.columnTitlePlaceholder', { + defaultMessage: 'Title', + })} + /> + + + + + + true, + }} + /> + + + + { + datasource.removeColumnInTableSpec(id); + const newColumns = [...state.columns]; + newColumns.splice(index); + setState({ + ...state, + columns: newColumns, + }); + }} + aria-label={i18n.translate('xpack.lens.datasource.removeColumnAriaLabel', { + defaultMessage: 'Remove', + })} + /> + + + + + ); + })} + +
+ { + const newColumns = [...state.columns]; + newColumns.push({ + id: datasource.generateColumnId(), + label: '', + }); + setState({ + ...state, + columns: newColumns, + }); + }} + iconType="plusInCircle" + /> +
+
+
+ ); +} + +export const datatableVisualization: Visualization< + DatatableVisualizationState, + DatatableVisualizationState +> = { + initialize(datasource, state) { + return ( + state || { + columns: [ + { + id: datasource.generateColumnId(), + label: '', + }, + ], + } + ); + }, + + getPersistableState: state => state, + + getSuggestions({ + tables, + }: SuggestionRequest): Array< + VisualizationSuggestion + > { + return tables.map(table => { + const title = 'Table: ' + table.columns.map(col => col.operation.label).join(' & '); + + return { + title, + score: 1, + datasourceSuggestionId: table.datasourceSuggestionId, + state: { + columns: table.columns.map(col => ({ + id: col.columnId, + label: col.operation.label, + })), + }, + }; + }); + }, + + renderConfigPanel: (domElement, props) => + render( + + + , + domElement + ), + + toExpression: (state, datasource) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable', + arguments: { + columns: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_columns', + arguments: { + columnIds: state.columns.map(({ id }) => id), + labels: state.columns.map(({ id, label }) => + label || datasource.getOperationForColumnId(id) + ? datasource.getOperationForColumnId(id)!.label + : '' + ), + }, + }, + ], + }, + ], + }, + }, + ], + }), +}; diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx index 6d16e3aff0fd1..2803aa5a6e567 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -19,6 +19,30 @@ interface ConfigPanelWrapperProps { datasourcePublicAPI: DatasourcePublicAPI; } +function getSuggestedVisualizationState( + visualization: Visualization, + datasource: DatasourcePublicAPI +) { + const suggestions = visualization.getSuggestions({ + tables: [ + { + datasourceSuggestionId: 0, + isMultiRow: true, + columns: datasource.getTableSpec().map(col => ({ + ...col, + operation: datasource.getOperationForColumnId(col.columnId)!, + })), + }, + ], + }); + + if (!suggestions.length) { + return visualization.initialize(datasource); + } + + return visualization.initialize(datasource, suggestions[0].state); +} + export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const context = useContext(DragContext); const setVisualizationState = useMemo( @@ -41,14 +65,15 @@ export function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { }))} value={props.activeVisualizationId || undefined} onChange={e => { + const newState = getSuggestedVisualizationState( + props.visualizationMap[e.target.value], + props.datasourcePublicAPI + ); + props.dispatch({ type: 'SWITCH_VISUALIZATION', newVisualizationId: e.target.value, - // TODO we probably want to have a separate API to "force" a visualization switch - // which isn't a result of a picked suggestion - initialState: props.visualizationMap[e.target.value].initialize( - props.datasourcePublicAPI - ), + initialState: newState, }); }} /> 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 b06c60fed9df9..b09fa24debae4 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 @@ -520,7 +520,7 @@ Object { expect(mockVisualization2.initialize).toHaveBeenCalled(); }); - it('should call visualization render with new state on switch', async () => { + it('should use suggestions to switch to new visualization', async () => { const initialState = {}; mockVisualization2.initialize = () => initialState; @@ -530,6 +530,8 @@ Object { .simulate('change', { target: { value: 'testVis2' } }); }); + expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: initialState }) diff --git a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx index cae1b6b90ccd9..b8a72ab8c2831 100644 --- a/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx +++ b/x-pack/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -22,7 +22,7 @@ export function FrameLayout(props: FrameLayoutProps) { {/* TODO style this and add workspace prop and loading flags */} {props.dataPanel} {props.workspacePanel} - + {props.configPanel} {props.suggestionsPanel} diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx index cb2c610aff548..d00c8328d5f49 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.test.tsx @@ -245,8 +245,8 @@ describe('IndexPattern Data Source', () => { expect(indexPatternDatasource.toExpression(state)).toMatchInlineSnapshot(` "esaggs index=\\"1\\" - metricsAtAllLevels=\\"false\\" - partialRows=\\"false\\" + metricsAtAllLevels=false + partialRows=false aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"timeRange\\":{\\"from\\":\\"now-1d\\",\\"to\\":\\"now\\"},\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]' | lens_rename_columns idMap='{\\"col-0-col1\\":\\"col1\\",\\"col-1-col2\\":\\"col2\\"}'" `); }); @@ -477,6 +477,10 @@ describe('IndexPattern Data Source', () => { isBucketed: true, } as Operation); }); + + it('should return null for non-existant columns', () => { + expect(publicAPI.getOperationForColumnId('col2')).toBe(null); + }); }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index 3ae7a20c24540..a1b3d9b6efe3d 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -257,6 +257,9 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return state.columnOrder.map(colId => ({ columnId: colId })); }, getOperationForColumnId: (columnId: string) => { + if (!state.columns[columnId]) { + return null; + } return columnToOperation(state.columns[columnId]); }, generateColumnId: () => { @@ -369,8 +372,26 @@ export function getIndexPatternDatasource(chrome: Chrome, toastNotifications: To return []; }, - getDatasourceSuggestionsFromCurrentState(state) { - return []; + getDatasourceSuggestionsFromCurrentState( + state + ): Array> { + if (!state.columnOrder.length) { + return []; + } + return [ + { + state, + + table: { + columns: state.columnOrder.map(id => ({ + columnId: id, + operation: columnToOperation(state.columns[id]), + })), + isMultiRow: true, + datasourceSuggestionId: 0, + }, + }, + ]; }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts index c284f51bffe65..7ea3bbf488083 100644 --- a/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -14,7 +14,7 @@ export function toExpression(state: IndexPatternPrivateState) { return null; } - const sortedColumns = state.columnOrder.map(col => state.columns[col]); + const columns = state.columnOrder.map(colId => state.columns[colId]); function getEsAggsConfig(column: C, columnId: string) { // Typescript is not smart enough to infer that definitionMap[C['operationType']] is always OperationDefinition, @@ -25,23 +25,27 @@ export function toExpression(state: IndexPatternPrivateState) { return operationDefinition.toEsAggsConfig(column, columnId); } - if (sortedColumns.length) { - const aggs = sortedColumns.map((col, index) => { - return getEsAggsConfig(col, state.columnOrder[index]); + if (columns.length) { + const aggs = columns.map(col => { + const columnId = state.columnOrder.find(colId => state.columns[colId] === col)!; + return getEsAggsConfig(col, columnId); }); - const idMap = state.columnOrder.reduce( - (currentIdMap, columnId, index) => ({ - ...currentIdMap, - [`col-${index}-${columnId}`]: columnId, - }), + const idMap = columns.reduce( + (currentIdMap, col, index) => { + const columnId = state.columnOrder.find(colId => state.columns[colId] === col)!; + return { + ...currentIdMap, + [`col-${index}-${columnId}`]: columnId, + }; + }, {} as Record ); return `esaggs index="${state.currentIndexPatternId}" - metricsAtAllLevels="false" - partialRows="false" + metricsAtAllLevels=false + partialRows=false aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; } diff --git a/x-pack/plugins/lens/public/interpreter_types.ts b/x-pack/plugins/lens/public/interpreter_types.ts new file mode 100644 index 0000000000000..b24f39080f827 --- /dev/null +++ b/x-pack/plugins/lens/public/interpreter_types.ts @@ -0,0 +1,33 @@ +/* + * 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 { Registry } from '@kbn/interpreter/target/common'; +// @ts-ignore untyped module +import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; + +// 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 +// are available +interface RenderHandlers { + done: () => void; + onDestroy: (fn: () => void) => void; +} +export interface RenderFunction { + name: string; + displayName: string; + help: string; + validate: () => void; + reuseDomNode: boolean; + render: (domNode: Element, data: T, handlers: RenderHandlers) => void; +} + +export interface InterpreterSetup { + renderersRegistry: Registry; + functionsRegistry: Registry< + ExpressionFunction, + ExpressionFunction + >; +} diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap index 85e0598114ce8..3a2c14973a72e 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap @@ -8,6 +8,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` @@ -58,6 +59,7 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` @@ -108,6 +110,7 @@ exports[`xy_expression XYChart component it renders line 1`] = ` diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap index 678272922f013..b699c79ca3b94 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap @@ -66,6 +66,10 @@ Object { "b", "c", ], + "labels": Array [ + "", + "", + ], "position": Array [ "left", ], 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 78e3b1964b99c..0b6eb70d47c7d 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Registry } from '@kbn/interpreter/target/common'; import { CoreSetup } from 'src/core/public'; import { xyVisualization } from './xy_visualization'; @@ -13,34 +12,10 @@ import { functionsRegistry, // @ts-ignore untyped dependency } from '../../../../../src/legacy/core_plugins/interpreter/public/registries'; -import { ExpressionFunction } from '../../../../../src/legacy/core_plugins/interpreter/public'; +import { InterpreterSetup, RenderFunction } from '../interpreter_types'; 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 -// are available -interface RenderHandlers { - done: () => void; - onDestroy: (fn: () => void) => void; -} -export interface RenderFunction { - name: string; - displayName: string; - help: string; - validate: () => void; - reuseDomNode: boolean; - render: (domNode: Element, data: T, handlers: RenderHandlers) => void; -} - -export interface InterpreterSetup { - renderersRegistry: Registry; - functionsRegistry: Registry< - ExpressionFunction, - ExpressionFunction - >; -} - export interface XyVisualizationPluginSetupPlugins { interpreter: InterpreterSetup; } diff --git a/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts b/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts index 7ffe0669d35e9..551ae231671b2 100644 --- a/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization_plugin/types.ts @@ -73,6 +73,7 @@ const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = export interface YConfig extends AxisConfig { accessors: string[]; + labels: string[]; } type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; @@ -92,6 +93,11 @@ export const yConfig: ExpressionFunction<'lens_xy_yConfig', null, YConfig, YConf help: 'The columns to display on the y axis.', multi: true, }, + labels: { + types: ['string'], + help: '', + multi: true, + }, }, fn: function fn(_context: unknown, args: YConfig) { return { 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 index 95c4543f32547..08a2cdb4a0d63 100644 --- 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 @@ -43,6 +43,7 @@ describe('XYConfigPanel', () => { }, y: { accessors: ['bar'], + labels: [''], position: Position.Left, showGridlines: true, title: 'Y', @@ -270,8 +271,8 @@ describe('XYConfigPanel', () => { }); }); - test('allows editing the y axis title', () => { - const testSetTitle = (title: string) => { + test('allows editing each y axis label', () => { + const testSetLabel = (title: string) => { const setState = jest.fn(); const component = mount( { /> ); - (testSubj(component, 'lnsXY_yTitle').onChange as Function)({ target: { value: title } }); + (testSubj(component, 'lnsXY_yLabel').onChange as Function)({ target: { value: title } }); expect(setState).toHaveBeenCalledTimes(1); return setState.mock.calls[0][0]; }; - expect(testSetTitle('Hoi')).toMatchObject({ - y: { title: 'Hoi' }, + expect(testSetLabel('Hoi')).toMatchObject({ + y: { labels: ['Hoi'] }, }); - expect(testSetTitle('There!')).toMatchObject({ - y: { title: 'There!' }, + expect(testSetLabel('There!')).toMatchObject({ + y: { labels: ['There!'] }, }); }); @@ -364,7 +365,10 @@ describe('XYConfigPanel', () => { dragDropContext={dragDropContext} datasource={{ ...mockDatasource(), generateColumnId: () => 'zed' }} setState={setState} - state={{ ...state, y: { ...state.y, accessors: ['a', 'b', 'c'] } }} + state={{ + ...state, + y: { ...state.y, accessors: ['a', 'b', 'c'], labels: ['', '', ''] }, + }} /> ); @@ -372,7 +376,7 @@ describe('XYConfigPanel', () => { expect(setState).toHaveBeenCalledTimes(1); expect(setState.mock.calls[0][0]).toMatchObject({ - y: { accessors: ['a', 'b', 'c', 'zed'] }, + y: { accessors: ['a', 'b', 'c', 'zed'], labels: ['', '', '', ''] }, }); }); 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 index 86883b4e629e3..d6d7156df0e97 100644 --- 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 @@ -18,7 +18,7 @@ import { IconType, } from '@elastic/eui'; import { State, SeriesType } from './types'; -import { VisualizationProps, Operation } from '../types'; +import { DatasourcePublicAPI, VisualizationProps, Operation } from '../types'; import { NativeRenderer } from '../native_renderer'; const chartTypeIcons: Array<{ id: SeriesType; label: string; iconType: IconType }> = [ @@ -67,6 +67,12 @@ const positionIcons = [ }, ]; +function getColumnLabel(datasource: DatasourcePublicAPI, columnId: string, defaultLabel: string) { + const operation = datasource.getOperationForColumnId(columnId); + + return operation ? operation.label : defaultLabel; +} + export function XYConfigPanel(props: VisualizationProps) { const { state, datasource, setState } = props; @@ -173,9 +179,13 @@ export function XYConfigPanel(props: VisualizationProps) { })} > setState({ ...state, x: { ...state.x, title: e.target.value } })} @@ -223,32 +233,35 @@ export function XYConfigPanel(props: VisualizationProps) { })} > <> - - setState({ ...state, y: { ...state.y, title: e.target.value } })} - aria-label={i18n.translate('xpack.lens.xyChart.yTitleAriaLabel', { - defaultMessage: 'Title', - })} - /> - - <> - {state.y.accessors.map(accessor => ( + {state.y.accessors.map((accessor, yIndex) => (
+ + { + const newLabels = [...state.y.labels]; + newLabels[yIndex] = e.target.value; + setState({ ...state, y: { ...state.y, labels: newLabels } }); + }} + aria-label={i18n.translate('xpack.lens.xyChart.yLabelAriaLabel', { + defaultMessage: 'Label', + })} + /> + ) { iconType="trash" onClick={() => { datasource.removeColumnInTableSpec(accessor); + const newLabels = [...state.y.labels]; + newLabels.splice(yIndex); setState({ ...state, y: { ...state.y, accessors: state.y.accessors.filter(col => col !== accessor), + labels: newLabels, }, }); }} @@ -288,6 +304,7 @@ export function XYConfigPanel(props: VisualizationProps) { y: { ...state.y, accessors: [...state.y.accessors, datasource.generateColumnId()], + labels: [...state.y.labels, ''], }, }) } 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 3b6f05f86d6d5..a09ee7b58d986 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Position } from '@elastic/charts'; +import { BarSeries, Position } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; import { KibanaDatatable } from '../types'; import React from 'react'; @@ -27,6 +27,7 @@ function sampleArgs() { }, y: { accessors: ['a', 'b'], + labels: ['First', 'Second'], position: Position.Left, showGridlines: false, title: 'A and B', @@ -75,6 +76,7 @@ describe('xy_expression', () => { test('yConfig produces the correct arguments', () => { const args: YConfig = { accessors: ['bar'], + labels: ['Label'], position: Position.Bottom, showGridlines: true, title: 'Barrrrrr!', @@ -123,5 +125,19 @@ describe('xy_expression', () => { shallow() ).toMatchSnapshot(); }); + + test('it remaps rows based on the labels', () => { + const { data, args } = sampleArgs(); + + const chart = shallow(); + const barSeries = chart.find(BarSeries); + + expect(barSeries.prop('yAccessors')).toEqual(['First', 'Second']); + expect(barSeries.prop('data')[0]).toEqual({ + First: 1, + Second: 2, + c: 3, + }); + }); }); }); 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 9b2b9290b54f1..f6403c83dc390 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 @@ -19,7 +19,7 @@ import { import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; import { XYArgs } from './types'; import { KibanaDatatable } from '../types'; -import { RenderFunction } from './plugin'; +import { RenderFunction } from '../interpreter_types'; export interface XYChartProps { data: KibanaDatatable; @@ -103,18 +103,38 @@ export const xyChartRenderer: RenderFunction = { export function XYChart({ data, args }: XYChartProps) { const { legend, x, y, splitSeriesAccessors, stackAccessors, seriesType } = args; + + const labelsWithData = y.labels.filter(label => label); + const seriesProps = { splitSeriesAccessors, stackAccessors, - id: getSpecId(y.accessors.join(',')), + id: getSpecId(labelsWithData.join(',')), xAccessor: x.accessor, - yAccessors: y.accessors, - data: data.rows, + yAccessors: labelsWithData, + data: data.rows.map(row => { + const newRow: typeof row = {}; + + // Remap data to { 'Count of documents': 5 } + Object.keys(row).forEach(key => { + const labelIndex = y.accessors.indexOf(key); + if (labelIndex > -1) { + newRow[y.labels[labelIndex]] = row[key]; + } else { + newRow[key] = row[key]; + } + }); + return newRow; + }), }; return ( - + col.columnId), + labels: yValues.map(col => col.operation.label), position: Position.Left, showGridlines: false, title: yTitle, 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 3590f86d09fe9..43071e4fc25ea 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 @@ -6,9 +6,10 @@ import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; -import { DatasourcePublicAPI } from '../types'; +import { DatasourcePublicAPI, Operation } from '../types'; import { State } from './types'; import { createMockDatasource } from '../editor_frame_plugin/mocks'; +import { Ast, ExpressionArgAST } from '@kbn/interpreter/target/common'; function exampleState(): State { return { @@ -25,6 +26,7 @@ function exampleState(): State { }, y: { accessors: ['b', 'c'], + labels: ['', ''], position: Position.Left, showGridlines: true, title: 'Bar', @@ -65,6 +67,9 @@ Object { "accessors": Array [ "test-id1", ], + "labels": Array [ + "", + ], "position": "left", "showGridlines": false, "title": "Y", @@ -89,8 +94,34 @@ Object { describe('#toExpression', () => { it('should map to a valid AST', () => { expect( - xyVisualization.toExpression(exampleState(), {} as DatasourcePublicAPI) + xyVisualization.toExpression(exampleState(), createMockDatasource().publicAPIMock) ).toMatchSnapshot(); }); + + it('should default to labeling all columns with their column label', () => { + const mockDatasource = createMockDatasource(); + + mockDatasource.publicAPIMock.getOperationForColumnId + .mockReturnValueOnce({ + label: 'First', + } as Operation) + .mockReturnValueOnce({ + label: 'Second', + } as Operation); + + const expression = xyVisualization.toExpression( + exampleState(), + mockDatasource.publicAPIMock + )! as Ast; + + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledTimes(2); + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b'); + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('c'); + + expect((expression.chain[0].arguments.y[0] as Ast).chain[0].arguments.labels).toEqual([ + 'First', + 'Second', + ]); + }); }); }); 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 d7f2f978cc01a..033814ba3cd48 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 @@ -30,6 +30,7 @@ export const xyVisualization: Visualization = { }, y: { accessors: [datasource.generateColumnId()], + labels: [''], position: Position.Left, showGridlines: false, title: 'Y', @@ -50,7 +51,7 @@ export const xyVisualization: Visualization = { domElement ), - toExpression: state => ({ + toExpression: (state, datasource) => ({ type: 'expression', chain: [ { @@ -103,6 +104,15 @@ export const xyVisualization: Visualization = { showGridlines: [state.y.showGridlines], position: [state.y.position], accessors: state.y.accessors, + labels: state.y.labels.map((label, index) => { + if (label) { + return label; + } + const operation = datasource.getOperationForColumnId( + state.y.accessors[index] + ); + return operation ? operation.label : ''; + }), }, }, ],