mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# Backport This will backport the following commits from `main` to `8.18`: - [[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:
parent
319e8f2ba3
commit
f2c8c4c0ce
8 changed files with 306 additions and 382 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 {
|
||||
|
@ -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<
|
||||
|
@ -426,6 +436,7 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
|
|||
externalControlColumns={leadingControlColumns}
|
||||
onUpdatePageIndex={onUpdatePageIndex}
|
||||
getRowIndicator={getTimelineRowTypeIndicator}
|
||||
settings={settings}
|
||||
/>
|
||||
</StyledTimelineUnifiedDataTable>
|
||||
</StatefulEventContext.Provider>
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
|
@ -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: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue