From e3dcee1c805ff9eb91d2900fcf254ab78e7bb5fb Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 4 Jan 2021 15:29:02 +0100 Subject: [PATCH 01/30] migrate data table visualization to data grid --- .../datatable_visualization/expression.scss | 4 + .../datatable_visualization/expression.tsx | 460 ++++++++++-------- .../public/datatable_visualization/index.ts | 2 + .../datatable_visualization/visualization.tsx | 43 +- x-pack/plugins/lens/public/types.ts | 3 + 5 files changed, 307 insertions(+), 205 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.scss b/x-pack/plugins/lens/public/datatable_visualization/expression.scss index 7d95d73143870d..4b7880b0bf91c4 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.scss +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.scss @@ -11,3 +11,7 @@ .lnsDataTable__filter:focus-within { opacity: 1; } + +.lnsDataTableCellContent { + @include euiTextTruncate; +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 4d1df5b519ba97..198181356c5696 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -10,21 +10,15 @@ import React, { useMemo } from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; -import { - EuiBasicTable, - EuiFlexGroup, - EuiButtonIcon, - EuiFlexItem, - EuiToolTip, - Direction, - EuiScreenReaderOnly, - EuiIcon, - EuiBasicTableColumn, - EuiTableActionsColumnType, -} from '@elastic/eui'; +import { EuiButtonIcon, Direction } from '@elastic/eui'; import { orderBy } from 'lodash'; import { IAggType } from 'src/plugins/data/public'; -import { Datatable, DatatableColumnMeta, RenderMode } from 'src/plugins/expressions'; +import { DatatableColumnMeta, RenderMode } from 'src/plugins/expressions'; +import { EuiDataGrid } from '@elastic/eui'; +import { EuiDataGridControlColumn } from '@elastic/eui'; +import { EuiDataGridColumn } from '@elastic/eui'; +import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { FormatFactory, ILensInterpreterRenderHandlers, @@ -43,23 +37,35 @@ import { desanitizeFilterContext } from '../utils'; import { LensIconChartDatatable } from '../assets/chart_datatable'; export const LENS_EDIT_SORT_ACTION = 'sort'; +export const LENS_EDIT_RESIZE_ACTION = 'resize'; export interface LensSortActionData { columnId: string | undefined; direction: 'asc' | 'desc' | 'none'; } -type LensSortAction = LensEditEvent; +export interface LensResizeActionData { + columnId: string; + width: number; +} -// This is a way to circumvent the explicit "any" forbidden type -type TableRowField = Datatable['rows'][number] & { rowIndex: number }; +type LensSortAction = LensEditEvent; +type LensResizeAction = LensEditEvent; export interface DatatableColumns { columnIds: string[]; sortBy: string; sortDirection: string; + columnWidth?: DatatableColumnWidthResult[]; +} + +export interface DatatableColumnWidth { + columnId: string; + width: number; } +type DatatableColumnWidthResult = DatatableColumnWidth & { type: 'lens_datatable_column_width' }; + interface Args { title: string; description?: string; @@ -74,7 +80,7 @@ export interface DatatableProps { type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; onClickValue: (data: LensFilterEvent['data']) => void; - onEditAction?: (data: LensSortAction['data']) => void; + onEditAction?: (data: LensSortAction['data'] | LensResizeAction['data']) => void; getType: (name: string) => IAggType; renderMode: RenderMode; onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; @@ -185,6 +191,11 @@ export const datatableColumns: ExpressionFunctionDefinition< multi: true, help: '', }, + columnWidth: { + types: ['lens_datatable_column_width'], + multi: true, + help: '', + }, }, fn: function fn(input: unknown, args: DatatableColumns) { return { @@ -194,6 +205,35 @@ export const datatableColumns: ExpressionFunctionDefinition< }, }; +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: '', + }, + }, + fn: function fn(input: unknown, args: DatatableColumnWidth) { + return { + type: 'lens_datatable_column_width', + ...args, + }; + }, +}; + export const getDatatableRenderer = (dependencies: { formatFactory: FormatFactory; getType: Promise<(name: string) => IAggType>; @@ -215,7 +255,7 @@ export const getDatatableRenderer = (dependencies: { handlers.event({ name: 'filter', data }); }; - const onEditAction = (data: LensSortAction['data']) => { + const onEditAction = (data: LensSortAction['data'] | LensResizeAction['data']) => { if (handlers.getRenderMode() === 'edit') { handlers.event({ name: 'edit', data }); } @@ -280,39 +320,6 @@ function getNextOrderValue(currentValue: LensSortAction['data']['direction']) { return states[newStateIndex]; } -function getDirectionLongLabel(sortDirection: LensSortAction['data']['direction']) { - if (sortDirection === 'none') { - return sortDirection; - } - return sortDirection === 'asc' ? 'ascending' : 'descending'; -} - -function getHeaderSortingCell( - name: string, - columnId: string, - sorting: Omit, - sortingLabel: string -) { - if (columnId !== sorting.columnId || sorting.direction === 'none') { - return name || ''; - } - // This is a workaround to hijack the title value of the header cell - return ( - - {name || ''} - - {sortingLabel} - - - - ); -} - export function DatatableComponent(props: DatatableRenderProps) { const [firstTable] = Object.values(props.data.tables); const formatters: Record> = {}; @@ -321,7 +328,7 @@ export function DatatableComponent(props: DatatableRenderProps) { formatters[column.id] = props.formatFactory(column.meta?.params); }); - const { onClickValue, onEditAction, onRowContextMenuClick } = props; + const { onClickValue, onEditAction: onEditAction, onRowContextMenuClick } = props; const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { const col = firstTable.columns[colIndex]; @@ -376,177 +383,238 @@ export function DatatableComponent(props: DatatableRenderProps) { const { sortBy, sortDirection } = props.args.columns; - const sortedRows: TableRowField[] = - firstTable?.rows.map((row, rowIndex) => ({ ...row, rowIndex })) || []; const isReadOnlySorted = props.renderMode !== 'edit'; - const sortedInLabel = i18n.translate('xpack.lens.datatableSortedInReadOnlyMode', { - defaultMessage: 'Sorted in {sortValue} order', - values: { - sortValue: sortDirection === 'asc' ? 'ascending' : 'descending', - }, - }); - - const tableColumns: Array> = visibleColumns.map((field) => { + // todo memoize this + const columns: EuiDataGridColumn[] = visibleColumns.map((field) => { const filterable = bucketColumns.includes(field); - const { name, index: colIndex, meta } = columnsReverseLookup[field]; - const fieldName = meta?.field; - const nameContent = !isReadOnlySorted - ? name - : getHeaderSortingCell( - name, - field, - { - columnId: sortBy, - direction: sortDirection as LensSortAction['data']['direction'], + const { name, index: colIndex } = columnsReverseLookup[field]; + + const cellActions = filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = firstTable.rows[rowIndex][columnId]; + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + + const cellContent = formatters[field]?.convert(rowValue); + + const filterForText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterForValueText', + { + defaultMessage: 'Filter for value', + } + ); + const filterForAriaLabel = i18n.translate( + 'spack.lens.table.tableCellFilter.filterForValueAriaLabel', + { + defaultMessage: 'Filter for value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + handleFilterClick(field, rowValue, colIndex); + closePopover(); + }} + iconType="plusInCircle" + > + {filterForText} + + ) + ); }, - sortedInLabel - ); - return { - field, - name: nameContent, - sortable: !isReadOnlySorted, - render: (value: unknown) => { - const formattedValue = formatters[field]?.convert(value); + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = firstTable.rows[rowIndex][columnId]; + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + const cellContent = formatters[field]?.convert(rowValue); - if (filterable) { - return ( - - {formattedValue} - - { + handleFilterClick(field, rowValue, colIndex, true); + closePopover(); + }} + iconType="minusInCircle" > - - handleFilterClick(field, value, colIndex)} - /> - - - - handleFilterClick(field, value, colIndex, true)} - /> - - - - - - ); - } - return {formattedValue}; + {filterOutText} + + ) + ); + }, + ] + : undefined; + + const columnDefinition: EuiDataGridColumn = { + id: field, + cellActions, + display: name, + displayAsText: name, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: isReadOnlySorted + ? false + : { + label: i18n.translate('visTypeTable.sort.ascLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: isReadOnlySorted + ? false + : { + label: i18n.translate('visTypeTable.sort.descLabel', { + defaultMessage: 'Sort desc', + }), + }, }, }; + + const initialWidth = props.args.columns.columnWidth?.find(({ columnId }) => columnId === field) + ?.width; + if (initialWidth) { + columnDefinition.initialWidth = initialWidth; + } + + return columnDefinition; }); + // TODO memoize this + const trailingControlColumns: EuiDataGridControlColumn[] = []; + if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) { const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x); if (hasAtLeastOneRowClickAction) { - const actions: EuiTableActionsColumnType = { - name: i18n.translate('xpack.lens.datatable.actionsColumnName', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: i18n.translate('xpack.lens.tableRowMore', { - defaultMessage: 'More', - }), - description: i18n.translate('xpack.lens.tableRowMoreDescription', { - defaultMessage: 'Table row context menu', - }), - type: 'icon', - icon: ({ rowIndex }: { rowIndex: number }) => { - if ( - !!props.rowHasRowClickTriggerActions && - !props.rowHasRowClickTriggerActions[rowIndex] - ) - return 'empty'; - return 'boxesVertical'; - }, - onClick: ({ rowIndex }) => { - onRowContextMenuClick({ - rowIndex, - table: firstTable, - columns: props.args.columns.columnIds, - }); - }, - }, - ], - }; - tableColumns.push(actions); + trailingControlColumns.push({ + headerCellRender: () => null, + width: 40, + id: 'trailingControlColumn', + rowCellRender: function RowCellRender({ rowIndex }) { + return ( + { + onRowContextMenuClick({ + rowIndex, + table: firstTable, + columns: props.args.columns.columnIds, + }); + }} + /> + ); + }, + }); } } + const renderCellValue = ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const rowValue = firstTable.rows[rowIndex][columnId]; + const content = formatters[columnId].convert(rowValue, 'html'); + + const cellContent = ( +
+ ); + + return cellContent; + }; + + const dataGridAriaLabel = + props.args.title || + i18n.translate('xpack.lens.table.defaultAriaLabel', { + defaultMessage: 'Data table visualization', + }); + return ( - {} }} + trailingControlColumns={trailingControlColumns} + rowCount={firstTable.rows.length} + renderCellValue={renderCellValue} + gridStyle={{ + border: 'horizontal', + header: 'underline', + }} sorting={{ - sort: - !sortBy || sortDirection === 'none' || isReadOnlySorted - ? undefined - : { - field: sortBy, - direction: sortDirection as Direction, - }, - allowNeutralSort: true, // this flag enables the 3rd Neutral state on the column header + columns: + !sortBy || sortDirection === 'none' + ? [] + : [ + { + id: sortBy, + direction: sortDirection as 'asc' | 'desc', + }, + ], + onSort: (sortingCols) => { + if (onEditAction) { + const newSortValue: + | { + id: string; + direction: 'desc' | 'asc'; + } + | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + const isNewColumn = sortBy !== (newSortValue?.id || ''); + // unfortunately the neutral state is not propagated and we need to manually handle it + const nextDirection = getNextOrderValue( + (isNewColumn ? 'none' : sortDirection) as LensSortAction['data']['direction'] + ); + return onEditAction({ + action: 'sort', + columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, + direction: nextDirection, + }); + } + }, }} - onChange={(event: { sort?: { field: string } }) => { - if (event.sort && onEditAction) { - const isNewColumn = sortBy !== event.sort.field; - // unfortunately the neutral state is not propagated and we need to manually handle it - const nextDirection = getNextOrderValue( - (isNewColumn ? 'none' : sortDirection) as LensSortAction['data']['direction'] - ); + onColumnResize={(eventData) => { + if (onEditAction) { return onEditAction({ - action: 'sort', - columnId: nextDirection !== 'none' || isNewColumn ? event.sort.field : undefined, - direction: nextDirection, + action: 'resize', + columnId: eventData.columnId, + width: eventData.width, }); } }} - columns={tableColumns} - items={sortedRows} + toolbarVisibility={false} /> ); diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 42d2ff6a220c04..cf23d56adb9157 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -29,12 +29,14 @@ export class DatatableVisualization { const { getDatatable, datatableColumns, + datatableColumnWidth, getDatatableRenderer, datatableVisualization, } = await import('../async_services'); const resolvedFormatFactory = await formatFactory; expressions.registerFunction(() => datatableColumns); + expressions.registerFunction(() => datatableColumnWidth); expressions.registerFunction(() => getDatatable({ formatFactory: resolvedFormatFactory })); expressions.registerRenderer(() => getDatatableRenderer({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index e4f787a2651866..71eb1a44f793ca 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -14,6 +14,7 @@ import { DatasourcePublicAPI, } from '../types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; +import { DatatableColumnWidth } from './expression'; export interface LayerState { layerId: string; @@ -26,6 +27,7 @@ export interface DatatableVisualizationState { columnId: string | undefined; direction: 'asc' | 'desc' | 'none'; }; + columnWidth?: DatatableColumnWidth[]; } function newLayerState(layerId: string): LayerState { @@ -239,6 +241,19 @@ export const datatableVisualization: Visualization 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], + }, + }, + ], + })), }, }, ], @@ -255,16 +270,26 @@ export const datatableVisualization: Visualization }, onEditAction(state, event) { - if (event.data.action !== 'sort') { - return state; + switch (event.data.action) { + case 'sort': + return { + ...state, + sorting: { + columnId: event.data.columnId, + direction: event.data.direction, + }, + }; + case 'resize': + return { + ...state, + columnWidth: [ + ...(state.columnWidth || []).filter(({ columnId }) => columnId !== event.data.columnId), + { columnId: event.data.columnId, width: event.data.width }, + ], + }; + default: + return state; } - return { - ...state, - sorting: { - columnId: event.data.columnId, - direction: event.data.direction, - }, - }; }, }; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a5e17a05cf71d1..a556860904c035 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -28,6 +28,8 @@ import { import type { LensSortActionData, LENS_EDIT_SORT_ACTION, + LENS_EDIT_RESIZE_ACTION, + LensResizeActionData, } from './datatable_visualization/expression'; export type ErrorCallback = (e: { message: string }) => void; @@ -634,6 +636,7 @@ export interface LensBrushEvent { // Use same technique as TriggerContext interface LensEditContextMapping { [LENS_EDIT_SORT_ACTION]: LensSortActionData; + [LENS_EDIT_RESIZE_ACTION]: LensResizeActionData; } type LensEditSupportedActions = keyof LensEditContextMapping; From a752f4d2b0b03982bf374f956364bc01e6fdfda8 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 4 Jan 2021 17:54:38 +0100 Subject: [PATCH 02/30] memoize as good as possible --- .../datatable_visualization/expression.tsx | 432 ++++++++++-------- 1 file changed, 244 insertions(+), 188 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 198181356c5696..81f6af396cf78b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -6,7 +6,7 @@ import './expression.scss'; -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; @@ -19,6 +19,7 @@ import { EuiDataGridControlColumn } from '@elastic/eui'; import { EuiDataGridColumn } from '@elastic/eui'; import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; import { FormatFactory, ILensInterpreterRenderHandlers, @@ -79,11 +80,9 @@ export interface DatatableProps { type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; - onClickValue: (data: LensFilterEvent['data']) => void; - onEditAction?: (data: LensSortAction['data'] | LensResizeAction['data']) => void; + dispatchEvent: ILensInterpreterRenderHandlers['event']; getType: (name: string) => IAggType; renderMode: RenderMode; - onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; /** * A boolean for each table row, which is true if the row active @@ -251,18 +250,6 @@ export const getDatatableRenderer = (dependencies: { handlers: ILensInterpreterRenderHandlers ) => { const resolvedGetType = await dependencies.getType; - const onClickValue = (data: LensFilterEvent['data']) => { - handlers.event({ name: 'filter', data }); - }; - - const onEditAction = (data: LensSortAction['data'] | LensResizeAction['data']) => { - if (handlers.getRenderMode() === 'edit') { - handlers.event({ name: 'edit', data }); - } - }; - const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => { - handlers.event({ name: 'tableRowContextMenuClick', data }); - }; const { hasCompatibleActions } = handlers; // An entry for each table row, whether it has any actions attached to @@ -297,10 +284,8 @@ export const getDatatableRenderer = (dependencies: { @@ -321,20 +306,57 @@ function getNextOrderValue(currentValue: LensSortAction['data']['direction']) { } export function DatatableComponent(props: DatatableRenderProps) { + const [columnConfig, setColumnConfig] = useState(props.args.columns); + + useDeepCompareEffect(() => { + setColumnConfig(props.args.columns); + }, [props.args.columns]); const [firstTable] = Object.values(props.data.tables); - const formatters: Record> = {}; - firstTable.columns.forEach((column) => { - formatters[column.id] = props.formatFactory(column.meta?.params); - }); + const firstTableRef = useRef(firstTable); + firstTableRef.current = firstTable; + + const formatFactory = props.formatFactory; + const formatters: Record< + string, + ReturnType + > = firstTableRef.current.columns.reduce( + (map, column) => ({ + ...map, + [column.id]: formatFactory(column.meta?.params), + }), + {} + ); + const { getType, dispatchEvent, renderMode, rowHasRowClickTriggerActions } = props; + const onClickValue = useCallback( + (data: LensFilterEvent['data']) => { + dispatchEvent({ name: 'filter', data }); + }, + [dispatchEvent] + ); + const hasAtLeastOneRowClickAction = rowHasRowClickTriggerActions?.find((x) => x); + + const onEditAction = useCallback( + (data: LensSortAction['data'] | LensResizeAction['data']) => { + if (renderMode === 'edit') { + dispatchEvent({ name: 'edit', data }); + } + }, + [dispatchEvent, renderMode] + ); + const onRowContextMenuClick = useCallback( + (data: LensTableRowContextMenuEvent['data']) => { + dispatchEvent({ name: 'tableRowContextMenuClick', data }); + }, + [dispatchEvent] + ); - const { onClickValue, onEditAction: onEditAction, onRowContextMenuClick } = props; const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { - const col = firstTable.columns[colIndex]; + const col = firstTableRef.current.columns[colIndex]; const isDate = col.meta?.type === 'date'; const timeFieldName = negate && isDate ? undefined : col?.meta?.field; - const rowIndex = firstTable.rows.findIndex((row) => row[field] === value); + const rowIndex = firstTableRef.current.rows.findIndex((row) => row[field] === value); const data: LensFilterEvent['data'] = { negate, @@ -343,24 +365,28 @@ export function DatatableComponent(props: DatatableRenderProps) { row: rowIndex, column: colIndex, value, - table: firstTable, + table: firstTableRef.current, }, ], timeFieldName, }; onClickValue(desanitizeFilterContext(data)); }, - [firstTable, onClickValue] + [firstTableRef, onClickValue] ); - const bucketColumns = firstTable.columns - .filter((col) => { - return ( - col?.meta?.sourceParams?.type && - props.getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); - }) - .map((col) => col.id); + const bucketColumns = useMemo( + () => + firstTableRef.current.columns + .filter((col) => { + return ( + col?.meta?.sourceParams?.type && + getType(col.meta.sourceParams.type as string)?.type === 'buckets' + ); + }) + .map((col) => col.id), + [firstTableRef, getType] + ); const isEmpty = firstTable.rows.length === 0 || @@ -369,145 +395,158 @@ export function DatatableComponent(props: DatatableRenderProps) { bucketColumns.every((col) => typeof row[col] === 'undefined') )); - if (isEmpty) { - return ; - } - - const visibleColumns = props.args.columns.columnIds.filter((field) => !!field); - const columnsReverseLookup = firstTable.columns.reduce< - Record - >((memo, { id, name, meta }, i) => { - memo[id] = { name, index: i, meta }; - return memo; - }, {}); + const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ + columnConfig, + ]); - const { sortBy, sortDirection } = props.args.columns; + const { sortBy, sortDirection } = columnConfig; const isReadOnlySorted = props.renderMode !== 'edit'; // todo memoize this - const columns: EuiDataGridColumn[] = visibleColumns.map((field) => { - const filterable = bucketColumns.includes(field); - const { name, index: colIndex } = columnsReverseLookup[field]; - - const cellActions = filterable - ? [ - ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { - const rowValue = firstTable.rows[rowIndex][columnId]; - const contentsIsDefined = rowValue !== null && rowValue !== undefined; - - const cellContent = formatters[field]?.convert(rowValue); - - const filterForText = i18n.translate( - 'xpack.lens.table.tableCellFilter.filterForValueText', - { - defaultMessage: 'Filter for value', - } - ); - const filterForAriaLabel = i18n.translate( - 'spack.lens.table.tableCellFilter.filterForValueAriaLabel', - { - defaultMessage: 'Filter for value: {cellContent}', - values: { - cellContent, - }, - } - ); - - return ( - contentsIsDefined && ( - { - handleFilterClick(field, rowValue, colIndex); - closePopover(); - }} - iconType="plusInCircle" - > - {filterForText} - - ) - ); - }, - ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { - const rowValue = firstTable.rows[rowIndex][columnId]; - const contentsIsDefined = rowValue !== null && rowValue !== undefined; - const cellContent = formatters[field]?.convert(rowValue); + const columns: EuiDataGridColumn[] = useMemo(() => { + const columnsReverseLookup = firstTableRef.current.columns.reduce< + Record + >((memo, { id, name, meta }, i) => { + memo[id] = { name, index: i, meta }; + return memo; + }, {}); - const filterOutText = i18n.translate('xpack.lens.tableCellFilter.filterOutValueText', { - defaultMessage: 'Filter out value', - }); - const filterOutAriaLabel = i18n.translate( - 'xpack.lens.tableCellFilter.filterOutValueAriaLabel', - { - defaultMessage: 'Filter out value: {cellContent}', - values: { - cellContent, - }, - } - ); - - return ( - contentsIsDefined && ( - { - handleFilterClick(field, rowValue, colIndex, true); - closePopover(); - }} - iconType="minusInCircle" - > - {filterOutText} - - ) - ); - }, - ] - : undefined; - - const columnDefinition: EuiDataGridColumn = { - id: field, - cellActions, - display: name, - displayAsText: name, - actions: { - showHide: false, - showMoveLeft: false, - showMoveRight: false, - showSortAsc: isReadOnlySorted - ? false - : { - label: i18n.translate('visTypeTable.sort.ascLabel', { - defaultMessage: 'Sort asc', - }), - }, - showSortDesc: isReadOnlySorted - ? false - : { - label: i18n.translate('visTypeTable.sort.descLabel', { - defaultMessage: 'Sort desc', - }), - }, - }, - }; + return visibleColumns.map((field) => { + const filterable = bucketColumns.includes(field); + const { name, index: colIndex } = columnsReverseLookup[field]; - const initialWidth = props.args.columns.columnWidth?.find(({ columnId }) => columnId === field) - ?.width; - if (initialWidth) { - columnDefinition.initialWidth = initialWidth; - } + const cellActions = filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = firstTableRef.current.rows[rowIndex][columnId]; + const column = firstTableRef.current.columns.find(({ id }) => id === columnId); + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + + const cellContent = formatFactory(column?.meta?.params).convert(rowValue); + + const filterForText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterForValueText', + { + defaultMessage: 'Filter for value', + } + ); + const filterForAriaLabel = i18n.translate( + 'spack.lens.table.tableCellFilter.filterForValueAriaLabel', + { + defaultMessage: 'Filter for value: {cellContent}', + values: { + cellContent, + }, + } + ); - return columnDefinition; - }); + return ( + contentsIsDefined && ( + { + handleFilterClick(field, rowValue, colIndex); + closePopover(); + }} + iconType="plusInCircle" + > + {filterForText} + + ) + ); + }, + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const rowValue = firstTableRef.current.rows[rowIndex][columnId]; + const column = firstTableRef.current.columns.find(({ id }) => id === columnId); + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + const cellContent = formatFactory(column?.meta?.params).convert(rowValue); + + const filterOutText = i18n.translate( + 'xpack.lens.tableCellFilter.filterOutValueText', + { + defaultMessage: 'Filter out value', + } + ); + const filterOutAriaLabel = i18n.translate( + 'xpack.lens.tableCellFilter.filterOutValueAriaLabel', + { + defaultMessage: 'Filter out value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + handleFilterClick(field, rowValue, colIndex, true); + closePopover(); + }} + iconType="minusInCircle" + > + {filterOutText} + + ) + ); + }, + ] + : undefined; + + const columnDefinition: EuiDataGridColumn = { + id: field, + cellActions, + display: name, + displayAsText: name, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: isReadOnlySorted + ? false + : { + label: i18n.translate('visTypeTable.sort.ascLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: isReadOnlySorted + ? false + : { + label: i18n.translate('visTypeTable.sort.descLabel', { + defaultMessage: 'Sort desc', + }), + }, + }, + }; - // TODO memoize this - const trailingControlColumns: EuiDataGridControlColumn[] = []; + const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) + ?.width; + if (initialWidth) { + columnDefinition.initialWidth = initialWidth; + } - if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) { - const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x); - if (hasAtLeastOneRowClickAction) { - trailingControlColumns.push({ + return columnDefinition; + }); + }, [ + bucketColumns, + firstTableRef, + handleFilterClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + ]); + + const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { + if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { + return []; + } + return [ + { headerCellRender: () => null, width: 40, id: 'trailingControlColumn', @@ -517,40 +556,57 @@ export function DatatableComponent(props: DatatableRenderProps) { aria-label={i18n.translate('xpack.lens.datatable.actionsLabel', { defaultMessage: 'Show actions', })} - iconType="boxesHorizontal" + iconType={ + !!rowHasRowClickTriggerActions && !rowHasRowClickTriggerActions[rowIndex] + ? 'empty' + : 'boxesVertical' + } color="text" onClick={() => { onRowContextMenuClick({ rowIndex, - table: firstTable, - columns: props.args.columns.columnIds, + table: firstTableRef.current, + columns: columnConfig.columnIds, }); }} /> ); }, - }); - } - } + }, + ]; + }, [ + firstTableRef, + onRowContextMenuClick, + columnConfig, + hasAtLeastOneRowClickAction, + rowHasRowClickTriggerActions, + ]); + + const renderCellValue = useCallback( + ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const rowValue = firstTableRef.current.rows[rowIndex][columnId]; + const content = formatters[columnId].convert(rowValue, 'html'); + + const cellContent = ( +
+ ); - const renderCellValue = ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { - const rowValue = firstTable.rows[rowIndex][columnId]; - const content = formatters[columnId].convert(rowValue, 'html'); - - const cellContent = ( -
- ); + return cellContent; + }, + [formatters, firstTableRef] + ); - return cellContent; - }; + if (isEmpty) { + return ; + } const dataGridAriaLabel = props.args.title || From 84c2a9d47af7e36c21c808f6d0569a85587d351e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 4 Jan 2021 18:08:53 +0100 Subject: [PATCH 03/30] improve visuals --- .../datatable_visualization/expression.tsx | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 81f6af396cf78b..9f673782c9b62c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -6,7 +6,7 @@ import './expression.scss'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; @@ -604,6 +604,34 @@ export function DatatableComponent(props: DatatableRenderProps) { [formatters, firstTableRef] ); + const onColumnResize = useCallback( + (eventData) => { + // 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 + ), + { + columnId: eventData.columnId, + width: eventData.width, + type: 'lens_datatable_column_width', + }, + ], + }); + if (onEditAction) { + return onEditAction({ + action: 'resize', + columnId: eventData.columnId, + width: eventData.width, + }); + } + }, + [onEditAction, setColumnConfig, columnConfig] + ); + if (isEmpty) { return ; } @@ -661,15 +689,7 @@ export function DatatableComponent(props: DatatableRenderProps) { } }, }} - onColumnResize={(eventData) => { - if (onEditAction) { - return onEditAction({ - action: 'resize', - columnId: eventData.columnId, - width: eventData.width, - }); - } - }} + onColumnResize={onColumnResize} toolbarVisibility={false} /> From 234891d8d1434cad7b621fb3bf991224f74f8893 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 5 Jan 2021 18:01:41 +0100 Subject: [PATCH 04/30] clean up and fix tests --- .../__snapshots__/expression.test.tsx.snap | 381 +++++++++++++----- .../expression.test.tsx | 169 ++++---- .../datatable_visualization/expression.tsx | 164 ++++---- .../visualization.test.tsx | 1 + 4 files changed, 465 insertions(+), 250 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap index 23460d442cfa8f..ba79c64f42760f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap @@ -4,64 +4,163 @@ exports[`datatable_expression DatatableComponent it renders actions column when - + + columns={ + Array [ + Object { + "actions": Object { + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "a", + "displayAsText": "a", + "id": "a", + }, + Object { + "actions": Object { + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "b", + "displayAsText": "b", + "id": "b", + }, + Object { + "actions": Object { + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "c", + "displayAsText": "c", + "id": "c", + }, + ] + } + gridStyle={ + Object { + "border": "horizontal", + "header": "underline", + } + } + onColumnResize={[Function]} + renderCellValue={[Function]} + rowCount={1} + sorting={ + Object { + "columns": Array [], + "onSort": [Function], + } + } + toolbarVisibility={false} + trailingControlColumns={ + Array [ + Object { + "headerCellRender": [Function], + "id": "trailingControlColumn", + "rowCellRender": [Function], + "width": 40, + }, + ] + } + /> + `; @@ -69,51 +168,149 @@ exports[`datatable_expression DatatableComponent it renders the title and value - + + toolbarVisibility={false} + trailingControlColumns={Array []} + /> + `; 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 d0811e0ad05a6f..a82743810e6143 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -15,7 +15,7 @@ import { IFieldFormat } from '../../../../../src/plugins/data/public'; import { IAggType } from 'src/plugins/data/public'; import { EmptyPlaceholder } from '../shared_components'; import { LensIconChartDatatable } from '../assets/chart_datatable'; -import { EuiBasicTable } from '@elastic/eui'; +import { EuiDataGrid } from '@elastic/eui'; function sampleArgs() { const indexPatternId = 'indexPatternId'; @@ -78,12 +78,10 @@ function sampleArgs() { } describe('datatable_expression', () => { - let onClickValue: jest.Mock; - let onEditAction: jest.Mock; + let onDispatchEvent: jest.Mock; beforeEach(() => { - onClickValue = jest.fn(); - onEditAction = jest.fn(); + onDispatchEvent = jest.fn(); }); describe('datatable renders', () => { @@ -113,7 +111,7 @@ describe('datatable_expression', () => { data={data} args={args} formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} + dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="edit" /> @@ -130,9 +128,8 @@ describe('datatable_expression', () => { data={data} args={args} formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} + dispatchEvent={onDispatchEvent} getType={jest.fn()} - onRowContextMenuClick={() => undefined} rowHasRowClickTriggerActions={[true, true, true]} renderMode="edit" /> @@ -153,8 +150,8 @@ describe('datatable_expression', () => { }, }} args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" /> @@ -162,17 +159,20 @@ describe('datatable_expression', () => { wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 'shoes', - }, - ], - negate: true, - timeFieldName: 'a', + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 'shoes', + }, + ], + negate: true, + timeFieldName: 'a', + }, }); }); @@ -189,8 +189,8 @@ describe('datatable_expression', () => { }, }} args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" /> @@ -198,17 +198,20 @@ describe('datatable_expression', () => { wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 1, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'b', + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 1, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'b', + }, }); }); @@ -264,8 +267,8 @@ describe('datatable_expression', () => { }, }} args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} renderMode="edit" /> @@ -273,17 +276,20 @@ describe('datatable_expression', () => { wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'a', + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'a', + }, }); }); @@ -303,8 +309,8 @@ describe('datatable_expression', () => { x as IFieldFormat} - onClickValue={onClickValue} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} getType={jest.fn((type) => type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) )} @@ -328,41 +334,40 @@ describe('datatable_expression', () => { sortDirection: 'desc', }, }} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - onEditAction={onEditAction} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="edit" /> ); - // there's currently no way to detect the sorting column via DOM - expect( - wrapper.exists('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') - ).toBe(true); - // check that the sorting is passing the right next state for the same column - wrapper - .find('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') - .first() - .simulate('click'); - - expect(onEditAction).toHaveBeenCalledWith({ - action: 'sort', - columnId: undefined, - direction: 'none', + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); + + wrapper.find(EuiDataGrid).prop('sorting')!.onSort([]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: undefined, + direction: 'none', + }, }); - // check that the sorting is passing the right next state for another column wrapper - .find('[data-test-subj="tableHeaderSortButton"]') - .not('[className*="isSorted"]') - .first() - .simulate('click'); - - expect(onEditAction).toHaveBeenCalledWith({ - action: 'sort', - columnId: 'a', - direction: 'asc', + .find(EuiDataGrid) + .prop('sorting')! + .onSort([{ id: 'a', direction: 'asc' }]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: 'a', + direction: 'asc', + }, }); }); @@ -380,18 +385,16 @@ describe('datatable_expression', () => { sortDirection: 'desc', }, }} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - onEditAction={onEditAction} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} getType={jest.fn()} renderMode="display" /> ); - expect(wrapper.find(EuiBasicTable).prop('sorting')).toMatchObject({ - sort: undefined, - allowNeutralSort: true, - }); + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); }); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 9f673782c9b62c..f069b428ed1bf8 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -6,7 +6,7 @@ import './expression.scss'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState, useContext } from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; @@ -19,6 +19,8 @@ import { EuiDataGridControlColumn } from '@elastic/eui'; import { EuiDataGridColumn } from '@elastic/eui'; import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { EuiDataGridSorting } from '@elastic/eui'; +import { EuiDataGridStyle } from '@elastic/eui'; import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; import { FormatFactory, @@ -29,6 +31,7 @@ import { LensTableRowContextMenuEvent, } from '../types'; import { + Datatable, ExpressionFunctionDefinition, ExpressionRenderDefinition, } from '../../../../../src/plugins/expressions/public'; @@ -97,6 +100,18 @@ export interface DatatableRender { value: DatatableProps; } +interface DataContextType { + table?: Datatable; + rowHasRowClickTriggerActions?: boolean[]; +} + +const DataContext = React.createContext({}); + +const gridStyle: EuiDataGridStyle = { + border: 'horizontal', + header: 'underline', +}; + export const getDatatable = ({ formatFactory, }: { @@ -299,12 +314,6 @@ export const getDatatableRenderer = (dependencies: { }, }); -function getNextOrderValue(currentValue: LensSortAction['data']['direction']) { - const states: Array = ['asc', 'desc', 'none']; - const newStateIndex = (1 + states.findIndex((state) => state === currentValue)) % states.length; - return states[newStateIndex]; -} - export function DatatableComponent(props: DatatableRenderProps) { const [columnConfig, setColumnConfig] = useState(props.args.columns); @@ -327,14 +336,14 @@ export function DatatableComponent(props: DatatableRenderProps) { }), {} ); - const { getType, dispatchEvent, renderMode, rowHasRowClickTriggerActions } = props; + const { getType, dispatchEvent, renderMode } = props; const onClickValue = useCallback( (data: LensFilterEvent['data']) => { dispatchEvent({ name: 'filter', data }); }, [dispatchEvent] ); - const hasAtLeastOneRowClickAction = rowHasRowClickTriggerActions?.find((x) => x); + const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions?.find((x) => x); const onEditAction = useCallback( (data: LensSortAction['data'] | LensResizeAction['data']) => { @@ -377,15 +386,14 @@ export function DatatableComponent(props: DatatableRenderProps) { const bucketColumns = useMemo( () => - firstTableRef.current.columns - .filter((col) => { - return ( - col?.meta?.sourceParams?.type && - getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); - }) - .map((col) => col.id), - [firstTableRef, getType] + 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' + ); + }), + [firstTableRef, columnConfig, getType] ); const isEmpty = @@ -403,7 +411,6 @@ export function DatatableComponent(props: DatatableRenderProps) { const isReadOnlySorted = props.renderMode !== 'edit'; - // todo memoize this const columns: EuiDataGridColumn[] = useMemo(() => { const columnsReverseLookup = firstTableRef.current.columns.reduce< Record @@ -445,7 +452,7 @@ export function DatatableComponent(props: DatatableRenderProps) { contentsIsDefined && ( { handleFilterClick(field, rowValue, colIndex); closePopover(); @@ -482,6 +489,7 @@ export function DatatableComponent(props: DatatableRenderProps) { return ( contentsIsDefined && ( { handleFilterClick(field, rowValue, colIndex, true); @@ -551,6 +559,7 @@ export function DatatableComponent(props: DatatableRenderProps) { width: 40, id: 'trailingControlColumn', rowCellRender: function RowCellRender({ rowIndex }) { + const { rowHasRowClickTriggerActions } = useContext(DataContext); return ( { - const rowValue = firstTableRef.current.rows[rowIndex][columnId]; - const content = formatters[columnId].convert(rowValue, 'html'); + function CellRenderer({ rowIndex, columnId }: EuiDataGridCellValueElementProps) { + const { table } = useContext(DataContext); + const rowValue = table?.rows[rowIndex][columnId]; + const content = formatters[columnId]?.convert(rowValue, 'html'); const cellContent = (
({ visibleColumns, setVisibleColumns: () => {} }), [ + visibleColumns, + ]); + + const sorting = useMemo( + () => ({ + columns: + !sortBy || sortDirection === 'none' + ? [] + : [ + { + id: sortBy, + direction: sortDirection as 'asc' | 'desc', + }, + ], + onSort: (sortingCols) => { + if (onEditAction) { + const newSortValue: + | { + id: string; + direction: 'desc' | 'asc'; + } + | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + const isNewColumn = sortBy !== (newSortValue?.id || ''); + const nextDirection = newSortValue ? newSortValue.direction : 'none'; + return onEditAction({ + action: 'sort', + columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, + direction: nextDirection, + }); + } + }, + }), + [onEditAction, sortBy, sortDirection] + ); + if (isEmpty) { return ; } @@ -647,51 +687,25 @@ export function DatatableComponent(props: DatatableRenderProps) { reportTitle={props.args.title} reportDescription={props.args.description} > - {} }} - trailingControlColumns={trailingControlColumns} - rowCount={firstTable.rows.length} - renderCellValue={renderCellValue} - gridStyle={{ - border: 'horizontal', - header: 'underline', - }} - sorting={{ - columns: - !sortBy || sortDirection === 'none' - ? [] - : [ - { - id: sortBy, - direction: sortDirection as 'asc' | 'desc', - }, - ], - onSort: (sortingCols) => { - if (onEditAction) { - const newSortValue: - | { - id: string; - direction: 'desc' | 'asc'; - } - | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; - const isNewColumn = sortBy !== (newSortValue?.id || ''); - // unfortunately the neutral state is not propagated and we need to manually handle it - const nextDirection = getNextOrderValue( - (isNewColumn ? 'none' : sortDirection) as LensSortAction['data']['direction'] - ); - return onEditAction({ - action: 'sort', - columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, - direction: nextDirection, - }); - } - }, + + > + + ); } 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 088246ccf4b9ce..675f696ef8ffb2 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -408,6 +408,7 @@ describe('Datatable Visualization', () => { columnIds: ['c', 'b'], sortBy: [''], sortDirection: ['none'], + columnWidth: [], }); }); From f298b4da9bf29b04983d3d90753249c8a5be026c Mon Sep 17 00:00:00 2001 From: dej611 Date: Tue, 12 Jan 2021 15:17:44 +0100 Subject: [PATCH 05/30] :truck: Refactor table codebase in modules --- .../components/cell_value.tsx | 31 ++ .../components/columns.tsx | 158 ++++++ .../components/constants.ts | 8 + .../components/table_actions.ts | 109 ++++ .../table_basic.scss} | 0 .../components/table_basic.tsx | 237 +++++++++ .../components/types.ts | 67 +++ .../expression.test.tsx | 4 +- .../datatable_visualization/expression.tsx | 498 +----------------- .../datatable_visualization/visualization.tsx | 4 +- x-pack/plugins/lens/public/types.ts | 8 +- 11 files changed, 633 insertions(+), 491 deletions(-) create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/constants.ts create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts rename x-pack/plugins/lens/public/datatable_visualization/{expression.scss => components/table_basic.scss} (100%) create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/types.ts diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx new file mode 100644 index 00000000000000..a77d0b69b62547 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -0,0 +1,31 @@ +/* + * 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, { useContext } from 'react'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import type { FormatFactory } from '../../types'; +import type { DataContextType } from './types'; + +export const createGridCell = ( + formatters: Record>, + DataContext: React.Context +) => ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const { table } = useContext(DataContext); + const rowValue = table?.rows[rowIndex][columnId]; + const content = formatters[columnId]?.convert(rowValue, 'html'); + + return ( +
+ ); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx new file mode 100644 index 00000000000000..47071bff886f0c --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import type { Datatable, DatatableColumnMeta } from 'src/plugins/expressions'; +import type { FormatFactory } from '../../types'; +import type { DatatableColumns } from './types'; + +export const createGridColumns = ( + bucketColumns: string[], + tableRef: React.MutableRefObject, + handleFilterClick: (field: string, value: unknown, colIndex: number, negate?: boolean) => void, + isReadOnlySorted: boolean, + columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, + visibleColumns: string[], + formatFactory: FormatFactory +) => { + const columnsReverseLookup = tableRef.current.columns.reduce< + Record + >((memo, { id, name, meta }, i) => { + memo[id] = { name, index: i, meta }; + return memo; + }, {}); + + const getContentData = ({ + rowIndex, + columnId, + }: Pick) => { + const rowValue = tableRef.current.rows[rowIndex][columnId]; + const column = tableRef.current.columns.find(({ id }) => id === columnId); + const contentsIsDefined = rowValue !== null && rowValue !== undefined; + + const cellContent = formatFactory(column?.meta?.params).convert(rowValue); + return { rowValue, contentsIsDefined, cellContent }; + }; + + return visibleColumns.map((field) => { + const filterable = bucketColumns.includes(field); + const { name, index: colIndex } = columnsReverseLookup[field]; + + const cellActions = filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const { rowValue, contentsIsDefined, cellContent } = getContentData({ + rowIndex, + columnId, + }); + + const filterForText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterForValueText', + { + defaultMessage: 'Filter for value', + } + ); + const filterForAriaLabel = i18n.translate( + 'spack.lens.table.tableCellFilter.filterForValueAriaLabel', + { + defaultMessage: 'Filter for value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + handleFilterClick(field, rowValue, colIndex); + closePopover(); + }} + iconType="plusInCircle" + > + {filterForText} + + ) + ); + }, + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const { rowValue, contentsIsDefined, cellContent } = getContentData({ + rowIndex, + columnId, + }); + + const filterOutText = i18n.translate('xpack.lens.tableCellFilter.filterOutValueText', { + defaultMessage: 'Filter out value', + }); + const filterOutAriaLabel = i18n.translate( + 'xpack.lens.tableCellFilter.filterOutValueAriaLabel', + { + defaultMessage: 'Filter out value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + { + handleFilterClick(field, rowValue, colIndex, true); + closePopover(); + }} + iconType="minusInCircle" + > + {filterOutText} + + ) + ); + }, + ] + : undefined; + + const columnDefinition: EuiDataGridColumn = { + id: field, + cellActions, + display: name, + displayAsText: name, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: isReadOnlySorted + ? false + : { + label: i18n.translate('visTypeTable.sort.ascLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: isReadOnlySorted + ? false + : { + label: i18n.translate('visTypeTable.sort.descLabel', { + defaultMessage: 'Sort desc', + }), + }, + }, + }; + + const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) + ?.width; + if (initialWidth) { + columnDefinition.initialWidth = initialWidth; + } + + return columnDefinition; + }); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts new file mode 100644 index 00000000000000..4779d42859a795 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 const LENS_EDIT_SORT_ACTION = 'sort'; +export const LENS_EDIT_RESIZE_ACTION = 'resize'; 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 new file mode 100644 index 00000000000000..e712ed32af4618 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -0,0 +1,109 @@ +/* + * 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 type { EuiDataGridSorting } from '@elastic/eui'; +import type { Datatable } from 'src/plugins/expressions'; +import type { LensFilterEvent } from '../../types'; +import type { + DatatableColumns, + DatatableColumnWidth, + LensGridDirection, + LensResizeAction, + LensSortAction, +} from './types'; + +import { desanitizeFilterContext } from '../../utils'; + +export const createGridResizeHandler = ( + columnConfig: DatatableColumns & { + type: 'lens_datatable_columns'; + }, + setColumnConfig: React.Dispatch< + React.SetStateAction< + DatatableColumns & { + type: 'lens_datatable_columns'; + } + > + >, + onEditAction: (data: LensResizeAction['data']) => void +) => (eventData: DatatableColumnWidth) => { + // 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), + { + columnId: eventData.columnId, + width: eventData.width, + type: 'lens_datatable_column_width', + }, + ], + }); + if (onEditAction) { + return onEditAction({ + action: 'resize', + columnId: eventData.columnId, + width: eventData.width, + }); + } +}; + +export const createGridFilterHandler = ( + tableRef: React.MutableRefObject, + onClickValue: (data: LensFilterEvent['data']) => void +) => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { + const col = tableRef.current.columns[colIndex]; + const isDate = col.meta?.type === 'date'; + const timeFieldName = negate && isDate ? undefined : col?.meta?.field; + const rowIndex = tableRef.current.rows.findIndex((row) => row[field] === value); + + const data: LensFilterEvent['data'] = { + negate, + data: [ + { + row: rowIndex, + column: colIndex, + value, + table: tableRef.current, + }, + ], + timeFieldName, + }; + onClickValue(desanitizeFilterContext(data)); +}; + +export const createGridSortingConfig = ( + sortBy: string, + sortDirection: LensGridDirection, + onEditAction: (data: LensSortAction['data']) => void +): EuiDataGridSorting => ({ + columns: + !sortBy || sortDirection === 'none' + ? [] + : [ + { + id: sortBy, + direction: sortDirection, + }, + ], + onSort: (sortingCols) => { + if (onEditAction) { + const newSortValue: + | { + id: string; + direction: Exclude; + } + | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + const isNewColumn = sortBy !== (newSortValue?.id || ''); + const nextDirection = newSortValue ? newSortValue.direction : 'none'; + return onEditAction({ + action: 'sort', + columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, + direction: nextDirection, + }); + } + }, +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.scss b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss similarity index 100% rename from x-pack/plugins/lens/public/datatable_visualization/expression.scss rename to x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss 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 new file mode 100644 index 00000000000000..5d77c86e489308 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -0,0 +1,237 @@ +/* + * 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 './table_basic.scss'; + +import React, { useCallback, useMemo, useRef, useState, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; +import { + EuiButtonIcon, + EuiDataGrid, + EuiDataGridControlColumn, + EuiDataGridColumn, + EuiDataGridSorting, + EuiDataGridStyle, +} from '@elastic/eui'; +import { FormatFactory, LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; +import { VisualizationContainer } from '../../visualization_container'; +import { EmptyPlaceholder } from '../../shared_components'; +import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { + DataContextType, + DatatableRenderProps, + LensSortAction, + LensResizeAction, + LensGridDirection, +} from './types'; +import { createGridColumns } from './columns'; +import { createGridCell } from './cell_value'; +import { + createGridFilterHandler, + createGridResizeHandler, + createGridSortingConfig, +} from './table_actions'; + +const DataContext = React.createContext({}); + +const gridStyle: EuiDataGridStyle = { + border: 'horizontal', + header: 'underline', +}; + +export const DatatableComponent = (props: DatatableRenderProps) => { + const [columnConfig, setColumnConfig] = useState(props.args.columns); + + useDeepCompareEffect(() => { + setColumnConfig(props.args.columns); + }, [props.args.columns]); + + const [firstTable] = Object.values(props.data.tables); + + const firstTableRef = useRef(firstTable); + firstTableRef.current = firstTable; + + const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions?.some((x) => x); + + const { getType, dispatchEvent, renderMode, formatFactory } = props; + + const formatters: Record< + string, + ReturnType + > = firstTableRef.current.columns.reduce( + (map, column) => ({ + ...map, + [column.id]: formatFactory(column.meta?.params), + }), + {} + ); + + const onClickValue = useCallback( + (data: LensFilterEvent['data']) => { + dispatchEvent({ name: 'filter', data }); + }, + [dispatchEvent] + ); + + const onEditAction = useCallback( + (data: LensSortAction['data'] | LensResizeAction['data']) => { + if (renderMode === 'edit') { + dispatchEvent({ name: 'edit', data }); + } + }, + [dispatchEvent, renderMode] + ); + const onRowContextMenuClick = useCallback( + (data: LensTableRowContextMenuEvent['data']) => { + dispatchEvent({ name: 'tableRowContextMenuClick', data }); + }, + [dispatchEvent] + ); + + const handleFilterClick = useMemo(() => createGridFilterHandler(firstTableRef, onClickValue), [ + firstTableRef, + onClickValue, + ]); + + 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' + ); + }), + [firstTableRef, columnConfig, getType] + ); + + const isEmpty = + firstTable.rows.length === 0 || + (bucketColumns.length && + firstTable.rows.every((row) => + bucketColumns.every((col) => typeof row[col] === 'undefined') + )); + + const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ + columnConfig, + ]); + + const { sortBy, sortDirection } = columnConfig; + + const isReadOnlySorted = renderMode !== 'edit'; + + const columns: EuiDataGridColumn[] = useMemo( + () => + createGridColumns( + bucketColumns, + firstTableRef, + handleFilterClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory + ), + [ + bucketColumns, + firstTableRef, + handleFilterClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + ] + ); + + const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { + if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { + return []; + } + return [ + { + headerCellRender: () => null, + width: 40, + id: 'trailingControlColumn', + rowCellRender: function RowCellRender({ rowIndex }) { + const { rowHasRowClickTriggerActions } = useContext(DataContext); + return ( + { + onRowContextMenuClick({ + rowIndex, + table: firstTableRef.current, + columns: columnConfig.columnIds, + }); + }} + /> + ); + }, + }, + ]; + }, [firstTableRef, onRowContextMenuClick, columnConfig, hasAtLeastOneRowClickAction]); + + const renderCellValue = useMemo(() => createGridCell(formatters, DataContext), [formatters]); + + const onColumnResize = useMemo( + () => createGridResizeHandler(columnConfig, setColumnConfig, onEditAction), + [onEditAction, setColumnConfig, columnConfig] + ); + + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ + visibleColumns, + ]); + + const sorting = useMemo( + () => createGridSortingConfig(sortBy, sortDirection as LensGridDirection, onEditAction), + [onEditAction, sortBy, sortDirection] + ); + + if (isEmpty) { + return ; + } + + const dataGridAriaLabel = + props.args.title || + i18n.translate('xpack.lens.table.defaultAriaLabel', { + defaultMessage: 'Data table visualization', + }); + + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts new file mode 100644 index 00000000000000..9f453dc9ecc01a --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -0,0 +1,67 @@ +/* + * 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 type { Direction } from '@elastic/eui'; +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'; + +export type LensGridDirection = 'none' | Direction; + +export interface LensSortActionData { + columnId: string | undefined; + direction: LensGridDirection; +} + +export interface LensResizeActionData { + columnId: string; + width: number; +} + +export type LensSortAction = LensEditEvent; +export type LensResizeAction = LensEditEvent; + +export interface DatatableColumns { + columnIds: string[]; + sortBy: string; + sortDirection: string; + columnWidth?: DatatableColumnWidthResult[]; +} + +export interface DatatableColumnWidth { + columnId: string; + width: number; +} + +export type DatatableColumnWidthResult = DatatableColumnWidth & { + type: 'lens_datatable_column_width'; +}; + +export type DatatableRenderProps = DatatableProps & { + formatFactory: FormatFactory; + dispatchEvent: ILensInterpreterRenderHandlers['event']; + getType: (name: string) => IAggType; + renderMode: RenderMode; + + /** + * A boolean for each table row, which is true if the row active + * ROW_CLICK_TRIGGER actions attached to it, otherwise false. + */ + rowHasRowClickTriggerActions?: boolean[]; +}; + +export interface DatatableRender { + type: 'render'; + as: 'lens_datatable_renderer'; + value: DatatableProps; +} + +export interface DataContextType { + table?: Datatable; + rowHasRowClickTriggerActions?: boolean[]; +} 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 a82743810e6143..8bd035b9f8dac7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -7,15 +7,15 @@ import React from 'react'; import { shallow } from 'enzyme'; import { mountWithIntl } from '@kbn/test/jest'; -import { getDatatable, DatatableComponent } from './expression'; +import { DatatableProps, getDatatable } from './expression'; import { LensMultiTable } from '../types'; -import { DatatableProps } from './expression'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { IFieldFormat } from '../../../../../src/plugins/data/public'; import { IAggType } from 'src/plugins/data/public'; import { EmptyPlaceholder } from '../shared_components'; import { LensIconChartDatatable } from '../assets/chart_datatable'; import { EuiDataGrid } from '@elastic/eui'; +import { DatatableComponent } from './components/tableBasic'; function sampleArgs() { const indexPatternId = 'indexPatternId'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index f069b428ed1bf8..0165fb5861aaa1 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -4,71 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import './expression.scss'; - -import React, { useCallback, useMemo, useRef, useState, useContext } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; -import { EuiButtonIcon, Direction } from '@elastic/eui'; import { orderBy } from 'lodash'; -import { IAggType } from 'src/plugins/data/public'; -import { DatatableColumnMeta, RenderMode } from 'src/plugins/expressions'; -import { EuiDataGrid } from '@elastic/eui'; -import { EuiDataGridControlColumn } from '@elastic/eui'; -import { EuiDataGridColumn } from '@elastic/eui'; -import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; -import { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { EuiDataGridSorting } from '@elastic/eui'; -import { EuiDataGridStyle } from '@elastic/eui'; -import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; -import { - FormatFactory, - ILensInterpreterRenderHandlers, - LensEditEvent, - LensFilterEvent, - LensMultiTable, - LensTableRowContextMenuEvent, -} from '../types'; -import { - Datatable, +import type { Direction } from '@elastic/eui'; +import type { IAggType } from 'src/plugins/data/public'; +import type { + DatatableColumnMeta, ExpressionFunctionDefinition, ExpressionRenderDefinition, -} from '../../../../../src/plugins/expressions/public'; -import { VisualizationContainer } from '../visualization_container'; -import { EmptyPlaceholder } from '../shared_components'; -import { desanitizeFilterContext } from '../utils'; -import { LensIconChartDatatable } from '../assets/chart_datatable'; - -export const LENS_EDIT_SORT_ACTION = 'sort'; -export const LENS_EDIT_RESIZE_ACTION = 'resize'; - -export interface LensSortActionData { - columnId: string | undefined; - direction: 'asc' | 'desc' | 'none'; -} - -export interface LensResizeActionData { - columnId: string; - width: number; -} +} from 'src/plugins/expressions'; -type LensSortAction = LensEditEvent; -type LensResizeAction = LensEditEvent; +import { DatatableComponent } from './components/tableBasic'; -export interface DatatableColumns { - columnIds: string[]; - sortBy: string; - sortDirection: string; - columnWidth?: DatatableColumnWidthResult[]; -} - -export interface DatatableColumnWidth { - columnId: string; - width: number; -} - -type DatatableColumnWidthResult = DatatableColumnWidth & { type: 'lens_datatable_column_width' }; +import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } from '../types'; +import type { + DatatableRender, + DatatableColumns, + DatatableColumnWidth, + DatatableColumnWidthResult, +} from './components/types'; interface Args { title: string; @@ -81,37 +38,6 @@ export interface DatatableProps { args: Args; } -type DatatableRenderProps = DatatableProps & { - formatFactory: FormatFactory; - dispatchEvent: ILensInterpreterRenderHandlers['event']; - getType: (name: string) => IAggType; - renderMode: RenderMode; - - /** - * A boolean for each table row, which is true if the row active - * ROW_CLICK_TRIGGER actions attached to it, otherwise false. - */ - rowHasRowClickTriggerActions?: boolean[]; -}; - -export interface DatatableRender { - type: 'render'; - as: 'lens_datatable_renderer'; - value: DatatableProps; -} - -interface DataContextType { - table?: Datatable; - rowHasRowClickTriggerActions?: boolean[]; -} - -const DataContext = React.createContext({}); - -const gridStyle: EuiDataGridStyle = { - border: 'horizontal', - header: 'underline', -}; - export const getDatatable = ({ formatFactory, }: { @@ -313,399 +239,3 @@ export const getDatatableRenderer = (dependencies: { handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); }, }); - -export function DatatableComponent(props: DatatableRenderProps) { - const [columnConfig, setColumnConfig] = useState(props.args.columns); - - useDeepCompareEffect(() => { - setColumnConfig(props.args.columns); - }, [props.args.columns]); - const [firstTable] = Object.values(props.data.tables); - - const firstTableRef = useRef(firstTable); - firstTableRef.current = firstTable; - - const formatFactory = props.formatFactory; - const formatters: Record< - string, - ReturnType - > = firstTableRef.current.columns.reduce( - (map, column) => ({ - ...map, - [column.id]: formatFactory(column.meta?.params), - }), - {} - ); - const { getType, dispatchEvent, renderMode } = props; - const onClickValue = useCallback( - (data: LensFilterEvent['data']) => { - dispatchEvent({ name: 'filter', data }); - }, - [dispatchEvent] - ); - const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions?.find((x) => x); - - const onEditAction = useCallback( - (data: LensSortAction['data'] | LensResizeAction['data']) => { - if (renderMode === 'edit') { - dispatchEvent({ name: 'edit', data }); - } - }, - [dispatchEvent, renderMode] - ); - const onRowContextMenuClick = useCallback( - (data: LensTableRowContextMenuEvent['data']) => { - dispatchEvent({ name: 'tableRowContextMenuClick', data }); - }, - [dispatchEvent] - ); - - const handleFilterClick = useMemo( - () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { - const col = firstTableRef.current.columns[colIndex]; - const isDate = col.meta?.type === 'date'; - const timeFieldName = negate && isDate ? undefined : col?.meta?.field; - const rowIndex = firstTableRef.current.rows.findIndex((row) => row[field] === value); - - const data: LensFilterEvent['data'] = { - negate, - data: [ - { - row: rowIndex, - column: colIndex, - value, - table: firstTableRef.current, - }, - ], - timeFieldName, - }; - onClickValue(desanitizeFilterContext(data)); - }, - [firstTableRef, onClickValue] - ); - - 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' - ); - }), - [firstTableRef, columnConfig, getType] - ); - - const isEmpty = - firstTable.rows.length === 0 || - (bucketColumns.length && - firstTable.rows.every((row) => - bucketColumns.every((col) => typeof row[col] === 'undefined') - )); - - const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ - columnConfig, - ]); - - const { sortBy, sortDirection } = columnConfig; - - const isReadOnlySorted = props.renderMode !== 'edit'; - - const columns: EuiDataGridColumn[] = useMemo(() => { - const columnsReverseLookup = firstTableRef.current.columns.reduce< - Record - >((memo, { id, name, meta }, i) => { - memo[id] = { name, index: i, meta }; - return memo; - }, {}); - - return visibleColumns.map((field) => { - const filterable = bucketColumns.includes(field); - const { name, index: colIndex } = columnsReverseLookup[field]; - - const cellActions = filterable - ? [ - ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { - const rowValue = firstTableRef.current.rows[rowIndex][columnId]; - const column = firstTableRef.current.columns.find(({ id }) => id === columnId); - const contentsIsDefined = rowValue !== null && rowValue !== undefined; - - const cellContent = formatFactory(column?.meta?.params).convert(rowValue); - - const filterForText = i18n.translate( - 'xpack.lens.table.tableCellFilter.filterForValueText', - { - defaultMessage: 'Filter for value', - } - ); - const filterForAriaLabel = i18n.translate( - 'spack.lens.table.tableCellFilter.filterForValueAriaLabel', - { - defaultMessage: 'Filter for value: {cellContent}', - values: { - cellContent, - }, - } - ); - - return ( - contentsIsDefined && ( - { - handleFilterClick(field, rowValue, colIndex); - closePopover(); - }} - iconType="plusInCircle" - > - {filterForText} - - ) - ); - }, - ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { - const rowValue = firstTableRef.current.rows[rowIndex][columnId]; - const column = firstTableRef.current.columns.find(({ id }) => id === columnId); - const contentsIsDefined = rowValue !== null && rowValue !== undefined; - const cellContent = formatFactory(column?.meta?.params).convert(rowValue); - - const filterOutText = i18n.translate( - 'xpack.lens.tableCellFilter.filterOutValueText', - { - defaultMessage: 'Filter out value', - } - ); - const filterOutAriaLabel = i18n.translate( - 'xpack.lens.tableCellFilter.filterOutValueAriaLabel', - { - defaultMessage: 'Filter out value: {cellContent}', - values: { - cellContent, - }, - } - ); - - return ( - contentsIsDefined && ( - { - handleFilterClick(field, rowValue, colIndex, true); - closePopover(); - }} - iconType="minusInCircle" - > - {filterOutText} - - ) - ); - }, - ] - : undefined; - - const columnDefinition: EuiDataGridColumn = { - id: field, - cellActions, - display: name, - displayAsText: name, - actions: { - showHide: false, - showMoveLeft: false, - showMoveRight: false, - showSortAsc: isReadOnlySorted - ? false - : { - label: i18n.translate('visTypeTable.sort.ascLabel', { - defaultMessage: 'Sort asc', - }), - }, - showSortDesc: isReadOnlySorted - ? false - : { - label: i18n.translate('visTypeTable.sort.descLabel', { - defaultMessage: 'Sort desc', - }), - }, - }, - }; - - const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) - ?.width; - if (initialWidth) { - columnDefinition.initialWidth = initialWidth; - } - - return columnDefinition; - }); - }, [ - bucketColumns, - firstTableRef, - handleFilterClick, - isReadOnlySorted, - columnConfig, - visibleColumns, - formatFactory, - ]); - - const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { - if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { - return []; - } - return [ - { - headerCellRender: () => null, - width: 40, - id: 'trailingControlColumn', - rowCellRender: function RowCellRender({ rowIndex }) { - const { rowHasRowClickTriggerActions } = useContext(DataContext); - return ( - { - onRowContextMenuClick({ - rowIndex, - table: firstTableRef.current, - columns: columnConfig.columnIds, - }); - }} - /> - ); - }, - }, - ]; - }, [firstTableRef, onRowContextMenuClick, columnConfig, hasAtLeastOneRowClickAction]); - - const renderCellValue = useCallback( - function CellRenderer({ rowIndex, columnId }: EuiDataGridCellValueElementProps) { - const { table } = useContext(DataContext); - const rowValue = table?.rows[rowIndex][columnId]; - const content = formatters[columnId]?.convert(rowValue, 'html'); - - const cellContent = ( -
- ); - - return cellContent; - }, - [formatters] - ); - - const onColumnResize = useCallback( - (eventData) => { - // 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 - ), - { - columnId: eventData.columnId, - width: eventData.width, - type: 'lens_datatable_column_width', - }, - ], - }); - if (onEditAction) { - return onEditAction({ - action: 'resize', - columnId: eventData.columnId, - width: eventData.width, - }); - } - }, - [onEditAction, setColumnConfig, columnConfig] - ); - - const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ - visibleColumns, - ]); - - const sorting = useMemo( - () => ({ - columns: - !sortBy || sortDirection === 'none' - ? [] - : [ - { - id: sortBy, - direction: sortDirection as 'asc' | 'desc', - }, - ], - onSort: (sortingCols) => { - if (onEditAction) { - const newSortValue: - | { - id: string; - direction: 'desc' | 'asc'; - } - | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; - const isNewColumn = sortBy !== (newSortValue?.id || ''); - const nextDirection = newSortValue ? newSortValue.direction : 'none'; - return onEditAction({ - action: 'sort', - columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, - direction: nextDirection, - }); - } - }, - }), - [onEditAction, sortBy, sortDirection] - ); - - if (isEmpty) { - return ; - } - - const dataGridAriaLabel = - props.args.title || - i18n.translate('xpack.lens.table.defaultAriaLabel', { - defaultMessage: 'Data table visualization', - }); - - return ( - - - - - - ); -} diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 71eb1a44f793ca..2e7a784eb65c4b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -6,15 +6,15 @@ import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; -import { +import type { SuggestionRequest, Visualization, VisualizationSuggestion, Operation, DatasourcePublicAPI, } from '../types'; +import type { DatatableColumnWidth } from './components/types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; -import { DatatableColumnWidth } from './expression'; export interface LayerState { layerId: string; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index f8f3c67df8b097..19483d27ff33fc 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -22,12 +22,14 @@ import { DateRange } from '../common'; import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public'; import { VisualizeFieldContext } from '../../../../src/plugins/ui_actions/public'; import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/embeddable/public'; -import type { - LensSortActionData, +import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION, +} from './datatable_visualization/components/constants'; +import type { + LensSortActionData, LensResizeActionData, -} from './datatable_visualization/expression'; +} from './datatable_visualization/components/types'; export type ErrorCallback = (e: { message: string }) => void; From c233e3b487c4c156da7208e1529a01649cbb4a6c Mon Sep 17 00:00:00 2001 From: dej611 Date: Tue, 12 Jan 2021 18:21:39 +0100 Subject: [PATCH 06/30] :truck: Move table component tests to its own folder --- .../__snapshots__/table_basic.test.tsx.snap} | 4 +- .../components/table_basic.test.tsx | 383 ++++++++++++++++++ .../expression.test.tsx | 305 -------------- 3 files changed, 385 insertions(+), 307 deletions(-) rename x-pack/plugins/lens/public/datatable_visualization/{__snapshots__/expression.test.tsx.snap => components/__snapshots__/table_basic.test.tsx.snap} (97%) create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx diff --git a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap similarity index 97% rename from x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap rename to x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap index ba79c64f42760f..4a312546dc7ee5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`datatable_expression DatatableComponent it renders actions column when there are row actions 1`] = ` +exports[`DatatableComponent it renders actions column when there are row actions 1`] = ` @@ -164,7 +164,7 @@ exports[`datatable_expression DatatableComponent it renders actions column when `; -exports[`datatable_expression DatatableComponent it renders the title and value 1`] = ` +exports[`DatatableComponent it renders the title and value 1`] = ` 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 new file mode 100644 index 00000000000000..dbf9b4772dec76 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -0,0 +1,383 @@ +/* + * 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 { shallow } from 'enzyme'; +import { mountWithIntl } from '@kbn/test/jest'; +import { EuiDataGrid } from '@elastic/eui'; +import { IAggType, IFieldFormat } from 'src/plugins/data/public'; +import { EmptyPlaceholder } from '../../shared_components'; +import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { DatatableComponent } from './table_basic'; +import { LensMultiTable } from '../../types'; +import { DatatableProps } from '../expression'; + +function sampleArgs() { + const indexPatternId = 'indexPatternId'; + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'string', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'terms', indexPatternId }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'date', + field: 'b', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + indexPatternId, + }, + }, + }, + { + id: 'c', + name: 'c', + meta: { + type: 'number', + source: 'esaggs', + field: 'c', + sourceParams: { indexPatternId, type: 'count' }, + }, + }, + ], + rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: 'My fanci metric chart', + columns: { + columnIds: ['a', 'b', 'c'], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }, + }; + + return { data, args }; +} + +function copyData(data: LensMultiTable): LensMultiTable { + return JSON.parse(JSON.stringify(data)); +} + +describe('DatatableComponent', () => { + let onDispatchEvent: jest.Mock; + + beforeEach(() => { + onDispatchEvent = jest.fn(); + }); + + test('it renders the title and value', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ) + ).toMatchSnapshot(); + }); + + test('it renders actions column when there are row actions', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + rowHasRowClickTriggerActions={[true, true, true]} + renderMode="edit" + /> + ) + ).toMatchSnapshot(); + }); + + test('it invokes executeTriggerActions with correct context on click on top value', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 'shoes', + }, + ], + negate: true, + timeFieldName: 'a', + }, + }); + }); + + test('it invokes executeTriggerActions with correct context on click on timefield', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 1, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'b', + }, + }); + }); + + test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'date', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'date_range', indexPatternId: 'a' }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'number', + source: 'esaggs', + sourceParams: { type: 'count', indexPatternId: 'a' }, + }, + }, + ], + rows: [{ a: 1588024800000, b: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: '', + columns: { + columnIds: ['a', 'b'], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }, + }; + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'a', + }, + }); + }); + + test('it shows emptyPlaceholder for undefined bucketed data', () => { + const { args, data } = sampleArgs(); + const emptyData: LensMultiTable = { + ...data, + tables: { + l1: { + ...data.tables.l1, + rows: [{ a: undefined, b: undefined, c: 0 }], + }, + }, + }; + + const component = shallow( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn((type) => + type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) + )} + renderMode="edit" + /> + ); + expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); + }); + + test('it renders the table with the given sorting', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); + + wrapper.find(EuiDataGrid).prop('sorting')!.onSort([]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: undefined, + direction: 'none', + }, + }); + + wrapper + .find(EuiDataGrid) + .prop('sorting')! + .onSort([{ id: 'a', direction: 'asc' }]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: 'a', + direction: 'asc', + }, + }); + }); + + test('it renders the table with the given sorting in readOnly mode', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); + }); +}); 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 8bd035b9f8dac7..95d10d32a3b99b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -4,18 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { shallow } from 'enzyme'; -import { mountWithIntl } from '@kbn/test/jest'; import { DatatableProps, getDatatable } from './expression'; import { LensMultiTable } from '../types'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { IFieldFormat } from '../../../../../src/plugins/data/public'; -import { IAggType } from 'src/plugins/data/public'; -import { EmptyPlaceholder } from '../shared_components'; -import { LensIconChartDatatable } from '../assets/chart_datatable'; -import { EuiDataGrid } from '@elastic/eui'; -import { DatatableComponent } from './components/tableBasic'; function sampleArgs() { const indexPatternId = 'indexPatternId'; @@ -100,301 +92,4 @@ describe('datatable_expression', () => { }); }); }); - - describe('DatatableComponent', () => { - test('it renders the title and value', () => { - const { data, args } = sampleArgs(); - - expect( - shallow( - x as IFieldFormat} - dispatchEvent={onDispatchEvent} - getType={jest.fn()} - renderMode="edit" - /> - ) - ).toMatchSnapshot(); - }); - - test('it renders actions column when there are row actions', () => { - const { data, args } = sampleArgs(); - - expect( - shallow( - x as IFieldFormat} - dispatchEvent={onDispatchEvent} - getType={jest.fn()} - rowHasRowClickTriggerActions={[true, true, true]} - renderMode="edit" - /> - ) - ).toMatchSnapshot(); - }); - - test('it invokes executeTriggerActions with correct context on click on top value', () => { - const { args, data } = sampleArgs(); - - const wrapper = mountWithIntl( - ({ convert: (x) => x } as IFieldFormat)} - dispatchEvent={onDispatchEvent} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); - - expect(onDispatchEvent).toHaveBeenCalledWith({ - name: 'filter', - data: { - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 'shoes', - }, - ], - negate: true, - timeFieldName: 'a', - }, - }); - }); - - test('it invokes executeTriggerActions with correct context on click on timefield', () => { - const { args, data } = sampleArgs(); - - const wrapper = mountWithIntl( - ({ convert: (x) => x } as IFieldFormat)} - dispatchEvent={onDispatchEvent} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); - - expect(onDispatchEvent).toHaveBeenCalledWith({ - name: 'filter', - data: { - data: [ - { - column: 1, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'b', - }, - }); - }); - - test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'date', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'date_range', indexPatternId: 'a' }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'number', - source: 'esaggs', - sourceParams: { type: 'count', indexPatternId: 'a' }, - }, - }, - ], - rows: [{ a: 1588024800000, b: 3 }], - }, - }, - }; - - const args: DatatableProps['args'] = { - title: '', - columns: { - columnIds: ['a', 'b'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, - }; - - const wrapper = mountWithIntl( - ({ convert: (x) => x } as IFieldFormat)} - dispatchEvent={onDispatchEvent} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); - - expect(onDispatchEvent).toHaveBeenCalledWith({ - name: 'filter', - data: { - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'a', - }, - }); - }); - - test('it shows emptyPlaceholder for undefined bucketed data', () => { - const { args, data } = sampleArgs(); - const emptyData: LensMultiTable = { - ...data, - tables: { - l1: { - ...data.tables.l1, - rows: [{ a: undefined, b: undefined, c: 0 }], - }, - }, - }; - - const component = shallow( - ({ convert: (x) => x } as IFieldFormat)} - dispatchEvent={onDispatchEvent} - getType={jest.fn((type) => - type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) - )} - renderMode="edit" - /> - ); - expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); - }); - - test('it renders the table with the given sorting', () => { - const { data, args } = sampleArgs(); - - const wrapper = mountWithIntl( - ({ convert: (x) => x } as IFieldFormat)} - dispatchEvent={onDispatchEvent} - getType={jest.fn()} - renderMode="edit" - /> - ); - - expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ - { id: 'b', direction: 'desc' }, - ]); - - wrapper.find(EuiDataGrid).prop('sorting')!.onSort([]); - - expect(onDispatchEvent).toHaveBeenCalledWith({ - name: 'edit', - data: { - action: 'sort', - columnId: undefined, - direction: 'none', - }, - }); - - wrapper - .find(EuiDataGrid) - .prop('sorting')! - .onSort([{ id: 'a', direction: 'asc' }]); - - expect(onDispatchEvent).toHaveBeenCalledWith({ - name: 'edit', - data: { - action: 'sort', - columnId: 'a', - direction: 'asc', - }, - }); - }); - - test('it renders the table with the given sorting in readOnly mode', () => { - const { data, args } = sampleArgs(); - - const wrapper = mountWithIntl( - ({ convert: (x) => x } as IFieldFormat)} - dispatchEvent={onDispatchEvent} - getType={jest.fn()} - renderMode="display" - /> - ); - - expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ - { id: 'b', direction: 'desc' }, - ]); - }); - }); }); From 9318950b3ac524bcb65b0189936f2a14db6c9acf Mon Sep 17 00:00:00 2001 From: dej611 Date: Tue, 12 Jan 2021 18:22:37 +0100 Subject: [PATCH 07/30] :bug: Fix deep check for table column header --- .../components/columns.tsx | 26 +++++++---- .../components/table_actions.ts | 9 +++- .../components/table_basic.test.tsx | 24 ++++++++++ .../components/table_basic.tsx | 44 ++++++++++--------- 4 files changed, 72 insertions(+), 31 deletions(-) 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 47071bff886f0c..81937d2ae886b7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -13,34 +13,42 @@ import type { DatatableColumns } from './types'; export const createGridColumns = ( bucketColumns: string[], - tableRef: React.MutableRefObject, - handleFilterClick: (field: string, value: unknown, colIndex: number, negate?: boolean) => void, + table: Datatable, + handleFilterClick: ( + field: string, + value: unknown, + colIndex: number, + rowIndex: number, + negate?: boolean + ) => void, isReadOnlySorted: boolean, columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, visibleColumns: string[], formatFactory: FormatFactory ) => { - const columnsReverseLookup = tableRef.current.columns.reduce< + const columnsReverseLookup = table.columns.reduce< Record >((memo, { id, name, meta }, i) => { memo[id] = { name, index: i, meta }; return memo; }, {}); + const bucketLookup = new Set(bucketColumns); + const getContentData = ({ rowIndex, columnId, }: Pick) => { - const rowValue = tableRef.current.rows[rowIndex][columnId]; - const column = tableRef.current.columns.find(({ id }) => id === columnId); - const contentsIsDefined = rowValue !== null && rowValue !== undefined; + const rowValue = table.rows[rowIndex][columnId]; + const column = columnsReverseLookup[columnId]; + const contentsIsDefined = rowValue != null; const cellContent = formatFactory(column?.meta?.params).convert(rowValue); return { rowValue, contentsIsDefined, cellContent }; }; return visibleColumns.map((field) => { - const filterable = bucketColumns.includes(field); + const filterable = bucketLookup.has(field); const { name, index: colIndex } = columnsReverseLookup[field]; const cellActions = filterable @@ -73,7 +81,7 @@ export const createGridColumns = ( aria-label={filterForAriaLabel} data-test-subj="lensDatatableFilterFor" onClick={() => { - handleFilterClick(field, rowValue, colIndex); + handleFilterClick(field, rowValue, colIndex, rowIndex); closePopover(); }} iconType="plusInCircle" @@ -108,7 +116,7 @@ export const createGridColumns = ( data-test-subj="lensDatatableFilterOut" aria-label={filterOutAriaLabel} onClick={() => { - handleFilterClick(field, rowValue, colIndex, true); + handleFilterClick(field, rowValue, colIndex, rowIndex, true); closePopover(); }} iconType="minusInCircle" 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 e712ed32af4618..6671819f2fa2f7 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 @@ -54,11 +54,16 @@ export const createGridResizeHandler = ( export const createGridFilterHandler = ( tableRef: React.MutableRefObject, onClickValue: (data: LensFilterEvent['data']) => void -) => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { +) => ( + field: string, + value: unknown, + colIndex: number, + rowIndex: number, + negate: boolean = false +) => { const col = tableRef.current.columns[colIndex]; const isDate = col.meta?.type === 'date'; const timeFieldName = negate && isDate ? undefined : col?.meta?.field; - const rowIndex = tableRef.current.rows.findIndex((row) => row[field] === value); const data: LensFilterEvent['data'] = { negate, 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 dbf9b4772dec76..41bd62ad464f0e 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 @@ -380,4 +380,28 @@ describe('DatatableComponent', () => { { id: 'b', direction: 'desc' }, ]); }); + + test('it should refresh the table header when the datatable data changes', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ); + // mnake a copy of the data, changing only the name of the first column + const newData = copyData(data); + newData.tables.l1.columns[0].name = 'new a'; + wrapper.setProps({ data: newData }); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="dataGridHeader"]').children().first().text()).toEqual( + 'new a' + ); + }); }); 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 5d77c86e489308..82c694f120d12c 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 @@ -44,30 +44,36 @@ const gridStyle: EuiDataGridStyle = { }; export const DatatableComponent = (props: DatatableRenderProps) => { + const [firstTable] = Object.values(props.data.tables); + const [columnConfig, setColumnConfig] = useState(props.args.columns); + const [firstLocalTable, updateTable] = useState(firstTable); useDeepCompareEffect(() => { setColumnConfig(props.args.columns); }, [props.args.columns]); - const [firstTable] = Object.values(props.data.tables); + useDeepCompareEffect(() => { + updateTable(firstTable); + }, [firstTable]); - const firstTableRef = useRef(firstTable); - firstTableRef.current = firstTable; + const firstTableRef = useRef(firstLocalTable); + firstTableRef.current = firstLocalTable; const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions?.some((x) => x); const { getType, dispatchEvent, renderMode, formatFactory } = props; - const formatters: Record< - string, - ReturnType - > = firstTableRef.current.columns.reduce( - (map, column) => ({ - ...map, - [column.id]: formatFactory(column.meta?.params), - }), - {} + const formatters: Record> = useMemo( + () => + firstLocalTable.columns.reduce( + (map, column) => ({ + ...map, + [column.id]: formatFactory(column.meta?.params), + }), + {} + ), + [firstLocalTable, formatFactory] ); const onClickValue = useCallback( @@ -110,11 +116,9 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ); const isEmpty = - firstTable.rows.length === 0 || + firstLocalTable.rows.length === 0 || (bucketColumns.length && - firstTable.rows.every((row) => - bucketColumns.every((col) => typeof row[col] === 'undefined') - )); + firstTable.rows.every((row) => bucketColumns.every((col) => row[col] == null))); const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ columnConfig, @@ -128,7 +132,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { () => createGridColumns( bucketColumns, - firstTableRef, + firstLocalTable, handleFilterClick, isReadOnlySorted, columnConfig, @@ -137,7 +141,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ), [ bucketColumns, - firstTableRef, + firstLocalTable, handleFilterClick, isReadOnlySorted, columnConfig, @@ -215,7 +219,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { > @@ -224,7 +228,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columns={columns} columnVisibility={columnVisibility} trailingControlColumns={trailingControlColumns} - rowCount={firstTable.rows.length} + rowCount={firstLocalTable.rows.length} renderCellValue={renderCellValue} gridStyle={gridStyle} sorting={sorting} From 79836aeddab5e31911a73e01403cefc028436125 Mon Sep 17 00:00:00 2001 From: dej611 Date: Tue, 12 Jan 2021 19:22:59 +0100 Subject: [PATCH 08/30] :label: Fix type check --- .../lens/public/datatable_visualization/expression.test.tsx | 6 ------ 1 file changed, 6 deletions(-) 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 95d10d32a3b99b..60d9461a5e0d9e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -70,12 +70,6 @@ function sampleArgs() { } describe('datatable_expression', () => { - let onDispatchEvent: jest.Mock; - - beforeEach(() => { - onDispatchEvent = jest.fn(); - }); - describe('datatable renders', () => { test('it renders with the specified data and args', () => { const { data, args } = sampleArgs(); From 3181b881a89851d6f9ad17673e5be4a366760b21 Mon Sep 17 00:00:00 2001 From: dej611 Date: Tue, 12 Jan 2021 19:28:22 +0100 Subject: [PATCH 09/30] :globe_with_meridians: Fix locatization tokens --- .../components/columns.tsx | 17 ++++++++++------- .../components/table_basic.tsx | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) 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 81937d2ae886b7..4076e990c15ed5 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -66,7 +66,7 @@ export const createGridColumns = ( } ); const filterForAriaLabel = i18n.translate( - 'spack.lens.table.tableCellFilter.filterForValueAriaLabel', + 'xpack.lens.table.tableCellFilter.filterForValueAriaLabel', { defaultMessage: 'Filter for value: {cellContent}', values: { @@ -97,11 +97,14 @@ export const createGridColumns = ( columnId, }); - const filterOutText = i18n.translate('xpack.lens.tableCellFilter.filterOutValueText', { - defaultMessage: 'Filter out value', - }); + const filterOutText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterOutValueText', + { + defaultMessage: 'Filter out value', + } + ); const filterOutAriaLabel = i18n.translate( - 'xpack.lens.tableCellFilter.filterOutValueAriaLabel', + 'xpack.lens.table.tableCellFilter.filterOutValueAriaLabel', { defaultMessage: 'Filter out value: {cellContent}', values: { @@ -141,14 +144,14 @@ export const createGridColumns = ( showSortAsc: isReadOnlySorted ? false : { - label: i18n.translate('visTypeTable.sort.ascLabel', { + label: i18n.translate('xpack.lens.table.sort.ascLabel', { defaultMessage: 'Sort asc', }), }, showSortDesc: isReadOnlySorted ? false : { - label: i18n.translate('visTypeTable.sort.descLabel', { + label: i18n.translate('xpack.lens.table.sort.descLabel', { defaultMessage: 'Sort desc', }), }, 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 82c694f120d12c..748de6bdb82cb8 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 @@ -163,7 +163,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const { rowHasRowClickTriggerActions } = useContext(DataContext); return ( Date: Wed, 13 Jan 2021 11:57:36 +0100 Subject: [PATCH 10/30] :bug: Fix functional tests --- .../datatable_visualization/components/table_basic.tsx | 1 + x-pack/test/functional/page_objects/lens_page.ts | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) 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 748de6bdb82cb8..44076612c96601 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 @@ -225,6 +225,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { > el.getVisibleText()); }, @@ -521,9 +521,9 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont async getDatatableCellText(rowIndex = 0, colIndex = 0) { return find .byCssSelector( - `[data-test-subj="lnsDataTable"] tr:nth-child(${rowIndex + 1}) td:nth-child(${ - colIndex + 1 - })` + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRow"]:nth-child(${ + rowIndex + 2 // this is a bit specific for EuiDataGrid: the first row is the Header + }) [data-test-subj="dataGridRowCell"]:nth-child(${colIndex + 1})` ) .then((el) => el.getVisibleText()); }, From eb720771612c11589ee58ca127cbccccf52355aa Mon Sep 17 00:00:00 2001 From: dej611 Date: Wed, 13 Jan 2021 12:09:21 +0100 Subject: [PATCH 11/30] :globe_with_meridians: Fix unused translation --- x-pack/plugins/translations/translations/ja-JP.json | 8 -------- x-pack/plugins/translations/translations/zh-CN.json | 8 -------- 2 files changed, 16 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8d0d17962ea936..6cd818f40ec4a9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11233,7 +11233,6 @@ "xpack.lens.configure.invalidConfigTooltipClick": "詳細はクリックしてください。", "xpack.lens.customBucketContainer.dragToReorder": "ドラッグして並べ替え", "xpack.lens.dataPanelWrapper.switchDatasource": "データソースに切り替える", - "xpack.lens.datatable.actionsColumnName": "アクション", "xpack.lens.datatable.breakdown": "内訳の基準", "xpack.lens.datatable.conjunctionSign": " & ", "xpack.lens.datatable.expressionHelpLabel": "データベースレンダー", @@ -11243,7 +11242,6 @@ "xpack.lens.datatable.titleLabel": "タイトル", "xpack.lens.datatable.visualizationName": "データベース", "xpack.lens.datatable.visualizationOf": "テーブル {operations}", - "xpack.lens.datatableSortedInReadOnlyMode": "{sortValue} 順で並べ替え", "xpack.lens.datatypes.boolean": "ブール", "xpack.lens.datatypes.date": "日付", "xpack.lens.datatypes.ipAddress": "IP", @@ -11279,8 +11277,6 @@ "xpack.lens.editorFrame.suggestionPanelTitle": "提案", "xpack.lens.embeddable.failure": "ビジュアライゼーションを表示できませんでした", "xpack.lens.embeddableDisplayName": "レンズ", - "xpack.lens.excludeValueButtonAriaLabel": "{value}を除外", - "xpack.lens.excludeValueButtonTooltip": "値を除外", "xpack.lens.fieldFormats.longSuffix.d": "日単位", "xpack.lens.fieldFormats.longSuffix.h": "時間単位", "xpack.lens.fieldFormats.longSuffix.m": "分単位", @@ -11311,8 +11307,6 @@ "xpack.lens.functions.renameColumns.idMap.help": "キーが古い列 ID で値が対応する新しい列 ID となるように JSON エンコーディングされたオブジェクトです。他の列 ID はすべてのそのままです。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定した dateColumnId {columnId} は存在しません。", "xpack.lens.functions.timeScale.timeInfoMissingMessage": "日付ヒストグラム情報を取得できませんでした", - "xpack.lens.includeValueButtonAriaLabel": "{value}を含める", - "xpack.lens.includeValueButtonTooltip": "値を含める", "xpack.lens.indexPattern.allFieldsLabel": "すべてのフィールド", "xpack.lens.indexPattern.allFieldsLabelHelp": "使用可能なフィールドには、フィルターと一致する最初の 500 件のドキュメントのデータがあります。すべてのフィールドを表示するには、空のフィールドを展開します。一部のフィールドタイプは、完全なテキストおよびグラフィックフィールドを含む Lens では、ビジュアライゼーションできません。", "xpack.lens.indexPattern.availableFieldsLabel": "利用可能なフィールド", @@ -11518,8 +11512,6 @@ "xpack.lens.sugegstion.refreshSuggestionLabel": "更新", "xpack.lens.suggestion.refreshSuggestionTooltip": "選択したビジュアライゼーションに基づいて、候補を更新します。", "xpack.lens.suggestions.currentVisLabel": "現在のビジュアライゼーション", - "xpack.lens.tableRowMore": "詳細", - "xpack.lens.tableRowMoreDescription": "テーブル行コンテキストメニュー", "xpack.lens.timeScale.removeLabel": "時間単位で正規化を削除", "xpack.lens.visTypeAlias.description": "ドラッグアンドドロップエディターでビジュアライゼーションを作成します。いつでもビジュアライゼーションタイプを切り替えることができます。", "xpack.lens.visTypeAlias.note": "ほとんどのユーザーに推奨されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 426bbb8567cca9..5a4185f83656ea 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11262,7 +11262,6 @@ "xpack.lens.configure.invalidConfigTooltipClick": "单击了解更多详情。", "xpack.lens.customBucketContainer.dragToReorder": "拖动以重新排序", "xpack.lens.dataPanelWrapper.switchDatasource": "切换到数据源", - "xpack.lens.datatable.actionsColumnName": "操作", "xpack.lens.datatable.breakdown": "细分方式", "xpack.lens.datatable.conjunctionSign": " & ", "xpack.lens.datatable.expressionHelpLabel": "数据表呈现器", @@ -11272,7 +11271,6 @@ "xpack.lens.datatable.titleLabel": "标题", "xpack.lens.datatable.visualizationName": "数据表", "xpack.lens.datatable.visualizationOf": "表{operations}", - "xpack.lens.datatableSortedInReadOnlyMode": "按 {sortValue} 排序", "xpack.lens.datatypes.boolean": "布尔值", "xpack.lens.datatypes.date": "日期", "xpack.lens.datatypes.ipAddress": "IP", @@ -11308,8 +11306,6 @@ "xpack.lens.editorFrame.suggestionPanelTitle": "建议", "xpack.lens.embeddable.failure": "无法显示可视化", "xpack.lens.embeddableDisplayName": "lens", - "xpack.lens.excludeValueButtonAriaLabel": "排除 {value}", - "xpack.lens.excludeValueButtonTooltip": "排除值", "xpack.lens.fieldFormats.longSuffix.d": "每天", "xpack.lens.fieldFormats.longSuffix.h": "每小时", "xpack.lens.fieldFormats.longSuffix.m": "每分钟", @@ -11340,8 +11336,6 @@ "xpack.lens.functions.renameColumns.idMap.help": "旧列 ID 为键且相应新列 ID 为值的 JSON 编码对象。所有其他列 ID 都将保留。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定的 dateColumnId {columnId} 不存在。", "xpack.lens.functions.timeScale.timeInfoMissingMessage": "无法获取日期直方图信息", - "xpack.lens.includeValueButtonAriaLabel": "包括 {value}", - "xpack.lens.includeValueButtonTooltip": "包括值", "xpack.lens.indexPattern.allFieldsLabel": "所有字段", "xpack.lens.indexPattern.allFieldsLabelHelp": "可用字段在与您的筛选匹配的前 500 个文档中有数据。要查看所有字段,请展开空字段。一些字段类型无法在 Lens 中可视化,包括全文本字段和地理字段。", "xpack.lens.indexPattern.availableFieldsLabel": "可用字段", @@ -11547,8 +11541,6 @@ "xpack.lens.sugegstion.refreshSuggestionLabel": "刷新", "xpack.lens.suggestion.refreshSuggestionTooltip": "基于选定可视化刷新建议。", "xpack.lens.suggestions.currentVisLabel": "当前可视化", - "xpack.lens.tableRowMore": "更多", - "xpack.lens.tableRowMoreDescription": "表格行上下文菜单", "xpack.lens.timeScale.removeLabel": "删除按时间单位标准化", "xpack.lens.visTypeAlias.description": "使用拖放编辑器创建可视化。随时在可视化类型之间切换。", "xpack.lens.visTypeAlias.note": "适合绝大多数用户。", From 07dc9a052e17b50006b03c8e3c4effc369cb1c3d Mon Sep 17 00:00:00 2001 From: dej611 Date: Wed, 13 Jan 2021 18:11:05 +0100 Subject: [PATCH 12/30] :camera_flash: Fix snapshot tests --- .../components/__snapshots__/table_basic.test.tsx.snap | 2 ++ 1 file changed, 2 insertions(+) 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 4a312546dc7ee5..90cdaf526dec02 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 @@ -133,6 +133,7 @@ exports[`DatatableComponent it renders actions column when there are row actions }, ] } + data-test-subj="lnsDataTable" gridStyle={ Object { "border": "horizontal", @@ -293,6 +294,7 @@ exports[`DatatableComponent it renders the title and value 1`] = ` }, ] } + data-test-subj="lnsDataTable" gridStyle={ Object { "border": "horizontal", From 1c9ce6853576839d0bbff274cbd76243f59aac1a Mon Sep 17 00:00:00 2001 From: dej611 Date: Thu, 14 Jan 2021 12:32:54 +0100 Subject: [PATCH 13/30] :white_check_mark: Add more functional tests for Lens table --- .../test/functional/apps/lens/smokescreen.ts | 35 ++++++++++ .../test/functional/page_objects/lens_page.ts | 65 +++++++++++++++---- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index f2d91c2ae577f9..24b8d93c18e825 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -539,5 +539,40 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); }); + + 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); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index ab6b4f64781fcf..38b0e4844d49cf 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -503,13 +503,8 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * @param index - index of th element in datatable */ async getDatatableHeaderText(index = 0) { - return find - .byCssSelector( - `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${ - index + 1 - })` - ) - .then((el) => el.getVisibleText()); + const el = await this.getDatatableHeader(index); + return el.getVisibleText(); }, /** @@ -519,13 +514,55 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * @param colIndex - index of column of the cell */ async getDatatableCellText(rowIndex = 0, colIndex = 0) { - return find - .byCssSelector( - `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRow"]:nth-child(${ - rowIndex + 2 // this is a bit specific for EuiDataGrid: the first row is the Header - }) [data-test-subj="dataGridRowCell"]:nth-child(${colIndex + 1})` - ) - .then((el) => el.getVisibleText()); + const el = await this.getDatatableCell(rowIndex, colIndex); + return el.getVisibleText(); + }, + + async getDatatableHeader(index = 0) { + return find.byCssSelector( + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${ + index + 1 + })` + ); + }, + + async getDatatableCell(rowIndex = 0, colIndex = 0) { + return await find.byCssSelector( + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRow"]:nth-child(${ + rowIndex + 2 // this is a bit specific for EuiDataGrid: the first row is the Header + }) [data-test-subj="dataGridRowCell"]:nth-child(${colIndex + 1})` + ); + }, + + async isDatatableHeaderSorted(index = 0) { + return find.existsByCssSelector( + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${ + index + 1 + }) [data-test-subj^="dataGridHeaderCellSortingIcon"]` + ); + }, + + async changeTableSortingBy(colIndex = 0, direction: 'none' | 'asc' | 'desc') { + const el = await this.getDatatableHeader(colIndex); + await el.click(); + let buttonEl; + if (direction !== 'none') { + buttonEl = await find.byCssSelector( + `[data-test-subj^="dataGridHeaderCellActionGroup"] [title="Sort ${direction}"]` + ); + } else { + buttonEl = await find.byCssSelector( + `[data-test-subj^="dataGridHeaderCellActionGroup"] li[class$="selected"] [title^="Sort"]` + ); + } + return buttonEl.click(); + }, + + async clickTableCellAction(rowIndex = 0, colIndex = 0, actionTestSub: string) { + const el = await this.getDatatableCell(rowIndex, colIndex); + await el.focus(); + const action = await el.findByTestSubject(actionTestSub); + return action.click(); }, /** From acc4f922cdde881ff22e4ab44e2251c5fb594dfa Mon Sep 17 00:00:00 2001 From: dej611 Date: Mon, 18 Jan 2021 10:43:39 +0100 Subject: [PATCH 14/30] :sparkles: Add resize reset + add more unit tests --- .../__snapshots__/table_basic.test.tsx.snap | 213 ++++++++++++++++ .../components/columns.tsx | 29 ++- .../components/table_actions.test.ts | 235 ++++++++++++++++++ .../components/table_actions.ts | 59 ++--- .../components/table_basic.test.tsx | 18 ++ .../components/table_basic.tsx | 14 +- .../components/types.ts | 2 +- .../visualization.test.tsx | 76 ++++++ .../datatable_visualization/visualization.tsx | 4 +- 9 files changed, 607 insertions(+), 43 deletions(-) create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.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 90cdaf526dec02..f7f442ef4c0a5b 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 @@ -82,6 +82,17 @@ exports[`DatatableComponent it renders actions column when there are row actions Array [ Object { "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -99,6 +110,17 @@ exports[`DatatableComponent it renders actions column when there are row actions }, Object { "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -116,6 +138,17 @@ exports[`DatatableComponent it renders actions column when there are row actions }, Object { "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -243,6 +276,17 @@ exports[`DatatableComponent it renders the title and value 1`] = ` Array [ Object { "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -260,6 +304,17 @@ exports[`DatatableComponent it renders the title and value 1`] = ` }, Object { "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -277,6 +332,17 @@ exports[`DatatableComponent it renders the title and value 1`] = ` }, Object { "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], "showHide": false, "showMoveLeft": false, "showMoveRight": false, @@ -316,3 +382,150 @@ exports[`DatatableComponent it renders the title and value 1`] = ` `; + +exports[`DatatableComponent it should not render actions on header when it is in read only mode 1`] = ` + + + + + +`; 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 4076e990c15ed5..83a8d026f13156 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -21,10 +21,11 @@ export const createGridColumns = ( rowIndex: number, negate?: boolean ) => void, - isReadOnlySorted: boolean, + isReadOnly: boolean, columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, visibleColumns: string[], - formatFactory: FormatFactory + formatFactory: FormatFactory, + onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void ) => { const columnsReverseLookup = table.columns.reduce< Record @@ -132,6 +133,9 @@ export const createGridColumns = ( ] : undefined; + const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) + ?.width; + const columnDefinition: EuiDataGridColumn = { id: field, cellActions, @@ -141,25 +145,38 @@ export const createGridColumns = ( showHide: false, showMoveLeft: false, showMoveRight: false, - showSortAsc: isReadOnlySorted + showSortAsc: isReadOnly ? false : { label: i18n.translate('xpack.lens.table.sort.ascLabel', { defaultMessage: 'Sort asc', }), }, - showSortDesc: isReadOnlySorted + showSortDesc: isReadOnly ? false : { label: i18n.translate('xpack.lens.table.sort.descLabel', { defaultMessage: 'Sort desc', }), }, + additional: isReadOnly + ? undefined + : [ + { + color: 'text', + size: 'xs', + onClick: () => onColumnResize({ columnId: field, width: undefined }), + iconType: 'empty', + label: i18n.translate('xpack.lens.table.resize.reset', { + defaultMessage: 'Reset width', + }), + 'data-test-subj': 'lensDatatableResetWidth', + isDisabled: initialWidth == null, + }, + ], }, }; - const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) - ?.width; if (initialWidth) { columnDefinition.initialWidth = initialWidth; } 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 new file mode 100644 index 00000000000000..dad9aa30b7712c --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts @@ -0,0 +1,235 @@ +/* + * 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 { EuiDataGridSorting } from '@elastic/eui'; +import { Datatable } from 'src/plugins/expressions'; + +import { + createGridFilterHandler, + createGridResizeHandler, + createGridSortingConfig, +} from './table_actions'; +import { DatatableColumns, LensGridDirection } from './types'; + +function getDefaultConfig(): DatatableColumns & { + type: 'lens_datatable_columns'; +} { + return { + columnIds: [], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }; +} + +function createTableRef( + { withDate }: { withDate: boolean } = { withDate: false } +): React.MutableRefObject { + return { + current: { + type: 'datatable', + rows: [], + columns: [ + { + id: 'a', + name: 'field', + meta: { type: withDate ? 'date' : 'number', field: 'a' }, + }, + ], + }, + }; +} + +describe('Table actions', () => { + const onEditAction = jest.fn(); + + describe('Table filtering', () => { + it('should set a filter on click with the correct configuration', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef(); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + + it('should set a negate filter on click with the correct confgiuration', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef(); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0, true); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: true, + timeFieldName: 'a', + }); + }); + + it('should set a time filter on click', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef({ withDate: true }); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + + it('should set a negative time filter on click', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef({ withDate: true }); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0, true); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: true, + timeFieldName: undefined, + }); + }); + }); + describe('Table sorting', () => { + it('should create the right configuration for all types of sorting', () => { + const configs: Array<{ + input: { direction: LensGridDirection; sortBy: string }; + output: EuiDataGridSorting['columns']; + }> = [ + { input: { direction: 'asc', sortBy: 'a' }, output: [{ id: 'a', direction: 'asc' }] }, + { input: { direction: 'none', sortBy: 'a' }, output: [] }, + { input: { direction: 'asc', sortBy: '' }, output: [] }, + ]; + for (const { input, output } of configs) { + const { sortBy, direction } = input; + expect(createGridSortingConfig(sortBy, direction, onEditAction)).toMatchObject( + expect.objectContaining({ columns: output }) + ); + } + }); + + it('should return the correct next configuration value based on the current state', () => { + const sorter = createGridSortingConfig('a', 'none', onEditAction); + // Click on the 'a' column + sorter.onSort([{ id: 'a', direction: 'asc' }]); + + // Click on another column 'b' + sorter.onSort([ + { id: 'a', direction: 'asc' }, + { id: 'b', direction: 'asc' }, + ]); + + // Change the sorting of 'a' + sorter.onSort([{ id: 'a', direction: 'desc' }]); + + // Toggle the 'a' current sorting (remove sorting) + sorter.onSort([]); + + expect(onEditAction.mock.calls).toEqual([ + [ + { + action: 'sort', + columnId: 'a', + direction: 'asc', + }, + ], + [ + { + action: 'sort', + columnId: 'b', + direction: 'asc', + }, + ], + [ + { + action: 'sort', + columnId: 'a', + direction: 'desc', + }, + ], + [ + { + action: 'sort', + columnId: undefined, + direction: 'none', + }, + ], + ]); + }); + }); + describe('Table resize', () => { + const setColumnConfig = jest.fn(); + + it('should resize the table locally and globally with the given size', () => { + const columnConfig = getDefaultConfig(); + const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); + resizer({ columnId: 'a', width: 100 }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columnWidth: [{ columnId: 'a', width: 100, type: 'lens_datatable_column_width' }], + }); + + expect(onEditAction).toHaveBeenCalledWith({ action: 'resize', columnId: 'a', width: 100 }); + }); + + 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' }, + ]; + + const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); + resizer({ columnId: 'a', width: undefined }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columnWidth: [], + }); + + expect(onEditAction).toHaveBeenCalledWith({ + action: 'resize', + columnId: 'a', + width: undefined, + }); + }); + }); +}); 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 6671819f2fa2f7..38534482b81fad 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 @@ -8,7 +8,6 @@ import type { Datatable } from 'src/plugins/expressions'; import type { LensFilterEvent } from '../../types'; import type { DatatableColumns, - DatatableColumnWidth, LensGridDirection, LensResizeAction, LensSortAction, @@ -28,27 +27,29 @@ export const createGridResizeHandler = ( > >, onEditAction: (data: LensResizeAction['data']) => void -) => (eventData: DatatableColumnWidth) => { +) => (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), - { - columnId: eventData.columnId, - width: eventData.width, - type: 'lens_datatable_column_width', - }, + ...(eventData.width !== undefined + ? [ + { + columnId: eventData.columnId, + width: eventData.width, + type: 'lens_datatable_column_width' as const, + }, + ] + : []), ], }); - if (onEditAction) { - return onEditAction({ - action: 'resize', - columnId: eventData.columnId, - width: eventData.width, - }); - } + return onEditAction({ + action: 'resize', + columnId: eventData.columnId, + width: eventData.width, + }); }; export const createGridFilterHandler = ( @@ -77,6 +78,7 @@ export const createGridFilterHandler = ( ], timeFieldName, }; + onClickValue(desanitizeFilterContext(data)); }; @@ -95,20 +97,19 @@ export const createGridSortingConfig = ( }, ], onSort: (sortingCols) => { - if (onEditAction) { - const newSortValue: - | { - id: string; - direction: Exclude; - } - | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; - const isNewColumn = sortBy !== (newSortValue?.id || ''); - const nextDirection = newSortValue ? newSortValue.direction : 'none'; - return onEditAction({ - action: 'sort', - columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, - direction: nextDirection, - }); - } + const newSortValue: + | { + id: string; + direction: Exclude; + } + | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + const isNewColumn = sortBy !== (newSortValue?.id || ''); + const nextDirection = newSortValue ? newSortValue.direction : 'none'; + + return onEditAction({ + action: 'sort', + columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, + direction: nextDirection, + }); }, }); 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 41bd62ad464f0e..df5dba749a60c1 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 @@ -121,6 +121,24 @@ describe('DatatableComponent', () => { ).toMatchSnapshot(); }); + test('it should not render actions on header when it is in read only mode', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + rowHasRowClickTriggerActions={[false, false, false]} + renderMode="display" + /> + ) + ).toMatchSnapshot(); + }); + test('it invokes executeTriggerActions with correct context on click on top value', () => { const { args, data } = 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 44076612c96601..a6845255b01096 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 @@ -128,6 +128,11 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const isReadOnlySorted = renderMode !== 'edit'; + const onColumnResize = useMemo( + () => createGridResizeHandler(columnConfig, setColumnConfig, onEditAction), + [onEditAction, setColumnConfig, columnConfig] + ); + const columns: EuiDataGridColumn[] = useMemo( () => createGridColumns( @@ -137,7 +142,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { isReadOnlySorted, columnConfig, visibleColumns, - formatFactory + formatFactory, + onColumnResize ), [ bucketColumns, @@ -147,6 +153,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig, visibleColumns, formatFactory, + onColumnResize, ] ); @@ -188,11 +195,6 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const renderCellValue = useMemo(() => createGridCell(formatters, DataContext), [formatters]); - const onColumnResize = useMemo( - () => createGridResizeHandler(columnConfig, setColumnConfig, onEditAction), - [onEditAction, setColumnConfig, columnConfig] - ); - const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ visibleColumns, ]); 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 9f453dc9ecc01a..4f1a1141fdaa84 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -20,7 +20,7 @@ export interface LensSortActionData { export interface LensResizeActionData { columnId: string; - width: number; + width: number | undefined; } export type LensSortAction = LensEditEvent; 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 675f696ef8ffb2..f067093891d295 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -468,4 +468,80 @@ describe('Datatable Visualization', () => { expect(error).toBeUndefined(); }); }); + + describe('#onEditAction', () => { + it('should add a sort column to the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'sort', columnId: 'saved', direction: 'none' }, + }) + ).toEqual({ + ...currentState, + sorting: { + columnId: 'saved', + direction: 'none', + }, + }); + }); + + it('should add a custom width to a column in the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'resize', columnId: 'saved', width: 500 }, + }) + ).toEqual({ + ...currentState, + columnWidth: [ + { + 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, + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'resize', columnId: 'saved', width: undefined }, + }) + ).toEqual({ + ...currentState, + columnWidth: [], + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 2e7a784eb65c4b..3df9e8a5145bc3 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -284,7 +284,9 @@ export const datatableVisualization: Visualization ...state, columnWidth: [ ...(state.columnWidth || []).filter(({ columnId }) => columnId !== event.data.columnId), - { columnId: event.data.columnId, width: event.data.width }, + ...(event.data.width !== undefined + ? [{ columnId: event.data.columnId, width: event.data.width }] + : []), ], }; default: From 063794839ea36f6d43f2d40c00a8cacd8e58bd78 Mon Sep 17 00:00:00 2001 From: dej611 Date: Mon, 18 Jan 2021 14:16:46 +0100 Subject: [PATCH 15/30] :lipstick: Make header sticky --- .../datatable_visualization/components/table_basic.scss | 5 +++++ .../datatable_visualization/components/table_basic.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss index 4b7880b0bf91c4..4ebe91ff18c67b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss @@ -1,3 +1,8 @@ +.lnsDataTableContainer { + height: 100%; + overflow: initial; +} + .lnsDataTable { align-self: flex-start; } 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 a6845255b01096..171074d6e6797c 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 @@ -216,6 +216,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { return ( From f6fc9a3b017f963b157a0a80c1fa4ed064715da1 Mon Sep 17 00:00:00 2001 From: dej611 Date: Mon, 18 Jan 2021 16:29:37 +0100 Subject: [PATCH 16/30] :camera_flash: Updated snapshots for sticky header fix --- .../components/__snapshots__/table_basic.test.tsx.snap | 3 +++ 1 file changed, 3 insertions(+) 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 f7f442ef4c0a5b..a4eb99a972b9b6 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 @@ -2,6 +2,7 @@ exports[`DatatableComponent it renders actions column when there are row actions 1`] = ` Date: Tue, 19 Jan 2021 15:29:14 +0100 Subject: [PATCH 17/30] add column toggle functionality --- .../components/columns.tsx | 13 +++- .../components/constants.ts | 1 + .../components/table_actions.ts | 28 +++++++++ .../components/table_basic.tsx | 23 +++++-- .../components/toolbar.tsx | 63 +++++++++++++++++++ .../components/types.ts | 8 ++- .../datatable_visualization/expression.tsx | 5 ++ .../datatable_visualization/visualization.tsx | 23 +++++++ .../shared_components/toolbar_popover.tsx | 1 + x-pack/plugins/lens/public/types.ts | 3 + 10 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx 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 83a8d026f13156..7a91d9922884c9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -25,7 +25,8 @@ export const createGridColumns = ( columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, 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 @@ -173,6 +174,16 @@ export const createGridColumns = ( 'data-test-subj': 'lensDatatableResetWidth', isDisabled: initialWidth == null, }, + { + color: 'text', + size: 'xs', + onClick: () => onColumnHide({ columnId: field }), + iconType: 'empty', + label: i18n.translate('xpack.lens.table.hide.hideLabel', { + defaultMessage: 'Hide', + }), + 'data-test-subj': 'lensDatatableHide', + }, ], }, }; 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 4779d42859a795..90f87354d6c9d1 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts @@ -6,3 +6,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/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts index 38534482b81fad..6d055c0ae6e310 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 @@ -11,6 +11,7 @@ import type { LensGridDirection, LensResizeAction, LensSortAction, + LensToggleAction, } from './types'; import { desanitizeFilterContext } from '../../utils'; @@ -52,6 +53,33 @@ export const createGridResizeHandler = ( }); }; +export const createGridHideHandler = ( + columnConfig: DatatableColumns & { + type: 'lens_datatable_columns'; + }, + setColumnConfig: React.Dispatch< + React.SetStateAction< + DatatableColumns & { + type: 'lens_datatable_columns'; + } + > + >, + 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, + hiddenColumnIds: [ + ...(columnConfig.hiddenColumnIds || []).filter((id) => id !== eventData.columnId), + eventData.columnId, + ], + }); + return onEditAction({ + action: 'toggle', + columnId: eventData.columnId, + }); +}; + export const createGridFilterHandler = ( tableRef: React.MutableRefObject, onClickValue: (data: LensFilterEvent['data']) => void 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 171074d6e6797c..e962013f3e63ec 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 @@ -27,11 +27,13 @@ import { LensSortAction, LensResizeAction, LensGridDirection, + LensToggleAction, } from './types'; import { createGridColumns } from './columns'; import { createGridCell } from './cell_value'; import { createGridFilterHandler, + createGridHideHandler, createGridResizeHandler, createGridSortingConfig, } from './table_actions'; @@ -84,7 +86,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 }); } @@ -120,9 +122,13 @@ 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.columnIds.filter( + (field) => !!field && !columnConfig.hiddenColumnIds?.includes(field) + ), + [columnConfig] + ); const { sortBy, sortDirection } = columnConfig; @@ -133,6 +139,11 @@ export const DatatableComponent = (props: DatatableRenderProps) => { [onEditAction, setColumnConfig, columnConfig] ); + const onColumnHide = useMemo( + () => createGridHideHandler(columnConfig, setColumnConfig, onEditAction), + [onEditAction, setColumnConfig, columnConfig] + ); + const columns: EuiDataGridColumn[] = useMemo( () => createGridColumns( @@ -143,7 +154,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig, visibleColumns, formatFactory, - onColumnResize + onColumnResize, + onColumnHide ), [ bucketColumns, @@ -154,6 +166,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { visibleColumns, formatFactory, onColumnResize, + onColumnHide, ] ); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx new file mode 100644 index 00000000000000..dbe2a12ae637f7 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexItem, EuiFlexGroup, EuiSwitch } from '@elastic/eui'; +import { VisualizationToolbarProps } from '../../types'; +import { ToolbarPopover } from '../../shared_components'; +import { DatatableVisualizationState } from '../visualization'; + +export function TableToolbar(props: VisualizationToolbarProps) { + const { state, setState } = props; + const layer = state.layers[0]; + if (!layer) { + return null; + } + return ( + + + + {layer.columns.map((columnId) => { + const label = props.frame.datasourceLayers[layer.layerId].getOperationForColumnId( + columnId + )?.label; + const isHidden = state.hiddenColumnIds?.includes(columnId); + return ( + + { + e.preventDefault(); + e.stopPropagation(); + const newState = { + ...state, + hiddenColumnIds: + isHidden && state.hiddenColumnIds + ? state.hiddenColumnIds.filter((id) => id !== columnId) + : [...(state.hiddenColumnIds || []), columnId], + }; + setState(newState); + }} + /> + + ); + })} + + + + ); +} 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 4f1a1141fdaa84..7be031607e4677 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -9,7 +9,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; @@ -23,11 +23,17 @@ export interface LensResizeActionData { width: number | undefined; } +export interface LensToggleActionData { + columnId: string; +} + export type LensSortAction = LensEditEvent; export type LensResizeAction = LensEditEvent; +export type LensToggleAction = LensEditEvent; export interface DatatableColumns { columnIds: string[]; + hiddenColumnIds?: string[]; sortBy: string; sortDirection: string; columnWidth?: DatatableColumnWidthResult[]; diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index e8a0abb0316dba..e2913d0cd31b5e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -136,6 +136,11 @@ export const datatableColumns: ExpressionFunctionDefinition< multi: true, help: '', }, + hiddenColumnIds: { + types: ['string'], + multi: true, + help: '', + }, columnWidth: { types: ['lens_datatable_column_width'], multi: true, diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 3df9e8a5145bc3..718862f52c8394 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +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, @@ -15,6 +18,7 @@ import type { } from '../types'; import type { DatatableColumnWidth } from './components/types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; +import { TableToolbar } from './components/toolbar'; export interface LayerState { layerId: string; @@ -28,6 +32,7 @@ export interface DatatableVisualizationState { direction: 'asc' | 'desc' | 'none'; }; columnWidth?: DatatableColumnWidth[]; + hiddenColumnIds?: string[]; } function newLayerState(layerId: string): LayerState { @@ -205,6 +210,14 @@ export const datatableVisualization: Visualization sorting: prevState.sorting?.columnId === columnId ? undefined : prevState.sorting, }; }, + renderToolbar(domElement, props) { + render( + + + , + domElement + ); + }, toExpression(state, datasourceLayers, { title, description } = {}): Ast | null { const { sortedColumns, datasource } = @@ -239,6 +252,7 @@ export const datatableVisualization: Visualization function: 'lens_datatable_columns', arguments: { columnIds: operations.map((o) => o.columnId), + hiddenColumnIds: state.hiddenColumnIds || [], sortBy: [state.sorting?.columnId || ''], sortDirection: [state.sorting?.direction || 'none'], columnWidth: (state.columnWidth || []).map((columnWidth) => ({ @@ -279,6 +293,15 @@ export const datatableVisualization: Visualization direction: event.data.direction, }, }; + case 'toggle': + const isHidden = state.hiddenColumnIds?.includes(event.data.columnId); + return { + ...state, + hiddenColumnIds: + isHidden && state.hiddenColumnIds + ? state.hiddenColumnIds.filter((id) => id !== event.data.columnId) + : [...(state.hiddenColumnIds || []), event.data.columnId], + }; case 'resize': return { ...state, diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx index cf2268c6eadf2e..f3156e5aae1b3d 100644 --- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx @@ -13,6 +13,7 @@ const typeToIconMap: { [type: string]: string | IconType } = { legend: EuiIconLegend as IconType, labels: 'visText', values: 'number', + list: 'list', }; export interface ToolbarPopoverProps { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index edf56032f05ea1..bbc098a491f0ab 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -25,10 +25,12 @@ import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/e import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION, + LENS_TOGGLE_ACTION, } from './datatable_visualization/components/constants'; import type { LensSortActionData, LensResizeActionData, + LensToggleActionData, } from './datatable_visualization/components/types'; export type ErrorCallback = (e: { message: string }) => void; @@ -639,6 +641,7 @@ export interface LensBrushEvent { interface LensEditContextMapping { [LENS_EDIT_SORT_ACTION]: LensSortActionData; [LENS_EDIT_RESIZE_ACTION]: LensResizeActionData; + [LENS_TOGGLE_ACTION]: LensToggleActionData; } type LensEditSupportedActions = keyof LensEditContextMapping; From 07967ed5d900c5c89628cfb0fc432c1dc5f1719b Mon Sep 17 00:00:00 2001 From: dej611 Date: Mon, 25 Jan 2021 12:35:03 +0100 Subject: [PATCH 18/30] :lipstick: Some css classes clean up --- .../components/table_basic.scss | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss index 4ebe91ff18c67b..a353275ea1a6dd 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss @@ -1,20 +1,5 @@ .lnsDataTableContainer { height: 100%; - overflow: initial; -} - -.lnsDataTable { - align-self: flex-start; -} - -.lnsDataTable__filter { - opacity: 0; - transition: opacity $euiAnimSpeedNormal ease-in-out; -} - -.lnsDataTable__cell:hover .lnsDataTable__filter, -.lnsDataTable__filter:focus-within { - opacity: 1; } .lnsDataTableCellContent { From 0fed67319974b7057fbcd147c918e73df43da2cb Mon Sep 17 00:00:00 2001 From: dej611 Date: Mon, 25 Jan 2021 12:40:36 +0100 Subject: [PATCH 19/30] :lipstick: Make truncate work by the datagrid component --- .../public/datatable_visualization/components/cell_value.tsx | 2 +- .../datatable_visualization/components/table_basic.scss | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx index a77d0b69b62547..a8328f5eefdca1 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -18,7 +18,7 @@ export const createGridCell = ( const content = formatters[columnId]?.convert(rowValue, 'html'); return ( -
Date: Tue, 26 Jan 2021 11:01:52 +0100 Subject: [PATCH 20/30] refactoring --- .../components/columns.tsx | 7 +- .../components/table_actions.ts | 56 ++---- .../components/table_basic.tsx | 51 ++++-- .../components/types.ts | 17 -- .../datatable_visualization/expression.tsx | 92 +++------- .../datatable_visualization/visualization.tsx | 169 ++++++++---------- 6 files changed, 161 insertions(+), 231 deletions(-) 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 7a91d9922884c9..e8765c5a7bd077 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -9,7 +9,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[], @@ -22,7 +22,7 @@ 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, @@ -134,8 +134,7 @@ export const createGridColumns = ( ] : undefined; - const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) - ?.width; + const initialWidth = columnConfig.columns.find(({ columnId }) => columnId === field)?.width; const columnDefinition: EuiDataGridColumn = { id: field, 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 6d055c0ae6e310..a944fc8d477300 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 @@ -7,44 +7,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,25 +40,19 @@ export const createGridResizeHandler = ( }; export const createGridHideHandler = ( - columnConfig: DatatableColumns & { - type: 'lens_datatable_columns'; - }, - setColumnConfig: React.Dispatch< - React.SetStateAction< - DatatableColumns & { - type: 'lens_datatable_columns'; - } - > - >, + 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, - hiddenColumnIds: [ - ...(columnConfig.hiddenColumnIds || []).filter((id) => id !== eventData.columnId), - eventData.columnId, - ], + columns: columnConfig.columns.map((column) => { + if (column.columnId === eventData.columnId) { + return { ...column, hidden: true }; + } + return column; + }), }); return onEditAction({ action: 'toggle', @@ -111,7 +91,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.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index e962013f3e63ec..d6fd0580a8c231 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 @@ -21,6 +21,7 @@ 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, @@ -45,15 +46,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); @@ -107,13 +126,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] ); @@ -124,13 +145,13 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const visibleColumns = useMemo( () => - columnConfig.columnIds.filter( - (field) => !!field && !columnConfig.hiddenColumnIds?.includes(field) - ), + 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'; @@ -196,7 +217,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 7be031607e4677..5d434f3d903233 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -31,23 +31,6 @@ export type LensSortAction = LensEditEvent; export type LensResizeAction = LensEditEvent; export type LensToggleAction = LensEditEvent; -export interface DatatableColumns { - columnIds: string[]; - hiddenColumnIds?: string[]; - sortBy: string; - sortDirection: string; - columnWidth?: DatatableColumnWidthResult[]; -} - -export interface DatatableColumnWidth { - columnId: string; - width: number; -} - -export type DatatableColumnWidthResult = DatatableColumnWidth & { - type: 'lens_datatable_column_width'; -}; - export type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; dispatchEvent: ILensInterpreterRenderHandlers['event']; diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index e2913d0cd31b5e..cb9673e8e8118c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -18,19 +18,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 { @@ -65,7 +63,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: '', }, }, @@ -78,7 +85,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 @@ -115,70 +122,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', + '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: '', - }, - hiddenColumnIds: { - 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, }; }, @@ -217,7 +181,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/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 718862f52c8394..77cdfa766198dc 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -16,30 +16,24 @@ import type { Operation, DatasourcePublicAPI, } from '../types'; -import type { DatatableColumnWidth } from './components/types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; import { TableToolbar } from './components/toolbar'; -export interface LayerState { - layerId: string; - columns: string[]; +export interface ColumnState { + columnId: string; + width?: number; + hidden?: boolean; } -export interface DatatableVisualizationState { - layers: LayerState[]; - sorting?: { - columnId: string | undefined; - direction: 'asc' | 'desc' | 'none'; - }; - columnWidth?: DatatableColumnWidth[]; - hiddenColumnIds?: string[]; +export interface SortingState { + columnId: string | undefined; + direction: 'asc' | 'desc' | 'none'; } -function newLayerState(layerId: string): LayerState { - return { - layerId, - columns: [], - }; +export interface DatatableVisualizationState { + columns: ColumnState[]; + layerId: string; + sorting?: SortingState; } export const datatableVisualization: Visualization = { @@ -60,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: [], }; }, @@ -83,7 +78,8 @@ export const datatableVisualization: Visualization initialize(frame, state) { return ( state || { - layers: [newLayerState(frame.addNewLayer())], + columns: [], + layerId: frame.addNewLayer(), } ); }, @@ -130,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 @@ -159,7 +151,7 @@ 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 })), @@ -172,7 +164,7 @@ export const datatableVisualization: Visualization 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 })), @@ -185,28 +177,19 @@ export const datatableVisualization: Visualization }; }, - 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, }; }, @@ -221,7 +204,7 @@ export const datatableVisualization: Visualization 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 && @@ -230,9 +213,15 @@ export const datatableVisualization: Visualization return null; } - const operations = sortedColumns! + const columnMap: Record = {}; + state.columns.forEach((column) => { + columnMap[column.columnId] = column; + }); + + const columns = sortedColumns! .map((columnId) => ({ columnId, operation: datasource!.getOperationForColumnId(columnId) })) - .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); + .filter((o): o is { columnId: string; operation: Operation } => !!o.operation) + .map(({ columnId }) => columnMap[columnId]); return { type: 'expression', @@ -243,36 +232,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), - hiddenColumnIds: state.hiddenColumnIds || [], - 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], }, - ], - }, - ], + }, + ], + })), + sortBy: [state.sorting?.columnId || ''], + sortDirection: [state.sorting?.direction || 'none'], }, }, ], @@ -294,23 +269,33 @@ export const datatableVisualization: Visualization }, }; case 'toggle': - const isHidden = state.hiddenColumnIds?.includes(event.data.columnId); return { ...state, - hiddenColumnIds: - isHidden && state.hiddenColumnIds - ? state.hiddenColumnIds.filter((id) => id !== event.data.columnId) - : [...(state.hiddenColumnIds || []), event.data.columnId], + 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; @@ -323,13 +308,11 @@ function getDataSourceAndSortedColumns( datasourceLayers: Record, layerId: string ) { - const layer = state.layers.find((l: LayerState) => 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 }; } From e821ca608ce72f1f7e71887100b6ec36bb7e62c9 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 26 Jan 2021 14:30:19 +0100 Subject: [PATCH 21/30] add tests and clean up --- .../__snapshots__/table_basic.test.tsx.snap | 48 ++++ .../components/table_actions.test.ts | 51 +++-- .../components/table_basic.test.tsx | 65 ++++-- .../components/toolbar.tsx | 35 +-- .../expression.test.tsx | 22 +- .../datatable_visualization/expression.tsx | 2 +- .../public/datatable_visualization/index.ts | 6 +- .../visualization.test.tsx | 206 +++++++----------- .../datatable_visualization/visualization.tsx | 4 +- 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 + .../test/functional/apps/lens/smokescreen.ts | 35 --- x-pack/test/functional/apps/lens/table.ts | 68 ++++++ .../test/functional/page_objects/lens_page.ts | 10 + 15 files changed, 460 insertions(+), 220 deletions(-) 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..24ced995cdaefc 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,14 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "empty", + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -121,6 +129,14 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "empty", + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -149,6 +165,14 @@ exports[`DatatableComponent it renders actions column when there are row actions "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "empty", + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -288,6 +312,14 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "empty", + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -316,6 +348,14 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "empty", + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, @@ -344,6 +384,14 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "onClick": [Function], "size": "xs", }, + Object { + "color": "text", + "data-test-subj": "lensDatatableHide", + "iconType": "empty", + "label": "Hide", + "onClick": [Function], + "size": "xs", + }, ], "showHide": false, "showMoveLeft": false, 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 dad9aa30b7712c..9f76917386eda6 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 @@ -12,17 +12,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', }; } @@ -205,7 +207,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 }); @@ -213,16 +221,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({ @@ -232,4 +238,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_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index df5dba749a60c1..b91d360367891a 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 @@ -64,12 +64,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 }; @@ -251,12 +252,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( @@ -330,11 +331,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} @@ -381,11 +379,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} @@ -399,6 +394,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/toolbar.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx index dbe2a12ae637f7..baa2b5a8c53da1 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx @@ -9,14 +9,14 @@ import { i18n } from '@kbn/i18n'; import { EuiFlexItem, EuiFlexGroup, EuiSwitch } from '@elastic/eui'; import { VisualizationToolbarProps } from '../../types'; import { ToolbarPopover } from '../../shared_components'; -import { DatatableVisualizationState } from '../visualization'; +import { DatatableVisualizationState, ColumnState } from '../visualization'; export function TableToolbar(props: VisualizationToolbarProps) { - const { state, setState } = props; - const layer = state.layers[0]; - if (!layer) { - return null; - } + const { state, setState, frame } = props; + const columnMap: Record = {}; + state.columns.forEach((column) => { + columnMap[column.columnId] = column; + }); return ( - {layer.columns.map((columnId) => { - const label = props.frame.datasourceLayers[layer.layerId].getOperationForColumnId( + {frame.datasourceLayers[state.layerId].getTableSpec().map(({ columnId }) => { + const label = props.frame.datasourceLayers[state.layerId].getOperationForColumnId( columnId )?.label; - const isHidden = state.hiddenColumnIds?.includes(columnId); + const isHidden = columnMap[columnId].hidden; return ( - + { e.preventDefault(); e.stopPropagation(); const newState = { ...state, - hiddenColumnIds: - isHidden && state.hiddenColumnIds - ? state.hiddenColumnIds.filter((id) => id !== columnId) - : [...(state.hiddenColumnIds || []), columnId], + columns: state.columns.map((currentColumn) => { + if (currentColumn.columnId === columnId) { + return { + ...currentColumn, + hidden: !isHidden, + }; + } else { + return currentColumn; + } + }), }; setState(newState); }} 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 60d9461a5e0d9e..c8941d07c05f99 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -58,12 +58,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 cb9673e8e8118c..990ef3e20ec49f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -124,7 +124,7 @@ export const getDatatable = ({ type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' }; -export const datatableColumns: ExpressionFunctionDefinition< +export const datatableColumn: ExpressionFunctionDefinition< 'lens_datatable_column', null, ColumnState, diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index cf23d56adb9157..5049123573ef2e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -28,15 +28,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 f067093891d295..f00e6152893993 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -29,23 +29,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); }); @@ -54,12 +46,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']); }); @@ -68,20 +56,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: [], }); }); }); @@ -112,7 +92,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, @@ -129,7 +110,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, @@ -146,7 +128,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, @@ -163,7 +146,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, @@ -202,7 +186,8 @@ describe('Datatable Visualization', () => { datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups @@ -217,7 +202,8 @@ describe('Datatable Visualization', () => { const filterOperations = datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups[0].filterOperations; @@ -248,7 +234,8 @@ describe('Datatable Visualization', () => { const filterOperations = datatableVisualization.getConfiguration({ layerId: 'first', state: { - layers: [{ layerId: 'first', columns: [] }], + layerId: 'first', + columns: [], }, frame, }).groups[1].filterOperations; @@ -273,7 +260,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' }]); @@ -281,7 +267,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' }]); @@ -290,95 +279,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' }], }); }); }); @@ -386,7 +355,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' }]); @@ -397,24 +365,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' }]); @@ -425,7 +404,7 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layers: [layer] }, + { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, frame.datasourceLayers ); @@ -436,7 +415,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' }]); @@ -446,14 +424,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' }]); @@ -463,7 +443,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(); }); @@ -472,12 +455,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, { @@ -495,12 +474,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, { @@ -509,29 +484,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, { @@ -540,7 +500,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 77cdfa766198dc..07cd8a34b55314 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -246,8 +246,8 @@ export const datatableVisualization: Visualization }, ], })), - sortBy: [state.sorting?.columnId || ''], - sortDirection: [state.sorting?.direction || 'none'], + sortingColumnId: [state.sorting?.columnId || ''], + sortingDirection: [state.sorting?.direction || 'none'], }, }, ], diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index 9764926fc03fc4..23bd776d0b9acb 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -596,4 +596,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', + columnIds: ['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 670a66d87e6d5f..170c9f13d351dc 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -82,6 +82,29 @@ interface XYStatePost77 { layers: Array>; } +interface DatatableStatePre711 { + layers: Array<{ + layerId: string; + columnIds: 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"} @@ -333,6 +356,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.columnIds.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 @@ -340,4 +393,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 642526d74b6874..3d11c9fa9a461e 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -28,6 +28,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/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 24b8d93c18e825..f2d91c2ae577f9 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -539,40 +539,5 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); }); - - 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); - }); }); } 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..84f19c5b0e3f18 --- /dev/null +++ b/x-pack/test/functional/apps/lens/table.ts @@ -0,0 +1,68 @@ +/* + * 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 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('Top values of ip'); + + 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('Top values of ip'); + + 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 dabead6ffbdad8..0615a991093d80 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -561,6 +561,16 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return buttonEl.click(); }, + async toggleColumnVisibility(label: string) { + const id = `lnsColumns-toggle-${label.replace(/ /g, '-')}`; + await testSubjects.click('lnsColumnsButton'); + await testSubjects.existOrFail(id); + const isChecked = await testSubjects.isEuiSwitchChecked(id); + await testSubjects.setEuiSwitch(id, isChecked ? 'uncheck' : 'check'); + await testSubjects.click('lnsColumnsButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + }, + async clickTableCellAction(rowIndex = 0, colIndex = 0, actionTestSub: string) { const el = await this.getDatatableCell(rowIndex, colIndex); await el.focus(); From 82d7e9f9ce1354bdaceea075591c9b3786b59159 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 26 Jan 2021 16:59:14 +0100 Subject: [PATCH 22/30] fix migrations --- x-pack/plugins/lens/server/migrations.test.ts | 2 +- x-pack/plugins/lens/server/migrations.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index 23bd776d0b9acb..ef61bae1cbe30c 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -611,7 +611,7 @@ describe('Lens migrations', () => { layers: [ { layerId: 'first', - columnIds: ['a', 'b', 'c'], + columns: ['a', 'b', 'c'], }, ], sorting: { diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index 170c9f13d351dc..bf40807306507f 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -85,7 +85,7 @@ interface XYStatePost77 { interface DatatableStatePre711 { layers: Array<{ layerId: string; - columnIds: string[]; + columns: string[]; }>; sorting?: { columnId: string | undefined; @@ -378,7 +378,7 @@ const transformTableState: SavedObjectMigrationFn< visualization: { sorting: oldState.sorting, layerId: layer.layerId, - columns: layer.columnIds.map((columnId) => ({ columnId })), + columns: layer.columns.map((columnId) => ({ columnId })), }, }, }, From 064794b7e707dd21cc192839a2de5457ec40df44 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 1 Feb 2021 11:41:49 +0100 Subject: [PATCH 23/30] fix bug --- .../public/datatable_visualization/components/toolbar.tsx | 7 +++---- .../lens/public/datatable_visualization/visualization.tsx | 8 +++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx index baa2b5a8c53da1..d7d776c6ea132a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx @@ -17,6 +17,7 @@ export function TableToolbar(props: VisualizationToolbarProps { columnMap[column.columnId] = column; }); + const datasourceLayer = frame.datasourceLayers[state.layerId]; return ( - {frame.datasourceLayers[state.layerId].getTableSpec().map(({ columnId }) => { - const label = props.frame.datasourceLayers[state.layerId].getOperationForColumnId( - columnId - )?.label; + {datasourceLayer.getTableSpec().map(({ columnId }) => { + const label = datasourceLayer.getOperationForColumnId(columnId)?.label; const isHidden = columnMap[columnId].hidden; return ( diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 07cd8a34b55314..003955c0ce4b92 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -13,7 +13,6 @@ import type { SuggestionRequest, Visualization, VisualizationSuggestion, - Operation, DatasourcePublicAPI, } from '../types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; @@ -219,9 +218,8 @@ export const datatableVisualization: Visualization }); const columns = sortedColumns! - .map((columnId) => ({ columnId, operation: datasource!.getOperationForColumnId(columnId) })) - .filter((o): o is { columnId: string; operation: Operation } => !!o.operation) - .map(({ columnId }) => columnMap[columnId]); + .filter((columnId) => datasource!.getOperationForColumnId(columnId)) + .map((columnId) => columnMap[columnId]); return { type: 'expression', @@ -275,7 +273,7 @@ export const datatableVisualization: Visualization if (column.columnId === event.data.columnId) { return { ...column, - hidden: !!column.hidden, + hidden: !column.hidden, }; } else { return column; From cfc5332f1f7b7d397cdedde10380f330098c2e18 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 2 Feb 2021 18:15:24 +0100 Subject: [PATCH 24/30] fix column config retention --- .../visualization.test.tsx | 26 +++++++++++++++++++ .../datatable_visualization/visualization.tsx | 11 +++++++- 2 files changed, 36 insertions(+), 1 deletion(-) 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 f00e6152893993..59d784e9d5e11a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -107,6 +107,32 @@ describe('Datatable Visualization', () => { expect(suggestions.length).toBeGreaterThan(0); }); + it('should retain width and hidden config from existing state', () => { + const suggestions = datatableVisualization.getSuggestions({ + state: { + layerId: 'first', + columns: [ + { columnId: 'col1', width: 123 }, + { columnId: 'col2', hidden: true }, + ], + }, + table: { + isMultiRow: true, + layerId: 'first', + changeType: 'initial', + columns: [numCol('col1'), strCol('col2'), strCol('col3')], + }, + keptLayerIds: [], + }); + + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions[0].state.columns).toEqual([ + { columnId: 'col1', width: 123 }, + { columnId: 'col2', hidden: true }, + { columnId: 'col3' }, + ]); + }); + it('should not make suggestions when the table is unchanged', () => { const suggestions = datatableVisualization.getSuggestions({ state: { diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 003955c0ce4b92..465df7dede1fbd 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -97,6 +97,12 @@ export const datatableVisualization: Visualization ) { return []; } + const oldColumnSettings: Record = {}; + if (state) { + state.columns.forEach((column) => { + oldColumnSettings[column.columnId] = column; + }); + } const title = table.changeType === 'unchanged' ? i18n.translate('xpack.lens.datatable.suggestionLabel', { @@ -126,7 +132,10 @@ export const datatableVisualization: Visualization score: (Math.min(table.columns.length, 10) / 10) * 0.4, state: { layerId: table.layerId, - columns: table.columns.map((col) => ({ columnId: col.columnId })), + columns: table.columns.map((col) => ({ + ...(oldColumnSettings[col.columnId] || {}), + columnId: col.columnId, + })), }, previewIcon: LensIconChartDatatable, // tables are hidden from suggestion bar, but used for drag & drop and chart switching From 4ebca5f6adb8ebac697ff72a9599d399e25a490e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 3 Feb 2021 14:19:39 +0100 Subject: [PATCH 25/30] move hidden flag to dimension editor --- .../components/dimension_editor.tsx | 57 +++++++++++++++ .../components/toolbar.tsx | 69 ------------------- .../datatable_visualization/visualization.tsx | 8 ++- x-pack/test/functional/apps/lens/table.ts | 4 +- .../test/functional/page_objects/lens_page.ts | 9 ++- 5 files changed, 68 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx delete mode 100644 x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx 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..c881688ddcc25c --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { 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); + + if (!column) { + return null; + } + + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx deleted file mode 100644 index d7d776c6ea132a..00000000000000 --- a/x-pack/plugins/lens/public/datatable_visualization/components/toolbar.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiFlexItem, EuiFlexGroup, EuiSwitch } from '@elastic/eui'; -import { VisualizationToolbarProps } from '../../types'; -import { ToolbarPopover } from '../../shared_components'; -import { DatatableVisualizationState, ColumnState } from '../visualization'; - -export function TableToolbar(props: VisualizationToolbarProps) { - const { state, setState, frame } = props; - const columnMap: Record = {}; - state.columns.forEach((column) => { - columnMap[column.columnId] = column; - }); - const datasourceLayer = frame.datasourceLayers[state.layerId]; - return ( - - - - {datasourceLayer.getTableSpec().map(({ columnId }) => { - const label = datasourceLayer.getOperationForColumnId(columnId)?.label; - const isHidden = columnMap[columnId].hidden; - return ( - - { - e.preventDefault(); - e.stopPropagation(); - const newState = { - ...state, - columns: state.columns.map((currentColumn) => { - if (currentColumn.columnId === columnId) { - return { - ...currentColumn, - hidden: !isHidden, - }; - } else { - return currentColumn; - } - }), - }; - setState(newState); - }} - /> - - ); - })} - - - - ); -} diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index a81d298aadd850..149d517febae7e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -16,7 +16,7 @@ import type { DatasourcePublicAPI, } from '../types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; -import { TableToolbar } from './components/toolbar'; +import { TableDimensionEditor } from './components/dimension_editor'; export interface ColumnState { columnId: string; @@ -165,6 +165,7 @@ export const datatableVisualization: Visualization supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, dataTestSubj: 'lnsDatatable_column', + enableDimensionEditor: true, }, { groupId: 'metrics', @@ -179,6 +180,7 @@ export const datatableVisualization: Visualization filterOperations: (op) => !op.isBucketed, required: true, dataTestSubj: 'lnsDatatable_metrics', + enableDimensionEditor: true, }, ], }; @@ -200,10 +202,10 @@ export const datatableVisualization: Visualization sorting: prevState.sorting?.columnId === columnId ? undefined : prevState.sorting, }; }, - renderToolbar(domElement, props) { + renderDimensionEditor(domElement, props) { render( - + , domElement ); diff --git a/x-pack/test/functional/apps/lens/table.ts b/x-pack/test/functional/apps/lens/table.ts index 84f19c5b0e3f18..7cce25c51c8fb1 100644 --- a/x-pack/test/functional/apps/lens/table.ts +++ b/x-pack/test/functional/apps/lens/table.ts @@ -53,12 +53,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 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('Top values of ip'); + 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('Top values of ip'); + 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'); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index ff97bac36b1c20..94b9afaadad157 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -561,13 +561,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont return buttonEl.click(); }, - async toggleColumnVisibility(label: string) { - const id = `lnsColumns-toggle-${label.replace(/ /g, '-')}`; - await testSubjects.click('lnsColumnsButton'); - await testSubjects.existOrFail(id); + 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 testSubjects.click('lnsColumnsButton'); + await this.closeDimensionEditor(); await PageObjects.header.waitUntilLoadingHasFinished(); }, From b2ba6b9b644ad83cf73a245d740128e56460337f Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 3 Feb 2021 14:41:40 +0100 Subject: [PATCH 26/30] fix i18n --- .../datatable_visualization/components/dimension_editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c881688ddcc25c..f37dd9ac17b56c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -30,7 +30,7 @@ export function TableDimensionEditor(