[Cases] Filter user activities pagination (#152702)

## Summary

Implements #130227



https://user-images.githubusercontent.com/117571355/224038340-6b1c8cc7-3795-4412-8e67-a026dfe10776.mov



### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### Flaky Test Runner
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2059

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2023-04-04 09:25:21 +02:00 committed by GitHub
parent 8b68ce8558
commit 6eb2c5ed91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 2360 additions and 741 deletions

View file

@ -31,6 +31,7 @@ import {
caseViewProps,
defaultGetCase,
defaultGetCaseMetrics,
defaultInfiniteUseFindCaseUserActions,
defaultUpdateCaseState,
defaultUseFindCaseUserActions,
} from './mocks';
@ -39,6 +40,8 @@ import { userProfiles } from '../../containers/user_profiles/api.mock';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { CASE_VIEW_PAGE_TABS } from '../../../common/types';
import { getCaseConnectorsMockResponse } from '../../common/mock/connectors';
import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions';
import { useGetCaseUserActionsStats } from '../../containers/use_get_case_user_actions_stats';
const mockSetTitle = jest.fn();
@ -46,6 +49,8 @@ jest.mock('../../containers/use_get_action_license');
jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_metrics');
jest.mock('../../containers/use_find_case_user_actions');
jest.mock('../../containers/use_infinite_find_case_user_actions');
jest.mock('../../containers/use_get_case_user_actions_stats');
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/use_get_case');
jest.mock('../../containers/configure/use_get_supported_action_connectors');
@ -80,6 +85,8 @@ const useUrlParamsMock = useUrlParams as jest.Mock;
const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock;
const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock;
const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock;
const useGetCaseUserActionsStatsMock = useGetCaseUserActionsStats as jest.Mock;
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
const useGetCaseConnectorsMock = useGetCaseConnectors as jest.Mock;
@ -111,6 +118,12 @@ export const caseClosedProps: CaseViewPageProps = {
caseData: basicCaseClosed,
};
const userActionsStats = {
total: 21,
totalComments: 9,
totalOtherActions: 11,
};
describe('CaseViewPage', () => {
const updateCaseProperty = defaultUpdateCaseState.updateCaseProperty;
const pushCaseToExternalService = jest.fn();
@ -118,14 +131,15 @@ describe('CaseViewPage', () => {
let appMockRenderer: AppMockRenderer;
const caseConnectors = getCaseConnectorsMockResponse();
const caseUsers = getCaseUsersMockResponse();
const refetchFindCaseUserActions = jest.fn();
beforeEach(() => {
mockGetCase();
jest.clearAllMocks();
mockGetCase();
useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState);
useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics);
useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions);
useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions);
useGetCaseUserActionsStatsMock.mockReturnValue({ data: userActionsStats, isLoading: false });
usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService });
useGetCaseConnectorsMock.mockReturnValue({
isLoading: false,
@ -349,22 +363,16 @@ describe('CaseViewPage', () => {
});
});
it('should show loading content when loading user actions', async () => {
it('should show loading content when loading user actions stats', async () => {
const useFetchAlertData = jest.fn().mockReturnValue([true]);
useFindCaseUserActionsMock.mockReturnValue({
data: undefined,
isError: false,
isLoading: true,
isFetching: true,
refetch: refetchFindCaseUserActions,
});
useGetCaseUserActionsStatsMock.mockReturnValue({ isLoading: true });
const result = appMockRenderer.render(
<CaseViewPage {...caseProps} useFetchAlertData={useFetchAlertData} />
);
await waitFor(() => {
expect(result.getByTestId('case-view-loading-content')).toBeInTheDocument();
expect(result.queryByTestId('user-actions')).not.toBeInTheDocument();
expect(result.queryByTestId('user-actions-list')).not.toBeInTheDocument();
});
});
@ -374,7 +382,7 @@ describe('CaseViewPage', () => {
<CaseViewPage {...caseProps} showAlertDetails={showAlertDetails} />
);
userEvent.click(result.getByTestId('comment-action-show-alert-alert-action-id'));
userEvent.click(result.getAllByTestId('comment-action-show-alert-alert-action-id')[1]);
await waitFor(() => {
expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1');
@ -387,7 +395,7 @@ describe('CaseViewPage', () => {
await waitFor(() => {
expect(
result
.getByTestId('user-action-alert-comment-create-action-alert-action-id')
.getAllByTestId('user-action-alert-comment-create-action-alert-action-id')[1]
.querySelector('.euiCommentEvent__headerEvent')
).toHaveTextContent('added an alert from Awesome rule');
});

View file

@ -32,10 +32,12 @@ import { useGetCaseConnectors } from '../../../containers/use_get_case_connector
import { useGetCaseUsers } from '../../../containers/use_get_case_users';
import { waitForComponentToUpdate } from '../../../common/test_utils';
import { getCaseConnectorsMockResponse } from '../../../common/mock/connectors';
import { defaultUseFindCaseUserActions } from '../mocks';
import { defaultInfiniteUseFindCaseUserActions, defaultUseFindCaseUserActions } from '../mocks';
import { ActionTypes } from '../../../../common/api';
import { useGetCaseUserActionsStats } from '../../../containers/use_get_case_user_actions_stats';
import { useInfiniteFindCaseUserActions } from '../../../containers/use_infinite_find_case_user_actions';
jest.mock('../../../containers/use_infinite_find_case_user_actions');
jest.mock('../../../containers/use_find_case_user_actions');
jest.mock('../../../containers/use_get_case_user_actions_stats');
jest.mock('../../../containers/configure/use_get_supported_action_connectors');
@ -82,8 +84,13 @@ const caseViewProps: CaseViewProps = {
},
],
};
const filterActionType = 'all';
const sortOrder = 'asc';
const userActivityQueryParams = {
type: 'all',
sortOrder: 'asc',
page: 1,
perPage: 10,
};
const pushCaseToExternalService = jest.fn();
@ -105,6 +112,7 @@ export const caseProps = {
const caseUsers = getCaseUsersMockResponse();
const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock;
const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock;
const useGetCaseUserActionsStatsMock = useGetCaseUserActionsStats as jest.Mock;
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
@ -119,6 +127,7 @@ describe.skip('Case View Page activity tab', () => {
beforeAll(() => {
useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions);
useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions);
useGetCaseUserActionsStatsMock.mockReturnValue({ data: userActionsStats, isLoading: false });
useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false });
usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService });
@ -138,24 +147,40 @@ describe.skip('Case View Page activity tab', () => {
});
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions);
useGetCaseUsersMock.mockReturnValue({ isLoading: false, data: caseUsers });
});
it('should render the activity content and main components', async () => {
appMockRender = createAppMockRenderer({ license: platinumLicense });
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(result.getByTestId('case-view-activity')).toBeInTheDocument();
expect(result.getByTestId('user-actions')).toBeInTheDocument();
expect(result.getByTestId('case-tags')).toBeInTheDocument();
expect(result.getByTestId('connector-edit-header')).toBeInTheDocument();
expect(result.getByTestId('case-view-status-action-button')).toBeInTheDocument();
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
expect(screen.getByTestId('case-view-activity')).toBeInTheDocument();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
expect(screen.getByTestId('case-tags')).toBeInTheDocument();
expect(screen.getByTestId('connector-edit-header')).toBeInTheDocument();
expect(screen.getByTestId('case-view-status-action-button')).toBeInTheDocument();
await waitForComponentToUpdate();
});
it('should call use get user actions as per top and bottom actions list', async () => {
appMockRender = createAppMockRenderer({ license: platinumLicense });
appMockRender.render(<CaseViewActivity {...caseProps} />);
const lastPageForAll = Math.ceil(userActionsStats.total / userActivityQueryParams.perPage);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
userActivityQueryParams,
true
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
{ ...userActivityQueryParams, page: lastPageForAll },
true
);
await waitForComponentToUpdate();
});
@ -168,14 +193,10 @@ describe.skip('Case View Page activity tab', () => {
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(result.getByTestId('case-view-activity')).toBeInTheDocument();
expect(result.getByTestId('user-actions')).toBeInTheDocument();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
expect(result.getByTestId('case-tags')).toBeInTheDocument();
expect(result.getByTestId('connector-edit-header')).toBeInTheDocument();
expect(result.queryByTestId('case-view-status-action-button')).not.toBeInTheDocument();
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
await waitForComponentToUpdate();
});
@ -188,32 +209,20 @@ describe.skip('Case View Page activity tab', () => {
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(result.getByTestId('case-view-activity')).toBeInTheDocument();
expect(result.getByTestId('user-actions')).toBeInTheDocument();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
expect(result.getByTestId('case-tags')).toBeInTheDocument();
expect(result.getByTestId('connector-edit-header')).toBeInTheDocument();
expect(result.getByTestId('case-severity-selection')).toBeDisabled();
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
await waitForComponentToUpdate();
});
it('should show a loading when is fetching data is true and hide the user actions activity', () => {
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
isFetching: true,
isLoading: true,
});
it('should show a loading when loading user actions stats', () => {
useGetCaseUserActionsStatsMock.mockReturnValue({ isLoading: true });
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(result.getByTestId('case-view-loading-content')).toBeInTheDocument();
expect(result.queryByTestId('case-view-activity')).not.toBeInTheDocument();
expect(result.queryByTestId('user-actions')).not.toBeInTheDocument();
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
expect(result.queryByTestId('user-actions-list')).not.toBeInTheDocument();
});
it('should not render the assignees on basic license', () => {
@ -251,104 +260,114 @@ describe.skip('Case View Page activity tab', () => {
describe('filter activity', () => {
beforeEach(() => {
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
});
jest.clearAllMocks();
useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions);
useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions);
useGetCaseUserActionsStatsMock.mockReturnValue({ data: userActionsStats, isLoading: false });
});
it('should show all filter as active', async () => {
appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
const lastPageForAll = Math.ceil(userActionsStats.total / userActivityQueryParams.perPage);
userEvent.click(screen.getByTestId('user-actions-filter-activity-button-all'));
await waitFor(() => {
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: 'all',
sortOrder,
});
expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id);
expect(screen.getByLabelText(`${userActionsStats.total - 1} active filters`));
expect(screen.getByLabelText(`${userActionsStats.totalComments} available filters`));
expect(
screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`)
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
userActivityQueryParams,
true
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
{ ...userActivityQueryParams, page: lastPageForAll },
true
);
expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id);
});
await waitFor(() => {
expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id);
expect(screen.getByLabelText(`${userActionsStats.total} active filters`));
expect(screen.getByLabelText(`${userActionsStats.totalComments} available filters`));
expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`));
});
});
it('should show comment filter as active', async () => {
appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
const lastPageForComment = Math.ceil(
userActionsStats.totalComments / userActivityQueryParams.perPage
);
userEvent.click(screen.getByTestId('user-actions-filter-activity-button-comments'));
await waitFor(() => {
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: 'user',
sortOrder,
});
expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id);
expect(screen.getByLabelText(`${userActionsStats.totalComments} active filters`));
expect(screen.getByLabelText(`${userActionsStats.total - 1} available filters`));
expect(
screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`)
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
{ ...userActivityQueryParams, type: 'user' },
true
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
{ ...userActivityQueryParams, type: 'user', page: lastPageForComment },
false
);
});
await waitFor(() => {
expect(screen.getByLabelText(`${userActionsStats.totalComments} active filters`));
expect(screen.getByLabelText(`${userActionsStats.total} available filters`));
expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`));
});
});
it('should show history filter as active', async () => {
appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
const lastPageForHistory = Math.ceil(
userActionsStats.totalOtherActions / userActivityQueryParams.perPage
);
userEvent.click(screen.getByTestId('user-actions-filter-activity-button-history'));
await waitFor(() => {
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: 'action',
sortOrder,
});
expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id);
expect(screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} active filters`));
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
{ ...userActivityQueryParams, type: 'action' },
true
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
caseData.id,
{ ...userActivityQueryParams, type: 'action', page: lastPageForHistory },
true
);
});
await waitFor(() => {
expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id);
expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} active filters`));
expect(screen.getByLabelText(`${userActionsStats.totalComments} available filters`));
expect(screen.getByLabelText(`${userActionsStats.total - 1} available filters`));
expect(screen.getByLabelText(`${userActionsStats.total} available filters`));
});
});
it('should render by desc sort order', async () => {
appMockRender.render(<CaseViewActivity {...caseProps} />);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: filterActionType,
sortOrder,
});
const sortSelect = screen.getByTestId('user-actions-sort-select');
fireEvent.change(sortSelect, { target: { value: 'desc' } });
await waitFor(() => {
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(caseData.id, {
type: 'all',
sortOrder: 'desc',
});
expect(useGetCaseUserActionsStatsMock).toHaveBeenCalledWith(caseData.id);
expect(screen.getByLabelText(`${userActionsStats.total - 1} active filters`));
expect(screen.getByLabelText(`${userActionsStats.total} active filters`));
expect(screen.getByLabelText(`${userActionsStats.totalComments} available filters`));
expect(
screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`)
);
expect(screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`));
});
});
});
@ -496,7 +515,7 @@ describe.skip('Case View Page activity tab', () => {
appMockRender = createAppMockRenderer();
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
const userActions = within(result.getByTestId('user-actions'));
const userActions = within(result.getAllByTestId('user-actions-list')[1]);
await waitFor(() => {
expect(userActions.getByText('cases_no_connectors')).toBeInTheDocument();
@ -524,7 +543,7 @@ describe.skip('Case View Page activity tab', () => {
appMockRender = createAppMockRenderer();
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
const userActions = within(result.getByTestId('user-actions'));
const userActions = within(result.getAllByTestId('user-actions-list')[1]);
await waitFor(() => {
expect(userActions.getByText('Fuzzy Marten')).toBeInTheDocument();
@ -580,7 +599,7 @@ describe.skip('Case View Page activity tab', () => {
appMockRender = createAppMockRenderer();
const result = appMockRender.render(<CaseViewActivity {...caseProps} />);
const userActions = within(result.getByTestId('user-actions'));
const userActions = within(result.getAllByTestId('user-actions-list')[1]);
await waitFor(() => {
expect(userActions.getByText('Participant 1')).toBeInTheDocument();

View file

@ -7,7 +7,7 @@
/* eslint-disable complexity */
import { EuiFlexGroup, EuiFlexItem, EuiSkeletonText, EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { isEqual } from 'lodash';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
@ -29,7 +29,6 @@ import { useOnUpdateField } from '../use_on_update_field';
import { useCasesContext } from '../../cases_context/use_cases_context';
import * as i18n from '../translations';
import { SeveritySidebarSelector } from '../../severity/sidebar_selector';
import { useFindCaseUserActions } from '../../../containers/use_find_case_user_actions';
import { useGetCaseUserActionsStats } from '../../../containers/use_get_case_user_actions_stats';
import { AssignUsers } from './assign_users';
import { UserActionsActivityBar } from '../../user_actions_activity_bar';
@ -90,6 +89,8 @@ export const CaseViewActivity = ({
const [userActivityQueryParams, setUserActivityQueryParams] = useState<UserActivityParams>({
type: 'all',
sortOrder: 'asc',
page: 1,
perPage: 10,
});
const { permissions } = useCasesContext();
@ -100,11 +101,6 @@ export const CaseViewActivity = ({
caseData.id
);
const { data: userActionsData, isLoading: isLoadingUserActions } = useFindCaseUserActions(
caseData.id,
userActivityQueryParams
);
const { data: userActionsStats, isLoading: isLoadingUserActionsStats } =
useGetCaseUserActionsStats(caseData.id);
@ -180,25 +176,11 @@ export const CaseViewActivity = ({
[onUpdateField]
);
const showUserActions =
!isLoadingUserActions &&
!isLoadingCaseConnectors &&
userActionsData &&
caseConnectors &&
caseUsers;
const showConnectorSidebar =
pushToServiceAuthorized && userActionsData && caseConnectors && supportedActionConnectors;
const reporterAsArray =
caseUsers?.reporter != null
? [caseUsers.reporter]
: [convertToCaseUserWithProfileInfo(caseData.createdBy)];
const handleUserActionsActivityChanged = useCallback(
(params: UserActivityParams) => {
setUserActivityQueryParams((oldParams) => ({
...oldParams,
page: 1,
type: params.type,
sortOrder: params.sortOrder,
}));
@ -206,6 +188,22 @@ export const CaseViewActivity = ({
[setUserActivityQueryParams]
);
const showUserActions =
!isLoadingUserActionsStats &&
!isLoadingCaseConnectors &&
!isLoadingCaseUsers &&
caseConnectors &&
caseUsers &&
userActionsStats;
const showConnectorSidebar =
pushToServiceAuthorized && caseConnectors && supportedActionConnectors;
const reporterAsArray =
caseUsers?.reporter != null
? [caseUsers.reporter]
: [convertToCaseUserWithProfileInfo(caseData.createdBy)];
const isLoadingDescription = isLoading && loadingKey === 'description';
return (
@ -228,43 +226,38 @@ export const CaseViewActivity = ({
/>
</EuiFlexItem>
<EuiSpacer size="l" />
<EuiSkeletonText
lines={8}
data-test-subj="case-view-loading-content"
isLoading={isLoadingUserActions || isLoadingCaseConnectors}
>
{(isLoadingUserActionsStats || isLoadingCaseConnectors || isLoadingCaseUsers) && (
<EuiLoadingSpinner data-test-subj="case-view-loading-content" size="l" />
)}
{showUserActions ? (
<EuiFlexGroup direction="column" responsive={false} data-test-subj="case-view-activity">
<EuiFlexItem>
{showUserActions && (
<UserActions
userProfiles={userProfiles}
currentUserProfile={currentUserProfile}
getRuleDetailsHref={ruleDetailsNavigation?.href}
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
caseConnectors={caseConnectors}
caseUserActions={userActionsData.userActions}
data={caseData}
actionsNavigation={actionsNavigation}
isLoadingUserActions={isLoadingUserActions}
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
statusActionButton={
permissions.update ? (
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
isLoading={isLoading && loadingKey === 'status'}
/>
) : null
}
filterOptions={userActivityQueryParams.type}
useFetchAlertData={useFetchAlertData}
/>
)}
<UserActions
userProfiles={userProfiles}
currentUserProfile={currentUserProfile}
getRuleDetailsHref={ruleDetailsNavigation?.href}
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
caseConnectors={caseConnectors}
data={caseData}
actionsNavigation={actionsNavigation}
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
statusActionButton={
permissions.update ? (
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
isLoading={isLoading && loadingKey === 'status'}
/>
) : null
}
useFetchAlertData={useFetchAlertData}
userActivityQueryParams={userActivityQueryParams}
userActionsStats={userActionsStats}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSkeletonText>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={2}>
<EuiFlexGroup direction="column" responsive={false} gutterSize="xl">
@ -297,7 +290,7 @@ export const CaseViewActivity = ({
dataTestSubj="case-view-user-list-participants"
theCase={caseData}
headline={i18n.PARTICIPANTS}
loading={isLoadingUserActions}
loading={isLoadingCaseUsers}
users={[...caseUsers.participants, ...caseUsers.assignees]}
userProfiles={userProfiles}
/>

View file

@ -100,9 +100,22 @@ export const defaultUpdateCaseState = {
};
export const defaultUseFindCaseUserActions = {
data: { userActions: [...caseUserActions, getAlertUserAction()] },
data: { total: 4, perPage: 10, page: 1, userActions: [...caseUserActions, getAlertUserAction()] },
refetch: jest.fn(),
isLoading: false,
isFetching: false,
isError: false,
};
export const defaultInfiniteUseFindCaseUserActions = {
data: {
pages: [
{ total: 4, perPage: 10, page: 1, userActions: [...caseUserActions, getAlertUserAction()] },
],
},
isLoading: false,
isFetching: false,
isError: false,
hasNextPage: false,
fetchNextPage: jest.fn(),
};

View file

@ -15,9 +15,11 @@ import { createSeverityUserActionBuilder } from './severity';
import { createStatusUserActionBuilder } from './status';
import { createTagsUserActionBuilder } from './tags';
import { createTitleUserActionBuilder } from './title';
import { createCaseUserActionBuilder } from './create_case';
import type { UserActionBuilderMap } from './types';
export const builderMap: UserActionBuilderMap = {
create_case: createCaseUserActionBuilder,
connector: createConnectorUserActionBuilder,
tags: createTagsUserActionBuilder,
title: createTitleUserActionBuilder,

View file

@ -11,7 +11,7 @@ import type { SupportedUserActionTypes } from './types';
export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft';
export const UNSUPPORTED_ACTION_TYPES = ['create_case', 'delete_case'] as const;
export const UNSUPPORTED_ACTION_TYPES = ['delete_case'] as const;
export const SUPPORTED_ACTION_TYPES: SupportedUserActionTypes[] = Object.keys(
omit(ActionTypes, UNSUPPORTED_ACTION_TYPES)
) as SupportedUserActionTypes[];

View file

@ -0,0 +1,45 @@
/*
* 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 { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions } from '../../../common/api';
import { getUserAction } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { createCaseUserActionBuilder } from './create_case';
import { getMockBuilderArgs } from './mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
describe('createCaseUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
const userAction = getUserAction('create_case', Actions.create);
// @ts-ignore no need to pass all the arguments
const builder = createCaseUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('created case "a title"')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { CreateCaseUserAction } from '../../../common/api';
import type { UserActionBuilder, UserActionResponse } from './types';
import { createCommonUpdateUserActionBuilder } from './common';
import * as i18n from './translations';
const getLabelTitle = (userAction: UserActionResponse<CreateCaseUserAction>) =>
`${i18n.CREATE_CASE.toLowerCase()} "${userAction.payload.title}"`;
export const createCaseUserActionBuilder: UserActionBuilder = ({
userAction,
userProfiles,
handleOutlineComment,
}) => ({
build: () => {
const createCaseUserAction = userAction as UserActionResponse<CreateCaseUserAction>;
const label = getLabelTitle(createCaseUserAction);
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
userProfiles,
handleOutlineComment,
label,
icon: 'dot',
});
return commonBuilder.build();
},
});

View file

@ -62,7 +62,7 @@ describe('helpers', () => {
['title', true],
['status', true],
['settings', true],
['create_case', false],
['create_case', true],
['delete_case', false],
];

View file

@ -6,64 +6,79 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { waitFor, screen } from '@testing-library/react';
import { waitFor, screen, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
// eslint-disable-next-line @kbn/eslint/module_migration
import routeData from 'react-router';
import { useUpdateComment } from '../../containers/use_update_comment';
import {
basicCase,
getUserAction,
caseUserActions,
getHostIsolationUserAction,
getUserAction,
hostIsolationComment,
} from '../../containers/mock';
import { UserActions } from '.';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer, TestProviders } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { Actions } from '../../../common/api';
import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock';
import { connectorsMock, getCaseConnectorsMockResponse } from '../../common/mock/connectors';
import type { UserActivityFilter } from '../user_actions_activity_bar/types';
import { getCaseConnectorsMockResponse } from '../../common/mock/connectors';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions';
import {
defaultInfiniteUseFindCaseUserActions,
defaultUseFindCaseUserActions,
} from '../case_view/mocks';
import { waitForComponentToUpdate } from '../../common/test_utils';
import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions';
import { getMockBuilderArgs } from './mock';
const fetchUserActions = jest.fn();
const onUpdateField = jest.fn();
const updateCase = jest.fn();
const onShowAlertDetails = jest.fn();
const filterOptions: UserActivityFilter = 'all';
const userActionsStats = {
total: 25,
totalComments: 9,
totalOtherActions: 16,
};
const userActivityQueryParams: UserActivityParams = {
type: 'all',
sortOrder: 'asc',
page: 1,
perPage: 10,
};
const builderArgs = getMockBuilderArgs();
const defaultProps = {
caseUserActions,
...builderArgs,
caseConnectors: getCaseConnectorsMockResponse(),
caseUserActions: [],
userProfiles: new Map(),
currentUserProfile: undefined,
connectors: connectorsMock,
actionsNavigation: { href: jest.fn(), onClick: jest.fn() },
getRuleDetailsHref: jest.fn(),
onRuleDetailsClick: jest.fn(),
data: basicCase,
fetchUserActions,
isLoadingUserActions: false,
manualAlertsData: { 'some-id': { _id: 'some-id' } },
onUpdateField,
selectedAlertPatterns: ['some-test-pattern'],
userActivityQueryParams,
userActionsStats,
statusActionButton: null,
updateCase,
useFetchAlertData: (): [boolean, Record<string, unknown>] => [
false,
{ 'some-id': { _id: 'some-id' } },
],
alerts: {},
onShowAlertDetails,
filterOptions,
};
jest.mock('../../containers/use_infinite_find_case_user_actions');
jest.mock('../../containers/use_find_case_user_actions');
jest.mock('../../containers/use_update_comment');
jest.mock('./timestamp', () => ({
UserActionTimestamp: () => <></>,
}));
jest.mock('../../common/lib/kibana');
const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock;
const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock;
const useUpdateCommentMock = useUpdateComment as jest.Mock;
const patchComment = jest.fn();
@ -79,47 +94,38 @@ describe(`UserActions`, () => {
isLoadingIds: [],
patchComment,
});
useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions);
useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions);
jest.spyOn(routeData, 'useParams').mockReturnValue({ detailName: 'case-id' });
appMockRender = createAppMockRenderer();
});
it('Loading spinner when user actions loading and displays fullName/username', () => {
appMockRender.render(
<UserActions
{...{ ...defaultProps, currentUserProfile: userProfiles[0], isLoadingUserActions: true }}
/>
);
expect(screen.getByTestId('user-actions-loading')).toBeInTheDocument();
expect(screen.getByTestId('case-user-profile-avatar-damaged_raccoon')).toBeInTheDocument();
expect(screen.getByText('DR')).toBeInTheDocument();
});
it('Renders service now update line with top and bottom when push is required', async () => {
const caseConnectors = getCaseConnectorsMockResponse({ 'push.needsToBePushed': true });
const ourActions = [
getUserAction('pushed', 'push_to_service', {
createdAt: '2023-01-17T09:46:29.813Z',
}),
];
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: ourActions },
});
const props = {
...defaultProps,
caseConnectors,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
);
appMockRender.render(<UserActions {...props} />);
await waitForComponentToUpdate();
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toEqual(true);
expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toEqual(true);
expect(screen.getByTestId('top-footer')).toBeInTheDocument();
expect(screen.getByTestId('bottom-footer')).toBeInTheDocument();
});
});
@ -130,300 +136,190 @@ describe(`UserActions`, () => {
}),
];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: ourActions },
});
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
);
appMockRender.render(<UserActions {...defaultProps} />);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toEqual(true);
expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toEqual(false);
expect(screen.getByTestId('top-footer')).toBeInTheDocument();
expect(screen.queryByTestId('bottom-footer')).not.toBeInTheDocument();
});
});
it('Outlines comment when update move to link is clicked', async () => {
const ourActions = [
getUserAction('comment', Actions.create),
getUserAction('comment', Actions.update),
];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
);
expect(
wrapper
.find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`)
.first()
.hasClass('outlined')
).toEqual(false);
wrapper
.find(
`[data-test-subj="comment-update-action-${ourActions[1].id}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]`
)
.first()
.simulate('click');
await waitFor(() => {
expect(
wrapper
.find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`)
.first()
.hasClass('outlined')
).toEqual(true);
});
});
it('Switches to markdown when edit is clicked and back to panel when canceled', async () => {
const ourActions = [getUserAction('comment', Actions.create)];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: ourActions },
});
appMockRender.render(<UserActions {...defaultProps} />);
userEvent.click(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).getByTestId('property-actions-user-action-ellipses')
);
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]`
)
.first()
.simulate('click');
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]`
)
.first()
.simulate('click');
await waitForEuiPopoverOpen();
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]`
)
.first()
.simulate('click');
userEvent.click(screen.getByTestId('property-actions-user-action-pencil'));
userEvent.click(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).getByTestId('user-action-cancel-markdown')
);
await waitFor(() => {
expect(
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
)
.exists()
).toEqual(false);
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).queryByTestId('user-action-markdown-form')
).not.toBeInTheDocument();
});
});
it('calls update comment when comment markdown is saved', async () => {
const ourActions = [getUserAction('comment', Actions.create)];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: ourActions },
});
appMockRender.render(<UserActions {...defaultProps} />);
userEvent.click(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).getByTestId('property-actions-user-action-ellipses')
);
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]`
)
.first()
.simulate('click');
await waitForEuiPopoverOpen();
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]`
)
.first()
.simulate('click');
userEvent.click(screen.getByTestId('property-actions-user-action-pencil'));
wrapper
.find(`.euiMarkdownEditorTextArea`)
.first()
.simulate('change', {
target: { value: sampleData.content },
});
await waitForComponentToUpdate();
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] button[data-test-subj="user-action-save-markdown"]`
)
.first()
.simulate('click');
fireEvent.change(screen.getAllByTestId(`euiMarkdownEditorTextArea`)[0], {
target: { value: sampleData.content },
});
userEvent.click(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).getByTestId('user-action-save-markdown')
);
await waitFor(() => {
wrapper.update();
expect(
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
)
.exists()
).toEqual(false);
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).queryByTestId('user-action-markdown-form')
).not.toBeInTheDocument();
expect(patchComment).toBeCalledWith({
commentUpdate: sampleData.content,
caseId: 'case-id',
commentId: props.data.comments[0].id,
version: props.data.comments[0].version,
commentId: defaultProps.data.comments[0].id,
version: defaultProps.data.comments[0].version,
});
});
});
it('shows quoted text in last MarkdownEditorTextArea', async () => {
const quoteableText = `> Solve this fast! \n\n`;
const ourActions = [getUserAction('comment', Actions.create)];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: ourActions },
});
appMockRender.render(<UserActions {...defaultProps} />);
expect((await screen.findByTestId(`euiMarkdownEditorTextArea`)).textContent).not.toContain(
quoteableText
);
expect(wrapper.find(`.euiMarkdownEditorTextArea`).text()).not.toContain(quoteableText);
userEvent.click(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).getByTestId('property-actions-user-action-ellipses')
);
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]`
)
.first()
.simulate('click');
await waitForEuiPopoverOpen();
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-quote"]`
)
.first()
.simulate('click');
userEvent.click(screen.getByTestId('property-actions-user-action-quote'));
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).first().text()).toContain(
quoteableText
);
expect(screen.getAllByTestId('add-comment')[0].textContent).toContain(quoteableText);
});
});
it('does not show add comment markdown when history filter is selected', async () => {
appMockRender.render(<UserActions {...defaultProps} filterOptions="action" />);
appMockRender.render(
<UserActions
{...defaultProps}
userActivityQueryParams={{ ...userActivityQueryParams, type: 'action' }}
/>
);
await waitFor(() => {
expect(screen.queryByTestId('add-comment')).not.toBeInTheDocument();
});
});
it('Outlines comment when url param is provided', async () => {
const commentId = 'basic-comment-id';
jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId });
const ourActions = [getUserAction('comment', Actions.create)];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find(`[data-test-subj="comment-create-action-${commentId}"]`)
.first()
.hasClass('outlined')
).toEqual(true);
});
});
it('it should persist the draft of new comment while existing old comment is updated', async () => {
const editedComment = 'it is an edited comment';
const newComment = 'another cool comment';
const ourActions = [getUserAction('comment', Actions.create)];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
);
// type new comment in text area
wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: newComment } });
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-ellipses"]`
)
.first()
.simulate('click');
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-user-action-pencil"]`
)
.first()
.simulate('click');
wrapper
.find(`.euiMarkdownEditorTextArea`)
.first()
.simulate('change', {
target: { value: editedComment },
});
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] button[data-test-subj="user-action-save-markdown"]`
)
.first()
.simulate('click');
await waitFor(() => {
wrapper.update();
expect(
wrapper
.find(
`[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]`
)
.exists()
).toEqual(false);
expect(patchComment).toBeCalledWith({
commentUpdate: editedComment,
caseId: 'case-id',
commentId: props.data.comments[0].id,
version: props.data.comments[0].version,
});
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: ourActions },
});
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(newComment);
appMockRender.render(<UserActions {...defaultProps} />);
userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea'));
userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), newComment);
userEvent.click(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).getByTestId('property-actions-user-action-ellipses')
);
await waitForEuiPopoverOpen();
userEvent.click(screen.getByTestId('property-actions-user-action-pencil'));
fireEvent.change(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], {
target: { value: editedComment },
});
userEvent.click(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).getByTestId('user-action-save-markdown')
);
await waitFor(() => {
expect(
within(
screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1]
).queryByTestId('user-action-markdown-form')
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getAllByTestId('add-comment')[1].textContent).toContain(newComment);
});
});
describe('Host isolation action', () => {
@ -431,17 +327,17 @@ describe(`UserActions`, () => {
const isolateAction = [getHostIsolationUserAction()];
const props = {
...defaultProps,
caseUserActions: isolateAction,
data: { ...defaultProps.data, comments: [...basicCase.comments, hostIsolationComment()] },
};
const wrapper = mount(
<TestProviders>
<UserActions {...props} />
</TestProviders>
);
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: isolateAction },
});
appMockRender.render(<UserActions {...props} />);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="endpoint-action"]`).exists()).toBe(true);
expect(screen.getByTestId('endpoint-action')).toBeInTheDocument();
});
});
@ -452,18 +348,263 @@ describe(`UserActions`, () => {
const props = {
...defaultProps,
userProfiles: userProfilesMap,
caseUserActions: isolateAction,
data: {
...defaultProps.data,
comments: [hostIsolationComment({ createdBy: { profileUid: userProfiles[0].uid } })],
},
};
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: isolateAction },
});
appMockRender.render(<UserActions {...props} />);
expect(screen.getByTestId('case-user-profile-avatar-damaged_raccoon')).toBeInTheDocument();
expect(screen.getByText('DR')).toBeInTheDocument();
expect(screen.getByText('Damaged Raccoon')).toBeInTheDocument();
expect(
screen.getAllByTestId('case-user-profile-avatar-damaged_raccoon')[0]
).toBeInTheDocument();
expect(screen.getAllByText('DR')[0]).toBeInTheDocument();
expect(screen.getAllByText('Damaged Raccoon')[0]).toBeInTheDocument();
});
});
describe('pagination', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Loading spinner when user actions loading', () => {
useFindCaseUserActionsMock.mockReturnValue({ isLoading: true });
useInfiniteFindCaseUserActionsMock.mockReturnValue({ isLoading: true });
appMockRender.render(
<UserActions {...{ ...defaultProps, currentUserProfile: userProfiles[0] }} />
);
expect(screen.getByTestId('user-actions-loading')).toBeInTheDocument();
});
it('renders two user actions list when user actions are more than 10', () => {
appMockRender.render(<UserActions {...defaultProps} />);
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
});
it('renders only one user actions list when last page is 0', async () => {
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: [] },
});
const props = {
...defaultProps,
userActionsStats: {
total: 0,
totalComments: 0,
totalOtherActions: 0,
},
};
appMockRender.render(<UserActions {...props} />);
await waitForComponentToUpdate();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(1);
});
it('renders only one user actions list when last page is 1', async () => {
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: [] },
});
const props = {
...defaultProps,
userActionsStats: {
total: 1,
totalComments: 0,
totalOtherActions: 1,
},
};
appMockRender.render(<UserActions {...props} />);
await waitForComponentToUpdate();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(1);
});
it('renders only one action list when user actions are less than or equal to 10', async () => {
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: { userActions: [] },
});
const props = {
...defaultProps,
userActionsStats: {
total: 10,
totalComments: 6,
totalOtherActions: 4,
},
};
appMockRender.render(<UserActions {...props} />);
await waitForComponentToUpdate();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(1);
});
it('shows more button visible when hasNext page is true', async () => {
useInfiniteFindCaseUserActionsMock.mockReturnValue({
...defaultInfiniteUseFindCaseUserActions,
hasNextPage: true,
});
const props = {
...defaultProps,
userActionsStats: {
total: 25,
totalComments: 10,
totalOtherActions: 15,
},
};
appMockRender.render(<UserActions {...props} />);
await waitForComponentToUpdate();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
expect(screen.getByTestId('cases-show-more-user-actions')).toBeInTheDocument();
});
it('call fetchNextPage on showMore button click', async () => {
useInfiniteFindCaseUserActionsMock.mockReturnValue({
...defaultInfiniteUseFindCaseUserActions,
hasNextPage: true,
});
const props = {
...defaultProps,
userActionsStats: {
total: 25,
totalComments: 10,
totalOtherActions: 15,
},
};
appMockRender.render(<UserActions {...props} />);
await waitForComponentToUpdate();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
const showMore = screen.getByTestId('cases-show-more-user-actions');
expect(showMore).toBeInTheDocument();
userEvent.click(showMore);
await waitFor(() => {
expect(defaultInfiniteUseFindCaseUserActions.fetchNextPage).toHaveBeenCalled();
});
});
it('shows more button visible 21st user action added', async () => {
const mockUserActions = [
...caseUserActions,
getUserAction('comment', Actions.create),
getUserAction('comment', Actions.update),
getUserAction('comment', Actions.create),
getUserAction('comment', Actions.update),
getUserAction('comment', Actions.create),
getUserAction('comment', Actions.update),
getUserAction('comment', Actions.create),
];
useInfiniteFindCaseUserActionsMock.mockReturnValue({
...defaultInfiniteUseFindCaseUserActions,
data: {
pages: [
{
total: 20,
page: 1,
perPage: 10,
userActions: mockUserActions,
},
],
},
});
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: {
total: 20,
page: 2,
perPage: 10,
userActions: mockUserActions,
},
});
const props = {
...defaultProps,
userActionsStats: {
total: 20,
totalComments: 10,
totalOtherActions: 10,
},
};
const { rerender } = appMockRender.render(<UserActions {...props} />);
await waitForComponentToUpdate();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
expect(screen.queryByTestId('cases-show-more-user-actions')).not.toBeInTheDocument();
useInfiniteFindCaseUserActionsMock.mockReturnValue({
...defaultInfiniteUseFindCaseUserActions,
data: {
pages: [
{
total: 21,
page: 1,
perPage: 10,
userActions: mockUserActions,
},
{
total: 21,
page: 2,
perPage: 10,
userActions: [getUserAction('comment', Actions.create)],
},
],
},
hasNextPage: true,
});
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: {
total: 21,
page: 2,
perPage: 10,
userActions: mockUserActions,
},
});
const newProps = {
...props,
userActionsStats: {
total: 21,
totalComments: 11,
totalOtherActions: 10,
},
};
rerender(<UserActions {...newProps} />);
await waitForComponentToUpdate();
expect(screen.getAllByTestId('user-actions-list')).toHaveLength(2);
const firstUserActionsList = screen.getAllByTestId('user-actions-list')[0];
expect(firstUserActionsList.getElementsByTagName('li')).toHaveLength(11);
expect(screen.getByTestId('cases-show-more-user-actions')).toBeInTheDocument();
});
});
});

View file

@ -5,16 +5,14 @@
* 2.0.
*/
import type { EuiCommentProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiCommentList } from '@elastic/eui';
import React, { useMemo, useState, useEffect } from 'react';
import styled from 'styled-components';
import { EuiFlexItem, EuiSkeletonText, useEuiTheme } from '@elastic/eui';
import type { EuiThemeComputed } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import { AddComment } from '../add_comment';
import { useCaseViewParams } from '../../common/navigation';
import { builderMap } from './builder';
import { isUserActionTypeSupported, getManualAlertIdsWithNoRuleId } from './helpers';
import { getManualAlertIdsWithNoRuleId } from './helpers';
import type { UserActionTreeProps } from './types';
import { useUserActionsHandler } from './use_user_actions_handler';
import { NEW_COMMENT_ID } from './constants';
@ -22,234 +20,200 @@ import { useCasesContext } from '../cases_context/use_cases_context';
import { UserToolTip } from '../user_profiles/user_tooltip';
import { Username } from '../user_profiles/username';
import { HoverableAvatar } from '../user_profiles/hoverable_avatar';
import { UserActionsList } from './user_actions_list';
import { useUserActionsPagination } from './use_user_actions_pagination';
import { useLastPageUserActions } from './use_user_actions_last_page';
import { ShowMoreButton } from './show_more_button';
import { useLastPage } from './use_last_page';
const MyEuiFlexGroup = styled(EuiFlexGroup)`
margin-bottom: 8px;
`;
const MyEuiCommentList = styled(EuiCommentList)`
${({ theme }) => `
& .userAction__comment.outlined .euiCommentEvent {
outline: solid 5px ${theme.eui.euiColorVis1_behindText};
margin: 0.5em;
transition: 0.8s;
}
& .draftFooter {
& .euiCommentEvent__body {
padding: 0;
const getIconsCss = (hasNextPage: boolean | undefined, euiTheme: EuiThemeComputed<{}>): string => {
const customSize = hasNextPage
? {
showMoreSectionSize: euiTheme.size.xxxl,
marginTopShowMoreSectionSize: euiTheme.size.xxl,
marginBottomShowMoreSectionSize: euiTheme.size.xxl,
}
}
: {
showMoreSectionSize: euiTheme.size.s,
marginTopShowMoreSectionSize: euiTheme.size.m,
marginBottomShowMoreSectionSize: euiTheme.size.m,
};
& .euiComment.isEdit {
& .euiCommentEvent {
border: none;
box-shadow: none;
}
const blockSize = `${customSize.showMoreSectionSize} + ${customSize.marginTopShowMoreSectionSize} +
${customSize.marginBottomShowMoreSectionSize}`;
return `
.commentList--hasShowMore
[class*='euiTimelineItem-center']:last-child:not(:only-child)
> [class*='euiTimelineItemIcon-']::before {
block-size: calc(
100% + ${blockSize}
);
}
.commentList--hasShowMore
[class*='euiTimelineItem-center']:first-child
> [class*='euiTimelineItemIcon-']::before {
inset-block-start: 0%;
block-size: calc(
100% + ${blockSize}
);
}
.commentList--hasShowMore
[class*='euiTimelineItem-']
> [class*='euiTimelineItemIcon-']::before {
block-size: calc(
100% + ${blockSize}
);
}
`;
};
& .euiCommentEvent__body {
padding: 0;
}
& .euiCommentEvent__header {
display: none;
}
}
& .comment-alert .euiCommentEvent {
background-color: ${theme.eui.euiColorLightestShade};
border: ${theme.eui.euiBorderThin};
padding: ${theme.eui.euiSizeS};
border-radius: ${theme.eui.euiSizeXS};
}
& .comment-alert .euiCommentEvent__headerData {
flex-grow: 1;
}
& .comment-action.empty-comment [class*="euiCommentEvent-regular"] {
box-shadow: none;
.euiCommentEvent__header {
padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeS};
border-bottom: 0;
}
}
`}
`;
export const UserActions = React.memo(
({
caseConnectors,
caseUserActions,
userProfiles,
export const UserActions = React.memo((props: UserActionTreeProps) => {
const {
currentUserProfile,
data: caseData,
getRuleDetailsHref,
actionsNavigation,
isLoadingUserActions,
onRuleDetailsClick,
onShowAlertDetails,
onUpdateField,
statusActionButton,
useFetchAlertData,
filterOptions,
}: UserActionTreeProps) => {
const { detailName: caseId, commentId } = useCaseViewParams();
const [initLoading, setInitLoading] = useState(true);
const {
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
appId,
} = useCasesContext();
userActivityQueryParams,
userActionsStats,
} = props;
const { detailName: caseId } = useCaseViewParams();
const alertIdsWithoutRuleInfo = useMemo(
() => getManualAlertIdsWithNoRuleId(caseData.comments),
[caseData.comments]
);
const { lastPage } = useLastPage({ userActivityQueryParams, userActionsStats });
const [loadingAlertData, manualAlertsData] = useFetchAlertData(alertIdsWithoutRuleInfo);
const {
infiniteCaseUserActions,
isLoadingInfiniteUserActions,
hasNextPage,
fetchNextPage,
showBottomList,
isFetchingNextPage,
} = useUserActionsPagination({
userActivityQueryParams,
caseId: caseData.id,
lastPage,
});
const {
loadingCommentIds,
commentRefs,
selectedOutlineCommentId,
manageMarkdownEditIds,
handleManageMarkdownEditId,
handleOutlineComment,
handleSaveComment,
handleManageQuote,
handleDeleteComment,
handleUpdate,
} = useUserActionsHandler();
const { euiTheme } = useEuiTheme();
const MarkdownNewComment = useMemo(
() => (
<AddComment
id={NEW_COMMENT_ID}
caseId={caseId}
ref={(element) => (commentRefs.current[NEW_COMMENT_ID] = element)}
onCommentPosted={handleUpdate}
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_COMMENT_ID)}
showLoading={false}
statusActionButton={statusActionButton}
/>
),
[caseId, handleUpdate, handleManageMarkdownEditId, statusActionButton, commentRefs]
);
const { isLoadingLastPageUserActions, lastPageUserActions } = useLastPageUserActions({
userActivityQueryParams,
caseId: caseData.id,
lastPage,
});
useEffect(() => {
if (initLoading && !isLoadingUserActions && loadingCommentIds.length === 0) {
setInitLoading(false);
if (commentId != null) {
handleOutlineComment(commentId);
}
}
}, [commentId, initLoading, isLoadingUserActions, loadingCommentIds, handleOutlineComment]);
const alertIdsWithoutRuleInfo = useMemo(
() => getManualAlertIdsWithNoRuleId(caseData.comments),
[caseData.comments]
);
const userActions: EuiCommentProps[] = useMemo(
() =>
caseUserActions.reduce<EuiCommentProps[]>((comments, userAction, index) => {
if (!isUserActionTypeSupported(userAction.type)) {
return comments;
}
const [loadingAlertData, manualAlertsData] = useFetchAlertData(alertIdsWithoutRuleInfo);
const builder = builderMap[userAction.type];
const { permissions } = useCasesContext();
if (builder == null) {
return comments;
}
// add-comment markdown is not visible in History filter
const showCommentEditor = permissions.create && userActivityQueryParams.type !== 'action';
const userActionBuilder = builder({
appId,
caseData,
caseConnectors,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
userAction,
userProfiles,
currentUserProfile,
comments: caseData.comments,
index,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
alertData: manualAlertsData,
handleOutlineComment,
handleManageMarkdownEditId,
handleDeleteComment,
handleSaveComment,
handleManageQuote,
onShowAlertDetails,
actionsNavigation,
getRuleDetailsHref,
onRuleDetailsClick,
});
return [...comments, ...userActionBuilder.build()];
}, []),
[
appId,
caseConnectors,
caseUserActions,
userProfiles,
currentUserProfile,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
caseData,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
manualAlertsData,
handleOutlineComment,
handleManageMarkdownEditId,
handleDeleteComment,
handleSaveComment,
handleManageQuote,
onShowAlertDetails,
actionsNavigation,
getRuleDetailsHref,
onRuleDetailsClick,
const {
commentRefs,
handleManageMarkdownEditId,
handleManageQuote,
handleUpdate,
loadingCommentIds,
} = useUserActionsHandler();
const MarkdownNewComment = useMemo(
() => (
<AddComment
id={NEW_COMMENT_ID}
caseId={caseId}
ref={(element) => (commentRefs.current[NEW_COMMENT_ID] = element)}
onCommentPosted={handleUpdate}
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_COMMENT_ID)}
showLoading={false}
statusActionButton={statusActionButton}
/>
),
[caseId, handleUpdate, handleManageMarkdownEditId, statusActionButton, commentRefs]
);
const bottomActions = showCommentEditor
? [
{
username: (
<UserToolTip userInfo={currentUserProfile}>
<Username userInfo={currentUserProfile} />
</UserToolTip>
),
'data-test-subj': 'add-comment',
timelineAvatar: <HoverableAvatar userInfo={currentUserProfile} />,
className: 'isEdit',
children: MarkdownNewComment,
},
]
);
: [];
const { permissions } = useCasesContext();
const handleShowMore = useCallback(() => {
if (fetchNextPage) {
fetchNextPage();
}
}, [fetchNextPage]);
const showCommentEditor = permissions.create && filterOptions !== 'action'; // add-comment markdown is not visible in History filter
const bottomActions = showCommentEditor
? [
{
username: (
<UserToolTip userInfo={currentUserProfile}>
<Username userInfo={currentUserProfile} />
</UserToolTip>
),
'data-test-subj': 'add-comment',
timelineAvatar: <HoverableAvatar userInfo={currentUserProfile} />,
className: 'isEdit',
children: MarkdownNewComment,
},
]
: [];
const comments = [...userActions, ...bottomActions];
return (
<>
<MyEuiCommentList comments={comments} data-test-subj="user-actions" />
{(isLoadingUserActions || loadingCommentIds.includes(NEW_COMMENT_ID)) && (
<MyEuiFlexGroup justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner data-test-subj="user-actions-loading" size="l" />
</EuiFlexItem>
</MyEuiFlexGroup>
return (
<EuiSkeletonText
lines={8}
data-test-subj="user-actions-loading"
isLoading={
isLoadingLastPageUserActions ||
loadingCommentIds.includes(NEW_COMMENT_ID) ||
isLoadingInfiniteUserActions
}
>
<EuiFlexItem
{...(showBottomList
? {
css: css`
${getIconsCss(hasNextPage, euiTheme)}
`,
}
: {})}
>
<UserActionsList
{...props}
caseUserActions={infiniteCaseUserActions}
loadingAlertData={loadingAlertData}
manualAlertsData={manualAlertsData}
commentRefs={commentRefs}
handleManageQuote={handleManageQuote}
bottomActions={lastPage <= 1 ? bottomActions : []}
isExpandable
/>
{hasNextPage && (
<ShowMoreButton onShowMoreClick={handleShowMore} isLoading={isFetchingNextPage} />
)}
</>
);
}
);
{lastPageUserActions?.length ? (
<EuiFlexItem
{...(!hasNextPage
? {
css: css`
margin-top: 24px;
`,
}
: {})}
>
<UserActionsList
{...props}
caseUserActions={lastPageUserActions}
loadingAlertData={loadingAlertData}
manualAlertsData={manualAlertsData}
bottomActions={bottomActions}
commentRefs={commentRefs}
handleManageQuote={handleManageQuote}
/>
</EuiFlexItem>
) : null}
</EuiFlexItem>
</EuiSkeletonText>
);
});
UserActions.displayName = 'UserActions';

View file

@ -0,0 +1,47 @@
/*
* 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 { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ShowMoreButton } from './show_more_button';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
const showMoreClickMock = jest.fn();
describe('ShowMoreButton', () => {
let appMockRender: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('renders correctly', () => {
appMockRender.render(<ShowMoreButton onShowMoreClick={showMoreClickMock} />);
expect(screen.getByTestId('cases-show-more-user-actions')).toBeInTheDocument();
});
it('shows loading state and is disabled when isLoading is true', () => {
appMockRender.render(<ShowMoreButton onShowMoreClick={showMoreClickMock} isLoading={true} />);
const btn = screen.getByTestId('cases-show-more-user-actions');
expect(btn).toBeInTheDocument();
expect(btn).toHaveAttribute('disabled');
expect(screen.getByRole('progressbar')).toBeTruthy();
});
it('calls onShowMoreClick on button click', () => {
appMockRender.render(<ShowMoreButton onShowMoreClick={showMoreClickMock} />);
userEvent.click(screen.getByTestId('cases-show-more-user-actions'));
expect(showMoreClickMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 { EuiButton, EuiPanel, useEuiTheme } from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import * as i18n from './translations';
interface ShowMoreButtonProps {
onShowMoreClick: () => void;
isLoading?: boolean;
}
export const ShowMoreButton = React.memo<ShowMoreButtonProps>(
({ onShowMoreClick, isLoading = false }) => {
const handleShowMore = () => {
onShowMoreClick();
};
const { euiTheme } = useEuiTheme();
return (
<EuiPanel
color="subdued"
css={css`
display: flex;
justify-content: center;
margin-block: ${euiTheme.size.xl};
margin-inline-start: ${euiTheme.size.xxxl};
`}
>
<EuiButton
fill
color="text"
size="s"
onClick={handleShowMore}
data-test-subj="cases-show-more-user-actions"
isLoading={isLoading}
>
{i18n.SHOW_MORE}
</EuiButton>
</EuiPanel>
);
}
);
ShowMoreButton.displayName = 'ShowMoreButton';

View file

@ -101,3 +101,11 @@ export const UNSAVED_DRAFT_DESCRIPTION = i18n.translate(
defaultMessage: 'You have unsaved edits for the description',
}
);
export const SHOW_MORE = i18n.translate('xpack.cases.caseView.userActions.showMore', {
defaultMessage: 'Show more',
});
export const CREATE_CASE = i18n.translate('xpack.cases.caseView.userActions.createCase', {
defaultMessage: 'Created case',
});

View file

@ -15,6 +15,7 @@ import type {
CaseUserActions,
Comment,
UseFetchAlertData,
CaseUserActionsStats,
} from '../../containers/types';
import type { AddCommentRefObject } from '../add_comment';
import type { UserActionMarkdownRefObject } from './markdown_form';
@ -24,23 +25,22 @@ import type { OnUpdateFields } from '../case_view/types';
import type { ExternalReferenceAttachmentTypeRegistry } from '../../client/attachment_framework/external_reference_registry';
import type { PersistableStateAttachmentTypeRegistry } from '../../client/attachment_framework/persistable_state_registry';
import type { CurrentUserProfile } from '../types';
import type { UserActivityFilter } from '../user_actions_activity_bar/types';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
export interface UserActionTreeProps {
caseConnectors: CaseConnectors;
caseUserActions: CaseUserActions[];
userProfiles: Map<string, UserProfileWithAvatar>;
currentUserProfile: CurrentUserProfile;
data: Case;
getRuleDetailsHref?: RuleDetailsNavigation['href'];
actionsNavigation?: ActionsNavigation;
isLoadingUserActions: boolean;
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
onShowAlertDetails: (alertId: string, index: string) => void;
onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void;
statusActionButton: JSX.Element | null;
useFetchAlertData: UseFetchAlertData;
filterOptions: UserActivityFilter;
userActivityQueryParams: UserActivityParams;
userActionsStats: CaseUserActionsStats;
}
type UnsupportedUserActionTypes = typeof UNSUPPORTED_ACTION_TYPES[number];

View file

@ -0,0 +1,103 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useLastPage } from './use_last_page';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
const userActivityQueryParams: UserActivityParams = {
type: 'all',
sortOrder: 'asc',
page: 1,
perPage: 10,
};
const userActionsStats = {
total: 5,
totalComments: 2,
totalOtherActions: 3,
};
jest.mock('../../common/lib/kibana');
describe('useLastPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns correctly', async () => {
const { result } = renderHook(() =>
useLastPage({
userActionsStats,
userActivityQueryParams,
})
);
expect(result.current).toEqual({
lastPage: 1,
});
});
it('returns 1 when actions stats are 0', async () => {
const { result } = renderHook(() =>
useLastPage({
userActionsStats: { total: 0, totalComments: 0, totalOtherActions: 0 },
userActivityQueryParams,
})
);
expect(result.current).toEqual({
lastPage: 1,
});
});
it('returns correct last page when filter type is all', async () => {
const { result } = renderHook(() =>
useLastPage({
userActionsStats: { total: 38, totalComments: 17, totalOtherActions: 21 },
userActivityQueryParams,
})
);
expect(result.current).toEqual({
lastPage: 4,
});
});
it('returns correct last page when filter type is user', async () => {
const { result } = renderHook(() =>
useLastPage({
userActionsStats: { total: 38, totalComments: 17, totalOtherActions: 21 },
userActivityQueryParams: {
...userActivityQueryParams,
type: 'user',
},
})
);
expect(result.current).toEqual({
lastPage: 2,
});
});
it('returns correct last page when filter type is action', async () => {
const { result } = renderHook(() =>
useLastPage({
userActionsStats: { total: 38, totalComments: 17, totalOtherActions: 21 },
userActivityQueryParams: {
...userActivityQueryParams,
type: 'action',
},
})
);
expect(result.current).toEqual({
lastPage: 3,
});
});
});

View file

@ -0,0 +1,47 @@
/*
* 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 { assertNever } from '@elastic/eui';
import { useMemo } from 'react';
import type { CaseUserActionsStats } from '../../containers/types';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
export const useLastPage = ({
userActivityQueryParams,
userActionsStats,
}: {
userActivityQueryParams: UserActivityParams;
userActionsStats: CaseUserActionsStats;
}) => {
const lastPage = useMemo(() => {
if (!userActionsStats) {
return 1;
}
const perPage = userActivityQueryParams.perPage;
let lastPageType = 1;
switch (userActivityQueryParams.type) {
case 'action':
lastPageType = Math.ceil(userActionsStats.totalOtherActions / perPage);
break;
case 'user':
lastPageType = Math.ceil(userActionsStats.totalComments / perPage);
break;
case 'all':
lastPageType = Math.ceil(userActionsStats.total / perPage);
break;
default:
return assertNever(userActivityQueryParams.type);
}
return Math.max(lastPageType, 1);
}, [userActionsStats, userActivityQueryParams]);
return { lastPage };
};

View file

@ -0,0 +1,136 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useLastPageUserActions } from './use_user_actions_last_page';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions';
import { defaultUseFindCaseUserActions } from '../case_view/mocks';
import { basicCase } from '../../containers/mock';
const userActivityQueryParams: UserActivityParams = {
type: 'all',
sortOrder: 'asc',
page: 1,
perPage: 10,
};
jest.mock('../../containers/use_find_case_user_actions');
jest.mock('../../common/lib/kibana');
const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock;
describe('useLastPageUserActions', () => {
beforeEach(() => {
jest.clearAllMocks();
useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions);
});
it('renders correctly', async () => {
const { result, waitFor } = renderHook(() =>
useLastPageUserActions({
lastPage: 5,
userActivityQueryParams,
caseId: basicCase.id,
})
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
{ ...userActivityQueryParams, page: 5 },
true
);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
isLoadingLastPageUserActions: false,
lastPageUserActions: defaultUseFindCaseUserActions.data.userActions,
})
);
});
});
it('calls find API hook with enabled as false when last page is 1', async () => {
renderHook(() =>
useLastPageUserActions({
lastPage: 1,
userActivityQueryParams,
caseId: basicCase.id,
})
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
{ ...userActivityQueryParams, page: 1 },
false
);
});
it('returns loading state correctly', async () => {
useFindCaseUserActionsMock.mockReturnValue({ isLoading: true });
const { result, waitFor } = renderHook(() =>
useLastPageUserActions({
lastPage: 2,
userActivityQueryParams,
caseId: basicCase.id,
})
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
{ ...userActivityQueryParams, page: 2 },
true
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
isLoadingLastPageUserActions: true,
lastPageUserActions: [],
})
);
});
});
it('returns empty array when data is undefined', async () => {
useFindCaseUserActionsMock.mockReturnValue({ isLoading: false, data: undefined });
const { result, waitFor } = renderHook(() =>
useLastPageUserActions({
lastPage: 2,
userActivityQueryParams,
caseId: basicCase.id,
})
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
{ ...userActivityQueryParams, page: 2 },
true
);
expect(useFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
isLoadingLastPageUserActions: false,
lastPageUserActions: [],
})
);
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { useMemo } from 'react';
import type { CaseUserActions } from '../../containers/types';
import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
interface LastPageUserActions {
userActivityQueryParams: UserActivityParams;
caseId: string;
lastPage: number;
}
export const useLastPageUserActions = ({
userActivityQueryParams,
caseId,
lastPage,
}: LastPageUserActions) => {
const { data: lastPageUserActionsData, isLoading: isLoadingLastPageUserActions } =
useFindCaseUserActions(caseId, { ...userActivityQueryParams, page: lastPage }, lastPage > 1);
const lastPageUserActions = useMemo<CaseUserActions[]>(() => {
if (isLoadingLastPageUserActions || !lastPageUserActionsData) {
return [];
}
return lastPageUserActionsData.userActions;
}, [lastPageUserActionsData, isLoadingLastPageUserActions]);
return {
isLoadingLastPageUserActions,
lastPageUserActions,
};
};

View file

@ -0,0 +1,190 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useUserActionsPagination } from './use_user_actions_pagination';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
import { defaultInfiniteUseFindCaseUserActions } from '../case_view/mocks';
import { basicCase } from '../../containers/mock';
import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions';
const userActivityQueryParams: UserActivityParams = {
type: 'all',
sortOrder: 'asc',
page: 1,
perPage: 10,
};
jest.mock('../../containers/use_infinite_find_case_user_actions');
jest.mock('../../common/lib/kibana');
const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock;
describe('useUserActionsPagination', () => {
beforeEach(() => {
jest.clearAllMocks();
useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions);
});
it('renders expandable option correctly when user actions are more than 10', async () => {
const { result, waitFor } = renderHook(() =>
useUserActionsPagination({
userActivityQueryParams,
caseId: basicCase.id,
lastPage: 3,
})
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
userActivityQueryParams,
true
);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
showBottomList: true,
isLoadingInfiniteUserActions: defaultInfiniteUseFindCaseUserActions.isLoading,
infiniteCaseUserActions: defaultInfiniteUseFindCaseUserActions.data.pages[0].userActions,
hasNextPage: defaultInfiniteUseFindCaseUserActions.hasNextPage,
fetchNextPage: defaultInfiniteUseFindCaseUserActions.fetchNextPage,
})
);
});
});
it('renders less than 10 user actions correctly', async () => {
const { result, waitFor } = renderHook(() =>
useUserActionsPagination({
userActivityQueryParams,
caseId: basicCase.id,
lastPage: 1,
})
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
userActivityQueryParams,
true
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
showBottomList: false,
isLoadingInfiniteUserActions: false,
infiniteCaseUserActions: defaultInfiniteUseFindCaseUserActions.data.pages[0].userActions,
hasNextPage: false,
})
);
});
});
it('returns loading state correctly', async () => {
useInfiniteFindCaseUserActionsMock.mockReturnValue({ isLoading: true });
const { result, waitFor } = renderHook(() =>
useUserActionsPagination({
userActivityQueryParams,
caseId: basicCase.id,
lastPage: 3,
})
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
userActivityQueryParams,
true
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
showBottomList: true,
isLoadingInfiniteUserActions: true,
infiniteCaseUserActions: [],
hasNextPage: undefined,
fetchNextPage: undefined,
})
);
});
});
it('returns empty array when data is undefined', async () => {
useInfiniteFindCaseUserActionsMock.mockReturnValue({ isLoading: false, data: undefined });
const { result, waitFor } = renderHook(() =>
useUserActionsPagination({
userActivityQueryParams,
caseId: basicCase.id,
lastPage: 3,
})
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
userActivityQueryParams,
true
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
showBottomList: true,
isLoadingInfiniteUserActions: false,
infiniteCaseUserActions: [],
hasNextPage: undefined,
fetchNextPage: undefined,
})
);
});
});
it('return hasNextPage as false when it has less than 10 user actions', async () => {
useInfiniteFindCaseUserActionsMock.mockReturnValue({
...defaultInfiniteUseFindCaseUserActions,
data: {
pages: { total: 25, perPage: 10, page: 1, userActions: [] },
},
});
const { result, waitFor } = renderHook(() =>
useUserActionsPagination({
userActivityQueryParams,
caseId: basicCase.id,
lastPage: 1,
})
);
expect(useInfiniteFindCaseUserActionsMock).toHaveBeenCalledWith(
basicCase.id,
userActivityQueryParams,
true
);
await waitFor(() => {
expect(result.current).toEqual(
expect.objectContaining({
showBottomList: false,
isLoadingInfiniteUserActions: defaultInfiniteUseFindCaseUserActions.isLoading,
infiniteCaseUserActions: [],
hasNextPage: false,
fetchNextPage: defaultInfiniteUseFindCaseUserActions.fetchNextPage,
})
);
});
});
});

View file

@ -0,0 +1,56 @@
/*
* 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 { useMemo } from 'react';
import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions';
import type { CaseUserActions } from '../../containers/types';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
interface UserActionsPagination {
userActivityQueryParams: UserActivityParams;
caseId: string;
lastPage: number;
}
export const useUserActionsPagination = ({
userActivityQueryParams,
caseId,
lastPage,
}: UserActionsPagination) => {
const {
data: caseInfiniteUserActionsData,
isLoading: isLoadingInfiniteUserActions,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteFindCaseUserActions(caseId, userActivityQueryParams, true);
const showBottomList = lastPage > 1;
const infiniteCaseUserActions = useMemo<CaseUserActions[]>(() => {
if (!caseInfiniteUserActionsData?.pages?.length || isLoadingInfiniteUserActions) {
return [];
}
const userActionsData: CaseUserActions[] = [];
caseInfiniteUserActionsData.pages.forEach((page) => userActionsData.push(...page.userActions));
return userActionsData;
}, [caseInfiniteUserActionsData, isLoadingInfiniteUserActions]);
return {
lastPage,
showBottomList,
isLoadingInfiniteUserActions,
infiniteCaseUserActions,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
};
};

View file

@ -0,0 +1,139 @@
/*
* 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 { waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// eslint-disable-next-line @kbn/eslint/module_migration
import routeData from 'react-router';
import { basicCase, caseUserActions, getUserAction } from '../../containers/mock';
import { UserActionsList } from './user_actions_list';
import type { AppMockRenderer } from '../../common/mock';
import { createAppMockRenderer } from '../../common/mock';
import { Actions } from '../../../common/api';
import { getCaseConnectorsMockResponse } from '../../common/mock/connectors';
import { getMockBuilderArgs } from './mock';
const builderArgs = getMockBuilderArgs();
const defaultProps = {
caseUserActions,
...builderArgs,
caseConnectors: getCaseConnectorsMockResponse(),
data: basicCase,
manualAlertsData: { 'some-id': { _id: 'some-id' } },
};
jest.mock('../../common/lib/kibana');
describe(`UserActionsList`, () => {
let appMockRender: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(routeData, 'useParams').mockReturnValue({ detailName: 'case-id' });
appMockRender = createAppMockRenderer();
});
it('renders list correctly with isExpandable option', async () => {
appMockRender.render(<UserActionsList {...defaultProps} isExpandable />);
await waitFor(() => {
expect(screen.getByTestId('user-actions-list')).toBeInTheDocument();
});
});
it('renders list correctly with isExpandable=false option', async () => {
appMockRender.render(<UserActionsList {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId('user-actions-list')).toBeInTheDocument();
});
});
it('renders user actions correctly', async () => {
appMockRender.render(<UserActionsList {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId(`description-create-action-${caseUserActions[0].id}`));
expect(screen.getByTestId(`comment-create-action-${caseUserActions[1].commentId}`));
expect(screen.getByTestId(`description-update-action-${caseUserActions[2].id}`));
});
});
it('renders bottom actions correctly', async () => {
const userName = 'Username';
const sample = 'This is an add comment bottom actions';
const bottomActions = [
{
username: <div>{userName}</div>,
'data-test-subj': 'add-comment',
timelineAvatar: null,
className: 'isEdit',
children: <span>{sample}</span>,
},
];
appMockRender.render(<UserActionsList {...defaultProps} bottomActions={bottomActions} />);
await waitFor(() => {
expect(screen.getByTestId('user-actions-list')).toBeInTheDocument();
expect(screen.getByTestId('add-comment')).toBeInTheDocument();
});
});
it('Outlines comment when url param is provided', async () => {
const commentId = 'basic-comment-id';
jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId });
const ourActions = [getUserAction('comment', Actions.create)];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
appMockRender.render(<UserActionsList {...props} />);
expect(
await screen.findAllByTestId(`comment-create-action-${commentId}`)
)[0]?.classList.contains('outlined');
});
it('Outlines comment when update move to link is clicked', async () => {
const ourActions = [
getUserAction('comment', Actions.create),
getUserAction('comment', Actions.update),
];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
appMockRender.render(<UserActionsList {...props} />);
expect(
screen
.queryAllByTestId(`comment-create-action-${props.data.comments[0].id}`)[0]
?.classList.contains('outlined')
).toBe(false);
expect(
screen
.queryAllByTestId(`comment-create-action-${props.data.comments[0].id}`)[0]
?.classList.contains('outlined')
).toBe(false);
userEvent.click(screen.getByTestId(`comment-update-action-${ourActions[1].id}`));
expect(
await screen.findAllByTestId(`comment-create-action-${props.data.comments[0].id}`)
)[0]?.classList.contains('outlined');
});
});

View file

@ -0,0 +1,216 @@
/*
* 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 { EuiCommentProps } from '@elastic/eui';
import { EuiCommentList } from '@elastic/eui';
import React, { useMemo, useEffect, useState } from 'react';
import styled from 'styled-components';
import type { CaseUserActions } from '../../containers/types';
import type { UserActionBuilderArgs, UserActionTreeProps } from './types';
import { isUserActionTypeSupported } from './helpers';
import { useCasesContext } from '../cases_context/use_cases_context';
import { builderMap } from './builder';
import { useCaseViewParams } from '../../common/navigation';
import { useUserActionsHandler } from './use_user_actions_handler';
const MyEuiCommentList = styled(EuiCommentList)`
${({ theme }) => `
& .userAction__comment.outlined .euiCommentEvent {
outline: solid 5px ${theme.eui.euiColorVis1_behindText};
margin: 0.5em;
transition: 0.8s;
}
& .draftFooter {
& .euiCommentEvent__body {
padding: 0;
}
}
& .euiComment.isEdit {
& .euiCommentEvent {
border: none;
box-shadow: none;
}
& .euiCommentEvent__body {
padding: 0;
}
& .euiCommentEvent__header {
display: none;
}
}
& .comment-alert .euiCommentEvent {
background-color: ${theme.eui.euiColorLightestShade};
border: ${theme.eui.euiBorderThin};
padding: ${theme.eui.euiSizeS};
border-radius: ${theme.eui.euiSizeXS};
}
& .comment-alert .euiCommentEvent__headerData {
flex-grow: 1;
}
& .comment-action.empty-comment [class*="euiCommentEvent-regular"] {
box-shadow: none;
.euiCommentEvent__header {
padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeS};
border-bottom: 0;
}
}
`}
`;
export type UserActionListProps = Omit<
UserActionTreeProps,
| 'userActivityQueryParams'
| 'userActionsStats'
| 'useFetchAlertData'
| 'onUpdateField'
| 'statusActionButton'
> &
Pick<UserActionBuilderArgs, 'commentRefs' | 'handleManageQuote'> & {
caseUserActions: CaseUserActions[];
loadingAlertData: boolean;
manualAlertsData: Record<string, unknown>;
bottomActions?: EuiCommentProps[];
isExpandable?: boolean;
};
export const UserActionsList = React.memo(
({
caseUserActions,
caseConnectors,
userProfiles,
currentUserProfile,
data: caseData,
getRuleDetailsHref,
actionsNavigation,
onRuleDetailsClick,
onShowAlertDetails,
loadingAlertData,
manualAlertsData,
commentRefs,
handleManageQuote,
bottomActions = [],
isExpandable = false,
}: UserActionListProps) => {
const {
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
appId,
} = useCasesContext();
const { commentId } = useCaseViewParams();
const [initLoading, setInitLoading] = useState(true);
const {
loadingCommentIds,
selectedOutlineCommentId,
manageMarkdownEditIds,
handleManageMarkdownEditId,
handleOutlineComment,
handleSaveComment,
handleDeleteComment,
} = useUserActionsHandler();
const builtUserActions: EuiCommentProps[] = useMemo(() => {
if (!caseUserActions) {
return [];
}
return caseUserActions.reduce<EuiCommentProps[]>((comments, userAction, index) => {
if (!isUserActionTypeSupported(userAction.type)) {
return comments;
}
const builder = builderMap[userAction.type];
if (builder == null) {
return comments;
}
const userActionBuilder = builder({
appId,
caseData,
caseConnectors,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
userAction,
userProfiles,
currentUserProfile,
comments: caseData?.comments,
index,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
alertData: manualAlertsData,
handleOutlineComment,
handleManageMarkdownEditId,
handleDeleteComment,
handleSaveComment,
handleManageQuote,
onShowAlertDetails,
actionsNavigation,
getRuleDetailsHref,
onRuleDetailsClick,
});
return [...comments, ...userActionBuilder.build()];
}, []);
}, [
appId,
caseConnectors,
caseUserActions,
userProfiles,
currentUserProfile,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
caseData,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
manualAlertsData,
handleOutlineComment,
handleManageMarkdownEditId,
handleDeleteComment,
handleSaveComment,
handleManageQuote,
onShowAlertDetails,
actionsNavigation,
getRuleDetailsHref,
onRuleDetailsClick,
]);
const comments = bottomActions?.length
? [...builtUserActions, ...bottomActions]
: [...builtUserActions];
useEffect(() => {
if (commentId != null && initLoading) {
setInitLoading(false);
handleOutlineComment(commentId);
}
}, [commentId, initLoading, handleOutlineComment]);
return (
<MyEuiCommentList
className={isExpandable ? 'commentList--hasShowMore' : ''}
comments={comments}
data-test-subj="user-actions-list"
/>
);
}
);
UserActionsList.displayName = 'UserActionsList';

View file

@ -76,14 +76,12 @@ describe('FilterActivity ', () => {
/>
);
expect(
screen.getByLabelText(`${userActionsStats.total - 1} active filters`)
).toBeInTheDocument();
expect(screen.getByLabelText(`${userActionsStats.total} active filters`)).toBeInTheDocument();
expect(
screen.getByLabelText(`${userActionsStats.totalComments} available filters`)
).toBeInTheDocument();
expect(
screen.getByLabelText(`${userActionsStats.totalOtherActions - 1} available filters`)
screen.getByLabelText(`${userActionsStats.totalOtherActions} available filters`)
).toBeInTheDocument();
});

View file

@ -50,9 +50,7 @@ export const FilterActivity = React.memo<FilterActivityProps>(
grow={false}
onClick={() => handleFilterChange('all')}
hasActiveFilters={type === 'all'}
numFilters={
userActionsStats && userActionsStats.total > 0 ? userActionsStats.total - 1 : 0
} // subtracting user action of description from total
numFilters={userActionsStats && userActionsStats.total > 0 ? userActionsStats.total : 0}
isLoading={isLoading}
isDisabled={isLoading}
data-test-subj="user-actions-filter-activity-button-all"
@ -77,10 +75,10 @@ export const FilterActivity = React.memo<FilterActivityProps>(
{i18n.COMMENTS}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={type === 'action'} // subtracting user action of description other actions
hasActiveFilters={type === 'action'}
numFilters={
userActionsStats && userActionsStats.totalOtherActions > 0
? userActionsStats.totalOtherActions - 1
? userActionsStats.totalOtherActions
: 0
}
onClick={() => handleFilterChange('action')}

View file

@ -22,6 +22,8 @@ describe('UserActionsActivityBar ', () => {
const params: UserActivityParams = {
type: 'all',
sortOrder: 'asc',
page: 1,
perPage: 10,
};
beforeEach(() => {
@ -120,6 +122,7 @@ describe('UserActionsActivityBar ', () => {
await waitFor(() =>
expect(onUserActionsActivityChanged).toHaveBeenCalledWith({
...params,
type: 'action',
sortOrder: 'asc',
})

View file

@ -12,4 +12,6 @@ export type UserActivitySortOrder = 'asc' | 'desc';
export interface UserActivityParams {
type: UserActivityFilter;
sortOrder: UserActivitySortOrder;
page: number;
perPage: number;
}

View file

@ -15,7 +15,6 @@ import {
CASES_URL,
INTERNAL_BULK_CREATE_ATTACHMENTS_URL,
SECURITY_SOLUTION_OWNER,
MAX_DOCS_PER_PAGE,
INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL,
} from '../../common/constants';
@ -469,8 +468,8 @@ describe('Cases API', () => {
describe('findCaseUserActions', () => {
const findCaseUserActionsSnake = {
page: 1,
perPage: 1000,
total: 20,
perPage: 10,
total: 30,
userActions: [...caseUserActionsWithRegisteredAttachmentsSnake],
};
const filterActionType: CaseUserActionTypeWithAll = 'all';
@ -478,6 +477,8 @@ describe('Cases API', () => {
const params = {
type: filterActionType,
sortOrder,
page: 1,
perPage: 10,
};
beforeEach(() => {
@ -493,7 +494,8 @@ describe('Cases API', () => {
query: {
types: [],
sortOrder: 'asc',
perPage: MAX_DOCS_PER_PAGE,
page: 1,
perPage: 10,
},
});
});
@ -501,7 +503,7 @@ describe('Cases API', () => {
it('should be called with action type user action and desc sort order', async () => {
await findCaseUserActions(
basicCase.id,
{ type: 'action', sortOrder: 'desc' },
{ type: 'action', sortOrder: 'desc', page: 2, perPage: 15 },
abortCtrl.signal
);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions/_find`, {
@ -510,7 +512,8 @@ describe('Cases API', () => {
query: {
types: ['action'],
sortOrder: 'desc',
perPage: MAX_DOCS_PER_PAGE,
page: 2,
perPage: 15,
},
});
});
@ -523,7 +526,8 @@ describe('Cases API', () => {
query: {
types: ['user'],
sortOrder: 'asc',
perPage: MAX_DOCS_PER_PAGE,
page: 1,
perPage: 10,
},
});
});

View file

@ -51,7 +51,6 @@ import {
CASE_TAGS_URL,
CASES_URL,
INTERNAL_BULK_CREATE_ATTACHMENTS_URL,
MAX_DOCS_PER_PAGE,
} from '../../common/constants';
import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api';
@ -161,13 +160,16 @@ export const findCaseUserActions = async (
params: {
type: CaseUserActionTypeWithAll;
sortOrder: 'asc' | 'desc';
page: number;
perPage: number;
},
signal: AbortSignal
): Promise<FindCaseUserActions> => {
const query = {
types: params.type !== 'all' ? [params.type] : [],
sortOrder: params.sortOrder ?? 'asc',
perPage: MAX_DOCS_PER_PAGE,
sortOrder: params.sortOrder,
page: params.page,
perPage: params.perPage,
};
const response = await KibanaServices.get().http.fetch<UserActionFindResponse>(

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { SingleCaseMetricsFeature, CaseUserActionTypeWithAll } from './types';
import type { SingleCaseMetricsFeature } from './types';
export const DEFAULT_TABLE_ACTIVE_PAGE = 1;
export const DEFAULT_TABLE_LIMIT = 10;
@ -27,17 +27,8 @@ export const casesQueriesKeys = {
[...casesQueriesKeys.case(id), 'metrics', features] as const,
caseConnectors: (id: string) => [...casesQueriesKeys.case(id), 'connectors'],
caseUsers: (id: string) => [...casesQueriesKeys.case(id), 'users'],
caseUserActions: (
id: string,
filterActionType: CaseUserActionTypeWithAll,
sortOrder: 'asc' | 'desc'
) =>
[
...casesQueriesKeys.case(id),
...casesQueriesKeys.userActions,
filterActionType,
sortOrder,
] as const,
caseUserActions: (id: string, params: unknown) =>
[...casesQueriesKeys.case(id), ...casesQueriesKeys.userActions, params] as const,
caseUserActionsStats: (id: string) => [
...casesQueriesKeys.case(id),
...casesQueriesKeys.userActions,

View file

@ -877,8 +877,8 @@ export const caseUserActionsWithRegisteredAttachments: CaseUserActions[] = [
export const findCaseUserActionsResponse: FindCaseUserActions = {
page: 1,
perPage: 1000,
total: 20,
perPage: 10,
total: 30,
userActions: [...caseUserActionsWithRegisteredAttachments],
};

View file

@ -29,8 +29,12 @@ describe('UseFindCaseUserActions', () => {
const params = {
type: filterActionType,
sortOrder,
page: 1,
perPage: 10,
};
const isEnabled = true;
let appMockRender: AppMockRenderer;
beforeEach(() => {
@ -41,7 +45,7 @@ describe('UseFindCaseUserActions', () => {
it('returns proper state on findCaseUserActions', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useFindCaseUserActions(basicCase.id, params),
() => useFindCaseUserActions(basicCase.id, params, isEnabled),
{ wrapper: appMockRender.AppWrapper }
);
@ -52,8 +56,8 @@ describe('UseFindCaseUserActions', () => {
...initialData,
data: {
userActions: [...findCaseUserActionsResponse.userActions],
total: 20,
perPage: 1000,
total: 30,
perPage: 10,
page: 1,
},
isError: false,
@ -67,7 +71,17 @@ describe('UseFindCaseUserActions', () => {
const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData);
const { waitForNextUpdate } = renderHook(
() => useFindCaseUserActions(basicCase.id, { type: 'user', sortOrder: 'desc' }),
() =>
useFindCaseUserActions(
basicCase.id,
{
type: 'user',
sortOrder: 'desc',
page: 1,
perPage: 5,
},
isEnabled
),
{ wrapper: appMockRender.AppWrapper }
);
@ -75,26 +89,50 @@ describe('UseFindCaseUserActions', () => {
expect(spy).toHaveBeenCalledWith(
basicCase.id,
{ type: 'user', sortOrder: 'desc' },
{ type: 'user', sortOrder: 'desc', page: 1, perPage: 5 },
expect.any(AbortSignal)
);
});
it('does not call API when not enabled', async () => {
const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData);
renderHook(
() =>
useFindCaseUserActions(
basicCase.id,
{
type: 'user',
sortOrder: 'desc',
page: 1,
perPage: 5,
},
false
),
{ wrapper: appMockRender.AppWrapper }
);
expect(spy).not.toHaveBeenCalled();
});
it('shows a toast error when the API returns an error', async () => {
const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(new Error("C'est la vie"));
const addError = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addError });
const { waitForNextUpdate } = renderHook(() => useFindCaseUserActions(basicCase.id, params), {
wrapper: appMockRender.AppWrapper,
});
const { waitForNextUpdate } = renderHook(
() => useFindCaseUserActions(basicCase.id, params, isEnabled),
{
wrapper: appMockRender.AppWrapper,
}
);
await waitForNextUpdate();
expect(spy).toHaveBeenCalledWith(
basicCase.id,
{ type: filterActionType, sortOrder },
{ type: filterActionType, sortOrder, page: 1, perPage: 10 },
expect.any(AbortSignal)
);
expect(addError).toHaveBeenCalled();

View file

@ -18,17 +18,21 @@ export const useFindCaseUserActions = (
params: {
type: CaseUserActionTypeWithAll;
sortOrder: 'asc' | 'desc';
}
page: number;
perPage: number;
},
isEnabled: boolean
) => {
const { showErrorToast } = useCasesToast();
const abortCtrlRef = new AbortController();
return useQuery<FindCaseUserActions, ServerError>(
casesQueriesKeys.caseUserActions(caseId, params.type, params.sortOrder),
casesQueriesKeys.caseUserActions(caseId, params),
async () => {
return findCaseUserActions(caseId, params, abortCtrlRef.signal);
},
{
enabled: isEnabled,
onError: (error: ServerError) => {
showErrorToast(error, { title: ERROR_TITLE });
},

View file

@ -0,0 +1,181 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { useInfiniteFindCaseUserActions } from './use_infinite_find_case_user_actions';
import type { CaseUserActionTypeWithAll } from '../../common/ui/types';
import { basicCase, findCaseUserActionsResponse } from './mock';
import * as api from './api';
import { useToasts } from '../common/lib/kibana';
import type { AppMockRenderer } from '../common/mock';
import { createAppMockRenderer } from '../common/mock';
jest.mock('./api');
jest.mock('../common/lib/kibana');
const initialData = {
data: undefined,
isError: false,
isLoading: true,
};
describe('UseInfiniteFindCaseUserActions', () => {
const filterActionType: CaseUserActionTypeWithAll = 'all';
const sortOrder: 'asc' | 'desc' = 'asc';
const params = {
type: filterActionType,
sortOrder,
perPage: 10,
};
const isEnabled = true;
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('returns proper state on findCaseUserActions', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled),
{ wrapper: appMockRender.AppWrapper }
);
await waitForNextUpdate();
expect(result.current).toEqual(
expect.objectContaining({
...initialData,
data: {
pages: [
{
userActions: [...findCaseUserActionsResponse.userActions],
total: 30,
perPage: 10,
page: 1,
},
],
pageParams: [undefined],
},
isError: false,
isLoading: false,
isFetching: false,
})
);
});
it('calls the API with correct parameters', async () => {
const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData);
const { waitForNextUpdate } = renderHook(
() =>
useInfiniteFindCaseUserActions(
basicCase.id,
{
type: 'user',
sortOrder: 'desc',
perPage: 5,
},
isEnabled
),
{ wrapper: appMockRender.AppWrapper }
);
await waitForNextUpdate();
expect(spy).toHaveBeenCalledWith(
basicCase.id,
{ type: 'user', sortOrder: 'desc', page: 1, perPage: 5 },
expect.any(AbortSignal)
);
});
it('does not call API when not enabled', async () => {
const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData);
renderHook(
() =>
useInfiniteFindCaseUserActions(
basicCase.id,
{
type: 'user',
sortOrder: 'desc',
perPage: 5,
},
false
),
{ wrapper: appMockRender.AppWrapper }
);
expect(spy).not.toHaveBeenCalled();
});
it('shows a toast error when the API returns an error', async () => {
const spy = jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(new Error("C'est la vie"));
const addError = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addError });
const { waitForNextUpdate } = renderHook(
() => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled),
{
wrapper: appMockRender.AppWrapper,
}
);
await waitForNextUpdate();
expect(spy).toHaveBeenCalledWith(
basicCase.id,
{ type: filterActionType, sortOrder, page: 1, perPage: 10 },
expect.any(AbortSignal)
);
expect(addError).toHaveBeenCalled();
});
it('fetches next page with correct params', async () => {
const spy = jest.spyOn(api, 'findCaseUserActions');
const { result, waitFor } = renderHook(
() => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled),
{ wrapper: appMockRender.AppWrapper }
);
await waitFor(() => result.current.isSuccess);
expect(result.current.data?.pages).toStrictEqual([findCaseUserActionsResponse]);
expect(result.current.hasNextPage).toBe(true);
act(() => {
result.current.fetchNextPage();
});
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(
basicCase.id,
{ type: 'all', sortOrder, page: 2, perPage: 10 },
expect.any(AbortSignal)
);
});
await waitFor(() => result.current.data?.pages.length === 2);
});
it('returns hasNextPage correctly', async () => {
jest.spyOn(api, 'findCaseUserActions').mockRejectedValue(initialData);
const { result } = renderHook(
() => useInfiniteFindCaseUserActions(basicCase.id, params, isEnabled),
{ wrapper: appMockRender.AppWrapper }
);
expect(result.current.hasNextPage).toBe(undefined);
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { useInfiniteQuery } from '@tanstack/react-query';
import type { FindCaseUserActions, CaseUserActionTypeWithAll } from '../../common/ui/types';
import { findCaseUserActions } from './api';
import type { ServerError } from '../types';
import { useCasesToast } from '../common/use_cases_toast';
import { ERROR_TITLE } from './translations';
import { casesQueriesKeys } from './constants';
export const useInfiniteFindCaseUserActions = (
caseId: string,
params: {
type: CaseUserActionTypeWithAll;
sortOrder: 'asc' | 'desc';
perPage: number;
},
isEnabled: boolean
) => {
const { showErrorToast } = useCasesToast();
const abortCtrlRef = new AbortController();
return useInfiniteQuery<FindCaseUserActions, ServerError>(
casesQueriesKeys.caseUserActions(caseId, params),
async ({ pageParam = 1 }) => {
return findCaseUserActions(caseId, { ...params, page: pageParam }, abortCtrlRef.signal);
},
{
enabled: isEnabled,
onError: (error: ServerError) => {
showErrorToast(error, { title: ERROR_TITLE });
},
getNextPageParam: (lastPage, pages) => {
const lastPageNumber = Math.ceil(lastPage.total / lastPage.perPage);
// here last page fetching is skipped because last page is fetched separately using useQuery hook
if (lastPage.page < lastPageNumber - 1) {
return lastPage.page + 1;
}
return undefined;
},
}
);
};
export type UseInfiniteFindCaseUserActions = ReturnType<typeof useInfiniteFindCaseUserActions>;

View file

@ -12,6 +12,7 @@ import { FtrProviderContext } from '../ftr_provider_context';
export function ObservabilityPageProvider({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const textValue = 'Foobar';
const PageObjects = getPageObjects(['common', 'header']);
return {
async clickSolutionNavigationEntry(appId: string, navId: string) {
@ -45,6 +46,7 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro
},
async expectAddCommentButton() {
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.setValue('add-comment', textValue);
const button = await testSubjects.find('submit-comment', 20000);
const disabledAttr = await button.getAttribute('disabled');

View file

@ -6,7 +6,12 @@
*/
import pMap from 'p-map';
import { CasePostRequest, CaseResponse, CaseStatuses } from '@kbn/cases-plugin/common/api';
import {
CasePostRequest,
CaseResponse,
CaseSeverity,
CaseStatuses,
} from '@kbn/cases-plugin/common/api';
import {
createCase as createCaseAPI,
deleteAllCaseItems,
@ -98,5 +103,43 @@ export function CasesAPIServiceProvider({ getService }: FtrProviderContext) {
async getCase({ caseId }: OmitSupertest<Parameters<typeof getCase>[0]>): Promise<CaseResponse> {
return getCase({ supertest: kbnSupertest, caseId });
},
async generateUserActions({
caseId,
caseVersion,
totalUpdates = 1,
}: {
caseId: string;
caseVersion: string;
totalUpdates: number;
}) {
let latestVersion = caseVersion;
const severities = Object.values(CaseSeverity);
const statuses = Object.values(CaseStatuses);
for (let index = 0; index < totalUpdates; index++) {
const severity = severities[index % severities.length];
const status = statuses[index % statuses.length];
const theCase = await updateCase({
supertest: kbnSupertest,
params: {
cases: [
{
id: caseId,
version: latestVersion,
title: `Title update ${index}`,
description: `Desc update ${index}`,
severity,
status,
tags: [`tag-${index}`],
},
],
},
});
latestVersion = theCase[0].version;
}
},
};
}

View file

@ -359,26 +359,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
describe('filter activity', () => {
createOneCaseBeforeDeleteAllAfter(getPageObject, getService);
it('filters by history successfully', async () => {
await cases.common.selectSeverity(CaseSeverity.MEDIUM);
await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']);
await header.waitUntilLoadingHasFinished();
await testSubjects.click('user-actions-filter-activity-button-history');
const historyBadge = await find.byCssSelector(
'[data-test-subj="user-actions-filter-activity-button-history"] span.euiNotificationBadge'
it('filters by comment successfully', async () => {
const commentBadge = await find.byCssSelector(
'[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge'
);
expect(await historyBadge.getVisibleText()).equal('2');
});
it('filters by comment successfully', async () => {
await testSubjects.click('user-actions-filter-activity-button-comments');
await header.waitUntilLoadingHasFinished();
expect(await commentBadge.getVisibleText()).equal('0');
const commentArea = await find.byCssSelector(
'[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea'
@ -389,11 +375,25 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await header.waitUntilLoadingHasFinished();
const commentBadge = await find.byCssSelector(
'[data-test-subj="user-actions-filter-activity-button-comments"] span.euiNotificationBadge'
expect(await commentBadge.getVisibleText()).equal('1');
});
it('filters by history successfully', async () => {
const historyBadge = await find.byCssSelector(
'[data-test-subj="user-actions-filter-activity-button-history"] span.euiNotificationBadge'
);
expect(await commentBadge.getVisibleText()).equal('1');
expect(await historyBadge.getVisibleText()).equal('1');
await cases.common.selectSeverity(CaseSeverity.MEDIUM);
await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']);
await header.waitUntilLoadingHasFinished();
await testSubjects.click('user-actions-filter-activity-button-history');
expect(await historyBadge.getVisibleText()).equal('3');
});
it('sorts by newest first successfully', async () => {
@ -403,7 +403,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
'[data-test-subj="user-actions-filter-activity-button-all"] span.euiNotificationBadge'
);
expect(await AllBadge.getVisibleText()).equal('3');
expect(await AllBadge.getVisibleText()).equal('4');
const sortDesc = await find.byCssSelector(
'[data-test-subj="user-actions-sort-select"] [value="desc"]'
@ -413,13 +413,63 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
await header.waitUntilLoadingHasFinished();
const userActions = await find.byCssSelector('[data-test-subj="user-actions"]');
const actionsList = await userActions.findAllByClassName('euiComment');
expect(await actionsList[0].getAttribute('data-test-subj')).contain(
'comment-create-action'
const userActionsLists = await find.allByCssSelector(
'[data-test-subj="user-actions-list"]'
);
const actionList = await userActionsLists[0].findAllByClassName('euiComment');
expect(await actionList[0].getAttribute('data-test-subj')).contain('status-update-action');
});
});
describe('pagination', async () => {
let createdCase: any;
before(async () => {
await cases.navigation.navigateToApp();
createdCase = await cases.api.createCase({ title: 'Pagination feature' });
await cases.casesTable.waitForCasesToBeListed();
await cases.casesTable.goToFirstListedCase();
await header.waitUntilLoadingHasFinished();
});
after(async () => {
await cases.api.deleteAllCases();
});
it('shows more actions on button click', async () => {
await cases.api.generateUserActions({
caseId: createdCase.id,
caseVersion: createdCase.version,
totalUpdates: 4,
});
await header.waitUntilLoadingHasFinished();
await testSubjects.click('case-refresh');
await header.waitUntilLoadingHasFinished();
expect(testSubjects.existOrFail('cases-show-more-user-actions'));
const userActionsLists = await find.allByCssSelector(
'[data-test-subj="user-actions-list"]'
);
expect(userActionsLists).length(2);
expect(await userActionsLists[0].findAllByClassName('euiComment')).length(10);
expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4);
testSubjects.click('cases-show-more-user-actions');
await header.waitUntilLoadingHasFinished();
expect(await userActionsLists[0].findAllByClassName('euiComment')).length(20);
expect(await userActionsLists[1].findAllByClassName('euiComment')).length(4);
});
});