mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Threat Hunting Investigations] Fix timeline column width bug (#214178)
## Summary Fixes: https://github.com/elastic/kibana/issues/213754 The issue above describes a bug in timeline that makes it impossible to change the width of a timeline column. This PR fixes that issue and makes sure that timeline column width settings are saved to localStorage. This mimics the behaviour of the alerts table elsewhere in security solution. https://github.com/user-attachments/assets/8b9803a0-406d-4f2d-ada5-4c0b76cd6ab8 --------- Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
a6ceafc891
commit
edbc618321
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 {
|
||||
|
@ -146,9 +149,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 }),
|
||||
|
@ -191,27 +209,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<
|
||||
|
@ -424,6 +434,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