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 30e45082da8a9..3b993e043158f 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< @@ -426,6 +436,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: {