diff --git a/packages/kbn-unified-data-table/README.md b/packages/kbn-unified-data-table/README.md index 0049949f74490..0dd94c7c0977d 100644 --- a/packages/kbn-unified-data-table/README.md +++ b/packages/kbn-unified-data-table/README.md @@ -13,7 +13,7 @@ Props description: | **dataView** | DataView | The used data view. | | **loadingState** | DataLoadingState | Determines if data is currently loaded. | | **onFilter** | DocViewFilterFn | Function to add a filter in the grid cell or document flyout. | -| **onResize** | (optional)(colSettings: { columnId: string; width: number }) => void; | Function triggered when a column is resized by the user. | +| **onResize** | (optional)(colSettings: { columnId: string; width: number | undefind }) => void; | Function triggered when a column is resized by the user, passes `undefined` for auto-width. | | **onSetColumns** | (columns: string[], hideTimeColumn: boolean) => void; | Function to set all columns. | | **onSort** | (optional)(sort: string[][]) => void; | Function to change sorting of the documents, skipped when isSortEnabled is set to false. | | **rows** | (optional)DataTableRecord[] | Array of documents provided by Elasticsearch. | @@ -81,7 +81,7 @@ Usage example: onFilter={() => { // Add logic to refetch the data when the filter by field was added/removed. Refetch data. }} - onResize={(colSettings: { columnId: string; width: number }) => { + onResize={(colSettings: { columnId: string; width: number | undefined }) => { // Update the table state with the new width for the column }} onSetColumns={(columns: string[], hideTimeColumn: boolean) => { diff --git a/packages/kbn-unified-data-table/index.ts b/packages/kbn-unified-data-table/index.ts index 9c62ef1a28abe..a41845e30f5f9 100644 --- a/packages/kbn-unified-data-table/index.ts +++ b/packages/kbn-unified-data-table/index.ts @@ -24,7 +24,7 @@ export * as columnActions from './src/components/actions/columns'; export { getRowsPerPageOptions } from './src/utils/rows_per_page'; export { popularizeField } from './src/utils/popularize_field'; -export { useColumns } from './src/hooks/use_data_grid_columns'; +export { useColumns, type UseColumnsProps } from './src/hooks/use_data_grid_columns'; export { OPEN_DETAILS, SELECT_ROW } from './src/components/data_table_columns'; // TODO: deprecate? export { DataTableRowControl } from './src/components/data_table_row_control'; diff --git a/packages/kbn-unified-data-table/src/components/actions/columns.test.ts b/packages/kbn-unified-data-table/src/components/actions/columns.test.ts index d8480cf2067b4..21b2e0b0bb355 100644 --- a/packages/kbn-unified-data-table/src/components/actions/columns.test.ts +++ b/packages/kbn-unified-data-table/src/components/actions/columns.test.ts @@ -10,9 +10,10 @@ import { getStateColumnActions } from './columns'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { Capabilities } from '@kbn/core/types'; import { dataViewsMock } from '../../../__mocks__/data_views'; +import { UnifiedDataTableSettings } from '../../types'; function getStateColumnAction( - state: { columns?: string[]; sort?: string[][] }, + state: { columns?: string[]; sort?: string[][]; settings?: UnifiedDataTableSettings }, setAppState: (state: { columns: string[]; sort?: string[][] }) => void ) { return getStateColumnActions({ @@ -28,6 +29,7 @@ function getStateColumnAction( columns: state.columns, sort: state.sort, defaultOrder: 'desc', + settings: state.settings, }); } @@ -41,6 +43,7 @@ describe('Test column actions', () => { actions.onAddColumn('test'); expect(setAppState).toHaveBeenCalledWith({ columns: ['test'] }); }); + test('getStateColumnActions with columns and sort in state', () => { const setAppState = jest.fn(); const actions = getStateColumnAction( @@ -77,4 +80,95 @@ describe('Test column actions', () => { columns: ['second', 'first'], }); }); + + it('should pass settings to setAppState', () => { + const setAppState = jest.fn(); + const settings: UnifiedDataTableSettings = { columns: { first: { width: 100 } } }; + const actions = getStateColumnAction({ columns: ['first'], settings }, setAppState); + actions.onAddColumn('second'); + expect(setAppState).toHaveBeenCalledWith({ columns: ['first', 'second'], settings }); + setAppState.mockClear(); + actions.onRemoveColumn('second'); + expect(setAppState).toHaveBeenCalledWith({ columns: ['first'], settings, sort: [] }); + setAppState.mockClear(); + actions.onMoveColumn('first', 0); + expect(setAppState).toHaveBeenCalledWith({ columns: ['first'], settings }); + setAppState.mockClear(); + actions.onSetColumns(['first', 'second'], true); + expect(setAppState).toHaveBeenCalledWith({ columns: ['first', 'second'], settings }); + setAppState.mockClear(); + }); + + it('should clean up settings to remove non-existing columns', () => { + const setAppState = jest.fn(); + const actions = getStateColumnAction( + { + columns: ['first', 'second', 'third'], + settings: { columns: { first: { width: 100 }, second: { width: 200 } } }, + }, + setAppState + ); + actions.onRemoveColumn('second'); + expect(setAppState).toHaveBeenCalledWith({ + columns: ['first', 'third'], + settings: { columns: { first: { width: 100 } } }, + sort: [], + }); + setAppState.mockClear(); + actions.onSetColumns(['first', 'third'], true); + expect(setAppState).toHaveBeenCalledWith({ + columns: ['first', 'third'], + settings: { columns: { first: { width: 100 } } }, + }); + }); + + it('should reset the last column to auto width if only absolute width columns remain', () => { + const setAppState = jest.fn(); + let actions = getStateColumnAction( + { + columns: ['first', 'second', 'third'], + settings: { columns: { second: { width: 100 }, third: { width: 100, display: 'test' } } }, + }, + setAppState + ); + actions.onRemoveColumn('first'); + expect(setAppState).toHaveBeenCalledWith({ + columns: ['second', 'third'], + settings: { columns: { second: { width: 100 }, third: { display: 'test' } } }, + sort: [], + }); + setAppState.mockClear(); + actions = getStateColumnAction( + { + columns: ['first', 'second', 'third'], + settings: { columns: { second: { width: 100 }, third: { width: 100 } } }, + }, + setAppState + ); + actions.onSetColumns(['second', 'third'], true); + expect(setAppState).toHaveBeenCalledWith({ + columns: ['second', 'third'], + settings: { columns: { second: { width: 100 } } }, + }); + }); + + it('should not reset the last column to auto width if there are remaining auto width columns', () => { + const setAppState = jest.fn(); + const actions = getStateColumnAction( + { columns: ['first', 'second', 'third'], settings: { columns: { third: { width: 100 } } } }, + setAppState + ); + actions.onRemoveColumn('first'); + expect(setAppState).toHaveBeenCalledWith({ + columns: ['second', 'third'], + settings: { columns: { third: { width: 100 } } }, + sort: [], + }); + setAppState.mockClear(); + actions.onSetColumns(['second', 'third'], true); + expect(setAppState).toHaveBeenCalledWith({ + columns: ['second', 'third'], + settings: { columns: { third: { width: 100 } } }, + }); + }); }); diff --git a/packages/kbn-unified-data-table/src/components/actions/columns.ts b/packages/kbn-unified-data-table/src/components/actions/columns.ts index 3355902ece86e..d69d1fe90a361 100644 --- a/packages/kbn-unified-data-table/src/components/actions/columns.ts +++ b/packages/kbn-unified-data-table/src/components/actions/columns.ts @@ -5,52 +5,13 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { Capabilities } from '@kbn/core/public'; + +import type { Capabilities } from '@kbn/core/public'; import type { DataViewsContract } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; +import { omit } from 'lodash'; import { popularizeField } from '../../utils/popularize_field'; - -/** - * Helper function to provide a fallback to a single _source column if the given array of columns - * is empty, and removes _source if there are more than 1 columns given - * @param columns - * @param useNewFieldsApi should a new fields API be used - */ -function buildColumns(columns: string[], useNewFieldsApi = false) { - if (columns.length > 1 && columns.indexOf('_source') !== -1) { - return columns.filter((col) => col !== '_source'); - } else if (columns.length !== 0) { - return columns; - } - return useNewFieldsApi ? [] : ['_source']; -} - -export function addColumn(columns: string[], columnName: string, useNewFieldsApi?: boolean) { - if (columns.includes(columnName)) { - return columns; - } - return buildColumns([...columns, columnName], useNewFieldsApi); -} - -export function removeColumn(columns: string[], columnName: string, useNewFieldsApi?: boolean) { - if (!columns.includes(columnName)) { - return columns; - } - return buildColumns( - columns.filter((col) => col !== columnName), - useNewFieldsApi - ); -} - -export function moveColumn(columns: string[], columnName: string, newIndex: number) { - if (newIndex < 0 || newIndex >= columns.length || !columns.includes(columnName)) { - return columns; - } - const modifiedColumns = [...columns]; - modifiedColumns.splice(modifiedColumns.indexOf(columnName), 1); // remove at old index - modifiedColumns.splice(newIndex, 0, columnName); // insert before new index - return modifiedColumns; -} +import type { UnifiedDataTableSettings } from '../../types'; export function getStateColumnActions({ capabilities, @@ -61,34 +22,50 @@ export function getStateColumnActions({ columns, sort, defaultOrder, + settings, }: { capabilities: Capabilities; dataView: DataView; dataViews: DataViewsContract; useNewFieldsApi: boolean; - setAppState: (state: { columns: string[]; sort?: string[][] }) => void; + setAppState: (state: { + columns: string[]; + sort?: string[][]; + settings?: UnifiedDataTableSettings; + }) => void; columns?: string[]; sort: string[][] | undefined; defaultOrder: string; + settings?: UnifiedDataTableSettings; }) { function onAddColumn(columnName: string) { popularizeField(dataView, columnName, dataViews, capabilities); const nextColumns = addColumn(columns || [], columnName, useNewFieldsApi); const nextSort = columnName === '_score' && !sort?.length ? [['_score', defaultOrder]] : sort; - setAppState({ columns: nextColumns, sort: nextSort }); + setAppState({ columns: nextColumns, sort: nextSort, settings }); } function onRemoveColumn(columnName: string) { popularizeField(dataView, columnName, dataViews, capabilities); + const nextColumns = removeColumn(columns || [], columnName, useNewFieldsApi); // The state's sort property is an array of [sortByColumn,sortDirection] const nextSort = sort && sort.length ? sort.filter((subArr) => subArr[0] !== columnName) : []; - setAppState({ columns: nextColumns, sort: nextSort }); + + let nextSettings = cleanColumnSettings(nextColumns, settings); + + // When columns are removed, reset the last column to auto width if only absolute + // width columns remain, to ensure the columns fill the available grid space + if (nextColumns.length < (columns?.length ?? 0)) { + nextSettings = adjustLastColumnWidth(nextColumns, nextSettings); + } + + setAppState({ columns: nextColumns, sort: nextSort, settings: nextSettings }); } function onMoveColumn(columnName: string, newIndex: number) { const nextColumns = moveColumn(columns || [], columnName, newIndex); - setAppState({ columns: nextColumns }); + setAppState({ columns: nextColumns, settings }); } function onSetColumns(nextColumns: string[], hideTimeColumn: boolean) { @@ -98,7 +75,15 @@ export function getStateColumnActions({ ? (nextColumns || []).slice(1) : nextColumns; - setAppState({ columns: actualColumns }); + let nextSettings = cleanColumnSettings(nextColumns, settings); + + // When columns are removed, reset the last column to auto width if only absolute + // width columns remain, to ensure the columns fill the available grid space + if (actualColumns.length < (columns?.length ?? 0)) { + nextSettings = adjustLastColumnWidth(actualColumns, nextSettings); + } + + setAppState({ columns: actualColumns, settings: nextSettings }); } return { onAddColumn, @@ -107,3 +92,94 @@ export function getStateColumnActions({ onSetColumns, }; } + +/** + * Helper function to provide a fallback to a single _source column if the given array of columns + * is empty, and removes _source if there are more than 1 columns given + * @param columns + * @param useNewFieldsApi should a new fields API be used + */ +function buildColumns(columns: string[], useNewFieldsApi = false) { + if (columns.length > 1 && columns.indexOf('_source') !== -1) { + return columns.filter((col) => col !== '_source'); + } else if (columns.length !== 0) { + return columns; + } + return useNewFieldsApi ? [] : ['_source']; +} + +function addColumn(columns: string[], columnName: string, useNewFieldsApi?: boolean) { + if (columns.includes(columnName)) { + return columns; + } + return buildColumns([...columns, columnName], useNewFieldsApi); +} + +function removeColumn(columns: string[], columnName: string, useNewFieldsApi?: boolean) { + if (!columns.includes(columnName)) { + return columns; + } + return buildColumns( + columns.filter((col) => col !== columnName), + useNewFieldsApi + ); +} + +function moveColumn(columns: string[], columnName: string, newIndex: number) { + if (newIndex < 0 || newIndex >= columns.length || !columns.includes(columnName)) { + return columns; + } + const modifiedColumns = [...columns]; + modifiedColumns.splice(modifiedColumns.indexOf(columnName), 1); // remove at old index + modifiedColumns.splice(newIndex, 0, columnName); // insert before new index + return modifiedColumns; +} + +function cleanColumnSettings( + columns: string[], + settings?: UnifiedDataTableSettings +): UnifiedDataTableSettings | undefined { + const columnSettings = settings?.columns; + + if (!columnSettings) { + return settings; + } + + const nextColumnSettings = columns.reduce>( + (acc, column) => (columnSettings[column] ? { ...acc, [column]: columnSettings[column] } : acc), + {} + ); + + return { ...settings, columns: nextColumnSettings }; +} + +function adjustLastColumnWidth( + columns: string[], + settings?: UnifiedDataTableSettings +): UnifiedDataTableSettings | undefined { + const columnSettings = settings?.columns; + + if (!columns.length || !columnSettings) { + return settings; + } + + const hasAutoWidthColumn = columns.some((colId) => columnSettings[colId]?.width == null); + + if (hasAutoWidthColumn) { + return settings; + } + + const lastColumn = columns[columns.length - 1]; + const lastColumnSettings = omit(columnSettings[lastColumn] ?? {}, 'width'); + const lastColumnSettingsOptional = Object.keys(lastColumnSettings).length + ? { [lastColumn]: lastColumnSettings } + : undefined; + + return { + ...settings, + columns: { + ...omit(columnSettings, lastColumn), + ...lastColumnSettingsOptional, + }, + }; +} diff --git a/packages/kbn-unified-data-table/src/components/data_table.test.tsx b/packages/kbn-unified-data-table/src/components/data_table.test.tsx index 2538d3df38778..f78405f12be82 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.test.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.test.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { ReactWrapper } from 'enzyme'; import { EuiButton, @@ -33,6 +33,10 @@ import { DatatableColumnType } from '@kbn/expressions-plugin/common'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CELL_CLASS } from '../utils/get_render_cell_value'; +import { defaultTimeColumnWidth } from '../constants'; +import { useColumns } from '../hooks/use_data_grid_columns'; +import { capabilitiesServiceMock } from '@kbn/core-capabilities-browser-mocks'; +import { dataViewsMock } from '../../__mocks__/data_views'; const mockUseDataGridColumnsCellActions = jest.fn((prop: unknown) => []); jest.mock('@kbn/cell-actions', () => ({ @@ -90,6 +94,56 @@ const DataTable = (props: Partial) => ( ); +const capabilities = capabilitiesServiceMock.createStartContract().capabilities; + +const renderDataTable = (props: Partial) => { + const DataTableWrapped = () => { + const [columns, setColumns] = useState(props.columns ?? []); + const [settings, setSettings] = useState(props.settings); + + const { onSetColumns } = useColumns({ + capabilities, + dataView: dataViewMock, + dataViews: dataViewsMock, + setAppState: useCallback((state) => { + if (state.columns) { + setColumns(state.columns); + } + if (state.settings) { + setSettings(state.settings); + } + }, []), + useNewFieldsApi: true, + columns, + settings, + }); + + return ( + + { + setSettings({ + ...settings, + columns: { + ...settings?.columns, + [columnId]: { + width, + }, + }, + }); + }} + /> + + ); + }; + + render(); +}; + async function getComponent(props: UnifiedDataTableProps = getProps()) { const component = mountWithIntl(); await act(async () => { @@ -265,34 +319,19 @@ describe('UnifiedDataTable', () => { describe('edit field button', () => { it('should render the edit field button if onFieldEdited is provided', async () => { - const component = await getComponent({ - ...getProps(), - columns: ['message'], - onFieldEdited: jest.fn(), - }); - expect(findTestSubject(component, 'dataGridHeaderCellActionGroup-message').exists()).toBe( - false - ); - findTestSubject(component, 'dataGridHeaderCell-message').find('button').simulate('click'); - expect(findTestSubject(component, 'dataGridHeaderCellActionGroup-message').exists()).toBe( - true - ); - expect(findTestSubject(component, 'gridEditFieldButton').exists()).toBe(true); + renderDataTable({ columns: ['message'], onFieldEdited: jest.fn() }); + expect(screen.queryByTestId('dataGridHeaderCellActionGroup-message')).not.toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'message' })); + expect(screen.getByTestId('dataGridHeaderCellActionGroup-message')).toBeInTheDocument(); + expect(screen.getByTestId('gridEditFieldButton')).toBeInTheDocument(); }); it('should not render the edit field button if onFieldEdited is not provided', async () => { - const component = await getComponent({ - ...getProps(), - columns: ['message'], - }); - expect(findTestSubject(component, 'dataGridHeaderCellActionGroup-message').exists()).toBe( - false - ); - findTestSubject(component, 'dataGridHeaderCell-message').find('button').simulate('click'); - expect(findTestSubject(component, 'dataGridHeaderCellActionGroup-message').exists()).toBe( - true - ); - expect(findTestSubject(component, 'gridEditFieldButton').exists()).toBe(false); + renderDataTable({ columns: ['message'] }); + expect(screen.queryByTestId('dataGridHeaderCellActionGroup-message')).not.toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: 'message' })); + expect(screen.getByTestId('dataGridHeaderCellActionGroup-message')).toBeInTheDocument(); + expect(screen.queryByTestId('gridEditFieldButton')).not.toBeInTheDocument(); }); }); @@ -793,14 +832,6 @@ describe('UnifiedDataTable', () => { }); describe('document comparison', () => { - const renderDataTable = (props: Partial) => { - render( - - - - ); - }; - const getSelectedDocumentsButton = () => screen.queryByTestId('unifiedDataTableSelectionBtn'); const selectDocument = (document: EsHitRecord) => @@ -920,4 +951,75 @@ describe('UnifiedDataTable', () => { expect(findTestSubject(component, 'dataGridHeaderCell-colorIndicator').exists()).toBeFalsy(); }); }); + + describe('columns', () => { + // Default column width in EUI is hardcoded to 100px for Jest envs + const EUI_DEFAULT_COLUMN_WIDTH = '100px'; + const getColumnHeader = (name: string) => screen.getByRole('columnheader', { name }); + const queryColumnHeader = (name: string) => screen.queryByRole('columnheader', { name }); + const getButton = (name: string) => screen.getByRole('button', { name }); + const queryButton = (name: string) => screen.queryByRole('button', { name }); + + it('should reset the last column to auto width if only absolute width columns remain', async () => { + renderDataTable({ + columns: ['message', 'extension', 'bytes'], + settings: { + columns: { + extension: { width: 50 }, + bytes: { width: 50 }, + }, + }, + }); + expect(getColumnHeader('message')).toHaveStyle({ width: EUI_DEFAULT_COLUMN_WIDTH }); + expect(getColumnHeader('extension')).toHaveStyle({ width: '50px' }); + expect(getColumnHeader('bytes')).toHaveStyle({ width: '50px' }); + userEvent.click(getButton('message')); + userEvent.click(getButton('Remove column'), undefined, { skipPointerEventsCheck: true }); + expect(queryColumnHeader('message')).not.toBeInTheDocument(); + expect(getColumnHeader('extension')).toHaveStyle({ width: '50px' }); + expect(getColumnHeader('bytes')).toHaveStyle({ width: EUI_DEFAULT_COLUMN_WIDTH }); + }); + + it('should not reset the last column to auto width when there are remaining auto width columns', async () => { + renderDataTable({ + columns: ['message', 'extension', 'bytes'], + settings: { + columns: { + bytes: { width: 50 }, + }, + }, + }); + expect(getColumnHeader('message')).toHaveStyle({ width: EUI_DEFAULT_COLUMN_WIDTH }); + expect(getColumnHeader('extension')).toHaveStyle({ width: EUI_DEFAULT_COLUMN_WIDTH }); + expect(getColumnHeader('bytes')).toHaveStyle({ width: '50px' }); + userEvent.click(getButton('message')); + userEvent.click(getButton('Remove column'), undefined, { skipPointerEventsCheck: true }); + expect(queryColumnHeader('message')).not.toBeInTheDocument(); + expect(getColumnHeader('extension')).toHaveStyle({ width: EUI_DEFAULT_COLUMN_WIDTH }); + expect(getColumnHeader('bytes')).toHaveStyle({ width: '50px' }); + }); + + it('should show the reset width button only for absolute width columns, and allow resetting to default width', async () => { + renderDataTable({ + columns: ['message', 'extension'], + settings: { + columns: { + '@timestamp': { width: 50 }, + extension: { width: 50 }, + }, + }, + }); + expect(getColumnHeader('@timestamp')).toHaveStyle({ width: '50px' }); + userEvent.click(getButton('@timestamp')); + userEvent.click(getButton('Reset width'), undefined, { skipPointerEventsCheck: true }); + expect(getColumnHeader('@timestamp')).toHaveStyle({ width: `${defaultTimeColumnWidth}px` }); + expect(getColumnHeader('message')).toHaveStyle({ width: EUI_DEFAULT_COLUMN_WIDTH }); + userEvent.click(getButton('message')); + expect(queryButton('Reset width')).not.toBeInTheDocument(); + expect(getColumnHeader('extension')).toHaveStyle({ width: '50px' }); + userEvent.click(getButton('extension')); + userEvent.click(getButton('Reset width'), undefined, { skipPointerEventsCheck: true }); + expect(getColumnHeader('extension')).toHaveStyle({ width: EUI_DEFAULT_COLUMN_WIDTH }); + }); + }); }); diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index 778c41c6f5365..96c5004c61a95 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -164,9 +164,9 @@ export interface UnifiedDataTableProps { */ onFilter?: DocViewFilterFn; /** - * Function triggered when a column is resized by the user + * Function triggered when a column is resized by the user, passes `undefined` for auto-width */ - onResize?: (colSettings: { columnId: string; width: number }) => void; + onResize?: (colSettings: { columnId: string; width: number | undefined }) => void; /** * Function to set all columns */ @@ -810,6 +810,7 @@ export const UnifiedDataTable = ({ showColumnTokens, headerRowHeightLines, customGridColumnsConfiguration, + onResize, }), [ columnsMeta, @@ -824,6 +825,7 @@ export const UnifiedDataTable = ({ isPlainRecord, isSortEnabled, onFilter, + onResize, settings, showColumnTokens, toastNotifications, diff --git a/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx b/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx index 6f438567dd94c..99a9e4767c6d1 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx @@ -50,6 +50,7 @@ describe('Data table columns', function () { hasEditDataViewPermission: () => servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, + onResize: () => {}, }); expect(actual).toMatchSnapshot(); }); @@ -72,6 +73,7 @@ describe('Data table columns', function () { hasEditDataViewPermission: () => servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, + onResize: () => {}, }); expect(actual).toMatchSnapshot(); }); @@ -99,6 +101,7 @@ describe('Data table columns', function () { message: { type: 'string', esType: 'keyword' }, timestamp: { type: 'date', esType: 'dateTime' }, }, + onResize: () => {}, }); expect(actual).toMatchSnapshot(); }); @@ -297,6 +300,7 @@ describe('Data table columns', function () { hasEditDataViewPermission: () => servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, + onResize: () => {}, }); expect(actual).toMatchSnapshot(); }); @@ -324,6 +328,7 @@ describe('Data table columns', function () { hasEditDataViewPermission: () => servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, + onResize: () => {}, }); expect(actual).toMatchSnapshot(); }); @@ -356,6 +361,7 @@ describe('Data table columns', function () { columnsMeta: { extension: { type: 'string' }, }, + onResize: () => {}, }); expect(gridColumns[1].schema).toBe('string'); }); @@ -386,6 +392,7 @@ describe('Data table columns', function () { columnsMeta: { var_test: { type: 'number' }, }, + onResize: () => {}, }); expect(gridColumns[1].schema).toBe('numeric'); }); @@ -412,6 +419,7 @@ describe('Data table columns', function () { extension: { type: 'string' }, message: { type: 'string', esType: 'keyword' }, }, + onResize: () => {}, }); const extensionGridColumn = gridColumns[0]; @@ -442,6 +450,7 @@ describe('Data table columns', function () { extension: { type: 'string' }, message: { type: 'string', esType: 'keyword' }, }, + onResize: () => {}, }); expect(customizedGridColumns).toMatchSnapshot(); @@ -484,6 +493,7 @@ describe('Data table columns', function () { }, hasEditDataViewPermission: () => servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), + onResize: () => {}, }); const columnDisplayNames = customizedGridColumns.map((column) => column.displayAsText); expect(columnDisplayNames.includes('test_column_one')).toBeTruthy(); diff --git a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx index f345c1093d9d1..cb90e18054891 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx @@ -12,6 +12,7 @@ import { type EuiDataGridColumn, type EuiDataGridColumnCellAction, EuiScreenReaderOnly, + EuiListGroupItemProps, } from '@elastic/eui'; import { type DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { ToastsStart, IUiSettingsClient } from '@kbn/core/public'; @@ -30,6 +31,7 @@ import { import { buildCopyColumnNameButton, buildCopyColumnValuesButton } from './build_copy_column_button'; import { buildEditFieldButton } from './build_edit_field_button'; import { DataTableColumnHeader, DataTableTimeColumnHeader } from './data_table_column_header'; +import { UnifiedDataTableProps } from './data_table'; export const getColumnDisplayName = ( columnName: string, @@ -105,6 +107,7 @@ function buildEuiGridColumn({ headerRowHeight, customGridColumnsConfiguration, columnDisplay, + onResize, }: { numberOfColumns: number; columnName: string; @@ -126,6 +129,7 @@ function buildEuiGridColumn({ headerRowHeight?: number; customGridColumnsConfiguration?: CustomGridColumnsConfiguration; columnDisplay?: string; + onResize: UnifiedDataTableProps['onResize']; }) { const dataViewField = !isPlainRecord ? dataView.getFieldByName(columnName) @@ -142,6 +146,26 @@ function buildEuiGridColumn({ editField && dataViewField && buildEditFieldButton({ hasEditDataViewPermission, dataView, field: dataViewField, editField }); + const resetWidthButton: EuiListGroupItemProps | undefined = + onResize && columnWidth > 0 + ? { + // @ts-expect-error + // We need to force a key here because EuiListGroup uses the array index as a key by default, + // which causes re-render issues with conditional items like this one, and can result in + // incorrect attributes (e.g. title) on the HTML element as well as test failures + key: 'reset-width', + label: i18n.translate('unifiedDataTable.grid.resetColumnWidthButton', { + defaultMessage: 'Reset width', + }), + iconType: 'refresh', + size: 'xs', + iconProps: { size: 'm' }, + onClick: () => { + onResize({ columnId: columnName, width: undefined }); + }, + 'data-test-subj': 'unifiedDataTableResetColumnWidth', + } + : undefined; const columnDisplayName = getColumnDisplayName( columnName, @@ -193,6 +217,7 @@ function buildEuiGridColumn({ showMoveLeft: !defaultColumns, showMoveRight: !defaultColumns, additional: [ + ...(resetWidthButton ? [resetWidthButton] : []), ...(columnName === '__source' ? [] : [ @@ -268,6 +293,7 @@ export function getEuiGridColumns({ showColumnTokens, headerRowHeightLines, customGridColumnsConfiguration, + onResize, }: { columns: string[]; columnsCellActions?: EuiDataGridColumnCellAction[][]; @@ -290,6 +316,7 @@ export function getEuiGridColumns({ showColumnTokens?: boolean; headerRowHeightLines: number; customGridColumnsConfiguration?: CustomGridColumnsConfiguration; + onResize: UnifiedDataTableProps['onResize']; }) { const getColWidth = (column: string) => settings?.columns?.[column]?.width ?? 0; const headerRowHeight = deserializeHeaderRowHeight(headerRowHeightLines); @@ -317,6 +344,7 @@ export function getEuiGridColumns({ headerRowHeight, customGridColumnsConfiguration, columnDisplay: settings?.columns?.[column]?.display, + onResize, }) ); } diff --git a/packages/kbn-unified-data-table/src/hooks/use_data_grid_columns.ts b/packages/kbn-unified-data-table/src/hooks/use_data_grid_columns.ts index 088f7b0491c69..4346d23a6584f 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_data_grid_columns.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_data_grid_columns.ts @@ -12,16 +12,22 @@ import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public' import { Capabilities } from '@kbn/core/public'; import { isEqual } from 'lodash'; import { getStateColumnActions } from '../components/actions/columns'; +import { UnifiedDataTableSettings } from '../types'; -interface UseColumnsProps { +export interface UseColumnsProps { capabilities: Capabilities; dataView: DataView; dataViews: DataViewsContract; useNewFieldsApi: boolean; - setAppState: (state: { columns: string[]; sort?: string[][] }) => void; + setAppState: (state: { + columns: string[]; + sort?: string[][]; + settings?: UnifiedDataTableSettings; + }) => void; columns?: string[]; sort?: string[][]; defaultOrder?: string; + settings?: UnifiedDataTableSettings; } export const useColumns = ({ @@ -33,6 +39,7 @@ export const useColumns = ({ columns, sort, defaultOrder = 'desc', + settings, }: UseColumnsProps) => { const [usedColumns, setUsedColumns] = useState(getColumns(columns, useNewFieldsApi)); useEffect(() => { @@ -53,6 +60,7 @@ export const useColumns = ({ columns: usedColumns, sort, defaultOrder, + settings, }), [ capabilities, @@ -60,6 +68,7 @@ export const useColumns = ({ dataViews, defaultOrder, setAppState, + settings, sort, useNewFieldsApi, usedColumns, diff --git a/packages/kbn-unified-data-table/tsconfig.json b/packages/kbn-unified-data-table/tsconfig.json index 0f55e72d44e0a..ca3372bd40f30 100644 --- a/packages/kbn-unified-data-table/tsconfig.json +++ b/packages/kbn-unified-data-table/tsconfig.json @@ -38,5 +38,6 @@ "@kbn/shared-ux-utility", "@kbn/unified-field-list", "@kbn/core-notifications-browser", + "@kbn/core-capabilities-browser-mocks", ] } diff --git a/src/plugins/discover/public/application/context/context_app.tsx b/src/plugins/discover/public/application/context/context_app.tsx index dc22a77fecede..6f750df367c09 100644 --- a/src/plugins/discover/public/application/context/context_app.tsx +++ b/src/plugins/discover/public/application/context/context_app.tsx @@ -23,8 +23,9 @@ import { SEARCH_FIELDS_FROM_SOURCE, SORT_DEFAULT_ORDER_SETTING, } from '@kbn/discover-utils'; -import { popularizeField, useColumns } from '@kbn/unified-data-table'; +import { UseColumnsProps, popularizeField, useColumns } from '@kbn/unified-data-table'; import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; import { ContextErrorMessage } from './components/context_error_message'; import { LoadingStatus } from './services/context_query_state'; import { AppState, GlobalState, isEqualFilters } from './services/context_state'; @@ -69,15 +70,23 @@ export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) => const prevAppState = useRef(); const prevGlobalState = useRef({ filters: [] }); + const setAppState = useCallback( + ({ settings, ...rest }) => { + stateContainer.setAppState({ ...rest, grid: settings as DiscoverGridSettings }); + }, + [stateContainer] + ); + const { columns, onAddColumn, onRemoveColumn, onSetColumns } = useColumns({ capabilities, defaultOrder: uiSettings.get(SORT_DEFAULT_ORDER_SETTING), dataView, dataViews, useNewFieldsApi, - setAppState: stateContainer.setAppState, + setAppState, columns: appState.columns, sort: appState.sort, + settings: appState.grid, }); useEffect(() => { @@ -260,6 +269,7 @@ export const ContextApp = ({ dataView, anchorId, referrer }: ContextAppProps) => useNewFieldsApi={useNewFieldsApi} isLegacy={isLegacy} columns={columns} + grid={appState.grid} onAddColumn={onAddColumn} onRemoveColumn={onRemoveColumn} onSetColumns={onSetColumns} diff --git a/src/plugins/discover/public/application/context/context_app_content.tsx b/src/plugins/discover/public/application/context/context_app_content.tsx index 6c82a9408d003..4d6d815fd796b 100644 --- a/src/plugins/discover/public/application/context/context_app_content.tsx +++ b/src/plugins/discover/public/application/context/context_app_content.tsx @@ -27,7 +27,7 @@ import { ROW_HEIGHT_OPTION, SHOW_MULTIFIELDS, } from '@kbn/discover-utils'; -import { DataLoadingState } from '@kbn/unified-data-table'; +import { DataLoadingState, UnifiedDataTableProps } from '@kbn/unified-data-table'; import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; import { DiscoverGrid } from '../../components/discover_grid'; import { getDefaultRowsPerPage } from '../../../common/constants'; @@ -43,6 +43,7 @@ import { onResizeGridColumn } from '../../utils/on_resize_grid_column'; export interface ContextAppContentProps { columns: string[]; + grid?: DiscoverGridSettings; onAddColumn: (columnsName: string) => void; onRemoveColumn: (columnsName: string) => void; onSetColumns: (columnsNames: string[], hideTimeColumn: boolean) => void; @@ -74,6 +75,7 @@ const ActionBarMemoized = React.memo(ActionBar); export function ContextAppContent({ columns, + grid, onAddColumn, onRemoveColumn, onSetColumns, @@ -94,7 +96,6 @@ export function ContextAppContent({ }: ContextAppContentProps) { const { uiSettings: config, uiActions } = useDiscoverServices(); const services = useDiscoverServices(); - const [gridSettings, setGridSettings] = useState(); const [expandedDoc, setExpandedDoc] = useState(); const isAnchorLoading = @@ -151,13 +152,11 @@ export function ContextAppContent({ [addFilter, dataView, onAddColumn, onRemoveColumn] ); - const onResize = useCallback( + const onResize = useCallback>( (colSettings) => { - setGridSettings((currentGridSettings) => - onResizeGridColumn(colSettings, currentGridSettings) - ); + setAppState({ grid: onResizeGridColumn(colSettings, grid) }); }, - [setGridSettings] + [grid, setAppState] ); return ( @@ -221,7 +220,7 @@ export function ContextAppContent({ renderDocumentView={renderDocumentView} services={services} configHeaderRowHeight={3} - settings={gridSettings} + settings={grid} onResize={onResize} /> diff --git a/src/plugins/discover/public/application/context/services/context_state.ts b/src/plugins/discover/public/application/context/services/context_state.ts index 8b41152496ec9..e67fbc7575a1e 100644 --- a/src/plugins/discover/public/application/context/services/context_state.ts +++ b/src/plugins/discover/public/application/context/services/context_state.ts @@ -20,6 +20,7 @@ import { import { connectToQueryState, DataPublicPluginStart, FilterManager } from '@kbn/data-plugin/public'; import { DataView } from '@kbn/data-views-plugin/common'; +import { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; import { getValidFilters } from '../../../utils/get_valid_filters'; import { handleSourceColumnState } from '../../../utils/state_helpers'; @@ -32,6 +33,10 @@ export interface AppState { * Array of filters */ filters: Filter[]; + /** + * Data Grid related state + */ + grid?: DiscoverGridSettings; /** * Number of records to be fetched before anchor records (newer records) */ diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index ea8b8ead62455..71193e904a794 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -28,6 +28,8 @@ import { getTextBasedColumnsMeta, getRenderCustomToolbarWithElements, type DataGridDensity, + UnifiedDataTableProps, + UseColumnsProps, } from '@kbn/unified-data-table'; import { DOC_HIDE_TIME_COLUMN_SETTING, @@ -41,6 +43,7 @@ import { } from '@kbn/discover-utils'; import useObservable from 'react-use/lib/useObservable'; import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; import { DiscoverGrid } from '../../../../components/discover_grid'; import { getDefaultRowsPerPage } from '../../../../../common/constants'; import { useInternalStateSelector } from '../../state_management/discover_internal_state_container'; @@ -86,7 +89,7 @@ const DiscoverGridMemoized = React.memo(DiscoverGrid); // export needs for testing export const onResize = ( - colSettings: { columnId: string; width: number }, + colSettings: { columnId: string; width: number | undefined }, stateContainer: DiscoverStateContainer ) => { const state = stateContainer.appState.getState(); @@ -166,6 +169,13 @@ function DiscoverDocumentsComponent({ stateContainer, }); + const setAppState = useCallback( + ({ settings, ...rest }) => { + stateContainer.appState.update({ ...rest, grid: settings as DiscoverGridSettings }); + }, + [stateContainer] + ); + const { columns: currentColumns, onAddColumn, @@ -177,10 +187,11 @@ function DiscoverDocumentsComponent({ defaultOrder: uiSettings.get(SORT_DEFAULT_ORDER_SETTING), dataView, dataViews, - setAppState: stateContainer.appState.update, + setAppState, useNewFieldsApi, columns, sort, + settings: grid, }); const setExpandedDoc = useCallback( @@ -190,7 +201,7 @@ function DiscoverDocumentsComponent({ [stateContainer] ); - const onResizeDataGrid = useCallback( + const onResizeDataGrid = useCallback>( (colSettings) => onResize(colSettings, stateContainer), [stateContainer] ); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index d4798089e09ee..b92719f9a8bb3 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -30,9 +30,10 @@ import { SHOW_FIELD_STATISTICS, SORT_DEFAULT_ORDER_SETTING, } from '@kbn/discover-utils'; -import { popularizeField, useColumns } from '@kbn/unified-data-table'; +import { UseColumnsProps, popularizeField, useColumns } from '@kbn/unified-data-table'; import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; import { BehaviorSubject } from 'rxjs'; +import { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; import { useSavedSearchInitial } from '../../state_management/discover_state_provider'; import { DiscoverStateContainer } from '../../state_management/discover_state'; import { VIEW_MODE } from '../../../../../common/constants'; @@ -80,11 +81,12 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { const pageBackgroundColor = useEuiBackgroundColor('plain'); const globalQueryState = data.query.getState(); const { main$ } = stateContainer.dataState.data$; - const [query, savedQuery, columns, sort] = useAppStateSelector((state) => [ + const [query, savedQuery, columns, sort, grid] = useAppStateSelector((state) => [ state.query, state.savedQuery, state.columns, state.sort, + state.grid, ]); const isEsqlMode = useIsEsqlMode(); @@ -126,6 +128,13 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { [dataState.fetchStatus, dataState.foundDocuments] ); + const setAppState = useCallback( + ({ settings, ...rest }) => { + stateContainer.appState.update({ ...rest, grid: settings as DiscoverGridSettings }); + }, + [stateContainer] + ); + const { columns: currentColumns, onAddColumn, @@ -135,10 +144,11 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { defaultOrder: uiSettings.get(SORT_DEFAULT_ORDER_SETTING), dataView, dataViews, - setAppState: stateContainer.appState.update, + setAppState, useNewFieldsApi, columns, sort, + settings: grid, }); // The assistant is getting the state from the url correctly diff --git a/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.test.ts b/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.test.ts index e36a753bd4c25..bdf62d909d964 100644 --- a/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.test.ts +++ b/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.test.ts @@ -53,10 +53,13 @@ describe('getDefaultProfileState', () => { }, defaultColumns: ['messsage', 'bytes'], dataView: emptyDataView, - esqlQueryColumns: [{ id: '1', name: 'foo', meta: { type: 'string' } }], + esqlQueryColumns: [ + { id: '1', name: 'foo', meta: { type: 'string' } }, + { id: '2', name: 'bar', meta: { type: 'string' } }, + ], }); expect(appState).toEqual({ - columns: ['foo'], + columns: ['foo', 'bar'], grid: { columns: { foo: { diff --git a/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.ts b/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.ts index b1bc2bc3e3f92..9454bd483637a 100644 --- a/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.ts +++ b/src/plugins/discover/public/application/main/state_management/utils/get_default_profile_state.ts @@ -43,8 +43,13 @@ export const getDefaultProfileState = ({ ); if (validColumns?.length) { + const hasAutoWidthColumn = validColumns.some(({ width }) => !width); const columns = validColumns.reduce( - (acc, { name, width }) => (width ? { ...acc, [name]: { width } } : acc), + (acc, { name, width }, index) => { + // Ensure there's at least one auto width column so the columns fill the grid + const skipColumnWidth = !hasAutoWidthColumn && index === validColumns.length - 1; + return width && !skipColumnWidth ? { ...acc, [name]: { width } } : acc; + }, undefined ); diff --git a/src/plugins/discover/public/context_awareness/__mocks__/index.tsx b/src/plugins/discover/public/context_awareness/__mocks__/index.tsx index 5d55dfa306b8e..873319c0e75ab 100644 --- a/src/plugins/discover/public/context_awareness/__mocks__/index.tsx +++ b/src/plugins/discover/public/context_awareness/__mocks__/index.tsx @@ -63,6 +63,10 @@ export const createContextAwarenessMocks = ({ name: 'foo', width: 300, }, + { + name: 'bar', + width: 400, + }, ], rowHeight: 3, })), diff --git a/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx index 6f6ce57a78fd0..f2ccc2b9b10c0 100644 --- a/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx +++ b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx @@ -13,6 +13,7 @@ import type { DataView } from '@kbn/data-views-plugin/common'; import { DOC_HIDE_TIME_COLUMN_SETTING, SEARCH_FIELDS_FROM_SOURCE, + SORT_DEFAULT_ORDER_SETTING, isLegacyTableEnabled, } from '@kbn/discover-utils'; import { Filter } from '@kbn/es-query'; @@ -22,9 +23,10 @@ import { } from '@kbn/presentation-publishing'; import { SortOrder } from '@kbn/saved-search-plugin/public'; import { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types'; -import { columnActions, DataGridDensity, DataLoadingState } from '@kbn/unified-data-table'; +import { DataGridDensity, DataLoadingState, useColumns } from '@kbn/unified-data-table'; import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; import { DiscoverDocTableEmbeddable } from '../../components/doc_table/create_doc_table_embeddable'; import { useDiscoverServices } from '../../hooks/use_discover_services'; import { getSortForEmbeddable } from '../../utils'; @@ -34,6 +36,7 @@ import { isEsqlMode } from '../initialize_fetch'; import type { SearchEmbeddableApi, SearchEmbeddableStateManager } from '../types'; import { DiscoverGridEmbeddable } from './saved_search_grid'; import { getSearchEmbeddableDefaults } from '../get_search_embeddable_defaults'; +import { onResizeGridColumn } from '../../utils/on_resize_grid_column'; interface SavedSearchEmbeddableComponentProps { api: SearchEmbeddableApi & { fetchWarnings$: BehaviorSubject }; @@ -60,6 +63,7 @@ export function SearchEmbeddableGridComponent({ rows, totalHitCount, columnsMeta, + grid, ] = useBatchedPublishingSubjects( api.dataLoading, api.savedSearch$, @@ -67,7 +71,8 @@ export function SearchEmbeddableGridComponent({ api.fetchWarnings$, stateManager.rows, stateManager.totalHitCount, - stateManager.columnsMeta + stateManager.columnsMeta, + stateManager.grid ); const [panelTitle, panelDescription, savedSearchTitle, savedSearchDescription] = @@ -92,32 +97,37 @@ export function SearchEmbeddableGridComponent({ return getSortForEmbeddable(savedSearch.sort, dataView, discoverServices.uiSettings, isEsql); }, [savedSearch.sort, dataView, isEsql, discoverServices.uiSettings]); + const originalColumns = useMemo(() => savedSearch.columns ?? [], [savedSearch.columns]); + const useNewFieldsApi = !discoverServices.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false); + + const { columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns } = useColumns({ + capabilities: discoverServices.capabilities, + defaultOrder: discoverServices.uiSettings.get(SORT_DEFAULT_ORDER_SETTING), + dataView, + dataViews: discoverServices.dataViews, + setAppState: (params) => { + if (params.columns) { + stateManager.columns.next(params.columns); + } + if (params.sort) { + stateManager.sort.next(params.sort as SortOrder[]); + } + if (params.settings) { + stateManager.grid.next(params.settings as DiscoverGridSettings); + } + }, + useNewFieldsApi, + columns: originalColumns, + sort, + settings: grid, + }); + const onStateEditedProps = useMemo( () => ({ - onAddColumn: (columnName: string) => { - if (!savedSearch.columns) { - return; - } - const updatedColumns = columnActions.addColumn(savedSearch.columns, columnName, true); - stateManager.columns.next(updatedColumns); - }, - onSetColumns: (updatedColumns: string[]) => { - stateManager.columns.next(updatedColumns); - }, - onMoveColumn: (columnName: string, newIndex: number) => { - if (!savedSearch.columns) { - return; - } - const updatedColumns = columnActions.moveColumn(savedSearch.columns, columnName, newIndex); - stateManager.columns.next(updatedColumns); - }, - onRemoveColumn: (columnName: string) => { - if (!savedSearch.columns) { - return; - } - const updatedColumns = columnActions.removeColumn(savedSearch.columns, columnName, true); - stateManager.columns.next(updatedColumns); - }, + onAddColumn, + onSetColumns, + onMoveColumn, + onRemoveColumn, onUpdateRowsPerPage: (newRowsPerPage: number | undefined) => { stateManager.rowsPerPage.next(newRowsPerPage); }, @@ -140,8 +150,24 @@ export function SearchEmbeddableGridComponent({ onUpdateDataGridDensity: (newDensity: DataGridDensity | undefined) => { stateManager.density.next(newDensity); }, + onResize: (newGridSettings: { columnId: string; width: number | undefined }) => { + stateManager.grid.next(onResizeGridColumn(newGridSettings, grid)); + }, }), - [stateManager, savedSearch.columns] + [ + onAddColumn, + onSetColumns, + onMoveColumn, + onRemoveColumn, + stateManager.rowsPerPage, + stateManager.rowHeight, + stateManager.headerRowHeight, + stateManager.sort, + stateManager.sampleSize, + stateManager.density, + stateManager.grid, + grid, + ] ); const fetchedSampleSize = useMemo(() => { @@ -151,7 +177,7 @@ export function SearchEmbeddableGridComponent({ const defaults = getSearchEmbeddableDefaults(discoverServices.uiSettings); const sharedProps = { - columns: savedSearch.columns ?? [], + columns, dataView, interceptedWarnings, onFilter: onAddFilter, @@ -161,7 +187,7 @@ export function SearchEmbeddableGridComponent({ searchDescription: panelDescription || savedSearchDescription, sort, totalHitCount, - useNewFieldsApi: !discoverServices.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE, false), + useNewFieldsApi, }; if (useLegacyTable) { diff --git a/src/plugins/discover/public/embeddable/constants.ts b/src/plugins/discover/public/embeddable/constants.ts index 2574c7abf2a8e..943e1363b5212 100644 --- a/src/plugins/discover/public/embeddable/constants.ts +++ b/src/plugins/discover/public/embeddable/constants.ts @@ -32,6 +32,7 @@ export const EDITABLE_SAVED_SEARCH_KEYS: Readonly sort$.next(value), (a, b) => deepEqual(a, b)], columns: [columns$, (value) => columns$.next(value), (a, b) => deepEqual(a, b)], + grid: [grid$, (value) => grid$.next(value), (a, b) => deepEqual(a, b)], sampleSize: [ sampleSize$, (value) => sampleSize$.next(value), @@ -198,7 +199,6 @@ export const initializeSearchEmbeddableApi = async ( (value) => serializedSearchSource$.next(value), ], viewMode: [savedSearchViewMode$, (value) => savedSearchViewMode$.next(value)], - grid: [grid$, (value) => grid$.next(value)], density: [density$, (value) => density$.next(value)], }, }; diff --git a/src/plugins/discover/public/utils/on_resize_grid_column.ts b/src/plugins/discover/public/utils/on_resize_grid_column.ts index cacbc2085b22f..9df887a3386a7 100644 --- a/src/plugins/discover/public/utils/on_resize_grid_column.ts +++ b/src/plugins/discover/public/utils/on_resize_grid_column.ts @@ -9,13 +9,13 @@ import type { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; export const onResizeGridColumn = ( - colSettings: { columnId: string; width: number }, + colSettings: { columnId: string; width: number | undefined }, gridState: DiscoverGridSettings | undefined ): DiscoverGridSettings => { const grid = { ...(gridState || {}) }; const newColumns = { ...(grid.columns || {}) }; - newColumns[colSettings.columnId] = { - width: Math.round(colSettings.width), - }; + newColumns[colSettings.columnId] = colSettings.width + ? { width: Math.round(colSettings.width) } + : {}; return { ...grid, columns: newColumns }; }; diff --git a/test/functional/apps/discover/group2_data_grid3/_data_grid_column_widths.ts b/test/functional/apps/discover/group2_data_grid3/_data_grid_column_widths.ts new file mode 100644 index 0000000000000..a7ae825420151 --- /dev/null +++ b/test/functional/apps/discover/group2_data_grid3/_data_grid_column_widths.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const dataGrid = getService('dataGrid'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const PageObjects = getPageObjects([ + 'common', + 'discover', + 'header', + 'timePicker', + 'dashboard', + 'unifiedFieldList', + ]); + const security = getService('security'); + + const testResizeColumn = async (field: string) => { + const { originalWidth, newWidth } = await dataGrid.resizeColumn(field, -100); + expect(newWidth).to.be(originalWidth - 100); + await dataGrid.clickResetColumnWidth(field); + const resetWidth = (await (await dataGrid.getHeaderElement(field)).getSize()).width; + expect(resetWidth).to.be(originalWidth); + }; + + describe('discover data grid column widths', function describeIndexTests() { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + beforeEach(async function () { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + it('should not show reset width button for auto width column', async () => { + await PageObjects.unifiedFieldList.clickFieldListItemAdd('@message'); + expect(await dataGrid.resetColumnWidthExists('@message')).to.be(false); + }); + + it('should show reset width button for absolute width column, and allow resetting to auto width', async () => { + await PageObjects.unifiedFieldList.clickFieldListItemAdd('@message'); + await testResizeColumn('@message'); + }); + + it('should reset the last column to auto width if only absolute width columns remain', async () => { + await PageObjects.unifiedFieldList.clickFieldListItemAdd('@message'); + const { originalWidth: messageOriginalWidth, newWidth: messageNewWidth } = + await dataGrid.resizeColumn('@message', -300); + expect(messageNewWidth).to.be(messageOriginalWidth - 300); + await PageObjects.unifiedFieldList.clickFieldListItemAdd('bytes'); + const { originalWidth: bytesOriginalWidth, newWidth: bytesNewWidth } = + await dataGrid.resizeColumn('bytes', -100); + expect(bytesNewWidth).to.be(bytesOriginalWidth - 100); + let messageWidth = (await (await dataGrid.getHeaderElement('@message')).getSize()).width; + expect(messageWidth).to.be(messageNewWidth); + await dataGrid.clickRemoveColumn('bytes'); + messageWidth = (await (await dataGrid.getHeaderElement('@message')).getSize()).width; + expect(messageWidth).to.be(messageOriginalWidth); + }); + + it('should not reset the last column to auto width when there are remaining auto width columns', async () => { + await PageObjects.unifiedFieldList.clickFieldListItemAdd('@message'); + await PageObjects.unifiedFieldList.clickFieldListItemAdd('bytes'); + const { originalWidth: bytesOriginalWidth, newWidth: bytesNewWidth } = + await dataGrid.resizeColumn('bytes', -200); + expect(bytesNewWidth).to.be(bytesOriginalWidth - 200); + await PageObjects.unifiedFieldList.clickFieldListItemAdd('agent'); + const { originalWidth: agentOriginalWidth, newWidth: agentNewWidth } = + await dataGrid.resizeColumn('agent', -100); + expect(agentNewWidth).to.be(agentOriginalWidth - 100); + await dataGrid.clickRemoveColumn('bytes'); + const agentWidth = (await (await dataGrid.getHeaderElement('agent')).getSize()).width; + expect(agentWidth).to.be(agentNewWidth); + }); + + it('should allow resetting column width in surrounding docs view', async () => { + await PageObjects.unifiedFieldList.clickFieldListItemAdd('@message'); + await dataGrid.clickRowToggle({ rowIndex: 0 }); + const [, surroundingActionEl] = await dataGrid.getRowActions({ rowIndex: 0 }); + await surroundingActionEl.click(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testResizeColumn('@message'); + }); + + it('should allow resetting column width in Dashboard panel', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.addSavedSearch('A Saved Search'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testResizeColumn('_source'); + }); + + it('should use custom column width on Dashboard when specified', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.addSavedSearch('A Saved Search'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const { originalWidth, newWidth } = await dataGrid.resizeColumn('_source', -100); + expect(newWidth).to.be(originalWidth - 100); + await PageObjects.dashboard.saveDashboard('test'); + await browser.refresh(); + await PageObjects.header.waitUntilLoadingHasFinished(); + const initialWidth = (await (await dataGrid.getHeaderElement('_source')).getSize()).width; + expect(initialWidth).to.be(newWidth); + }); + }); +} diff --git a/test/functional/apps/discover/group2_data_grid3/index.ts b/test/functional/apps/discover/group2_data_grid3/index.ts index e3e0fd5b9d2be..66584faa2dc6d 100644 --- a/test/functional/apps/discover/group2_data_grid3/index.ts +++ b/test/functional/apps/discover/group2_data_grid3/index.ts @@ -26,5 +26,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_data_grid_sample_size')); loadTestFile(require.resolve('./_data_grid_pagination')); loadTestFile(require.resolve('./_data_grid_density')); + loadTestFile(require.resolve('./_data_grid_column_widths')); }); } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index aa79b36260a96..207bd808d8ebb 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -27,6 +27,7 @@ export class DataGridService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); async getDataGridTableData(): Promise { const table = await this.find.byCssSelector('.euiDataGrid'); @@ -82,6 +83,20 @@ export class DataGridService extends FtrService { .map((cell) => $(cell).text()); } + public getHeaderElement(field: string) { + return this.testSubjects.find(`dataGridHeaderCell-${field}`); + } + + public async resizeColumn(field: string, delta: number) { + const header = await this.getHeaderElement(field); + const originalWidth = (await header.getSize()).width; + const resizer = await header.findByCssSelector( + this.testSubjects.getCssSelector('dataGridColumnResizer') + ); + await this.browser.dragAndDrop({ location: resizer }, { location: { x: delta, y: 0 } }); + return { originalWidth, newWidth: (await header.getSize()).width }; + } + private getCellElementSelector(rowIndex: number = 0, columnIndex: number = 0) { return `[data-test-subj="euiDataGridBody"] [data-test-subj="dataGridRowCell"][data-gridcell-column-index="${columnIndex}"][data-gridcell-visible-row-index="${rowIndex}"]`; } @@ -465,6 +480,16 @@ export class DataGridService extends FtrService { await this.testSubjects.click('gridEditFieldButton'); } + public async resetColumnWidthExists(field: string) { + await this.openColMenuByField(field); + return await this.testSubjects.exists('unifiedDataTableResetColumnWidth'); + } + + public async clickResetColumnWidth(field: string) { + await this.openColMenuByField(field); + await this.testSubjects.click('unifiedDataTableResetColumnWidth'); + } + public async clickGridSettings() { await this.testSubjects.click('dataGridDisplaySelectorButton'); } diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx index 5c0cbd048f9d1..68a28a869efb7 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx @@ -258,12 +258,12 @@ export const CloudSecurityDataTable = ({ [dataView, filterManager, setUrlQuery] ); - const onResize = (colSettings: { columnId: string; width: number }) => { + const onResize = (colSettings: { columnId: string; width: number | undefined }) => { const grid = persistedSettings || {}; const newColumns = { ...(grid.columns || {}) }; - newColumns[colSettings.columnId] = { - width: Math.round(colSettings.width), - }; + newColumns[colSettings.columnId] = colSettings.width + ? { width: Math.round(colSettings.width) } + : {}; const newGrid = { ...grid, columns: newColumns }; setPersistedSettings(newGrid); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx index 6a3552e78ecd2..42b23945ac7b8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx @@ -186,7 +186,7 @@ export const TimelineDataTableComponent: React.FC = memo( ); const onColumnResize = useCallback( - ({ columnId, width }: { columnId: string; width: number }) => { + ({ columnId, width }: { columnId: string; width?: number }) => { dispatch( timelineActions.updateColumnWidth({ columnId, @@ -198,9 +198,12 @@ export const TimelineDataTableComponent: React.FC = memo( [dispatch, timelineId] ); - const onResizeDataGrid = useCallback( + const onResizeDataGrid = useCallback>( (colSettings) => { - onColumnResize({ columnId: colSettings.columnId, width: Math.round(colSettings.width) }); + onColumnResize({ + columnId: colSettings.columnId, + ...(colSettings.width ? { width: Math.round(colSettings.width) } : {}), + }); }, [onColumnResize] ); diff --git a/x-pack/plugins/security_solution/public/timelines/store/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/actions.ts index 2517b44d2daae..fbf061529c495 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/actions.ts @@ -291,7 +291,7 @@ export const setChanged = actionCreator<{ id: string; changed: boolean }>('SET_C export const updateColumnWidth = actionCreator<{ columnId: string; id: string; - width: number; + width?: number; }>('UPDATE_COLUMN_WIDTH'); export const updateRowHeight = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/timelines/store/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/helpers.ts index dca69b8615804..0342c28f28aa9 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/helpers.ts @@ -1531,7 +1531,7 @@ export const updateTimelineColumnWidth = ({ columnId: string; id: string; timelineById: TimelineById; - width: number; + width?: number; }): TimelineById => { const timeline = timelineById[id];