[9.0] [Threat Hunting Investigations] Fix timeline column width bug (#214178) (#214473)

# Backport

This will backport the following commits from `main` to `9.0`:
- [[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","Team:Threat
Hunting:Investigations","backport:all-open","v9.1.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":"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"}}]}]
BACKPORT-->

Co-authored-by: Jan Monschke <jan.monschke@elastic.co>
This commit is contained in:
Kibana Machine 2025-03-14 07:57:30 +11:00 committed by GitHub
parent 57c26701f7
commit 6c1aa0a4d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 306 additions and 382 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 {
@ -147,9 +150,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 }),
@ -192,27 +210,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<
@ -425,6 +435,7 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
externalControlColumns={leadingControlColumns}
onUpdatePageIndex={onUpdatePageIndex}
getRowIndicator={getTimelineRowTypeIndicator}
settings={settings}
/>
</StyledTimelineUnifiedDataTable>
</StatefulEventContext.Provider>

View file

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

View file

@ -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', () => {

View file

@ -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<string, TimelineNonEcsData[]>;
@ -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,

View file

@ -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,
];
}

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

@ -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: {