diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index 857cee9adbc64..a559c0a94465b 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { editorFrameSetup, editorFrameStop } from '../editor_frame_plugin'; import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin'; +import { + datatableVisualizationSetup, + datatableVisualizationStop, +} from '../datatable_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({}); @@ -39,6 +45,7 @@ export class AppPlugin { // TODO this will be handled by the plugin platform itself indexPatternDatasourceStop(); xyVisualizationStop(); + datatableVisualizationStop(); editorFrameStop(); } } diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx new file mode 100644 index 0000000000000..44799bc7a403b --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/lens/public/datatable_visualization_plugin/index.ts b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx new file mode 100644 index 0000000000000..356e18ddc8419 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx new file mode 100644 index 0000000000000..2cde89fe2d5d8 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx @@ -0,0 +1,155 @@ +/* + * 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 { createMockDatasource } from '../editor_frame_plugin/mocks'; +import { + DatatableVisualizationState, + DatatableConfigPanel, + datatableVisualization, +} from './visualization'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { Operation, DataType } from '../types'; + +describe('Datatable Visualization', () => { + describe('#initialize', () => { + it('should initialize from the empty state', () => { + const datasource = createMockDatasource(); + datasource.publicAPIMock.generateColumnId.mockReturnValueOnce('id'); + expect(datatableVisualization.initialize(datasource.publicAPIMock)).toEqual({ + columns: [{ id: 'id', label: '' }], + }); + }); + + it('should initialize from a persisted state', () => { + const datasource = createMockDatasource(); + const expectedState: DatatableVisualizationState = { + columns: [{ id: 'saved', label: 'label' }], + }; + expect(datasource.publicAPIMock.generateColumnId).not.toHaveBeenCalled(); + expect(datatableVisualization.initialize(datasource.publicAPIMock, expectedState)).toEqual( + expectedState + ); + }); + }); + + describe('#getPersistableState', () => { + it('should persist the internal state', () => { + const expectedState: DatatableVisualizationState = { + columns: [{ id: 'saved', label: 'label' }], + }; + expect(datatableVisualization.getPersistableState(expectedState)).toEqual(expectedState); + }); + }); + + describe('DatatableConfigPanel', () => { + it('should update the column label', () => { + const setState = jest.fn(); + const wrapper = mount( + {} }} + datasource={createMockDatasource().publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: 'label' }] }} + /> + ); + + const labelEditor = wrapper.find('[data-test-subj="lnsDatatable-columnLabel"]').at(1); + + act(() => { + labelEditor.simulate('change', { target: { value: 'New Label' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + columns: [{ id: 'saved', label: 'New Label' }], + }); + }); + + it('should allow all operations to be shown', () => { + const setState = jest.fn(); + const datasource = createMockDatasource(); + + mount( + {} }} + datasource={datasource.publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: 'label' }] }} + /> + ); + + expect(datasource.publicAPIMock.renderDimensionPanel).toHaveBeenCalled(); + + const filterOperations = + datasource.publicAPIMock.renderDimensionPanel.mock.calls[0][1].filterOperations; + + const baseOperation: Operation = { + dataType: 'string', + isBucketed: true, + label: '', + id: '', + }; + expect(filterOperations({ ...baseOperation })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'number' })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'date' })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'boolean' })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'other' as DataType })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'date', isBucketed: false })).toEqual( + true + ); + }); + + it('should remove a column', () => { + const setState = jest.fn(); + const wrapper = mount( + {} }} + datasource={createMockDatasource().publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: '' }, { id: 'second', label: '' }] }} + /> + ); + + act(() => { + wrapper + .find('[data-test-subj="lnsDatatable_dimensionPanelRemove_saved"]') + .first() + .simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + columns: [{ id: 'second', label: '' }], + }); + }); + + it('should be able to add more columns', () => { + const setState = jest.fn(); + const datasource = createMockDatasource(); + const wrapper = mount( + {} }} + datasource={datasource.publicAPIMock} + setState={setState} + state={{ columns: [{ id: 'saved', label: 'label' }] }} + /> + ); + + datasource.publicAPIMock.generateColumnId.mockReturnValueOnce('newId'); + + act(() => { + wrapper + .find('[data-test-subj="lnsDatatable_dimensionPanel_add"]') + .first() + .simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + columns: [{ id: 'saved', label: 'label' }, { id: 'newId', label: '' }], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx new file mode 100644 index 0000000000000..1cd7993922f6d --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -0,0 +1,221 @@ +/* + * 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, 1); + setState({ + ...state, + columns: newColumns, + }); + }} + aria-label={i18n.translate('xpack.lens.datatable.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 = i18n.translate('xpack.lens.datatable.visualizationOf', { + defaultMessage: 'Table: ${operations}', + values: { + operations: 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 }) => { + if (label) { + return label; + } + const operation = datasource.getOperationForColumnId(id); + return operation ? operation.label : ''; + }), + }, + }, + ], + }, + ], + }, + }, + ], + }), +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index af454ffb00108..3556c38453c95 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -11,7 +11,7 @@ import { Chrome } from 'ui/chrome'; import { ToastNotifications } from 'ui/notify/toasts/toast_notifications'; import { EuiComboBox } from '@elastic/eui'; import uuid from 'uuid'; -import { Datasource, DataType } from '../../public'; +import { Datasource, DataType } from '..'; import { DatasourceDimensionPanelProps, DatasourceDataPanelProps, @@ -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: () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts index c284f51bffe65..b1428d008dc40 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -14,8 +14,6 @@ export function toExpression(state: IndexPatternPrivateState) { return null; } - const sortedColumns = state.columnOrder.map(col => state.columns[col]); - function getEsAggsConfig(column: C, columnId: string) { // Typescript is not smart enough to infer that definitionMap[C['operationType']] is always OperationDefinition, // but this is made sure by the typing of the operation map @@ -25,16 +23,22 @@ 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]); + const columnEntries = state.columnOrder.map( + colId => [colId, state.columns[colId]] as [string, IndexPatternColumn] + ); + + if (columnEntries.length) { + const aggs = columnEntries.map(([colId, col]) => { + return getEsAggsConfig(col, colId); }); - const idMap = state.columnOrder.reduce( - (currentIdMap, columnId, index) => ({ - ...currentIdMap, - [`col-${index}-${columnId}`]: columnId, - }), + const idMap = columnEntries.reduce( + (currentIdMap, [colId], index) => { + return { + ...currentIdMap, + [`col-${index}-${colId}`]: colId, + }; + }, {} as Record ); diff --git a/x-pack/legacy/plugins/lens/public/interpreter_types.ts b/x-pack/legacy/plugins/lens/public/interpreter_types.ts new file mode 100644 index 0000000000000..b24f39080f827 --- /dev/null +++ b/x-pack/legacy/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/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx index 79ecc5c33af87..bb89646715645 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx +++ b/x-pack/legacy/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/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index 9b2b9290b54f1..a60040f5555dc 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -19,7 +19,7 @@ import { 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;