From e3dcee1c805ff9eb91d2900fcf254ab78e7bb5fb Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 4 Jan 2021 15:29:02 +0100 Subject: [PATCH 01/18] 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 7d95d73143870..4b7880b0bf91c 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 4d1df5b519ba9..198181356c569 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 42d2ff6a220c0..cf23d56adb915 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 e4f787a265186..71eb1a44f793c 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 a5e17a05cf71d..a556860904c03 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/18] 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 198181356c569..81f6af396cf78 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/18] 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 81f6af396cf78..9f673782c9b62 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/18] 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 23460d442cfa8..ba79c64f42760 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 d0811e0ad05a6..a82743810e614 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 9f673782c9b62..f069b428ed1bf 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 088246ccf4b9c..675f696ef8ffb 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/18] :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 0000000000000..a77d0b69b6254 --- /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 0000000000000..47071bff886f0 --- /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 0000000000000..4779d42859a79 --- /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 0000000000000..e712ed32af461 --- /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 0000000000000..5d77c86e48930 --- /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 0000000000000..9f453dc9ecc01 --- /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 a82743810e614..8bd035b9f8dac 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 f069b428ed1bf..0165fb5861aaa 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 71eb1a44f793c..2e7a784eb65c4 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 f8f3c67df8b09..19483d27ff33f 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/18] :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 ba79c64f42760..4a312546dc7ee 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 0000000000000..dbf9b4772dec7 --- /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 8bd035b9f8dac..95d10d32a3b99 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/18] :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 47071bff886f0..81937d2ae886b 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 e712ed32af461..6671819f2fa2f 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 dbf9b4772dec7..41bd62ad464f0 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 5d77c86e48930..82c694f120d12 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/18] :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 95d10d32a3b99..60d9461a5e0d9 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/18] :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 81937d2ae886b..4076e990c15ed 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 82c694f120d12..748de6bdb82cb 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/18] :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 748de6bdb82cb..44076612c9660 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/18] :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 8d0d17962ea93..6cd818f40ec4a 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 426bbb8567cca..5a4185f83656e 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/18] :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 4a312546dc7ee..90cdaf526dec0 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/18] :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 f2d91c2ae577f..24b8d93c18e82 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 ab6b4f64781fc..38b0e4844d49c 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/18] :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 90cdaf526dec0..f7f442ef4c0a5 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 4076e990c15ed..83a8d026f1315 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 0000000000000..dad9aa30b7712 --- /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 6671819f2fa2f..38534482b81fa 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 41bd62ad464f0..df5dba749a60c 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 44076612c9660..a6845255b0109 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 9f453dc9ecc01..4f1a1141fdaa8 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 675f696ef8ffb..f067093891d29 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 2e7a784eb65c4..3df9e8a5145bc 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/18] :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 4b7880b0bf91c..4ebe91ff18c67 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 a6845255b0109..171074d6e6797 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/18] :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 f7f442ef4c0a5..a4eb99a972b9b 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: Mon, 25 Jan 2021 12:35:03 +0100 Subject: [PATCH 17/18] :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 4ebe91ff18c67..a353275ea1a6d 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 18/18] :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 a77d0b69b6254..a8328f5eefdca 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 ( -