Jatin Kathuria 2024-06-17 16:11:48 +02:00 committed by GitHub
parent c9bd32623a
commit 089c61efb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 493 additions and 225 deletions

View file

@ -19,7 +19,9 @@ import React, { memo, useEffect } from 'react';
*
* */
export const getCustomCellPopoverRenderer = () => {
return memo(function RenderCustomCellPopover(props: EuiDataGridCellPopoverElementProps) {
const RenderCustomCellPopoverMemoized = memo(function RenderCustomCellPopoverMemoized(
props: EuiDataGridCellPopoverElementProps
) {
const { setCellPopoverProps, DefaultCellPopover } = props;
useEffect(() => {
@ -30,4 +32,10 @@ export const getCustomCellPopoverRenderer = () => {
return <DefaultCellPopover {...props} />;
});
// Components passed to EUI DataGrid cannot be memoized components
// otherwise EUI throws an error `typeof Component !== 'function'`
return (props: EuiDataGridCellPopoverElementProps) => (
<RenderCustomCellPopoverMemoized {...props} />
);
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { memo, useEffect, useContext } from 'react';
import React, { useEffect, useContext } from 'react';
import { i18n } from '@kbn/i18n';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import {
@ -47,10 +47,7 @@ export const getRenderCellValueFn = ({
externalCustomRenderers?: CustomCellRenderer;
isPlainRecord?: boolean;
}) => {
/**
* memo is imperative here otherwise the cell will re-render on every hover on every cell
*/
return memo(function UnifiedDataTableRenderCellValue({
return function UnifiedDataTableRenderCellValue({
rowIndex,
columnId,
isDetails,
@ -149,7 +146,7 @@ export const getRenderCellValueFn = ({
}}
/>
);
});
};
};
/**

View file

@ -11,6 +11,7 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
import { TimelineTabs, TableId } from '@kbn/securitysolution-data-table';
import { selectTimelineById } from '../../../timelines/store/selectors';
import {
eventHasNotes,
getEventType,
@ -18,7 +19,7 @@ import {
} from '../../../timelines/components/timeline/body/helpers';
import { getScopedActions, isTimelineScope } from '../../../helpers';
import { useIsInvestigateInResolverActionEnabled } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { timelineActions, timelineSelectors } from '../../../timelines/store';
import { timelineActions } from '../../../timelines/store';
import type { ActionProps, OnPinEvent } from '../../../../common/types';
import { TimelineId } from '../../../../common/types';
import { AddEventNoteAction } from './add_note_icon_item';
@ -66,11 +67,10 @@ const ActionsComponent: React.FC<ActionProps> = ({
'unifiedComponentsInTimelineEnabled'
);
const emptyNotes: string[] = [];
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const timelineType = useShallowEqualSelector(
(state) =>
(isTimelineScope(timelineId) ? getTimeline(state, timelineId) : timelineDefaults).timelineType
const { timelineType } = useShallowEqualSelector((state) =>
isTimelineScope(timelineId) ? selectTimelineById(state, timelineId) : timelineDefaults
);
const { startTransaction } = useStartTransaction();
const isEnterprisePlus = useLicense().isEnterprise();
@ -213,8 +213,8 @@ const ActionsComponent: React.FC<ActionProps> = ({
onEventDetailsPanelOpened();
}, [activeStep, incrementStep, isTourAnchor, isTourShown, onEventDetailsPanelOpened]);
const showExpandEvent = useMemo(
() => !unifiedComponentsInTimelineEnabled || isEventViewer || timelineId !== TimelineId.active,
[isEventViewer, timelineId, unifiedComponentsInTimelineEnabled]
() => !unifiedComponentsInTimelineEnabled || isEventViewer,
[isEventViewer, unifiedComponentsInTimelineEnabled]
);
return (

View file

@ -16,8 +16,7 @@ import { TimelineTabs, TimelineId } from '../../../../common/types';
import { isFullScreen } from '../../../timelines/components/timeline/body/column_headers';
import { isActiveTimeline } from '../../../helpers';
import { getColumnHeader } from '../../../timelines/components/timeline/body/column_headers/helpers';
import { timelineActions, timelineSelectors } from '../../../timelines/store';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { timelineActions } from '../../../timelines/store';
import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use_full_screen';
import { useKibana } from '../../lib/kibana';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '.';
@ -27,6 +26,8 @@ import { EXIT_FULL_SCREEN } from '../exit_full_screen/translations';
import { EventsSelect } from '../../../timelines/components/timeline/body/column_headers/events_select';
import * as i18n from './translations';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { selectTimelineById } from '../../../timelines/store/selectors';
const SortingColumnsContainer = styled.div`
button {
@ -90,14 +91,14 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = memo(
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const dispatch = useDispatch();
const getManageTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const { defaultColumns } = useDeepEqualSelector((state) =>
getManageTimeline(state, timelineId)
);
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineEnabled'
);
const { defaultColumns } = useDeepEqualSelector((state) =>
selectTimelineById(state, timelineId)
);
const toggleFullScreen = useCallback(() => {
if (timelineId === TimelineId.active) {
setTimelineFullScreen(!timelineFullScreen);

View file

@ -152,8 +152,9 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
refetchQuery([timelineQuery]);
} else {
refetchQuery(globalQuery);
if (refetch) refetch();
}
if (refetch) refetch();
}, [scopeId, globalQuery, timelineQuery, refetch]);
const ruleIndex =

View file

@ -145,14 +145,16 @@ describe('EventColumnView', () => {
});
test('it renders correct tooltip for NotesButton - timeline template', () => {
(useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.template);
(useShallowEqualSelector as jest.Mock).mockReturnValue({
timelineType: TimelineType.template,
});
const wrapper = mount(<EventColumnView {...props} />, { wrappingComponent: TestProviders });
expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual(
NOTES_DISABLE_TOOLTIP
);
(useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default);
(useShallowEqualSelector as jest.Mock).mockReturnValue({ timelineType: TimelineType.default });
});
test('it does NOT render a pin button when isEventViewer is true', () => {

View file

@ -46,6 +46,8 @@ const defaultProps: UnifiedTimelineBodyProps = {
activePage: 0,
querySize: 0,
},
eventIdToNoteIds: {} as Record<string, string[]>,
pinnedEventIds: {} as Record<string, boolean>,
};
const renderTestComponents = (props?: UnifiedTimelineBodyProps) => {

View file

@ -92,8 +92,6 @@ export const EqlTabContentComponent: React.FC<Props> = ({
} = useSourcererDataView(SourcererScopeName.timeline);
const { augmentedColumnHeaders, timelineQueryFieldsFromColumns } = useTimelineColumns(columns);
const leadingControlColumns = useTimelineControlColumn(columns, TIMELINE_NO_SORTING);
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
'unifiedComponentsInTimelineEnabled'
);
@ -137,6 +135,14 @@ export const EqlTabContentComponent: React.FC<Props> = ({
timerangeKind,
});
const leadingControlColumns = useTimelineControlColumn({
columns,
sort: TIMELINE_NO_SORTING,
timelineId,
activeTab: TimelineTabs.eql,
refetch,
});
const isQueryLoading = useMemo(
() =>
dataLoadingState === DataLoadingState.loading ||

View file

@ -171,7 +171,13 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
timerangeKind: undefined,
});
const leadingControlColumns = useTimelineControlColumn(columns, sort);
const leadingControlColumns = useTimelineControlColumn({
columns,
sort,
timelineId,
activeTab: TimelineTabs.pinned,
refetch,
});
const isQueryLoading = useMemo(
() => [DataLoadingState.loading, DataLoadingState.loadingMore].includes(queryLoadingState),

View file

@ -203,7 +203,13 @@ export const QueryTabContentComponent: React.FC<Props> = ({
timerangeKind,
});
const leadingControlColumns = useTimelineControlColumn(columns, sort);
const leadingControlColumns = useTimelineControlColumn({
columns,
sort,
timelineId,
activeTab: TimelineTabs.query,
refetch,
});
useEffect(() => {
dispatch(

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { ComponentProps } from 'react';
import type { ComponentProps, FunctionComponent } from 'react';
import React, { useEffect } from 'react';
import QueryTabContent from '.';
import { defaultRowRenderers } from '../../body/renderers';
@ -15,7 +15,9 @@ import { useTimelineEventsDetails } from '../../../../containers/details';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { mockSourcererScope } from '../../../../../sourcerer/containers/mocks';
import {
createMockStore,
createSecuritySolutionStorageMock,
mockGlobalState,
mockTimelineData,
TestProviders,
} from '../../../../../common/mock';
@ -29,7 +31,13 @@ import { timelineActions } from '../../../../store';
import type { ExperimentalFeatures } from '../../../../../../common';
import { allowedExperimentalValues } from '../../../../../../common';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { cloneDeep, flatten } from 'lodash';
import { defaultUdtHeaders } from '../../unified_components/default_headers';
import { defaultColumnHeaderType } from '../../body/column_headers/default_headers';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks';
import userEvent from '@testing-library/user-event';
jest.mock('../../../../../common/components/user_privileges');
jest.mock('../../../../containers', () => ({
useTimelineEvents: jest.fn(),
@ -54,9 +62,17 @@ jest.mock('../../../../../common/lib/kuery');
jest.mock('../../../../../common/hooks/use_experimental_features');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(() => ({
pathname: '',
search: '',
})),
}));
// These tests can take more than standard timeout of 5s
// that is why we are setting it to 15s
const SPECIAL_TEST_TIMEOUT = 15000;
// that is why we are increasing it.
const SPECIAL_TEST_TIMEOUT = 50000;
const useIsExperimentalFeatureEnabledMock = jest.fn((feature: keyof ExperimentalFeatures) => {
if (feature === 'unifiedComponentsInTimelineEnabled') {
@ -67,7 +83,7 @@ const useIsExperimentalFeatureEnabledMock = jest.fn((feature: keyof Experimental
jest.mock('../../../../../common/lib/kibana');
// unified-field-list is is reporiting multiple analytics events
// unified-field-list is reporting multiple analytics events
jest.mock(`@kbn/analytics-client`);
const TestComponent = (props: Partial<ComponentProps<typeof QueryTabContent>>) => {
@ -98,44 +114,41 @@ const TestComponent = (props: Partial<ComponentProps<typeof QueryTabContent>>) =
return <QueryTabContent {...testComponentDefaultProps} {...props} />;
};
const customColumnOrder = [
...defaultUdtHeaders,
{
columnHeaderType: defaultColumnHeaderType,
id: 'event.severity',
},
];
const mockState = {
...structuredClone(mockGlobalState),
};
mockState.timeline.timelineById[TimelineId.test].columns = customColumnOrder;
const TestWrapper: FunctionComponent = ({ children }) => {
return <TestProviders store={createMockStore(mockState)}>{children}</TestProviders>;
};
const renderTestComponents = (props?: Partial<ComponentProps<typeof TestComponent>>) => {
return render(<TestComponent {...props} />, {
wrapper: TestProviders,
wrapper: TestWrapper,
});
};
const changeItemsPerPageTo = (newItemsPerPage: number) => {
fireEvent.click(screen.getByTestId('tablePaginationPopoverButton'));
fireEvent.click(screen.getByTestId(`tablePagination-${newItemsPerPage}-rows`));
expect(screen.getByTestId('tablePaginationPopoverButton')).toHaveTextContent(
`Rows per page: ${newItemsPerPage}`
);
};
const loadPageMock = jest.fn();
const useTimelineEventsMock = jest.fn(() => [
false,
{
events: cloneDeep(mockTimelineData),
pageInfo: {
activePage: 0,
totalPages: 10,
},
refreshedAt: Date.now(),
totalCount: 70,
loadPage: loadPageMock,
},
]);
const useSourcererDataViewMocked = jest.fn().mockReturnValue({
...mockSourcererScope,
});
const { storage: storageMock } = createSecuritySolutionStorageMock();
// Flaky : See https://github.com/elastic/kibana/issues/179831
describe.skip('query tab with unified timeline', () => {
let useTimelineEventsMock = jest.fn();
describe('query tab with unified timeline', () => {
const kibanaServiceMock: StartServices = {
...createStartServicesMock(),
storage: storageMock,
@ -149,9 +162,20 @@ describe.skip('query tab with unified timeline', () => {
});
beforeEach(() => {
// increase timeout for these tests as they are rendering a complete table with ~30 rows which can take time.
const ONE_SECOND = 1000;
jest.setTimeout(10 * ONE_SECOND);
useTimelineEventsMock = jest.fn(() => [
false,
{
events: structuredClone(mockTimelineData.slice(0, 1)),
pageInfo: {
activePage: 0,
totalPages: 3,
},
refreshedAt: Date.now(),
totalCount: 3,
loadPage: loadPageMock,
},
]);
HTMLElement.prototype.getBoundingClientRect = jest.fn(() => {
return {
width: 1000,
@ -176,6 +200,12 @@ describe.skip('query tab with unified timeline', () => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(
useIsExperimentalFeatureEnabledMock
);
(useUserPrivileges as jest.Mock).mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
endpointPrivileges: getEndpointPrivilegesInitialStateMock(),
detectionEnginePrivileges: { loading: false, error: undefined, result: undefined },
});
});
describe('render', () => {
@ -235,15 +265,39 @@ describe.skip('query tab with unified timeline', () => {
fireEvent.click(screen.getByLabelText('Closes this modal window'));
expect(screen.queryByTestId('row-renderers-modal')).toBeFalsy();
expect(screen.queryByTestId('row-renderers-modal')).not.toBeInTheDocument();
expect(screen.queryByTestId('timeline-row-renderer-0')).toBeFalsy();
expect(screen.queryByTestId('timeline-row-renderer-0')).not.toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
});
describe('pagination', () => {
beforeEach(() => {
// should return all the records instead just 3
// as the case in the default mock
useTimelineEventsMock = jest.fn(() => [
false,
{
events: structuredClone(mockTimelineData),
pageInfo: {
activePage: 0,
totalPages: 10,
},
refreshedAt: Date.now(),
totalCount: 70,
loadPage: loadPageMock,
},
]);
(useTimelineEvents as jest.Mock).mockImplementation(useTimelineEventsMock);
});
afterEach(() => {
jest.clearAllMocks();
});
it(
'should paginate correctly',
async () => {
@ -296,9 +350,14 @@ describe.skip('query tab with unified timeline', () => {
await waitFor(() => {
expect(screen.getByTestId('discoverDocTable')).toBeVisible();
});
const messageColumnIndex =
customColumnOrder.findIndex((header) => header.id === 'message') + 3;
// 3 is the offset for additional leading columns on left
expect(container.querySelector('[data-gridcell-column-id="message"]')).toHaveAttribute(
'data-gridcell-column-index',
'12'
String(messageColumnIndex)
);
expect(container.querySelector('[data-gridcell-column-id="message"]')).toBeInTheDocument();
@ -318,7 +377,7 @@ describe.skip('query tab with unified timeline', () => {
await waitFor(() => {
expect(container.querySelector('[data-gridcell-column-id="message"]')).toHaveAttribute(
'data-gridcell-column-index',
'11'
String(messageColumnIndex - 1)
);
});
},
@ -391,7 +450,7 @@ describe.skip('query tab with unified timeline', () => {
sort: [
{
direction: 'asc',
esTypes: [],
esTypes: ['date'],
field: '@timestamp',
type: 'date',
},
@ -439,7 +498,7 @@ describe.skip('query tab with unified timeline', () => {
sort: [
{
direction: 'desc',
esTypes: [],
esTypes: ['date'],
field: '@timestamp',
type: 'date',
},
@ -498,7 +557,7 @@ describe.skip('query tab with unified timeline', () => {
sort: [
{
direction: 'desc',
esTypes: [],
esTypes: ['date'],
field: '@timestamp',
type: 'date',
},
@ -547,7 +606,6 @@ describe.skip('query tab with unified timeline', () => {
SPECIAL_TEST_TIMEOUT
);
// Failing: See https://github.com/elastic/kibana/issues/179831
it(
'should be able to sort by multiple columns',
async () => {
@ -608,7 +666,7 @@ describe.skip('query tab with unified timeline', () => {
await waitFor(() => {
expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
'11'
String(customColumnOrder.length)
);
});
@ -620,7 +678,7 @@ describe.skip('query tab with unified timeline', () => {
// column not longer exists in the table
await waitFor(() => {
expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
'10'
String(customColumnOrder.length - 1)
);
});
expect(screen.queryAllByTestId(`dataGridHeaderCell-${field.name}`)).toHaveLength(0);
@ -640,7 +698,7 @@ describe.skip('query tab with unified timeline', () => {
await waitFor(() => {
expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
'11'
String(customColumnOrder.length)
);
});
@ -653,7 +711,7 @@ describe.skip('query tab with unified timeline', () => {
await waitFor(() => {
expect(screen.getByTestId('fieldListGroupedSelectedFields-count')).toHaveTextContent(
'12'
String(customColumnOrder.length + 1)
);
});
expect(screen.queryAllByTestId(`dataGridHeaderCell-${field.name}`)).toHaveLength(1);
@ -693,50 +751,64 @@ describe.skip('query tab with unified timeline', () => {
async () => {
renderTestComponents();
expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
await waitFor(() => {
expect(screen.getByTestId('fieldListGroupedAvailableFields-count')).toHaveTextContent(
'37'
);
});
expect(screen.getByTestId('fieldListGroupedFieldGroups')).toBeVisible();
fireEvent.click(screen.getByTitle('Hide sidebar'));
await waitFor(() => {
expect(screen.queryAllByTestId('fieldListGroupedAvailableFields-count')).toHaveLength(0);
expect(screen.queryByTestId('fieldListGroupedFieldGroups')).not.toBeInTheDocument();
});
},
SPECIAL_TEST_TIMEOUT
);
});
describe('row leading actions', () => {
it(
'should be able to add notes',
async () => {
renderTestComponents();
expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
await waitFor(() => {
expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('timeline-notes-button-small'));
await waitFor(() => {
expect(screen.getByTestId('add-note-container')).toBeVisible();
});
},
SPECIAL_TEST_TIMEOUT
);
it(
'should have all populated fields in Available fields section',
'should be cancel adding notes',
async () => {
const listOfPopulatedFields = new Set(
flatten(
mockTimelineData.map((dataItem) =>
dataItem.data.map((item) =>
item.value && item.value.length > 0 ? item.field : undefined
)
)
).filter((item) => typeof item !== 'undefined')
);
renderTestComponents();
expect(await screen.findByTestId('timeline-sidebar')).toBeVisible();
expect(await screen.findByTestId('discoverDocTable')).toBeVisible();
changeItemsPerPageTo(100);
await waitFor(() => {
expect(screen.getByTestId('timeline-notes-button-small')).not.toBeDisabled();
});
const availableFields = screen.getByTestId('fieldListGroupedAvailableFields');
fireEvent.click(screen.getByTestId('timeline-notes-button-small'));
for (const field of listOfPopulatedFields) {
fireEvent.change(screen.getByTestId('fieldListFiltersFieldSearch'), {
target: { value: field },
});
await waitFor(() => {
expect(screen.getByTestId('add-note-container')).toBeVisible();
});
expect(within(availableFields).getByTestId(`field-${field}`));
}
userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), 'Test Note 1');
expect(screen.getByTestId('cancel')).not.toBeDisabled();
fireEvent.click(screen.getByTestId('cancel'));
await waitFor(() => {
expect(screen.queryByTestId('add-note-container')).not.toBeInTheDocument();
});
},
SPECIAL_TEST_TIMEOUT
);

View file

@ -5,11 +5,7 @@ Array [
Object {
"headerCellRender": [Function],
"id": "default-timeline-control-column",
"rowCellRender": Object {
"$$typeof": Symbol(react.memo),
"compare": null,
"type": [Function],
},
"rowCellRender": [Function],
"width": 152,
},
]

View file

@ -10,6 +10,8 @@ import { renderHook } from '@testing-library/react-hooks';
import { useLicense } from '../../../../../common/hooks/use_license';
import { useTimelineControlColumn } from './use_timeline_control_columns';
import type { ColumnHeaderOptions } from '../../../../../../common/types/timeline/columns';
import { TimelineId } from '@kbn/timelines-plugin/public/store/timeline';
import { TimelineTabs } from '../../../../../../common/types';
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
@ -37,20 +39,42 @@ describe('useTimelineColumns', () => {
},
];
const refetchMock = jest.fn();
describe('leadingControlColumns', () => {
it('should return the leading control columns', () => {
const { result } = renderHook(() => useTimelineControlColumn(mockColumns, []), {
wrapper: TestProviders,
});
const { result } = renderHook(
() =>
useTimelineControlColumn({
columns: mockColumns,
sort: [],
timelineId: TimelineId.test,
activeTab: TimelineTabs.query,
refetch: refetchMock,
}),
{
wrapper: TestProviders,
}
);
expect(result.current).toMatchSnapshot();
});
it('should have a width of 124 for 5 actions', () => {
useLicenseMock.mockReturnValue({
isEnterprise: () => false,
});
const { result } = renderHook(() => useTimelineControlColumn(mockColumns, []), {
wrapper: TestProviders,
});
const { result } = renderHook(
() =>
useTimelineControlColumn({
columns: mockColumns,
sort: [],
timelineId: TimelineId.test,
activeTab: TimelineTabs.query,
refetch: refetchMock,
}),
{
wrapper: TestProviders,
}
);
const controlColumn = result.current[0] as EuiDataGridControlColumn;
expect(controlColumn.width).toBe(124);
});
@ -58,9 +82,19 @@ describe('useTimelineColumns', () => {
useLicenseMock.mockReturnValue({
isEnterprise: () => true,
});
const { result } = renderHook(() => useTimelineControlColumn(mockColumns, []), {
wrapper: TestProviders,
});
const { result } = renderHook(
() =>
useTimelineControlColumn({
columns: mockColumns,
sort: [],
timelineId: TimelineId.test,
activeTab: TimelineTabs.query,
refetch: refetchMock,
}),
{
wrapper: TestProviders,
}
);
const controlColumn = result.current[0] as EuiDataGridControlColumn;
expect(controlColumn.width).toBe(152);
});

View file

@ -6,7 +6,7 @@
*/
import React, { useMemo } from 'react';
import type { EuiDataGridControlColumn } from '@elastic/eui';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import type { SortColumnTable } from '@kbn/securitysolution-data-table';
import { useLicense } from '../../../../../common/hooks/use_license';
import { SourcererScopeName } from '../../../../../sourcerer/store/model';
@ -14,17 +14,32 @@ import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { getDefaultControlColumn } from '../../body/control_columns';
import type { UnifiedActionProps } from '../../unified_components/data_table/control_column_cell_render';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import type { TimelineTabs } from '../../../../../../common/types/timeline';
import { HeaderActions } from '../../../../../common/components/header_actions/header_actions';
import { ControlColumnCellRender } from '../../unified_components/data_table/control_column_cell_render';
import type { ColumnHeaderOptions } from '../../../../../../common/types';
import { useTimelineColumns } from './use_timeline_columns';
import type { TimelineDataGridCellContext } from '../../types';
interface UseTimelineControlColumnArgs {
columns: ColumnHeaderOptions[];
sort: SortColumnTable[];
timelineId: string;
activeTab: TimelineTabs;
refetch: () => void;
}
const EMPTY_STRING_ARRAY: string[] = [];
const noOp = () => {};
const noSelectAll = ({ isSelected }: { isSelected: boolean }) => {};
export const useTimelineControlColumn = (
columns: ColumnHeaderOptions[],
sort: SortColumnTable[]
) => {
export const useTimelineControlColumn = ({
columns,
sort,
timelineId,
activeTab,
refetch,
}: UseTimelineControlColumnArgs) => {
const { browserFields } = useSourcererDataView(SourcererScopeName.timeline);
const unifiedComponentsInTimelineEnabled = useIsExperimentalFeatureEnabled(
@ -55,14 +70,35 @@ export const useTimelineControlColumn = (
showSelectAllCheckbox={false}
showFullScreenToggle={false}
sort={sort}
tabType={TimelineTabs.pinned}
tabType={activeTab}
{...props}
timelineId={TimelineId.active}
timelineId={timelineId}
/>
);
},
rowCellRender: ControlColumnCellRender,
})) as unknown as EuiDataGridControlColumn[];
rowCellRender: (props: EuiDataGridCellValueElementProps & TimelineDataGridCellContext) => {
return (
<ControlColumnCellRender
{...props}
timelineId={timelineId}
ariaRowindex={props.rowIndex}
checked={false}
columnValues=""
data={props.events[props.rowIndex].data}
ecsData={props.events[props.rowIndex].ecs}
loadingEventIds={EMPTY_STRING_ARRAY}
eventId={props.events[props.rowIndex]?._id}
index={props.rowIndex}
onEventDetailsPanelOpened={noOp}
onRowSelected={noOp}
refetch={refetch}
showCheckboxes={false}
setEventsLoading={noOp}
setEventsDeleted={noOp}
/>
);
},
}));
} else {
return getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({
...x,
@ -76,5 +112,8 @@ export const useTimelineControlColumn = (
localColumns,
sort,
unifiedComponentsInTimelineEnabled,
timelineId,
activeTab,
refetch,
]);
};

View file

@ -0,0 +1,18 @@
/*
* 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 { TimelineItem } from '@kbn/timelines-plugin/common';
import type { TimelineModel } from '../../store/model';
export interface TimelineDataGridCellContext {
events: TimelineItem[];
pinnedEventIds: TimelineModel['pinnedEventIds'];
eventIdsAddingNotes: Set<string>;
onToggleShowNotes: (eventId?: string) => void;
eventIdToNoteIds: Record<string, string[]>;
refetch: () => void;
}

View file

@ -9,7 +9,6 @@ import React, { memo, useMemo } from 'react';
import type { TimelineItem } from '@kbn/timelines-plugin/common';
import { eventIsPinned } from '../../body/helpers';
import { Actions } from '../../../../../common/components/header_actions';
import { TimelineId } from '../../../../../../common/types';
import type { TimelineModel } from '../../../../store/model';
import type { ActionProps } from '../../../../../../common/types';
@ -20,8 +19,12 @@ export interface UnifiedActionProps extends ActionProps {
pinnedEventIds: TimelineModel['pinnedEventIds'];
}
export const ControlColumnCellRender = memo(function RowCellRender(props: UnifiedActionProps) {
const { rowIndex, events, ecsData, pinnedEventIds, onToggleShowNotes, eventIdToNoteIds } = props;
export const ControlColumnCellRender = memo(function ControlColumnCellRender(
props: UnifiedActionProps
) {
const { rowIndex, events, pinnedEventIds, onToggleShowNotes, eventIdToNoteIds, timelineId } =
props;
const event = useMemo(() => events && events[rowIndex], [events, rowIndex]);
const isPinned = useMemo(
() => eventIsPinned({ eventId: event?._id, pinnedEventIds }),
@ -32,17 +35,14 @@ export const ControlColumnCellRender = memo(function RowCellRender(props: Unifie
{...props}
ariaRowindex={rowIndex}
columnValues="columnValues"
ecsData={ecsData ?? event.ecs}
eventId={event?._id}
eventIdToNoteIds={eventIdToNoteIds}
isEventPinned={isPinned}
isEventViewer={false}
onEventDetailsPanelOpened={noOp}
onRuleChange={noOp}
showNotes={true}
timelineId={TimelineId.active}
timelineId={timelineId}
toggleShowNotes={onToggleShowNotes}
refetch={noOp}
rowIndex={rowIndex}
/>
);

View file

@ -17,15 +17,29 @@ import type { ComponentProps } from 'react';
import { getColumnHeaders } from '../../body/column_headers/helpers';
import { mockSourcererScope } from '../../../../../sourcerer/containers/mocks';
import { timelineActions } from '../../../../store';
import type { ExpandedDetailTimeline } from '../../../../../../common/types';
import { useUnifiedTableExpandableFlyout } from '../hooks/use_unified_timeline_expandable_flyout';
jest.mock('../../../../../sourcerer/containers');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(() => ({
pathname: '',
search: '',
})),
}));
const onFieldEditedMock = jest.fn();
const refetchMock = jest.fn();
const onEventClosedMock = jest.fn();
const onChangePageMock = jest.fn();
const openFlyoutMock = jest.fn();
const closeFlyoutMock = jest.fn();
const isExpandableFlyoutDisabled = false;
jest.mock('../hooks/use_unified_timeline_expandable_flyout');
const initialEnrichedColumns = getColumnHeaders(
defaultUdtHeaders,
mockSourcererScope.browserFields
@ -39,7 +53,7 @@ type TestComponentProps = Partial<ComponentProps<typeof TimelineDataTable>> & {
// These tests can take more than standard timeout of 5s
// that is why we are setting it to 10s
const SPECIAL_TEST_TIMEOUT = 10000;
const SPECIAL_TEST_TIMEOUT = 50000;
const TestComponent = (props: TestComponentProps) => {
const { store = createMockStore(), ...restProps } = props;
@ -81,10 +95,17 @@ const getTimelineFromStore = (
return store.getState().timeline.timelineById[timelineId];
};
// FLAKY: https://github.com/elastic/kibana/issues/179843
describe.skip('unified data table', () => {
describe('unified data table', () => {
beforeEach(() => {
(useSourcererDataView as jest.Mock).mockReturnValue(mockSourcererScope);
(useUnifiedTableExpandableFlyout as jest.Mock).mockReturnValue({
isExpandableFlyoutDisabled,
openFlyout: openFlyoutMock,
closeFlyout: closeFlyoutMock,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it(
@ -269,86 +290,11 @@ describe.skip('unified data table', () => {
fireEvent.click(screen.getAllByTestId('docTableExpandToggleColumn')[0]);
await waitFor(() => {
expect(screen.getByTestId('timeline:details-panel:flyout')).toBeVisible();
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
});
},
SPECIAL_TEST_TIMEOUT
);
it(
'should show details flyout when expandedDetails state is set',
async () => {
const customMockStore = createMockStore();
const mockExpandedDetail: ExpandedDetailTimeline = {
query: {
params: {
eventId: 'some_id',
indexName: 'security-*',
},
panelView: 'eventDetail',
},
};
customMockStore.dispatch(
timelineActions.toggleDetailPanel({
id: TimelineId.test,
tabType: TimelineTabs.query,
...mockExpandedDetail.query,
})
);
render(
<TestComponent
store={customMockStore}
showExpandedDetails={true}
expandedDetail={mockExpandedDetail}
/>
);
await waitFor(() => {
expect(screen.getByTestId('timeline:details-panel:flyout')).toBeVisible();
});
},
SPECIAL_TEST_TIMEOUT
);
it(
'should close details flyout when close icon is clicked',
async () => {
const customMockStore = createMockStore();
const mockExpandedDetail: ExpandedDetailTimeline = {
query: {
params: {
eventId: 'some_id',
indexName: 'security-*',
},
panelView: 'eventDetail',
},
};
customMockStore.dispatch(
timelineActions.toggleDetailPanel({
id: TimelineId.test,
tabType: TimelineTabs.query,
...mockExpandedDetail.query,
})
);
render(
<TestComponent
store={customMockStore}
showExpandedDetails={true}
expandedDetail={mockExpandedDetail}
/>
);
await waitFor(() => {
expect(screen.getByTestId('euiFlyoutCloseButton')).toBeVisible();
});
fireEvent.click(screen.getByTestId('euiFlyoutCloseButton'));
expect(onEventClosedMock).toHaveBeenCalledTimes(1);
},
SPECIAL_TEST_TIMEOUT
);
});
describe('pagination', () => {

View file

@ -58,6 +58,14 @@ jest.mock('../../../../common/lib/kuery');
jest.mock('../../../../common/hooks/use_experimental_features');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(() => ({
pathname: '',
search: '',
})),
}));
const useIsExperimentalFeatureEnabledMock = jest.fn((feature: keyof ExperimentalFeatures) => {
if (feature === 'unifiedComponentsInTimelineEnabled') {
return true;
@ -79,8 +87,8 @@ const columnsToDisplay = [
];
// These tests can take more than standard timeout of 5s
// that is why we are setting it to 10s
const SPECIAL_TEST_TIMEOUT = 10000;
// that is why we are increasing the timeout
const SPECIAL_TEST_TIMEOUT = 50000;
const localMockedTimelineData = structuredClone(mockTimelineData);
@ -110,6 +118,8 @@ const TestComponent = (props: Partial<ComponentProps<typeof UnifiedTimeline>>) =
dataLoadingState: DataLoadingState.loaded,
updatedAt: Date.now(),
isTextBasedQuery: false,
eventIdToNoteIds: {} as Record<string, string[]>,
pinnedEventIds: {} as Record<string, boolean>,
};
const dispatch = useDispatch();
@ -188,8 +198,6 @@ describe('unified timeline', () => {
});
beforeEach(() => {
const ONE_SECOND = 1000;
jest.setTimeout(10 * ONE_SECOND);
HTMLElement.prototype.getBoundingClientRect = jest.fn(() => {
return {
width: 1000,
@ -216,9 +224,7 @@ describe('unified timeline', () => {
);
});
// Flaky : See https://github.com/elastic/kibana/issues/179831
// removing/moving column current leads to infitinite loop, will be fixed in further PRs.
describe.skip('columns', () => {
describe('columns', () => {
it(
'should move column left correctly ',
async () => {
@ -297,7 +303,7 @@ describe('unified timeline', () => {
SPECIAL_TEST_TIMEOUT
);
it.skip(
it(
'should remove column ',
async () => {
const field = {
@ -539,9 +545,7 @@ describe('unified timeline', () => {
);
});
// FLAKY: https://github.com/elastic/kibana/issues/180937
// FLAKY: https://github.com/elastic/kibana/issues/180956
describe.skip('unified field list', () => {
describe('unified field list', () => {
it(
'should be able to add filters',
async () => {

View file

@ -50,6 +50,7 @@ import { timelineActions } from '../../../store';
import type { TimelineModel } from '../../../store/model';
import { getFieldsListCreationOptions } from './get_fields_list_creation_options';
import { defaultUdtHeaders } from './default_headers';
import type { TimelineDataGridCellContext } from '../types';
const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({
className: `${className}`,
@ -119,8 +120,8 @@ interface Props {
dataView: DataView;
trailingControlColumns?: EuiDataGridProps['trailingControlColumns'];
leadingControlColumns?: EuiDataGridProps['leadingControlColumns'];
pinnedEventIds?: TimelineModel['pinnedEventIds'];
eventIdToNoteIds?: TimelineModel['eventIdToNoteIds'];
pinnedEventIds: TimelineModel['pinnedEventIds'];
eventIdToNoteIds: TimelineModel['eventIdToNoteIds'];
}
const UnifiedTimelineComponent: React.FC<Props> = ({
@ -170,8 +171,10 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
} = timelineDataService;
const [eventIdsAddingNotes, setEventIdsAddingNotes] = useState<Set<string>>(new Set());
const onToggleShowNotes = useCallback(
(eventId: string) => {
(eventId?: string) => {
if (!eventId) return;
const newSet = new Set(eventIdsAddingNotes);
if (newSet.has(eventId)) {
newSet.delete(eventId);
@ -370,7 +373,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
onFieldEdited();
}, [onFieldEdited]);
const cellContext = useMemo(() => {
const cellContext: TimelineDataGridCellContext = useMemo(() => {
return {
events,
pinnedEventIds,

View file

@ -0,0 +1,48 @@
/*
* 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 { getNewRule } from '../../../objects/rule';
import { deleteAlertsAndRules } from '../../../tasks/api_calls/common';
import { createRule } from '../../../tasks/api_calls/rules';
import { login } from '../../../tasks/login';
import { visitWithTimeRange } from '../../../tasks/navigation';
import { openTimelineUsingToggle } from '../../../tasks/security_main';
import { ALERTS_URL } from '../../../urls/navigation';
import {
createNewTimeline,
executeTimelineKQL,
executeTimelineSearch,
openTimelineEventContextMenu,
} from '../../../tasks/timeline';
import { MARK_ALERT_ACKNOWLEDGED_BTN } from '../../../screens/alerts';
import { GET_TIMELINE_GRID_CELL } from '../../../screens/timeline';
describe(
'Timeline table Row Actions',
{
tags: ['@ess', '@serverless', '@skipInServerlessMKI'],
},
() => {
beforeEach(() => {
deleteAlertsAndRules();
createRule(getNewRule());
login();
visitWithTimeRange(ALERTS_URL);
openTimelineUsingToggle();
createNewTimeline();
executeTimelineSearch('*');
});
it('should refresh the table when alert status is changed', () => {
executeTimelineKQL('kibana.alert.workflow_status:open');
cy.get(GET_TIMELINE_GRID_CELL('@timestamp')).should('have.length', 1);
openTimelineEventContextMenu();
cy.get(MARK_ALERT_ACKNOWLEDGED_BTN).click();
cy.get(GET_TIMELINE_GRID_CELL('@timestamp')).should('have.length', 0);
});
}
);

View file

@ -0,0 +1,57 @@
/*
* 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 { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
import { getNewRule } from '../../../../objects/rule';
import { createRule } from '../../../../tasks/api_calls/rules';
import { MARK_ALERT_ACKNOWLEDGED_BTN } from '../../../../screens/alerts';
import { GET_UNIFIED_DATA_GRID_CELL } from '../../../../screens/unified_timeline';
import { login } from '../../../../tasks/login';
import { visitWithTimeRange } from '../../../../tasks/navigation';
import { openTimelineUsingToggle } from '../../../../tasks/security_main';
import {
createNewTimeline,
executeTimelineKQL,
executeTimelineSearch,
openTimelineEventContextMenu,
} from '../../../../tasks/timeline';
import { ALERTS_URL } from '../../../../urls/navigation';
describe(
'Unified Timeline table Row Actions',
{
tags: ['@ess', '@serverless', '@skipInServerlessMKI'],
env: {
ftrConfig: {
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'unifiedComponentsInTimelineEnabled',
])}`,
],
},
},
},
() => {
beforeEach(() => {
deleteAlertsAndRules();
createRule(getNewRule());
login();
visitWithTimeRange(ALERTS_URL);
openTimelineUsingToggle();
createNewTimeline();
executeTimelineSearch('*');
});
it('should refresh the table when alert status is changed', () => {
executeTimelineKQL('kibana.alert.workflow_status:open');
cy.get(GET_UNIFIED_DATA_GRID_CELL('@timestamp', 0)).should('be.visible');
openTimelineEventContextMenu();
cy.get(MARK_ALERT_ACKNOWLEDGED_BTN).click();
cy.get(GET_UNIFIED_DATA_GRID_CELL('@timestamp', 0)).should('not.exist');
});
}
);

View file

@ -89,12 +89,14 @@ import {
BOTTOM_BAR_TIMELINE_PLUS_ICON,
BOTTOM_BAR_CREATE_NEW_TIMELINE,
BOTTOM_BAR_CREATE_NEW_TIMELINE_TEMPLATE,
TIMELINE_FLYOUT,
} from '../screens/timeline';
import { REFRESH_BUTTON, TIMELINE, TIMELINES_TAB_TEMPLATE } from '../screens/timelines';
import { drag, drop, waitForTabToBeLoaded } from './common';
import { closeFieldsBrowser, filterFieldsBrowser } from './fields_browser';
import { TIMELINE_CONTEXT_MENU_BTN } from '../screens/alerts';
const hostExistsQuery = 'host.name: *';
@ -505,3 +507,23 @@ export const selectKqlSearchMode = () => {
cy.get(TIMELINE_SEARCH_OR_FILTER).click();
cy.get(TIMELINE_KQLMODE_SEARCH).click();
};
export const openTimelineEventContextMenu = (rowIndex: number = 0) => {
cy.get(TIMELINE_FLYOUT).within(() => {
const togglePopover = () => {
cy.get(TIMELINE_CONTEXT_MENU_BTN).eq(rowIndex).should('be.visible');
cy.get(TIMELINE_CONTEXT_MENU_BTN).eq(rowIndex).click();
cy.get(TIMELINE_CONTEXT_MENU_BTN)
.first()
.should('be.visible')
.then(($btnEl) => {
if ($btnEl.attr('data-popover-open') !== 'true') {
cy.log(`${TIMELINE_CONTEXT_MENU_BTN} was flaky, attempting to re-open popover`);
togglePopover();
}
});
};
togglePopover();
});
};