From 6a63a50d687271dafa44807ab08afa0525f2596e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 4 Feb 2021 16:22:09 +0100 Subject: [PATCH] [Lens] Hide column in table (#88680) --- .../__snapshots__/table_basic.test.tsx.snap | 54 +++++ .../components/columns.tsx | 23 +- .../components/constants.ts | 1 + .../components/dimension_editor.tsx | 61 ++++++ .../components/table_actions.test.ts | 51 +++-- .../components/table_actions.ts | 56 +++-- .../components/table_basic.test.tsx | 65 ++++-- .../components/table_basic.tsx | 68 ++++-- .../components/types.ts | 21 +- .../expression.test.tsx | 22 +- .../datatable_visualization/expression.tsx | 89 +++----- .../public/datatable_visualization/index.ts | 6 +- .../visualization.test.tsx | 206 +++++++----------- .../datatable_visualization/visualization.tsx | 197 +++++++++-------- .../config_panel/color_indicator.tsx | 10 + x-pack/plugins/lens/public/index.ts | 5 +- .../shared_components/toolbar_popover.tsx | 1 + x-pack/plugins/lens/public/types.ts | 5 +- x-pack/plugins/lens/server/migrations.test.ts | 73 +++++++ x-pack/plugins/lens/server/migrations.ts | 54 +++++ x-pack/test/functional/apps/lens/index.ts | 1 + x-pack/test/functional/apps/lens/table.ts | 69 ++++++ .../test/functional/page_objects/lens_page.ts | 9 + 23 files changed, 762 insertions(+), 385 deletions(-) create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx create mode 100644 x-pack/test/functional/apps/lens/table.ts diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index a4eb99a972b9b6..d69af298018e74 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -93,6 +93,15 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -121,6 +130,15 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -149,6 +167,15 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -288,6 +315,15 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -316,6 +352,15 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -344,6 +389,15 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "eyeClosed", + "isDisabled": false, + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx index 366e002f50cd81..5ff1e84276ba77 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui'; import type { Datatable, DatatableColumnMeta } from 'src/plugins/expressions'; import type { FormatFactory } from '../../types'; -import type { DatatableColumns } from './types'; +import { ColumnConfig } from './table_basic'; export const createGridColumns = ( bucketColumns: string[], @@ -23,10 +23,11 @@ export const createGridColumns = ( negate?: boolean ) => void, isReadOnly: boolean, - columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, + columnConfig: ColumnConfig, visibleColumns: string[], formatFactory: FormatFactory, - onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void + onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void, + onColumnHide: (eventData: { columnId: string }) => void ) => { const columnsReverseLookup = table.columns.reduce< Record @@ -134,8 +135,9 @@ export const createGridColumns = ( ] : undefined; - const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) - ?.width; + const column = columnConfig.columns.find(({ columnId }) => columnId === field); + const initialWidth = column?.width; + const isHidden = column?.hidden; const columnDefinition: EuiDataGridColumn = { id: field, @@ -174,6 +176,17 @@ export const createGridColumns = ( 'data-test-subj': 'lensDatatableResetWidth', isDisabled: initialWidth == null, }, + { + color: 'text', + size: 'xs', + onClick: () => onColumnHide({ columnId: field }), + iconType: 'eyeClosed', + label: i18n.translate('xpack.lens.table.hide.hideLabel', { + defaultMessage: 'Hide', + }), + 'data-test-subj': 'lensDatatableHide', + isDisabled: !isHidden && visibleColumns.length <= 1, + }, ], }, }; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts index db72f8a4e4a92f..84ee4f0e8a18e5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts @@ -7,3 +7,4 @@ export const LENS_EDIT_SORT_ACTION = 'sort'; export const LENS_EDIT_RESIZE_ACTION = 'resize'; +export const LENS_TOGGLE_ACTION = 'toggle'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx new file mode 100644 index 00000000000000..008b805bc8fed3 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSwitch, EuiFormRow } from '@elastic/eui'; +import { VisualizationDimensionEditorProps } from '../../types'; +import { DatatableVisualizationState } from '../visualization'; + +export function TableDimensionEditor( + props: VisualizationDimensionEditorProps +) { + const { state, setState, accessor } = props; + const column = state.columns.find((c) => c.columnId === accessor); + + const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; + + if (!column) { + return null; + } + + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts index b0b7d46e4c3b7f..68416ac9a60aa7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts @@ -14,17 +14,19 @@ import { createGridFilterHandler, createGridResizeHandler, createGridSortingConfig, + createGridHideHandler, } from './table_actions'; -import { DatatableColumns, LensGridDirection } from './types'; +import { LensGridDirection } from './types'; +import { ColumnConfig } from './table_basic'; -function getDefaultConfig(): DatatableColumns & { - type: 'lens_datatable_columns'; -} { +function getDefaultConfig(): ColumnConfig { return { - columnIds: [], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', + columns: [ + { columnId: 'a', type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; } @@ -207,7 +209,13 @@ describe('Table actions', () => { expect(setColumnConfig).toHaveBeenCalledWith({ ...columnConfig, - columnWidth: [{ columnId: 'a', width: 100, type: 'lens_datatable_column_width' }], + columns: [ + { columnId: 'a', width: 100, type: 'lens_datatable_column' }, + { + columnId: 'b', + type: 'lens_datatable_column', + }, + ], }); expect(onEditAction).toHaveBeenCalledWith({ action: 'resize', columnId: 'a', width: 100 }); @@ -215,16 +223,14 @@ describe('Table actions', () => { it('should pull out the table custom width from the local state when passing undefined', () => { const columnConfig = getDefaultConfig(); - columnConfig.columnWidth = [ - { columnId: 'a', width: 100, type: 'lens_datatable_column_width' }, - ]; + columnConfig.columns = [{ columnId: 'a', width: 100, type: 'lens_datatable_column' }]; const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); resizer({ columnId: 'a', width: undefined }); expect(setColumnConfig).toHaveBeenCalledWith({ ...columnConfig, - columnWidth: [], + columns: [{ columnId: 'a', width: undefined, type: 'lens_datatable_column' }], }); expect(onEditAction).toHaveBeenCalledWith({ @@ -234,4 +240,23 @@ describe('Table actions', () => { }); }); }); + describe('Column hiding', () => { + const setColumnConfig = jest.fn(); + + it('should allow to hide column', () => { + const columnConfig = getDefaultConfig(); + const hiding = createGridHideHandler(columnConfig, setColumnConfig, onEditAction); + hiding({ columnId: 'a' }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columns: [ + { columnId: 'a', hidden: true, type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + ], + }); + + expect(onEditAction).toHaveBeenCalledWith({ action: 'toggle', columnId: 'a' }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts index ca4ec7f3a8d0c1..4f0271b758ffb0 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -9,43 +9,30 @@ import type { EuiDataGridSorting } from '@elastic/eui'; import type { Datatable } from 'src/plugins/expressions'; import type { LensFilterEvent } from '../../types'; import type { - DatatableColumns, LensGridDirection, LensResizeAction, LensSortAction, + LensToggleAction, } from './types'; +import { ColumnConfig } from './table_basic'; import { desanitizeFilterContext } from '../../utils'; export const createGridResizeHandler = ( - columnConfig: DatatableColumns & { - type: 'lens_datatable_columns'; - }, - setColumnConfig: React.Dispatch< - React.SetStateAction< - DatatableColumns & { - type: 'lens_datatable_columns'; - } - > - >, + columnConfig: ColumnConfig, + setColumnConfig: React.Dispatch>, onEditAction: (data: LensResizeAction['data']) => void ) => (eventData: { columnId: string; width: number | undefined }) => { // directly set the local state of the component to make sure the visualization re-renders immediately, // re-layouting and taking up all of the available space. setColumnConfig({ ...columnConfig, - columnWidth: [ - ...(columnConfig.columnWidth || []).filter(({ columnId }) => columnId !== eventData.columnId), - ...(eventData.width !== undefined - ? [ - { - columnId: eventData.columnId, - width: eventData.width, - type: 'lens_datatable_column_width' as const, - }, - ] - : []), - ], + columns: columnConfig.columns.map((column) => { + if (column.columnId === eventData.columnId) { + return { ...column, width: eventData.width }; + } + return column; + }), }); return onEditAction({ action: 'resize', @@ -54,6 +41,27 @@ export const createGridResizeHandler = ( }); }; +export const createGridHideHandler = ( + columnConfig: ColumnConfig, + setColumnConfig: React.Dispatch>, + onEditAction: (data: LensToggleAction['data']) => void +) => (eventData: { columnId: string }) => { + // directly set the local state of the component to make sure the visualization re-renders immediately + setColumnConfig({ + ...columnConfig, + columns: columnConfig.columns.map((column) => { + if (column.columnId === eventData.columnId) { + return { ...column, hidden: true }; + } + return column; + }), + }); + return onEditAction({ + action: 'toggle', + columnId: eventData.columnId, + }); +}; + export const createGridFilterHandler = ( tableRef: React.MutableRefObject, onClickValue: (data: LensFilterEvent['data']) => void @@ -85,7 +93,7 @@ export const createGridFilterHandler = ( }; export const createGridSortingConfig = ( - sortBy: string, + sortBy: string | undefined, sortDirection: LensGridDirection, onEditAction: (data: LensSortAction['data']) => void ): EuiDataGridSorting => ({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 6935e8313afb05..50d040bc5c3976 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -65,12 +65,13 @@ function sampleArgs() { const args: DatatableProps['args'] = { title: 'My fanci metric chart', - columns: { - columnIds: ['a', 'b', 'c'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, + columns: [ + { columnId: 'a', type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + { columnId: 'c', type: 'lens_datatable_column' }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; return { data, args }; @@ -252,12 +253,12 @@ describe('DatatableComponent', () => { const args: DatatableProps['args'] = { title: '', - columns: { - columnIds: ['a', 'b'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, + columns: [ + { columnId: 'a', type: 'lens_datatable_column' }, + { columnId: 'b', type: 'lens_datatable_column' }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; const wrapper = mountWithIntl( @@ -331,11 +332,8 @@ describe('DatatableComponent', () => { data={data} args={{ ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, + sortingColumnId: 'b', + sortingDirection: 'desc', }} formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -382,11 +380,8 @@ describe('DatatableComponent', () => { data={data} args={{ ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, + sortingColumnId: 'b', + sortingDirection: 'desc', }} formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -400,6 +395,32 @@ describe('DatatableComponent', () => { ]); }); + test('it does not render a hidden column', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('columns')!.length).toEqual(2); + }); + test('it should refresh the table header when the datatable data changes', () => { const { data, args } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index b4852895a1e20e..f685990f12dd26 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -22,17 +22,20 @@ import { FormatFactory, LensFilterEvent, LensTableRowContextMenuEvent } from '.. import { VisualizationContainer } from '../../visualization_container'; import { EmptyPlaceholder } from '../../shared_components'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { ColumnState } from '../visualization'; import { DataContextType, DatatableRenderProps, LensSortAction, LensResizeAction, LensGridDirection, + LensToggleAction, } from './types'; import { createGridColumns } from './columns'; import { createGridCell } from './cell_value'; import { createGridFilterHandler, + createGridHideHandler, createGridResizeHandler, createGridSortingConfig, } from './table_actions'; @@ -44,15 +47,33 @@ const gridStyle: EuiDataGridStyle = { header: 'underline', }; +export interface ColumnConfig { + columns: Array< + ColumnState & { + type: 'lens_datatable_column'; + } + >; + sortingColumnId: string | undefined; + sortingDirection: LensGridDirection; +} + export const DatatableComponent = (props: DatatableRenderProps) => { const [firstTable] = Object.values(props.data.tables); - const [columnConfig, setColumnConfig] = useState(props.args.columns); + const [columnConfig, setColumnConfig] = useState({ + columns: props.args.columns, + sortingColumnId: props.args.sortingColumnId, + sortingDirection: props.args.sortingDirection, + }); const [firstLocalTable, updateTable] = useState(firstTable); useDeepCompareEffect(() => { - setColumnConfig(props.args.columns); - }, [props.args.columns]); + setColumnConfig({ + columns: props.args.columns, + sortingColumnId: props.args.sortingColumnId, + sortingDirection: props.args.sortingDirection, + }); + }, [props.args.columns, props.args.sortingColumnId, props.args.sortingDirection]); useDeepCompareEffect(() => { updateTable(firstTable); @@ -85,7 +106,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ); const onEditAction = useCallback( - (data: LensSortAction['data'] | LensResizeAction['data']) => { + (data: LensSortAction['data'] | LensResizeAction['data'] | LensToggleAction['data']) => { if (renderMode === 'edit') { dispatchEvent({ name: 'edit', data }); } @@ -106,13 +127,15 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const bucketColumns = useMemo( () => - columnConfig.columnIds.filter((_colId, index) => { - const col = firstTableRef.current.columns[index]; - return ( - col?.meta?.sourceParams?.type && - getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); - }), + columnConfig.columns + .filter((_col, index) => { + const col = firstTableRef.current.columns[index]; + return ( + col?.meta?.sourceParams?.type && + getType(col.meta.sourceParams.type as string)?.type === 'buckets' + ); + }) + .map((col) => col.columnId), [firstTableRef, columnConfig, getType] ); @@ -121,11 +144,15 @@ export const DatatableComponent = (props: DatatableRenderProps) => { (bucketColumns.length && firstTable.rows.every((row) => bucketColumns.every((col) => row[col] == null))); - const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ - columnConfig, - ]); + const visibleColumns = useMemo( + () => + columnConfig.columns + .filter((col) => !!col.columnId && !col.hidden) + .map((col) => col.columnId), + [columnConfig] + ); - const { sortBy, sortDirection } = columnConfig; + const { sortingColumnId: sortBy, sortingDirection: sortDirection } = props.args; const isReadOnlySorted = renderMode !== 'edit'; @@ -134,6 +161,11 @@ export const DatatableComponent = (props: DatatableRenderProps) => { [onEditAction, setColumnConfig, columnConfig] ); + const onColumnHide = useMemo( + () => createGridHideHandler(columnConfig, setColumnConfig, onEditAction), + [onEditAction, setColumnConfig, columnConfig] + ); + const columns: EuiDataGridColumn[] = useMemo( () => createGridColumns( @@ -144,7 +176,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig, visibleColumns, formatFactory, - onColumnResize + onColumnResize, + onColumnHide ), [ bucketColumns, @@ -155,6 +188,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { visibleColumns, formatFactory, onColumnResize, + onColumnHide, ] ); @@ -184,7 +218,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { onRowContextMenuClick({ rowIndex, table: firstTableRef.current, - columns: columnConfig.columnIds, + columns: columnConfig.columns.map((col) => col.columnId), }); }} /> diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts index e2cc1daf0f900f..8a280b3d15bca5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -10,7 +10,7 @@ import type { IAggType } from 'src/plugins/data/public'; import type { Datatable, RenderMode } from 'src/plugins/expressions'; import type { FormatFactory, ILensInterpreterRenderHandlers, LensEditEvent } from '../../types'; import type { DatatableProps } from '../expression'; -import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION } from './constants'; +import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION, LENS_TOGGLE_ACTION } from './constants'; export type LensGridDirection = 'none' | Direction; @@ -24,24 +24,13 @@ export interface LensResizeActionData { width: number | undefined; } -export type LensSortAction = LensEditEvent; -export type LensResizeAction = LensEditEvent; - -export interface DatatableColumns { - columnIds: string[]; - sortBy: string; - sortDirection: string; - columnWidth?: DatatableColumnWidthResult[]; -} - -export interface DatatableColumnWidth { +export interface LensToggleActionData { columnId: string; - width: number; } -export type DatatableColumnWidthResult = DatatableColumnWidth & { - type: 'lens_datatable_column_width'; -}; +export type LensSortAction = LensEditEvent; +export type LensResizeAction = LensEditEvent; +export type LensToggleAction = LensEditEvent; export type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index 5e51cb2c93c7c8..3ee41d4e9aeed7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -59,12 +59,22 @@ function sampleArgs() { const args: DatatableProps['args'] = { title: 'My fanci metric chart', - columns: { - columnIds: ['a', 'b', 'c'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, + columns: [ + { + columnId: 'a', + type: 'lens_datatable_column', + }, + { + columnId: 'b', + type: 'lens_datatable_column', + }, + { + columnId: 'c', + type: 'lens_datatable_column', + }, + ], + sortingColumnId: '', + sortingDirection: 'none', }; return { data, args }; diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 82964a03e29e56..7ead7be67947c9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -19,19 +19,17 @@ import type { import { getSortingCriteria } from './sorting'; import { DatatableComponent } from './components/table_basic'; +import { ColumnState } from './visualization'; import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } from '../types'; -import type { - DatatableRender, - DatatableColumns, - DatatableColumnWidth, - DatatableColumnWidthResult, -} from './components/types'; +import type { DatatableRender } from './components/types'; interface Args { title: string; description?: string; - columns: DatatableColumns & { type: 'lens_datatable_columns' }; + columns: Array; + sortingColumnId: string | undefined; + sortingDirection: 'asc' | 'desc' | 'none'; } export interface DatatableProps { @@ -66,7 +64,16 @@ export const getDatatable = ({ help: '', }, columns: { - types: ['lens_datatable_columns'], + types: ['lens_datatable_column'], + help: '', + multi: true, + }, + sortingColumnId: { + types: ['string'], + help: '', + }, + sortingDirection: { + types: ['string'], help: '', }, }, @@ -79,7 +86,7 @@ export const getDatatable = ({ firstTable.columns.forEach((column) => { formatters[column.id] = formatFactory(column.meta?.params); }); - const { sortBy, sortDirection } = args.columns; + const { sortingColumnId: sortBy, sortingDirection: sortDirection } = args; const columnsReverseLookup = firstTable.columns.reduce< Record @@ -116,65 +123,27 @@ export const getDatatable = ({ }, }); -type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' }; +type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' }; -export const datatableColumns: ExpressionFunctionDefinition< - 'lens_datatable_columns', +export const datatableColumn: ExpressionFunctionDefinition< + 'lens_datatable_column', null, - DatatableColumns, - DatatableColumnsResult + ColumnState, + DatatableColumnResult > = { - name: 'lens_datatable_columns', + name: 'lens_datatable_column', aliases: [], - type: 'lens_datatable_columns', + type: 'lens_datatable_column', help: '', inputTypes: ['null'], args: { - sortBy: { types: ['string'], help: '' }, - sortDirection: { types: ['string'], help: '' }, - columnIds: { - types: ['string'], - multi: true, - help: '', - }, - columnWidth: { - types: ['lens_datatable_column_width'], - multi: true, - help: '', - }, - }, - fn: function fn(input: unknown, args: DatatableColumns) { - return { - type: 'lens_datatable_columns', - ...args, - }; - }, -}; - -export const datatableColumnWidth: ExpressionFunctionDefinition< - 'lens_datatable_column_width', - null, - DatatableColumnWidth, - DatatableColumnWidthResult -> = { - name: 'lens_datatable_column_width', - aliases: [], - type: 'lens_datatable_column_width', - help: '', - inputTypes: ['null'], - args: { - columnId: { - types: ['string'], - help: '', - }, - width: { - types: ['number'], - help: '', - }, + columnId: { types: ['string'], help: '' }, + hidden: { types: ['boolean'], help: '' }, + width: { types: ['number'], help: '' }, }, - fn: function fn(input: unknown, args: DatatableColumnWidth) { + fn: function fn(input: unknown, args: ColumnState) { return { - type: 'lens_datatable_column_width', + type: 'lens_datatable_column', ...args, }; }, @@ -213,7 +182,7 @@ export const getDatatableRenderer = (dependencies: { data: { rowIndex, table, - columns: config.args.columns.columnIds, + columns: config.args.columns.map((column) => column.columnId), }, }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 23e0a2b7918a45..f0939f61952294 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -29,15 +29,13 @@ export class DatatableVisualization { editorFrame.registerVisualization(async () => { const { getDatatable, - datatableColumns, - datatableColumnWidth, + datatableColumn, getDatatableRenderer, datatableVisualization, } = await import('../async_services'); const resolvedFormatFactory = await formatFactory; - expressions.registerFunction(() => datatableColumns); - expressions.registerFunction(() => datatableColumnWidth); + expressions.registerFunction(() => datatableColumn); expressions.registerFunction(() => getDatatable({ formatFactory: resolvedFormatFactory })); expressions.registerRenderer(() => getDatatableRenderer({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 0627effa30be7a..25275ba8e2249b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -30,23 +30,15 @@ describe('Datatable Visualization', () => { describe('#initialize', () => { it('should initialize from the empty state', () => { expect(datatableVisualization.initialize(mockFrame(), undefined)).toEqual({ - layers: [ - { - layerId: 'aaa', - columns: [], - }, - ], + layerId: 'aaa', + columns: [], }); }); it('should initialize from a persisted state', () => { const expectedState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved' }], }; expect(datatableVisualization.initialize(mockFrame(), expectedState)).toEqual(expectedState); }); @@ -55,12 +47,8 @@ describe('Datatable Visualization', () => { describe('#getLayerIds', () => { it('return the layer ids', () => { const state: DatatableVisualizationState = { - layers: [ - { - layerId: 'baz', - columns: ['a', 'b', 'c'], - }, - ], + layerId: 'baz', + columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']); }); @@ -69,20 +57,12 @@ describe('Datatable Visualization', () => { describe('#clearLayer', () => { it('should reset the layer', () => { const state: DatatableVisualizationState = { - layers: [ - { - layerId: 'baz', - columns: ['a', 'b', 'c'], - }, - ], + layerId: 'baz', + columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({ - layers: [ - { - layerId: 'baz', - columns: [], - }, - ], + layerId: 'baz', + columns: [], }); }); }); @@ -113,7 +93,8 @@ describe('Datatable Visualization', () => { it('should accept a single-layer suggestion', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'first', columns: ['col1'] }], + layerId: 'first', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -130,7 +111,8 @@ describe('Datatable Visualization', () => { it('should not make suggestions when the table is unchanged', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'first', columns: ['col1'] }], + layerId: 'first', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -147,7 +129,8 @@ describe('Datatable Visualization', () => { it('should not make suggestions when multiple layers are involved', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'first', columns: ['col1'] }], + layerId: 'first', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -164,7 +147,8 @@ describe('Datatable Visualization', () => { it('should not make suggestions when the suggestion keeps a different layer', () => { const suggestions = datatableVisualization.getSuggestions({ state: { - layers: [{ layerId: 'older', columns: ['col1'] }], + layerId: 'older', + columns: [{ columnId: 'col1' }], }, table: { isMultiRow: true, @@ -203,7 +187,8 @@ describe('Datatable Visualization', () => { datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups @@ -218,7 +203,8 @@ describe('Datatable Visualization', () => { const filterOperations = datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups[0].filterOperations; @@ -249,7 +235,8 @@ describe('Datatable Visualization', () => { const filterOperations = datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups[1].filterOperations; @@ -274,7 +261,6 @@ describe('Datatable Visualization', () => { it('reorders the rendered colums based on the order from the datasource', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -282,7 +268,10 @@ describe('Datatable Visualization', () => { expect( datatableVisualization.getConfiguration({ layerId: 'a', - state: { layers: [layer] }, + state: { + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, frame, }).groups[1].accessors ).toEqual([{ columnId: 'c' }, { columnId: 'b' }]); @@ -291,95 +280,75 @@ describe('Datatable Visualization', () => { describe('#removeDimension', () => { it('allows columns to be removed', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; expect( datatableVisualization.removeDimension({ - prevState: { layers: [layer] }, + prevState: { + layerId: 'layer1', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, layerId: 'layer1', columnId: 'b', }) ).toEqual({ - layers: [ - { - layerId: 'layer1', - columns: ['c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'c' }], }); }); it('should handle correctly the sorting state on removing dimension', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; + const state = { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }; expect( datatableVisualization.removeDimension({ - prevState: { layers: [layer], sorting: { columnId: 'b', direction: 'asc' } }, + prevState: { ...state, sorting: { columnId: 'b', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', }) ).toEqual({ sorting: undefined, - layers: [ - { - layerId: 'layer1', - columns: ['c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'c' }], }); expect( datatableVisualization.removeDimension({ - prevState: { layers: [layer], sorting: { columnId: 'c', direction: 'asc' } }, + prevState: { ...state, sorting: { columnId: 'c', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', }) ).toEqual({ sorting: { columnId: 'c', direction: 'asc' }, - layers: [ - { - layerId: 'layer1', - columns: ['c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'c' }], }); }); }); describe('#setDimension', () => { it('allows columns to be added', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; expect( datatableVisualization.setDimension({ - prevState: { layers: [layer] }, + prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, layerId: 'layer1', columnId: 'd', groupId: '', }) ).toEqual({ - layers: [ - { - layerId: 'layer1', - columns: ['b', 'c', 'd'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'b' }, { columnId: 'c' }, { columnId: 'd' }], }); }); it('does not set a duplicate dimension', () => { - const layer = { layerId: 'layer1', columns: ['b', 'c'] }; expect( datatableVisualization.setDimension({ - prevState: { layers: [layer] }, + prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, layerId: 'layer1', columnId: 'b', groupId: '', }) ).toEqual({ - layers: [ - { - layerId: 'layer1', - columns: ['b', 'c'], - }, - ], + layerId: 'layer1', + columns: [{ columnId: 'b' }, { columnId: 'c' }], }); }); }); @@ -387,7 +356,6 @@ describe('Datatable Visualization', () => { describe('#toExpression', () => { it('reorders the rendered colums based on the order from the datasource', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -398,24 +366,35 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layers: [layer] }, + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, frame.datasourceLayers ) as Ast; - const tableArgs = buildExpression(expression).findFunction('lens_datatable_columns'); + const tableArgs = buildExpression(expression).findFunction('lens_datatable'); expect(tableArgs).toHaveLength(1); - expect(tableArgs[0].arguments).toEqual({ - columnIds: ['c', 'b'], - sortBy: [''], - sortDirection: ['none'], - columnWidth: [], + expect(tableArgs[0].arguments).toEqual( + expect.objectContaining({ + sortingColumnId: [''], + sortingDirection: ['none'], + }) + ); + const columnArgs = buildExpression(expression).findFunction('lens_datatable_column'); + expect(columnArgs).toHaveLength(2); + expect(columnArgs[0].arguments).toEqual({ + columnId: ['c'], + hidden: [], + width: [], + }); + expect(columnArgs[1].arguments).toEqual({ + columnId: ['b'], + hidden: [], + width: [], }); }); it('returns no expression if the metric dimension is not defined', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -426,7 +405,7 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layers: [layer] }, + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, frame.datasourceLayers ); @@ -437,7 +416,6 @@ describe('Datatable Visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if the datasource is missing a metric dimension', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -447,14 +425,16 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame); + const error = datatableVisualization.getErrorMessages( + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + frame + ); expect(error).toBeUndefined(); }); it('returns undefined if the metric dimension is defined', () => { const datasource = createMockDatasource('test'); - const layer = { layerId: 'a', columns: ['b', 'c'] }; const frame = mockFrame(); frame.datasourceLayers = { a: datasource.publicAPIMock }; datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); @@ -464,7 +444,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages({ layers: [layer] }, frame); + const error = datatableVisualization.getErrorMessages( + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + frame + ); expect(error).toBeUndefined(); }); @@ -473,12 +456,8 @@ describe('Datatable Visualization', () => { describe('#onEditAction', () => { it('should add a sort column to the state', () => { const currentState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved' }], }; expect( datatableVisualization.onEditAction!(currentState, { @@ -496,12 +475,8 @@ describe('Datatable Visualization', () => { it('should add a custom width to a column in the state', () => { const currentState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved' }], }; expect( datatableVisualization.onEditAction!(currentState, { @@ -510,29 +485,14 @@ describe('Datatable Visualization', () => { }) ).toEqual({ ...currentState, - columnWidth: [ - { - columnId: 'saved', - width: 500, - }, - ], + columns: [{ columnId: 'saved', width: 500 }], }); }); it('should clear custom width value for the column from the state', () => { const currentState: DatatableVisualizationState = { - layers: [ - { - layerId: 'foo', - columns: ['saved'], - }, - ], - columnWidth: [ - { - columnId: 'saved', - width: 500, - }, - ], + layerId: 'foo', + columns: [{ columnId: 'saved', width: 5000 }], }; expect( datatableVisualization.onEditAction!(currentState, { @@ -541,7 +501,7 @@ describe('Datatable Visualization', () => { }) ).toEqual({ ...currentState, - columnWidth: [], + columns: [{ columnId: 'saved', width: undefined }], }); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 6a221396b8a849..77fda43c37fef9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -5,37 +5,35 @@ * 2.0. */ +import React from 'react'; +import { render } from 'react-dom'; import { Ast } from '@kbn/interpreter/common'; +import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import type { SuggestionRequest, Visualization, VisualizationSuggestion, - Operation, DatasourcePublicAPI, } from '../types'; -import type { DatatableColumnWidth } from './components/types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; +import { TableDimensionEditor } from './components/dimension_editor'; -export interface DatatableLayerState { - layerId: string; - columns: string[]; +export interface ColumnState { + columnId: string; + width?: number; + hidden?: boolean; } -export interface DatatableVisualizationState { - layers: DatatableLayerState[]; - sorting?: { - columnId: string | undefined; - direction: 'asc' | 'desc' | 'none'; - }; - columnWidth?: DatatableColumnWidth[]; +export interface SortingState { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; } -function newLayerState(layerId: string): DatatableLayerState { - return { - layerId, - columns: [], - }; +export interface DatatableVisualizationState { + columns: ColumnState[]; + layerId: string; + sorting?: SortingState; } export const datatableVisualization: Visualization = { @@ -56,12 +54,13 @@ export const datatableVisualization: Visualization }, getLayerIds(state) { - return state.layers.map((l) => l.layerId); + return [state.layerId]; }, clearLayer(state) { return { - layers: state.layers.map((l) => newLayerState(l.layerId)), + ...state, + columns: [], }; }, @@ -79,7 +78,8 @@ export const datatableVisualization: Visualization initialize(frame, state) { return ( state || { - layers: [newLayerState(frame.addNewLayer())], + columns: [], + layerId: frame.addNewLayer(), } ); }, @@ -126,12 +126,8 @@ export const datatableVisualization: Visualization // table with >= 10 columns will have a score of 0.4, fewer columns reduce score score: (Math.min(table.columns.length, 10) / 10) * 0.4, state: { - layers: [ - { - layerId: table.layerId, - columns: table.columns.map((col) => col.columnId), - }, - ], + layerId: table.layerId, + columns: table.columns.map((col) => ({ columnId: col.columnId })), }, previewIcon: LensIconChartDatatable, // tables are hidden from suggestion bar, but used for drag & drop and chart switching @@ -144,6 +140,11 @@ export const datatableVisualization: Visualization const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, frame.datasourceLayers, layerId) || {}; + const columnMap: Record = {}; + state.columns.forEach((column) => { + columnMap[column.columnId] = column; + }); + if (!sortedColumns) { return { groups: [] }; } @@ -155,61 +156,68 @@ export const datatableVisualization: Visualization groupLabel: i18n.translate('xpack.lens.datatable.breakdown', { defaultMessage: 'Break down by', }), - layerId: state.layers[0].layerId, + layerId: state.layerId, accessors: sortedColumns .filter((c) => datasource!.getOperationForColumnId(c)?.isBucketed) - .map((accessor) => ({ columnId: accessor })), + .map((accessor) => ({ + columnId: accessor, + triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined, + })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, dataTestSubj: 'lnsDatatable_column', + enableDimensionEditor: true, }, { groupId: 'metrics', groupLabel: i18n.translate('xpack.lens.datatable.metrics', { defaultMessage: 'Metrics', }), - layerId: state.layers[0].layerId, + layerId: state.layerId, accessors: sortedColumns .filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed) - .map((accessor) => ({ columnId: accessor })), + .map((accessor) => ({ + columnId: accessor, + triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined, + })), supportsMoreColumns: true, filterOperations: (op) => !op.isBucketed, required: true, dataTestSubj: 'lnsDatatable_metrics', + enableDimensionEditor: true, }, ], }; }, - setDimension({ prevState, layerId, columnId }) { + setDimension({ prevState, columnId }) { + if (prevState.columns.some((column) => column.columnId === columnId)) { + return prevState; + } return { ...prevState, - layers: prevState.layers.map((l) => { - if (l.layerId !== layerId || l.columns.includes(columnId)) { - return l; - } - return { ...l, columns: [...l.columns, columnId] }; - }), + columns: [...prevState.columns, { columnId }], }; }, - removeDimension({ prevState, layerId, columnId }) { + removeDimension({ prevState, columnId }) { return { ...prevState, - layers: prevState.layers.map((l) => - l.layerId === layerId - ? { - ...l, - columns: l.columns.filter((c) => c !== columnId), - } - : l - ), + columns: prevState.columns.filter((column) => column.columnId !== columnId), sorting: prevState.sorting?.columnId === columnId ? undefined : prevState.sorting, }; }, + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, toExpression(state, datasourceLayers, { title, description } = {}): Ast | null { const { sortedColumns, datasource } = - getDataSourceAndSortedColumns(state, datasourceLayers, state.layers[0].layerId) || {}; + getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {}; if ( sortedColumns?.length && @@ -218,9 +226,14 @@ export const datatableVisualization: Visualization return null; } - const operations = sortedColumns! - .map((columnId) => ({ columnId, operation: datasource!.getOperationForColumnId(columnId) })) - .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); + const columnMap: Record = {}; + state.columns.forEach((column) => { + columnMap[column.columnId] = column; + }); + + const columns = sortedColumns! + .filter((columnId) => datasource!.getOperationForColumnId(columnId)) + .map((columnId) => columnMap[columnId]); return { type: 'expression', @@ -231,35 +244,22 @@ export const datatableVisualization: Visualization arguments: { title: [title || ''], description: [description || ''], - columns: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_columns', - arguments: { - columnIds: operations.map((o) => o.columnId), - sortBy: [state.sorting?.columnId || ''], - sortDirection: [state.sorting?.direction || 'none'], - columnWidth: (state.columnWidth || []).map((columnWidth) => ({ - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_datatable_column_width', - arguments: { - columnId: [columnWidth.columnId], - width: [columnWidth.width], - }, - }, - ], - })), - }, + columns: columns.map((column) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_column', + arguments: { + columnId: [column.columnId], + hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], + width: typeof column.width === 'undefined' ? [] : [column.width], }, - ], - }, - ], + }, + ], + })), + sortingColumnId: [state.sorting?.columnId || ''], + sortingDirection: [state.sorting?.direction || 'none'], }, }, ], @@ -280,15 +280,34 @@ export const datatableVisualization: Visualization direction: event.data.direction, }, }; + case 'toggle': + return { + ...state, + columns: state.columns.map((column) => { + if (column.columnId === event.data.columnId) { + return { + ...column, + hidden: !column.hidden, + }; + } else { + return column; + } + }), + }; case 'resize': + const targetWidth = event.data.width; return { ...state, - columnWidth: [ - ...(state.columnWidth || []).filter(({ columnId }) => columnId !== event.data.columnId), - ...(event.data.width !== undefined - ? [{ columnId: event.data.columnId, width: event.data.width }] - : []), - ], + columns: state.columns.map((column) => { + if (column.columnId === event.data.columnId) { + return { + ...column, + width: targetWidth, + }; + } else { + return column; + } + }), }; default: return state; @@ -301,13 +320,11 @@ function getDataSourceAndSortedColumns( datasourceLayers: Record, layerId: string ) { - const layer = state.layers.find((l: DatatableLayerState) => l.layerId === layerId); - if (!layer) { - return undefined; - } - const datasource = datasourceLayers[layer.layerId]; + const datasource = datasourceLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); // When we add a column it could be empty, and therefore have no order - const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns))); + const sortedColumns = Array.from( + new Set(originalOrder.concat(state.columns.map(({ columnId }) => columnId))) + ); return { datasource, sortedColumns }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx index e3a30883a2209c..a3d5c6fd22fcd5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx @@ -49,6 +49,16 @@ export function ColorIndicator({ })} /> )} + {accessorConfig.triggerIcon === 'invisible' && ( + + )} {accessorConfig.triggerIcon === 'colorBy' && ( void; @@ -368,7 +370,7 @@ export type VisualizationDimensionEditorProps = VisualizationConfig export interface AccessorConfig { columnId: string; - triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none'; + triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible'; color?: string; palette?: string[]; } @@ -649,6 +651,7 @@ export interface LensBrushEvent { interface LensEditContextMapping { [LENS_EDIT_SORT_ACTION]: LensSortActionData; [LENS_EDIT_RESIZE_ACTION]: LensResizeActionData; + [LENS_TOGGLE_ACTION]: LensToggleActionData; } type LensEditSupportedActions = keyof LensEditContextMapping; diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index 077204b07ed733..01329d85baf008 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -597,4 +597,77 @@ describe('Lens migrations', () => { expect(layersWithSuggestedPriority).toEqual(0); }); }); + + describe('7.12.0 restructure datatable state', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mock-saved-object-id', + attributes: { + state: { + datasourceStates: { + indexpattern: {}, + }, + visualization: { + layers: [ + { + layerId: 'first', + columns: ['a', 'b', 'c'], + }, + ], + sorting: { + columnId: 'a', + direction: 'asc', + }, + }, + query: { query: '', language: 'kuery' }, + filters: [], + }, + title: 'Table', + visualizationType: 'lnsDatatable', + }, + }; + + it('should not touch non datatable visualization', () => { + const xyChart = { + ...example, + attributes: { ...example.attributes, visualizationType: 'xy' }, + }; + const result = migrations['7.12.0'](xyChart, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result).toBe(xyChart); + }); + + it('should remove layer array and reshape state', () => { + const result = migrations['7.12.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + expect(result.attributes.state.visualization).toEqual({ + layerId: 'first', + columns: [ + { + columnId: 'a', + }, + { + columnId: 'b', + }, + { + columnId: 'c', + }, + ], + sorting: { + columnId: 'a', + direction: 'asc', + }, + }); + // should leave other parts alone + expect(result.attributes.state.datasourceStates).toEqual( + example.attributes.state.datasourceStates + ); + expect(result.attributes.state.query).toEqual(example.attributes.state.query); + expect(result.attributes.state.filters).toEqual(example.attributes.state.filters); + expect(result.attributes.title).toEqual(example.attributes.title); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index bb078ff204f2bd..4c6dfcd7949be8 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -83,6 +83,29 @@ interface XYStatePost77 { layers: Array>; } +interface DatatableStatePre711 { + layers: Array<{ + layerId: string; + columns: string[]; + }>; + sorting?: { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; + }; +} +interface DatatableStatePost711 { + layerId: string; + columns: Array<{ + columnId: string; + width?: number; + hidden?: boolean; + }>; + sorting?: { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; + }; +} + /** * Removes the `lens_auto_date` subexpression from a stored expression * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} @@ -334,6 +357,36 @@ const removeSuggestedPriority: SavedObjectMigrationFn, + LensDocShape +> = (doc) => { + // nothing to do for non-datatable visualizations + if (doc.attributes.visualizationType !== 'lnsDatatable') + return (doc as unknown) as SavedObjectUnsanitizedDoc>; + const oldState = doc.attributes.state.visualization; + const layer = oldState.layers[0] || { + layerId: '', + columns: [], + }; + // put together new saved object format + const newDoc: SavedObjectUnsanitizedDoc> = { + ...doc, + attributes: { + ...doc.attributes, + state: { + ...doc.attributes.state, + visualization: { + sorting: oldState.sorting, + layerId: layer.layerId, + columns: layer.columns.map((columnId) => ({ columnId })), + }, + }, + }, + }; + return newDoc; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -341,4 +394,5 @@ export const migrations: SavedObjectMigrationMap = { '7.8.0': (doc, context) => addTimeFieldToEsaggs(removeLensAutoDate(doc, context), context), '7.10.0': extractReferences, '7.11.0': removeSuggestedPriority, + '7.12.0': transformTableState, }; diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 6cbd18bdeef045..10b1f4d30145f8 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -29,6 +29,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { this.tags(['ciGroup4', 'skipFirefox']); loadTestFile(require.resolve('./smokescreen')); + loadTestFile(require.resolve('./table')); loadTestFile(require.resolve('./dashboard')); loadTestFile(require.resolve('./persistent_context')); loadTestFile(require.resolve('./colors')); diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts new file mode 100644 index 00000000000000..f79d1c342b72f5 --- /dev/null +++ b/x-pack/test/functional/apps/lens/table.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const listingTable = getService('listingTable'); + const find = getService('find'); + + describe('lens datatable', () => { + it('should able to sort a table by a column', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.searchForItemWithName('lnsXYvis'); + await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + // Sort by number + await PageObjects.lens.changeTableSortingBy(2, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); + // Now sort by IP + await PageObjects.lens.changeTableSortingBy(0, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('78.83.247.30'); + // Change the sorting + await PageObjects.lens.changeTableSortingBy(0, 'desc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('169.228.188.120'); + // Remove the sorting + await PageObjects.lens.changeTableSortingBy(0, 'none'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.isDatatableHeaderSorted(0)).to.eql(false); + }); + + it('should able to use filters cell actions in table', async () => { + const firstCellContent = await PageObjects.lens.getDatatableCellText(0, 0); + await PageObjects.lens.clickTableCellAction(0, 0, 'lensDatatableFilterOut'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await find.existsByCssSelector( + `[data-test-subj*="filter-value-${firstCellContent}"][data-test-subj*="filter-negated"]` + ) + ).to.eql(true); + }); + + it('should allow to configure column visibility', async () => { + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('Top values of ip'); + expect(await PageObjects.lens.getDatatableHeaderText(1)).to.equal('@timestamp per 3 hours'); + expect(await PageObjects.lens.getDatatableHeaderText(2)).to.equal('Average of bytes'); + + await PageObjects.lens.toggleColumnVisibility('lnsDatatable_column > lns-dimensionTrigger'); + + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('@timestamp per 3 hours'); + expect(await PageObjects.lens.getDatatableHeaderText(1)).to.equal('Average of bytes'); + + await PageObjects.lens.toggleColumnVisibility('lnsDatatable_column > lns-dimensionTrigger'); + + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal('Top values of ip'); + expect(await PageObjects.lens.getDatatableHeaderText(1)).to.equal('@timestamp per 3 hours'); + expect(await PageObjects.lens.getDatatableHeaderText(2)).to.equal('Average of bytes'); + }); + }); +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 37d97cd014c9f1..f6960600a6d7c9 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -562,6 +562,15 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return buttonEl.click(); }, + async toggleColumnVisibility(dimension: string) { + await this.openDimensionEditor(dimension); + const id = 'lns-table-column-hidden'; + const isChecked = await testSubjects.isEuiSwitchChecked(id); + await testSubjects.setEuiSwitch(id, isChecked ? 'uncheck' : 'check'); + await this.closeDimensionEditor(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }, + async clickTableCellAction(rowIndex = 0, colIndex = 0, actionTestSub: string) { const el = await this.getDatatableCell(rowIndex, colIndex); await el.focus();