From 62916feeb26944ba4023df0a6884b6c8389516cb Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Thu, 13 Mar 2025 19:49:16 +0100 Subject: [PATCH] [Threat Hunting Investigations] Fix timeline column width bug (#214178) ## Summary Fixes: https://github.com/elastic/kibana/issues/213754 The issue above describes a bug in timeline that makes it impossible to change the width of a timeline column. This PR fixes that issue and makes sure that timeline column width settings are saved to localStorage. This mimics the behaviour of the alerts table elsewhere in security solution. https://github.com/user-attachments/assets/8b9803a0-406d-4f2d-ada5-4c0b76cd6ab8 --------- Co-authored-by: Elastic Machine (cherry picked from commit edbc618321930e358b2e0910f1c5cb5f7606e621) --- .../unified_components/data_table/index.tsx | 55 ++-- .../public/timelines/store/actions.ts | 6 - .../public/timelines/store/helpers.test.ts | 203 +++++++------ .../public/timelines/store/helpers.ts | 284 +++--------------- .../create_timeline_middlewares.ts | 2 + .../middlewares/timeline_localstorage.test.ts | 41 +++ .../middlewares/timeline_localstorage.ts | 70 +++++ .../public/timelines/store/reducer.ts | 27 +- 8 files changed, 306 insertions(+), 382 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.test.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx index 35bed4ddd804b..b763eb1e5b502 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx @@ -9,7 +9,10 @@ import React, { memo, useMemo, useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import type { DataTableRecord } from '@kbn/discover-utils/types'; -import type { UnifiedDataTableProps } from '@kbn/unified-data-table'; +import type { + UnifiedDataTableProps, + UnifiedDataTableSettingsColumn, +} from '@kbn/unified-data-table'; import { UnifiedDataTable, DataLoadingState } from '@kbn/unified-data-table'; import type { DataView } from '@kbn/data-views-plugin/public'; import type { @@ -147,9 +150,24 @@ export const TimelineDataTableComponent: React.FC = memo( const showTimeCol = useMemo(() => !!dataView && !!dataView.timeFieldName, [dataView]); - const { rowHeight, sampleSize, excludedRowRendererIds } = useSelector((state: State) => - selectTimelineById(state, timelineId) - ); + const { + rowHeight, + sampleSize, + excludedRowRendererIds, + columns: timelineColumns, + } = useSelector((state: State) => selectTimelineById(state, timelineId)); + + const settings: UnifiedDataTableProps['settings'] = useMemo(() => { + const _columns: Record = {}; + timelineColumns.forEach((timelineColumn) => { + _columns[timelineColumn.id] = { + width: timelineColumn.initialWidth ?? undefined, + }; + }); + return { + columns: _columns, + }; + }, [timelineColumns]); const { tableRows, tableStylesOverride } = useMemo( () => transformTimelineItemToUnifiedRows({ events, dataView }), @@ -192,27 +210,19 @@ export const TimelineDataTableComponent: React.FC = memo( [tableRows, handleOnEventDetailPanelOpened, closeFlyout] ); - const onColumnResize = useCallback( - ({ columnId, width }: { columnId: string; width?: number }) => { - dispatch( - timelineActions.updateColumnWidth({ - columnId, - id: timelineId, - width, // initialWidth? - }) - ); - }, - [dispatch, timelineId] - ); - const onResizeDataGrid = useCallback>( (colSettings) => { - onColumnResize({ - columnId: colSettings.columnId, - ...(colSettings.width ? { width: Math.round(colSettings.width) } : {}), - }); + if (colSettings.width) { + dispatch( + timelineActions.updateColumnWidth({ + columnId: colSettings.columnId, + id: timelineId, + width: Math.round(colSettings.width), + }) + ); + } }, - [onColumnResize] + [dispatch, timelineId] ); const onChangeItemsPerPage = useCallback< @@ -425,6 +435,7 @@ export const TimelineDataTableComponent: React.FC = memo( externalControlColumns={leadingControlColumns} onUpdatePageIndex={onUpdatePageIndex} getRowIndicator={getTimelineRowTypeIndicator} + settings={settings} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/actions.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/actions.ts index 78f74ef4670e7..fbe24bc1f2b60 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/actions.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/actions.ts @@ -244,12 +244,6 @@ export const updateItemsPerPageOptions = actionCreator<{ itemsPerPageOptions: number[]; }>('UPDATE_ITEMS_PER_PAGE_OPTIONS'); -export const applyDeltaToColumnWidth = actionCreator<{ - id: string; - columnId: string; - delta: number; -}>('APPLY_DELTA_TO_COLUMN_WIDTH'); - export const clearEventsLoading = actionCreator<{ id: string; }>('CLEAR_TGRID_EVENTS_LOADING'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.test.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.test.ts index 491cc429066cc..0632d8d6a9833 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.test.ts @@ -22,16 +22,12 @@ import { defaultUdtHeaders, defaultColumnHeaderType, } from '../components/timeline/body/column_headers/default_headers'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - RESIZED_COLUMN_MIN_WITH, -} from '../components/timeline/body/constants'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../components/timeline/body/constants'; import { defaultHeaders } from '../../common/mock'; import { addNewTimeline, addTimelineProviders, addTimelineToStore, - applyDeltaToTimelineColumnWidth, removeTimelineColumn, removeTimelineProvider, updateTimelineColumns, @@ -45,14 +41,18 @@ import { updateTimelineShowTimeline, updateTimelineSort, updateTimelineTitleAndDescription, - upsertTimelineColumn, updateTimelineGraphEventId, updateTimelineColumnWidth, + upsertTimelineColumn, } from './helpers'; import type { TimelineModel } from './model'; import { timelineDefaults } from './defaults'; import type { TimelineById } from './types'; import { Direction } from '../../../common/search_strategy'; +import { + type LocalStorageColumnSettings, + setStoredTimelineColumnsConfig, +} from './middlewares/timeline_localstorage'; jest.mock('../../common/utils/normalize_time_range'); jest.mock('../../common/utils/default_date_settings', () => { @@ -157,6 +157,10 @@ const columnsMock: ColumnHeaderOptions[] = [ ]; describe('Timeline', () => { + beforeEach(() => { + setStoredTimelineColumnsConfig(undefined); + }); + describe('#add saved object Timeline to store ', () => { test('should return a timelineModel with default value and not just a timelineResult ', () => { const update = addTimelineToStore({ @@ -175,6 +179,47 @@ describe('Timeline', () => { }); }); + test('should apply the locally stored column config', () => { + const initialWidth = 123456789; + const storedConfig: LocalStorageColumnSettings = { + '@timestamp': { + id: '@timestamp', + initialWidth, + }, + }; + setStoredTimelineColumnsConfig(storedConfig); + const update = addTimelineToStore({ + id: 'foo', + timeline: { + ...basicTimeline, + columns: [{ id: '@timestamp', columnHeaderType: 'not-filtered' }], + }, + timelineById: timelineByIdMock, + }); + + expect(update.foo.columns.find((col) => col.id === '@timestamp')).toEqual( + expect.objectContaining({ + initialWidth, + }) + ); + }); + + test('should not apply changes to the columns when no previous config is stored in localStorage', () => { + const update = addTimelineToStore({ + id: 'foo', + timeline: { + ...basicTimeline, + columns: [{ id: '@timestamp', columnHeaderType: 'not-filtered' }], + }, + timelineById: timelineByIdMock, + }); + + expect(update.foo.columns.find((col) => col.id === '@timestamp')).toEqual({ + id: '@timestamp', + columnHeaderType: 'not-filtered', + }); + }); + test('should override timerange if adding an immutable template', () => { const update = addTimelineToStore({ id: 'foo', @@ -458,6 +503,49 @@ describe('Timeline', () => { expect(update.foo.columns).toEqual(expectedColumns); }); + + test('should apply the locally stored column config to new columns', () => { + const initialWidth = 123456789; + const storedConfig: LocalStorageColumnSettings = { + 'event.action': { + id: 'event.action', + initialWidth, + }, + }; + setStoredTimelineColumnsConfig(storedConfig); + const expectedColumns = [{ ...columnToAdd, initialWidth }]; + const update = upsertTimelineColumn({ + column: columnToAdd, + id: 'foo', + index: 0, + timelineById, + }); + + expect(update.foo.columns).toEqual(expectedColumns); + }); + + test('should apply the locally stored column config to existing columns', () => { + const initialWidth = 123456789; + const storedConfig: LocalStorageColumnSettings = { + '@timestamp': { + id: '@timestamp', + initialWidth, + }, + }; + setStoredTimelineColumnsConfig(storedConfig); + const update = upsertTimelineColumn({ + column: columns[0], + id: 'foo', + index: 0, + timelineById: mockWithExistingColumns, + }); + + expect(update.foo.columns.find((col) => col.id === '@timestamp')).toEqual( + expect.objectContaining({ + initialWidth, + }) + ); + }); }); describe('#addTimelineProvider', () => { @@ -599,87 +687,6 @@ describe('Timeline', () => { }); }); - describe('#applyDeltaToColumnWidth', () => { - let mockWithExistingColumns: TimelineById; - beforeEach(() => { - mockWithExistingColumns = { - ...timelineByIdMock, - foo: { - ...timelineByIdMock.foo, - columns: columnsMock, - }, - }; - }); - test('should return a new reference and not the same reference', () => { - const delta = 50; - const update = applyDeltaToTimelineColumnWidth({ - id: 'foo', - columnId: columnsMock[0].id, - delta, - timelineById: mockWithExistingColumns, - }); - - expect(update).not.toBe(timelineByIdMock); - }); - - test('should update initialWidth with the specified delta when the delta is positive', () => { - const aDateColumn = columnsMock[0]; - const delta = 50; - const expectedToHaveNewWidth = { - ...aDateColumn, - initialWidth: Number(aDateColumn.initialWidth) + 50, - }; - const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; - - const update = applyDeltaToTimelineColumnWidth({ - id: 'foo', - columnId: aDateColumn.id, - delta, - timelineById: mockWithExistingColumns, - }); - - expect(update.foo.columns).toEqual(expectedColumns); - }); - - test('should update initialWidth with the specified delta when the delta is negative, and the resulting width is greater than the min column width', () => { - const aDateColumn = columnsMock[0]; - const delta = 50 * -1; // the result will still be above the min column size - const expectedToHaveNewWidth = { - ...aDateColumn, - initialWidth: Number(aDateColumn.initialWidth) - 50, - }; - const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; - - const update = applyDeltaToTimelineColumnWidth({ - id: 'foo', - columnId: aDateColumn.id, - delta, - timelineById: mockWithExistingColumns, - }); - - expect(update.foo.columns).toEqual(expectedColumns); - }); - - test('should set initialWidth to `RESIZED_COLUMN_MIN_WITH` when the requested delta results in a column that is too small ', () => { - const aDateColumn = columnsMock[0]; - const delta = (Number(aDateColumn.initialWidth) - 5) * -1; // the requested delta would result in a width of just 5 pixels, which is too small - const expectedToHaveNewWidth = { - ...aDateColumn, - initialWidth: RESIZED_COLUMN_MIN_WITH, // we expect the minimum - }; - const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; - - const update = applyDeltaToTimelineColumnWidth({ - id: 'foo', - columnId: aDateColumn.id, - delta, - timelineById: mockWithExistingColumns, - }); - - expect(update.foo.columns).toEqual(expectedColumns); - }); - }); - describe('#addAndProviderToTimelineProvider', () => { test('should add a new and provider to an existing timeline provider', () => { const providerToAdd: DataProvider[] = [ @@ -861,6 +868,28 @@ describe('Timeline', () => { }); expect(update.foo.columns).toEqual([...columnsMock]); }); + + test('should apply the locally stored column config', () => { + const initialWidth = 123456789; + const storedConfig: LocalStorageColumnSettings = { + '@timestamp': { + id: '@timestamp', + initialWidth, + }, + }; + setStoredTimelineColumnsConfig(storedConfig); + const update = updateTimelineColumns({ + id: 'foo', + columns: columnsMock, + timelineById: timelineByIdMock, + }); + + expect(update.foo.columns.find((col) => col.id === '@timestamp')).toEqual( + expect.objectContaining({ + initialWidth, + }) + ); + }); }); describe('#updateTimelineTitleAndDescription', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.ts index 0c50dd0490456..2a2109cdcc7d7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/helpers.ts @@ -25,11 +25,9 @@ import { import { TimelineId, TimelineTabs } from '../../../common/types/timeline'; import type { ColumnHeaderOptions, - TimelineEventsType, SerializedFilterQuery, TimelinePersistInput, SortColumnTimeline, - SortColumnTimeline as Sort, } from '../../../common/types/timeline'; import type { RowRendererId, TimelineType } from '../../../common/api/timeline'; import { normalizeTimeRange } from '../../common/utils/normalize_time_range'; @@ -37,14 +35,11 @@ import { getTimelineManageDefaults, timelineDefaults } from './defaults'; import type { KqlMode, TimelineModel } from './model'; import type { TimelineById, TimelineModelSettings } from './types'; import { DEFAULT_FROM_MOMENT, DEFAULT_TO_MOMENT } from '../../common/utils/default_date_settings'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - RESIZED_COLUMN_MIN_WITH, -} from '../components/timeline/body/constants'; import { activeTimeline } from '../containers/active_timeline_context'; import type { ResolveTimelineConfig } from '../components/open_timeline/types'; import { getDisplayValue } from '../components/timeline/data_providers/helpers'; import type { PrimitiveOrArrayOfPrimitives } from '../../common/lib/kuery'; +import { getStoredTimelineColumnsConfig } from './middlewares/timeline_localstorage'; interface AddTimelineNoteParams { id: string; @@ -114,6 +109,20 @@ export const shouldResetActiveTimelineContext = ( return false; }; +/** + * Merges a given timeline column config with locally stored timeline column config + */ +function mergeInLocalColumnConfig(columns: TimelineModel['columns']) { + const storedColumnsConfig = getStoredTimelineColumnsConfig(); + if (storedColumnsConfig) { + return columns.map((column) => ({ + ...column, + initialWidth: storedColumnsConfig[column.id]?.initialWidth || column.initialWidth, + })); + } + return columns; +} + /** * Add a saved object timeline to the store * and default the value to what need to be if values are null @@ -127,10 +136,12 @@ export const addTimelineToStore = ({ if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) { activeTimeline.setActivePage(0); } + return { ...timelineById, [id]: { ...timeline, + columns: mergeInLocalColumnConfig(timeline.columns), initialized: timeline.initialized ?? timelineById[id].initialized, resolveTimelineConfig, dateRange: @@ -168,19 +179,23 @@ export const addNewTimeline = ({ templateTimelineVersion: 1, } : {}; + const newTimeline = { + id, + ...(timeline ? timeline : {}), + ...timelineDefaults, + ...timelineProps, + dateRange, + savedObjectId: null, + version: null, + isSaving: false, + timelineType, + ...templateTimelineInfo, + }; return { ...timelineById, [id]: { - id, - ...(timeline ? timeline : {}), - ...timelineDefaults, - ...timelineProps, - dateRange, - savedObjectId: null, - version: null, - isSaving: false, - timelineType, - ...templateTimelineInfo, + ...newTimeline, + columns: mergeInLocalColumnConfig(newTimeline.columns), }, }; }; @@ -404,7 +419,7 @@ export const upsertTimelineColumn = ({ ...timelineById, [id]: { ...timeline, - columns: reordered, + columns: mergeInLocalColumnConfig(reordered), }, }; } @@ -417,7 +432,7 @@ export const upsertTimelineColumn = ({ ...timelineById, [id]: { ...timeline, - columns, + columns: mergeInLocalColumnConfig(columns), }, }; }; @@ -441,57 +456,7 @@ export const removeTimelineColumn = ({ ...timelineById, [id]: { ...timeline, - columns, - }, - }; -}; - -interface ApplyDeltaToTimelineColumnWidth { - id: string; - columnId: string; - delta: number; - timelineById: TimelineById; -} - -export const applyDeltaToTimelineColumnWidth = ({ - id, - columnId, - delta, - timelineById, -}: ApplyDeltaToTimelineColumnWidth): TimelineById => { - const timeline = timelineById[id]; - - const columnIndex = timeline.columns.findIndex((c) => c.id === columnId); - if (columnIndex === -1) { - // the column was not found - return { - ...timelineById, - [id]: { - ...timeline, - }, - }; - } - - const requestedWidth = - (timeline.columns[columnIndex].initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH) + delta; // raw change in width - const initialWidth = Math.max(RESIZED_COLUMN_MIN_WITH, requestedWidth); // if the requested width is smaller than the min, use the min - - const columnWithNewWidth = { - ...timeline.columns[columnIndex], - initialWidth, - }; - - const columns = [ - ...timeline.columns.slice(0, columnIndex), - columnWithNewWidth, - ...timeline.columns.slice(columnIndex + 1), - ]; - - return { - ...timelineById, - [id]: { - ...timeline, - columns, + columns: mergeInLocalColumnConfig(columns), }, }; }; @@ -579,7 +544,7 @@ export const updateTimelineColumns = ({ ...timelineById, [id]: { ...timeline, - columns, + columns: mergeInLocalColumnConfig(columns), }, }; }; @@ -609,28 +574,6 @@ export const updateTimelineTitleAndDescription = ({ }; }; -interface UpdateTimelineEventTypeParams { - id: string; - eventType: TimelineEventsType; - timelineById: TimelineById; -} - -export const updateTimelineEventType = ({ - id, - eventType, - timelineById, -}: UpdateTimelineEventTypeParams): TimelineById => { - const timeline = timelineById[id]; - - return { - ...timelineById, - [id]: { - ...timeline, - eventType, - }, - }; -}; - interface UpdateTimelineIsFavoriteParams { id: string; isFavorite: boolean; @@ -703,7 +646,7 @@ export const updateTimelineRange = ({ interface UpdateTimelineSortParams { id: string; - sort: Sort[]; + sort: SortColumnTimeline[]; timelineById: TimelineById; } @@ -1260,111 +1203,6 @@ export const setLoadingTableEvents = ({ }; }; -interface RemoveTableColumnParams { - id: string; - columnId: string; - timelineById: TimelineById; -} - -export const removeTableColumn = ({ - id, - columnId, - timelineById, -}: RemoveTableColumnParams): TimelineById => { - const timeline = timelineById[id]; - - const columns = timeline.columns.filter((c) => c.id !== columnId); - - return { - ...timelineById, - [id]: { - ...timeline, - columns, - }, - }; -}; - -/** - * Adds or updates a column. When updating a column, it will be moved to the - * new index - */ -export const upsertTableColumn = ({ - column, - id, - index, - timelineById, -}: AddTimelineColumnParams): TimelineById => { - const timeline = timelineById[id]; - const alreadyExistsAtIndex = timeline.columns.findIndex((c) => c.id === column.id); - - if (alreadyExistsAtIndex !== -1) { - // remove the existing entry and add the new one at the specified index - const reordered = timeline.columns.filter((c) => c.id !== column.id); - reordered.splice(index, 0, column); // ⚠️ mutation - - return { - ...timelineById, - [id]: { - ...timeline, - columns: reordered, - }, - }; - } - // add the new entry at the specified index - const columns = [...timeline.columns]; - columns.splice(index, 0, column); // ⚠️ mutation - - return { - ...timelineById, - [id]: { - ...timeline, - columns, - }, - }; -}; - -interface UpdateTableColumnsParams { - id: string; - columns: ColumnHeaderOptions[]; - timelineById: TimelineById; -} - -export const updateTableColumns = ({ - id, - columns, - timelineById, -}: UpdateTableColumnsParams): TimelineById => { - const timeline = timelineById[id]; - return { - ...timelineById, - [id]: { - ...timeline, - columns, - }, - }; -}; - -interface UpdateTableSortParams { - id: string; - sort: SortColumnTimeline[]; - timelineById: TimelineById; -} - -export const updateTableSort = ({ - id, - sort, - timelineById, -}: UpdateTableSortParams): TimelineById => { - const timeline = timelineById[id]; - return { - ...timelineById, - [id]: { - ...timeline, - sort, - }, - }; -}; - interface SetSelectedTableEventsParams { id: string; eventIds: Record; @@ -1474,56 +1312,6 @@ export const setInitializeTimelineSettings = ({ }; }; -interface ApplyDeltaToTableColumnWidth { - id: string; - columnId: string; - delta: number; - timelineById: TimelineById; -} - -export const applyDeltaToTableColumnWidth = ({ - id, - columnId, - delta, - timelineById, -}: ApplyDeltaToTableColumnWidth): TimelineById => { - const timeline = timelineById[id]; - - const columnIndex = timeline.columns.findIndex((c) => c.id === columnId); - if (columnIndex === -1) { - // the column was not found - return { - ...timelineById, - [id]: { - ...timeline, - }, - }; - } - - const requestedWidth = - (timeline.columns[columnIndex].initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH) + delta; // raw change in width - const initialWidth = Math.max(RESIZED_COLUMN_MIN_WITH, requestedWidth); // if the requested width is smaller than the min, use the min - - const columnWithNewWidth = { - ...timeline.columns[columnIndex], - initialWidth, - }; - - const columns = [ - ...timeline.columns.slice(0, columnIndex), - columnWithNewWidth, - ...timeline.columns.slice(columnIndex + 1), - ]; - - return { - ...timelineById, - [id]: { - ...timeline, - columns, - }, - }; -}; - export const updateTimelineColumnWidth = ({ columnId, id, diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts index effb4b0819773..1381e12a57f57 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts @@ -13,6 +13,7 @@ import { addNoteToTimelineMiddleware } from './timeline_note'; import { addPinnedEventToTimelineMiddleware } from './timeline_pinned_event'; import { saveTimelineMiddleware } from './timeline_save'; import { timelinePrivilegesMiddleware } from './timeline_privileges'; +import { timelineLocalStorageMiddleware } from './timeline_localstorage'; export function createTimelineMiddlewares(kibana: CoreStart) { return [ @@ -22,5 +23,6 @@ export function createTimelineMiddlewares(kibana: CoreStart) { addNoteToTimelineMiddleware(kibana), addPinnedEventToTimelineMiddleware(kibana), saveTimelineMiddleware(kibana), + timelineLocalStorageMiddleware, ]; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.test.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.test.ts new file mode 100644 index 0000000000000..83312db96e518 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMockStore, kibanaMock } from '../../../common/mock'; +import { TimelineId } from '../../../../common/types/timeline'; +import { updateColumnWidth } from '../actions'; +import { + TIMELINE_COLUMNS_CONFIG_KEY, + getStoredTimelineColumnsConfig, + setStoredTimelineColumnsConfig, +} from './timeline_localstorage'; + +const initialWidth = 123456789; + +describe('Timeline localStorage middleware', () => { + let store = createMockStore(undefined, undefined, kibanaMock); + + beforeEach(() => { + store = createMockStore(undefined, undefined, kibanaMock); + jest.clearAllMocks(); + setStoredTimelineColumnsConfig(undefined); + }); + + it('should write the timeline column settings to localStorage', async () => { + await store.dispatch( + updateColumnWidth({ id: TimelineId.test, columnId: '@timestamp', width: initialWidth }) + ); + const storedConfig = getStoredTimelineColumnsConfig(); + expect(storedConfig!['@timestamp'].initialWidth).toBe(initialWidth); + }); + + it('should not fail to read the column config when localStorage contains a malformatted config', () => { + localStorage.setItem(TIMELINE_COLUMNS_CONFIG_KEY, '1234'); + const storedConfig = getStoredTimelineColumnsConfig(); + expect(storedConfig).toBe(undefined); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.ts new file mode 100644 index 0000000000000..7ab9aa4adb1c3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/middlewares/timeline_localstorage.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Action, Middleware } from 'redux'; +import { z } from '@kbn/zod'; + +import { selectTimelineById } from '../selectors'; +import { updateColumnWidth } from '../actions'; + +const LocalStorageColumnSettingsSchema = z.record( + z.string(), + z.object({ + initialWidth: z.number().optional(), + id: z.string(), + }) +); +export type LocalStorageColumnSettings = z.infer; + +export const TIMELINE_COLUMNS_CONFIG_KEY = 'timeline:columnsConfig'; + +type UpdateColumnWidthAction = ReturnType; + +function isUpdateColumnWidthAction(action: Action): action is UpdateColumnWidthAction { + return action.type === updateColumnWidth.type; +} + +/** + * Saves the timeline column settings to localStorage when it changes + */ +export const timelineLocalStorageMiddleware: Middleware = + ({ getState }) => + (next) => + (action: Action) => { + // perform the action + const ret = next(action); + + // Store the column config when it changes + if (isUpdateColumnWidthAction(action)) { + const timeline = selectTimelineById(getState(), action.payload.id); + const timelineColumnsConfig = timeline.columns.reduce( + (columnSettings, { initialWidth, id }) => { + columnSettings[id] = { initialWidth, id }; + return columnSettings; + }, + {} + ); + setStoredTimelineColumnsConfig(timelineColumnsConfig); + } + + return ret; + }; + +export function getStoredTimelineColumnsConfig() { + const storedConfigStr = localStorage.getItem(TIMELINE_COLUMNS_CONFIG_KEY); + if (storedConfigStr) { + try { + return LocalStorageColumnSettingsSchema.parse(JSON.parse(storedConfigStr)); + } catch (_) { + /* empty */ + } + } +} + +export function setStoredTimelineColumnsConfig(config?: LocalStorageColumnSettings) { + localStorage.setItem(TIMELINE_COLUMNS_CONFIG_KEY, JSON.stringify(config)); +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/reducer.ts b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/reducer.ts index 546e60e47d10b..8df45701a8fa0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/store/reducer.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/store/reducer.ts @@ -52,7 +52,6 @@ import { initializeTimelineSettings, updateItemsPerPage, updateItemsPerPageOptions, - applyDeltaToColumnWidth, clearEventsDeleted, clearEventsLoading, updateSavedSearchId, @@ -93,14 +92,13 @@ import { updateFilters, updateTimelineSessionViewConfig, setLoadingTableEvents, - removeTableColumn, - upsertTableColumn, - updateTableColumns, - updateTableSort, + removeTimelineColumn, + upsertTimelineColumn, + updateTimelineColumns, + updateTimelineSort, setSelectedTableEvents, setDeletedTableEvents, setInitializeTimelineSettings, - applyDeltaToTableColumnWidth, updateTimelinePerPageOptions, updateTimelineItemsPerPage, updateTimelineColumnWidth, @@ -390,7 +388,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) })) .case(removeColumn, (state, { id, columnId }) => ({ ...state, - timelineById: removeTableColumn({ + timelineById: removeTimelineColumn({ id, columnId, timelineById: state.timelineById, @@ -398,11 +396,11 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) })) .case(upsertColumn, (state, { column, id, index }) => ({ ...state, - timelineById: upsertTableColumn({ column, id, index, timelineById: state.timelineById }), + timelineById: upsertTimelineColumn({ column, id, index, timelineById: state.timelineById }), })) .case(updateColumns, (state, { id, columns }) => ({ ...state, - timelineById: updateTableColumns({ + timelineById: updateTimelineColumns({ id, columns, timelineById: state.timelineById, @@ -410,7 +408,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) })) .case(updateSort, (state, { id, sort }) => ({ ...state, - timelineById: updateTableSort({ id, sort, timelineById: state.timelineById }), + timelineById: updateTimelineSort({ id, sort, timelineById: state.timelineById }), })) .case(setSelected, (state, { id, eventIds, isSelected, isSelectAllChecked }) => ({ ...state, @@ -466,15 +464,6 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineById: state.timelineById, }), })) - .case(applyDeltaToColumnWidth, (state, { id, columnId, delta }) => ({ - ...state, - timelineById: applyDeltaToTableColumnWidth({ - id, - columnId, - delta, - timelineById: state.timelineById, - }), - })) .case(clearEventsDeleted, (state, { id }) => ({ ...state, timelineById: {