[8.16] [Threat Hunting Investigations] Fix timeline column width bug (#214178) (#214520)

# Backport

This will backport the following commits from `main` to `8.16`:
- [[Threat Hunting Investigations] Fix timeline column width bug
(#214178)](https://github.com/elastic/kibana/pull/214178)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Jan
Monschke","email":"jan.monschke@elastic.co"},"sourceCommit":{"committedDate":"2025-03-13T18:49:16Z","message":"[Threat
Hunting Investigations] Fix timeline column width bug (#214178)\n\n##
Summary\n\nFixes: https://github.com/elastic/kibana/issues/213754\n\nThe
issue above describes a bug in timeline that makes it impossible
to\nchange the width of a timeline column. This PR fixes that issue
and\nmakes sure that timeline column width settings are saved
to\nlocalStorage. This mimics the behaviour of the alerts table
elsewhere in\nsecurity
solution.\n\n\nhttps://github.com/user-attachments/assets/8b9803a0-406d-4f2d-ada5-4c0b76cd6ab8\n\n---------\n\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"edbc618321930e358b2e0910f1c5cb5f7606e621","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","v9.0.0","Team:Threat
Hunting:Investigations","backport:all-open","v8.18.0","v9.1.0","v8.19.0"],"title":"[Threat
Hunting Investigations] Fix timeline column width
bug","number":214178,"url":"https://github.com/elastic/kibana/pull/214178","mergeCommit":{"message":"[Threat
Hunting Investigations] Fix timeline column width bug (#214178)\n\n##
Summary\n\nFixes: https://github.com/elastic/kibana/issues/213754\n\nThe
issue above describes a bug in timeline that makes it impossible
to\nchange the width of a timeline column. This PR fixes that issue
and\nmakes sure that timeline column width settings are saved
to\nlocalStorage. This mimics the behaviour of the alerts table
elsewhere in\nsecurity
solution.\n\n\nhttps://github.com/user-attachments/assets/8b9803a0-406d-4f2d-ada5-4c0b76cd6ab8\n\n---------\n\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"edbc618321930e358b2e0910f1c5cb5f7606e621"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"9.0","label":"v9.0.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/214473","number":214473,"state":"MERGED","mergeCommit":{"sha":"6c1aa0a4d9f5d605ad037ace4515c848af38f6f4","message":"[9.0]
[Threat Hunting Investigations] Fix timeline column width bug (#214178)
(#214473)\n\n# Backport\n\nThis will backport the following commits from
`main` to `9.0`:\n- [[Threat Hunting Investigations] Fix timeline column
width
bug\n(#214178)](https://github.com/elastic/kibana/pull/214178)\n\n\n\n###
Questions ?\nPlease refer to the [Backport
tool\ndocumentation](https://github.com/sorenlouv/backport)\n\n\n\nCo-authored-by:
Jan Monschke
<jan.monschke@elastic.co>"}},{"branch":"8.18","label":"v8.18.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/214471","number":214471,"state":"MERGED","mergeCommit":{"sha":"f2c8c4c0ce81a88387e0eb400ae6166aceb751f0","message":"[8.18]
[Threat Hunting Investigations] Fix timeline column width bug (#214178)
(#214471)\n\n# Backport\n\nThis will backport the following commits from
`main` to `8.18`:\n- [[Threat Hunting Investigations] Fix timeline
column width
bug\n(#214178)](https://github.com/elastic/kibana/pull/214178)\n\n\n\n###
Questions ?\nPlease refer to the [Backport
tool\ndocumentation](https://github.com/sorenlouv/backport)\n\n\n\nCo-authored-by:
Jan Monschke
<jan.monschke@elastic.co>"}},{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/214178","number":214178,"mergeCommit":{"message":"[Threat
Hunting Investigations] Fix timeline column width bug (#214178)\n\n##
Summary\n\nFixes: https://github.com/elastic/kibana/issues/213754\n\nThe
issue above describes a bug in timeline that makes it impossible
to\nchange the width of a timeline column. This PR fixes that issue
and\nmakes sure that timeline column width settings are saved
to\nlocalStorage. This mimics the behaviour of the alerts table
elsewhere in\nsecurity
solution.\n\n\nhttps://github.com/user-attachments/assets/8b9803a0-406d-4f2d-ada5-4c0b76cd6ab8\n\n---------\n\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"edbc618321930e358b2e0910f1c5cb5f7606e621"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/214472","number":214472,"state":"MERGED","mergeCommit":{"sha":"b578cc110edf15ae10f5a284a4bc110dbbbd5173","message":"[8.x]
[Threat Hunting Investigations] Fix timeline column width bug (#214178)
(#214472)\n\n# Backport\n\nThis will backport the following commits from
`main` to `8.x`:\n- [[Threat Hunting Investigations] Fix timeline column
width
bug\n(#214178)](https://github.com/elastic/kibana/pull/214178)\n\n\n\n###
Questions ?\nPlease refer to the [Backport
tool\ndocumentation](https://github.com/sorenlouv/backport)\n\n\n\nCo-authored-by:
Jan Monschke <jan.monschke@elastic.co>"}}]}] BACKPORT-->
This commit is contained in:
Jan Monschke 2025-03-14 17:47:01 +01:00 committed by GitHub
parent 46b9080e7a
commit f219410251
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 311 additions and 231 deletions

View file

@ -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 {
@ -145,9 +148,24 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = 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<string, UnifiedDataTableSettingsColumn> = {};
timelineColumns.forEach((timelineColumn) => {
_columns[timelineColumn.id] = {
width: timelineColumn.initialWidth ?? undefined,
};
});
return {
columns: _columns,
};
}, [timelineColumns]);
const { tableRows, tableStylesOverride } = useMemo(
() => transformTimelineItemToUnifiedRows({ events, dataView }),
@ -190,27 +208,19 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = 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<NonNullable<UnifiedDataTableProps['onResize']>>(
(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<
@ -423,6 +433,7 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
trailingControlColumns={finalTrailControlColumns}
externalControlColumns={leadingControlColumns}
onUpdatePageIndex={onUpdatePageIndex}
settings={settings}
/>
</StyledTimelineUnifiedDataTable>
</StatefulEventContext.Provider>

View file

@ -28,7 +28,7 @@ import {
addNewTimeline,
addTimelineProviders,
addTimelineToStore,
applyDeltaToTimelineColumnWidth,
applyDeltaToTableColumnWidth,
removeTimelineColumn,
removeTimelineProvider,
updateTimelineColumns,
@ -42,15 +42,19 @@ 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 { defaultUdtHeaders } from '../components/timeline/unified_components/default_headers';
import {
type LocalStorageColumnSettings,
setStoredTimelineColumnsConfig,
} from './middlewares/timeline_localstorage';
jest.mock('../../common/utils/normalize_time_range');
jest.mock('../../common/utils/default_date_settings', () => {
@ -156,6 +160,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({
@ -174,6 +182,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',
@ -457,6 +506,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', () => {
@ -611,7 +703,7 @@ describe('Timeline', () => {
});
test('should return a new reference and not the same reference', () => {
const delta = 50;
const update = applyDeltaToTimelineColumnWidth({
const update = applyDeltaToTableColumnWidth({
id: 'foo',
columnId: columnsMock[0].id,
delta,
@ -630,7 +722,7 @@ describe('Timeline', () => {
};
const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]];
const update = applyDeltaToTimelineColumnWidth({
const update = applyDeltaToTableColumnWidth({
id: 'foo',
columnId: aDateColumn.id,
delta,
@ -649,7 +741,7 @@ describe('Timeline', () => {
};
const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]];
const update = applyDeltaToTimelineColumnWidth({
const update = applyDeltaToTableColumnWidth({
id: 'foo',
columnId: aDateColumn.id,
delta,
@ -668,7 +760,7 @@ describe('Timeline', () => {
};
const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]];
const update = applyDeltaToTimelineColumnWidth({
const update = applyDeltaToTableColumnWidth({
id: 'foo',
columnId: aDateColumn.id,
delta,
@ -860,6 +952,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', () => {

View file

@ -10,7 +10,6 @@ import { v4 as uuidv4 } from 'uuid';
import type { Filter } from '@kbn/es-query';
import type { SessionViewConfig } from '../../../common/types';
import type { TimelineNonEcsData } from '../../../common/search_strategy';
import type { Sort } from '../components/timeline/body/sort';
import type {
DataProvider,
QueryOperator,
@ -25,7 +24,6 @@ import {
} from '../../../common/api/timeline';
import type {
ColumnHeaderOptions,
TimelineEventsType,
SerializedFilterQuery,
TimelinePersistInput,
SortColumnTimeline,
@ -45,6 +43,7 @@ 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 +113,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,11 +140,13 @@ export const addTimelineToStore = ({
if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) {
activeTimeline.setActivePage(0);
}
return {
...timelineById,
[id]: {
...timeline,
isLoading: timelineById[id].isLoading,
columns: mergeInLocalColumnConfig(timeline.columns),
initialized: timeline.initialized ?? timelineById[id].initialized,
resolveTimelineConfig,
dateRange:
@ -169,20 +184,24 @@ export const addNewTimeline = ({
templateTimelineVersion: 1,
}
: {};
const newTimeline = {
id,
...(timeline ? timeline : {}),
...timelineDefaults,
...timelineProps,
dateRange,
savedObjectId: null,
version: null,
isSaving: false,
isLoading: false,
timelineType,
...templateTimelineInfo,
};
return {
...timelineById,
[id]: {
id,
...(timeline ? timeline : {}),
...timelineDefaults,
...timelineProps,
dateRange,
savedObjectId: null,
version: null,
isSaving: false,
isLoading: false,
timelineType,
...templateTimelineInfo,
...newTimeline,
columns: mergeInLocalColumnConfig(newTimeline.columns),
},
};
};
@ -403,7 +422,7 @@ export const upsertTimelineColumn = ({
...timelineById,
[id]: {
...timeline,
columns: reordered,
columns: mergeInLocalColumnConfig(reordered),
},
};
}
@ -416,7 +435,7 @@ export const upsertTimelineColumn = ({
...timelineById,
[id]: {
...timeline,
columns,
columns: mergeInLocalColumnConfig(columns),
},
};
};
@ -440,57 +459,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),
},
};
};
@ -578,7 +547,7 @@ export const updateTimelineColumns = ({
...timelineById,
[id]: {
...timeline,
columns,
columns: mergeInLocalColumnConfig(columns),
},
};
};
@ -608,28 +577,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;
@ -702,7 +649,7 @@ export const updateTimelineRange = ({
interface UpdateTimelineSortParams {
id: string;
sort: Sort[];
sort: SortColumnTimeline[];
timelineById: TimelineById;
}
@ -1259,111 +1206,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<string, TimelineNonEcsData[]>;

View file

@ -12,6 +12,7 @@ import { favoriteTimelineMiddleware } from './timeline_favorite';
import { addNoteToTimelineMiddleware } from './timeline_note';
import { addPinnedEventToTimelineMiddleware } from './timeline_pinned_event';
import { saveTimelineMiddleware } from './timeline_save';
import { timelineLocalStorageMiddleware } from './timeline_localstorage';
export function createTimelineMiddlewares(kibana: CoreStart) {
return [
@ -20,5 +21,6 @@ export function createTimelineMiddlewares(kibana: CoreStart) {
addNoteToTimelineMiddleware(kibana),
addPinnedEventToTimelineMiddleware(kibana),
saveTimelineMiddleware(kibana),
timelineLocalStorageMiddleware,
];
}

View file

@ -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);
});
});

View file

@ -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<typeof LocalStorageColumnSettingsSchema>;
export const TIMELINE_COLUMNS_CONFIG_KEY = 'timeline:columnsConfig';
type UpdateColumnWidthAction = ReturnType<typeof updateColumnWidth>;
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<LocalStorageColumnSettings>(
(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));
}

View file

@ -94,10 +94,10 @@ import {
updateFilters,
updateTimelineSessionViewConfig,
setLoadingTableEvents,
removeTableColumn,
upsertTableColumn,
updateTableColumns,
updateTableSort,
removeTimelineColumn,
upsertTimelineColumn,
updateTimelineColumns,
updateTimelineSort,
setSelectedTableEvents,
setDeletedTableEvents,
setInitializeTimelineSettings,
@ -391,7 +391,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState)
}))
.case(removeColumn, (state, { id, columnId }) => ({
...state,
timelineById: removeTableColumn({
timelineById: removeTimelineColumn({
id,
columnId,
timelineById: state.timelineById,
@ -399,11 +399,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,
@ -421,7 +421,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,