Skip to content

Commit

Permalink
Locked Status and Assignee Controls for Alert Page (elastic#7820)
Browse files Browse the repository at this point in the history
  • Loading branch information
e40pud committed Oct 18, 2023
1 parent c3d4613 commit 508695f
Show file tree
Hide file tree
Showing 7 changed files with 352 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const TEST_IDS = {
EDIT: 'filter-group__context--edit',
DISCARD: `filter-group__context--discard`,
},
FILTER_BY_ASSIGNEES_BUTTON: 'filter-popover-button-assignees',
};

export const COMMON_OPTIONS_LIST_CONTROL_INPUTS: Partial<AddOptionsListControlProps> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';

import { FilterByAssigneesPopover } from './filter_by_assignees';
import { TEST_IDS } from './constants';
import { TestProviders } from '../../mock';

const mockUserProfiles: UserProfileWithAvatar[] = [
{
uid: 'user-id-1',
enabled: true,
user: { username: 'user1', full_name: 'User 1', email: '[email protected]' },
data: {},
},
{
uid: 'user-id-2',
enabled: true,
user: { username: 'user2', full_name: 'User 2', email: '[email protected]' },
data: {},
},
{
uid: 'user-id-3',
enabled: true,
user: { username: 'user3', full_name: 'User 3', email: '[email protected]' },
data: {},
},
];
jest.mock('../../../detections/containers/detection_engine/alerts/use_suggest_users', () => {
return {
useSuggestUsers: () => ({
loading: false,
userProfiles: mockUserProfiles,
}),
};
});

const renderFilterByAssigneesPopover = (alertAssignees: string[], onUsersChange = jest.fn()) =>
render(
<TestProviders>
<FilterByAssigneesPopover
existingAssigneesIds={alertAssignees}
onUsersChange={onUsersChange}
/>
</TestProviders>
);

describe('<FilterByAssigneesPopover />', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should render closed popover component', () => {
const { getByTestId, queryByTestId } = renderFilterByAssigneesPopover([]);

expect(getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON)).toBeInTheDocument();
expect(queryByTestId('euiSelectableList')).not.toBeInTheDocument();
});

it('should render opened popover component', () => {
const { getByTestId } = renderFilterByAssigneesPopover([]);

getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();
expect(getByTestId('euiSelectableList')).toBeInTheDocument();
});

it('should render assignees', () => {
const { getByTestId } = renderFilterByAssigneesPopover([]);

getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();

const assigneesList = getByTestId('euiSelectableList');
expect(assigneesList).toHaveTextContent('User 1');
expect(assigneesList).toHaveTextContent('[email protected]');
expect(assigneesList).toHaveTextContent('User 2');
expect(assigneesList).toHaveTextContent('[email protected]');
expect(assigneesList).toHaveTextContent('User 3');
expect(assigneesList).toHaveTextContent('[email protected]');
});

it('should call onUsersChange on clsing the popover', () => {
const onUsersChangeMock = jest.fn();
const { getByTestId, getByText } = renderFilterByAssigneesPopover([], onUsersChangeMock);

getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();

getByText('User 1').click();
getByText('User 2').click();
getByText('User 3').click();
getByText('User 3').click();
getByText('User 2').click();
getByText('User 1').click();

expect(onUsersChangeMock).toHaveBeenCalledTimes(6);
expect(onUsersChangeMock.mock.calls).toEqual([
[['user-id-1']],
[['user-id-2', 'user-id-1']],
[['user-id-3', 'user-id-2', 'user-id-1']],
[['user-id-2', 'user-id-1']],
[['user-id-1']],
[[]],
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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 { isEqual } from 'lodash/fp';
import type { FC } from 'react';
import React, { memo, useCallback, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { UserProfilesPopover } from '@kbn/user-profile-components';

import { EuiFilterButton } from '@elastic/eui';
import { useSuggestUsers } from '../../../detections/containers/detection_engine/alerts/use_suggest_users';
import { TEST_IDS } from './constants';

export interface FilterByAssigneesPopoverProps {
/**
* Ids of the users assigned to the alert
*/
existingAssigneesIds: string[];

/**
* Callback to handle changing of the assignees selection
*/
onUsersChange: (users: string[]) => void;
}

/**
* The popover to filter alerts by assigned users
*/
export const FilterByAssigneesPopover: FC<FilterByAssigneesPopoverProps> = memo(
({ existingAssigneesIds, onUsersChange }) => {
const [searchTerm, setSearchTerm] = useState('');
const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm);

const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []);

const [selectedAssignees, setSelectedAssignees] = useState<UserProfileWithAvatar[]>([]);
useEffect(() => {
if (isLoadingUsers) {
return;
}
const assignees = userProfiles.filter((user) => existingAssigneesIds.includes(user.uid));
setSelectedAssignees(assignees);
}, [existingAssigneesIds, isLoadingUsers, userProfiles]);

const handleSelectedAssignees = useCallback(
(newAssignees: UserProfileWithAvatar[]) => {
if (!isEqual(newAssignees, selectedAssignees)) {
setSelectedAssignees(newAssignees);
onUsersChange(newAssignees.map((user) => user.uid));
}
},
[onUsersChange, selectedAssignees]
);

const selectedStatusMessage = useCallback(
(total: number) =>
i18n.translate(
'xpack.securitySolution.flyout.right.visualizations.assignees.totalUsersAssigned',
{
defaultMessage: '{total, plural, one {# filter} other {# filters}} selected',
values: { total },
}
),
[]
);

return (
<UserProfilesPopover
title={i18n.translate(
'xpack.securitySolution.flyout.right.visualizations.assignees.popoverTitle',
{
defaultMessage: 'Assignees',
}
)}
button={
<EuiFilterButton
data-test-subj={TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON}
iconType="arrowDown"
onClick={togglePopover}
isLoading={isLoadingUsers}
isSelected={isPopoverOpen}
hasActiveFilters={selectedAssignees.length > 0}
numActiveFilters={selectedAssignees.length}
>
{i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', {
defaultMessage: 'Assignees',
})}
</EuiFilterButton>
}
isOpen={isPopoverOpen}
closePopover={togglePopover}
panelStyle={{
minWidth: 520,
}}
selectableProps={{
onSearchChange: (term: string) => {
setSearchTerm(term);
},
onChange: handleSelectedAssignees,
selectedStatusMessage,
options: userProfiles,
selectedOptions: selectedAssignees,
isLoading: isLoadingUsers,
height: 'full',
}}
/>
);
}
);

FilterByAssigneesPopover.displayName = 'FilterByAssigneesPopover';
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import type { ExistsFilter, Filter } from '@kbn/es-query';
import {
buildAlertAssigneesFilter,
buildAlertsFilter,
buildAlertStatusesFilter,
buildAlertStatusFilter,
Expand Down Expand Up @@ -158,6 +159,47 @@ describe('alerts default_config', () => {
});
});

describe('buildAlertAssigneesFilter', () => {
test('given an empty list of assignees ids will return an empty filter', () => {
const filters: Filter[] = buildAlertAssigneesFilter([]);
expect(filters).toHaveLength(0);
});

test('builds filter containing all assignees ids passed into function', () => {
const filters = buildAlertAssigneesFilter(['user-id-1', 'user-id-2', 'user-id-3']);
const expected = {
meta: {
alias: null,
disabled: false,
negate: false,
},
query: {
bool: {
should: [
{
term: {
'kibana.alert.workflow_assignee_ids': 'user-id-1',
},
},
{
term: {
'kibana.alert.workflow_assignee_ids': 'user-id-2',
},
},
{
term: {
'kibana.alert.workflow_assignee_ids': 'user-id-3',
},
},
],
},
},
};
expect(filters).toHaveLength(1);
expect(filters[0]).toEqual(expected);
});
});

// TODO: move these tests to ../timelines/components/timeline/body/events/event_column_view.tsx
// describe.skip('getAlertActions', () => {
// let setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ALERT_BUILDING_BLOCK_TYPE,
ALERT_WORKFLOW_STATUS,
ALERT_RULE_RULE_ID,
ALERT_WORKFLOW_ASSIGNEE_IDS,
} from '@kbn/rule-data-utils';

import type { Filter } from '@kbn/es-query';
Expand Down Expand Up @@ -152,6 +153,32 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean):
]
: [];

export const buildAlertAssigneesFilter = (assigneesIds: string[]): Filter[] => {
if (!assigneesIds.length) {
return [];
}
const combinedQuery = {
bool: {
should: assigneesIds.map((id) => ({
term: {
[ALERT_WORKFLOW_ASSIGNEE_IDS]: id,
},
})),
},
};

return [
{
meta: {
alias: null,
negate: false,
disabled: false,
},
query: combinedQuery,
},
];
};

export const getAlertsDefaultModel = (license?: LicenseService): SubsetDataTableModel => ({
...tableDefaults,
columns: getColumns(license),
Expand Down
Loading

0 comments on commit 508695f

Please sign in to comment.