mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
# 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:
parent
46b9080e7a
commit
f219410251
7 changed files with 311 additions and 231 deletions
|
@ -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>
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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[]>;
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue