[SIEM] [Case] Bulk status update, add comment avatar, id => title in breadcrumbs (#60410)

This commit is contained in:
Steph Milovic 2020-03-19 17:08:53 -06:00 committed by GitHub
parent d5989e8baa
commit 0163a71d24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 565 additions and 126 deletions

View file

@ -38,18 +38,18 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
data-test-subj="stat-item"
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
data-test-subj="stat-item"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
data-test-subj="stat-item"
>
<InspectButtonContainer
show={true}
>
<div
className="sc-AykKF jvsAjF"
className="sc-AykKI jTjOxk"
>
<EuiPanel>
<div
@ -152,10 +152,10 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
key="stat-items-field-hosts"
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<EuiFlexGroup
alignItems="center"
@ -167,17 +167,17 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
>
<FlexItem>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<StatValue>
<EuiTitle
className="sc-AykKH eFqnYS"
className="sc-AykKK iEuJmh"
>
<p
className="euiTitle euiTitle--medium sc-AykKH eFqnYS"
className="euiTitle euiTitle--medium sc-AykKK iEuJmh"
data-test-subj="stat-title"
>
<EmptyWrapper>
@ -258,18 +258,18 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
data-test-subj="stat-item"
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
data-test-subj="stat-item"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
data-test-subj="stat-item"
>
<InspectButtonContainer
show={true}
>
<div
className="sc-AykKF jvsAjF"
className="sc-AykKI jTjOxk"
>
<EuiPanel>
<div
@ -372,10 +372,10 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
key="stat-items-field-hosts"
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<EuiFlexGroup
alignItems="center"
@ -388,17 +388,17 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
0
<FlexItem>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<StatValue>
<EuiTitle
className="sc-AykKH eFqnYS"
className="sc-AykKK iEuJmh"
>
<p
className="euiTitle euiTitle--medium sc-AykKH eFqnYS"
className="euiTitle euiTitle--medium sc-AykKK iEuJmh"
data-test-subj="stat-title"
>
<EmptyWrapper>
@ -548,18 +548,18 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
data-test-subj="stat-item"
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
data-test-subj="stat-item"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
data-test-subj="stat-item"
>
<InspectButtonContainer
show={true}
>
<div
className="sc-AykKF jbBKkl"
className="sc-AykKI gltyKM"
>
<EuiPanel>
<div
@ -662,10 +662,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
key="stat-items-field-uniqueSourceIps"
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<EuiFlexGroup
alignItems="center"
@ -679,11 +679,11 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
grow={false}
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero sc-AykKG dLVTBE"
className="euiFlexItem euiFlexItem--flexGrowZero sc-AykKJ hHoWqT"
>
<EuiIcon
color="#D36086"
@ -703,17 +703,17 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
</FlexItem>
<FlexItem>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<StatValue>
<EuiTitle
className="sc-AykKH eFqnYS"
className="sc-AykKK iEuJmh"
>
<p
className="euiTitle euiTitle--medium sc-AykKH eFqnYS"
className="euiTitle euiTitle--medium sc-AykKK iEuJmh"
data-test-subj="stat-title"
>
1,714
@ -734,10 +734,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
key="stat-items-field-uniqueDestinationIps"
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<EuiFlexGroup
alignItems="center"
@ -751,11 +751,11 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
grow={false}
>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero sc-AykKG dLVTBE"
className="euiFlexItem euiFlexItem--flexGrowZero sc-AykKJ hHoWqT"
>
<EuiIcon
color="#9170B8"
@ -775,17 +775,17 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
</FlexItem>
<FlexItem>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<StatValue>
<EuiTitle
className="sc-AykKH eFqnYS"
className="sc-AykKK iEuJmh"
>
<p
className="euiTitle euiTitle--medium sc-AykKH eFqnYS"
className="euiTitle euiTitle--medium sc-AykKK iEuJmh"
data-test-subj="stat-title"
>
2,359
@ -815,10 +815,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
>
<FlexItem>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<BarChart
barChart={
@ -874,10 +874,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
</FlexItem>
<FlexItem>
<EuiFlexItem
className="sc-AykKG dLVTBE"
className="sc-AykKJ hHoWqT"
>
<div
className="euiFlexItem sc-AykKG dLVTBE"
className="euiFlexItem sc-AykKJ hHoWqT"
>
<AreaChart
areaChart={

View file

@ -15,7 +15,15 @@ import {
User,
} from '../../../../../../plugins/case/common/api';
import { KibanaServices } from '../../lib/kibana';
import { AllCases, Case, CasesStatus, Comment, FetchCasesProps, SortFieldCase } from './types';
import {
AllCases,
BulkUpdateStatus,
Case,
CasesStatus,
Comment,
FetchCasesProps,
SortFieldCase,
} from './types';
import { CASES_URL } from './constants';
import {
convertToCamelCase,
@ -92,7 +100,7 @@ export const getCases = async ({
};
export const postCase = async (newCase: CaseRequest): Promise<Case> => {
const response = await KibanaServices.get().http.fetch<CaseResponse>(`${CASES_URL}`, {
const response = await KibanaServices.get().http.fetch<CaseResponse>(CASES_URL, {
method: 'POST',
body: JSON.stringify(newCase),
});
@ -104,13 +112,21 @@ export const patchCase = async (
updatedCase: Partial<CaseRequest>,
version: string
): Promise<Case[]> => {
const response = await KibanaServices.get().http.fetch<CasesResponse>(`${CASES_URL}`, {
const response = await KibanaServices.get().http.fetch<CasesResponse>(CASES_URL, {
method: 'PATCH',
body: JSON.stringify({ cases: [{ ...updatedCase, id: caseId, version }] }),
});
return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response));
};
export const patchCasesStatus = async (cases: BulkUpdateStatus[]): Promise<Case[]> => {
const response = await KibanaServices.get().http.fetch<CasesResponse>(CASES_URL, {
method: 'PATCH',
body: JSON.stringify({ cases }),
});
return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response));
};
export const postComment = async (newComment: CommentRequest, caseId: string): Promise<Comment> => {
const response = await KibanaServices.get().http.fetch<CommentResponse>(
`${CASES_URL}/${caseId}/comments`,
@ -139,7 +155,7 @@ export const patchComment = async (
};
export const deleteCases = async (caseIds: string[]): Promise<boolean> => {
const response = await KibanaServices.get().http.fetch<string>(`${CASES_URL}`, {
const response = await KibanaServices.get().http.fetch<string>(CASES_URL, {
method: 'DELETE',
query: { ids: JSON.stringify(caseIds) },
});

View file

@ -78,3 +78,9 @@ export interface FetchCasesProps {
export interface ApiProps {
signal: AbortSignal;
}
export interface BulkUpdateStatus {
status: string;
id: string;
version: string;
}

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useReducer } from 'react';
import { errorToToaster, useStateToaster } from '../../components/toasters';
import * as i18n from './translations';
import { patchCasesStatus } from './api';
import { BulkUpdateStatus, Case } from './types';
interface UpdateState {
isUpdated: boolean;
isLoading: boolean;
isError: boolean;
}
type Action =
| { type: 'FETCH_INIT' }
| { type: 'FETCH_SUCCESS'; payload: boolean }
| { type: 'FETCH_FAILURE' }
| { type: 'RESET_IS_UPDATED' };
const dataFetchReducer = (state: UpdateState, action: Action): UpdateState => {
switch (action.type) {
case 'FETCH_INIT':
return {
...state,
isLoading: true,
isError: false,
};
case 'FETCH_SUCCESS':
return {
...state,
isLoading: false,
isError: false,
isUpdated: action.payload,
};
case 'FETCH_FAILURE':
return {
...state,
isLoading: false,
isError: true,
};
case 'RESET_IS_UPDATED':
return {
...state,
isUpdated: false,
};
default:
return state;
}
};
interface UseUpdateCase extends UpdateState {
updateBulkStatus: (cases: Case[], status: string) => void;
dispatchResetIsUpdated: () => void;
}
export const useUpdateCases = (): UseUpdateCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
isUpdated: false,
});
const [, dispatchToaster] = useStateToaster();
const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[]) => {
let cancel = false;
const patchData = async () => {
try {
dispatch({ type: 'FETCH_INIT' });
await patchCasesStatus(cases);
if (!cancel) {
dispatch({ type: 'FETCH_SUCCESS', payload: true });
}
} catch (error) {
if (!cancel) {
errorToToaster({
title: i18n.ERROR_TITLE,
error: error.body && error.body.message ? new Error(error.body.message) : error,
dispatchToaster,
});
dispatch({ type: 'FETCH_FAILURE' });
}
}
};
patchData();
return () => {
cancel = true;
};
}, []);
const dispatchResetIsUpdated = useCallback(() => {
dispatch({ type: 'RESET_IS_UPDATED' });
}, []);
const updateBulkStatus = useCallback((cases: Case[], status: string) => {
const updateCasesStatus: BulkUpdateStatus[] = cases.map(theCase => ({
status,
id: theCase.id,
version: theCase.version,
}));
dispatchUpdateCases(updateCasesStatus);
}, []);
return { ...state, updateBulkStatus, dispatchResetIsUpdated };
};

View file

@ -5,19 +5,12 @@
*/
import { npSetup, npStart } from 'ui/new_platform';
import { PluginsSetup, PluginsStart } from 'ui/new_platform/new_platform';
import { PluginInitializerContext } from '../../../../../src/core/public';
import { plugin } from './';
import {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '../../../../plugins/triggers_actions_ui/public';
import { SetupPlugins, StartPlugins } from './plugin';
const pluginInstance = plugin({} as PluginInitializerContext);
type myPluginsSetup = PluginsSetup & { triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup };
type myPluginsStart = PluginsStart & { triggers_actions_ui: TriggersAndActionsUIPublicPluginStart };
pluginInstance.setup(npSetup.core, npSetup.plugins as myPluginsSetup);
pluginInstance.start(npStart.core, npStart.plugins as myPluginsStart);
pluginInstance.setup(npSetup.core, (npSetup.plugins as unknown) as SetupPlugins);
pluginInstance.start(npStart.core, (npStart.plugins as unknown) as StartPlugins);

View file

@ -6,8 +6,13 @@
import moment from 'moment-timezone';
import { useCallback, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../common/constants';
import { useUiSetting, useKibana } from './kibana_react';
import { errorToToaster, useStateToaster } from '../../components/toasters';
import { AuthenticatedUser } from '../../../../../../plugins/security/common/model';
import { convertToCamelCase } from '../../containers/case/utils';
export const useDateFormat = (): string => useUiSetting<string>(DEFAULT_DATE_FORMAT);
@ -17,3 +22,62 @@ export const useTimeZone = (): string => {
};
export const useBasePath = (): string => useKibana().services.http.basePath.get();
interface UserRealm {
name: string;
type: string;
}
export interface AuthenticatedElasticUser {
username: string;
email: string;
fullName: string;
roles: string[];
enabled: boolean;
metadata?: {
_reserved: boolean;
};
authenticationRealm: UserRealm;
lookupRealm: UserRealm;
authenticationProvider: string;
}
export const useCurrentUser = (): AuthenticatedElasticUser | null => {
const [user, setUser] = useState<AuthenticatedElasticUser | null>(null);
const [, dispatchToaster] = useStateToaster();
const { security } = useKibana().services;
const fetchUser = useCallback(() => {
let didCancel = false;
const fetchData = async () => {
try {
const response = await security.authc.getCurrentUser();
if (!didCancel) {
setUser(convertToCamelCase<AuthenticatedUser, AuthenticatedElasticUser>(response));
}
} catch (error) {
if (!didCancel) {
errorToToaster({
title: i18n.translate('xpack.siem.getCurrentUser.Error', {
defaultMessage: 'Error getting user',
}),
error: error.body && error.body.message ? new Error(error.body.message) : error,
dispatchToaster,
});
setUser(null);
}
}
};
fetchData();
return () => {
didCancel = true;
};
}, [security]);
useEffect(() => {
fetchUser();
}, []);
return user;
};

View file

@ -10,7 +10,7 @@ import { UseGetCasesState } from '../../../../../containers/case/use_get_cases';
export const useGetCasesMockState: UseGetCasesState = {
data: {
countClosedCases: 0,
countOpenCases: 0,
countOpenCases: 5,
cases: [
{
closedAt: null,

View file

@ -10,35 +10,86 @@ import moment from 'moment-timezone';
import { AllCases } from './';
import { TestProviders } from '../../../../mock';
import { useGetCasesMockState } from './__mock__';
import * as apiHook from '../../../../containers/case/use_get_cases';
import { act } from '@testing-library/react';
import { wait } from '../../../../lib/helpers';
import { useDeleteCases } from '../../../../containers/case/use_delete_cases';
import { useGetCases } from '../../../../containers/case/use_get_cases';
import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status';
import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case';
jest.mock('../../../../containers/case/use_bulk_update_case');
jest.mock('../../../../containers/case/use_delete_cases');
jest.mock('../../../../containers/case/use_get_cases');
jest.mock('../../../../containers/case/use_get_cases_status');
const useDeleteCasesMock = useDeleteCases as jest.Mock;
const useGetCasesMock = useGetCases as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useUpdateCasesMock = useUpdateCases as jest.Mock;
describe('AllCases', () => {
const dispatchResetIsDeleted = jest.fn();
const dispatchResetIsUpdated = jest.fn();
const dispatchUpdateCaseProperty = jest.fn();
const handleOnDeleteConfirm = jest.fn();
const handleToggleModal = jest.fn();
const refetchCases = jest.fn();
const setFilters = jest.fn();
const setQueryParams = jest.fn();
const setSelectedCases = jest.fn();
const updateBulkStatus = jest.fn();
const fetchCasesStatus = jest.fn();
const defaultGetCases = {
...useGetCasesMockState,
dispatchUpdateCaseProperty,
refetchCases,
setFilters,
setQueryParams,
setSelectedCases,
};
const defaultDeleteCases = {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isDeleted: false,
isDisplayConfirmDeleteModal: false,
isLoading: false,
};
const defaultCasesStatus = {
countClosedCases: 0,
countOpenCases: 5,
fetchCasesStatus,
isError: false,
isLoading: true,
};
const defaultUpdateCases = {
isUpdated: false,
isLoading: false,
isError: false,
dispatchResetIsUpdated,
updateBulkStatus,
};
/* eslint-disable no-console */
// Silence until enzyme fixed to use ReactTestUtils.act()
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
/* eslint-enable no-console */
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(apiHook, 'useGetCases').mockReturnValue({
...useGetCasesMockState,
dispatchUpdateCaseProperty,
refetchCases,
setFilters,
setQueryParams,
setSelectedCases,
});
useUpdateCasesMock.mockImplementation(() => defaultUpdateCases);
useGetCasesMock.mockImplementation(() => defaultGetCases);
useDeleteCasesMock.mockImplementation(() => defaultDeleteCases);
useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus);
moment.tz.setDefault('UTC');
});
it('should render AllCases', async () => {
it('should render AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
await act(() => wait());
expect(
wrapper
.find(`a[data-test-subj="case-details-link"]`)
@ -76,13 +127,12 @@ describe('AllCases', () => {
.text()
).toEqual('Showing 10 cases');
});
it('should tableHeaderSortButton AllCases', async () => {
it('should tableHeaderSortButton AllCases', () => {
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
await act(() => wait());
wrapper
.find('[data-test-subj="tableHeaderSortButton"]')
.first()
@ -94,4 +144,139 @@ describe('AllCases', () => {
sortOrder: 'asc',
});
});
it('closes case when row action icon clicked', () => {
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
wrapper
.find('[data-test-subj="action-close"]')
.first()
.simulate('click');
const firstCase = useGetCasesMockState.data.cases[0];
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
updateValue: 'closed',
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
});
it('Bulk delete', () => {
useGetCasesMock.mockImplementation(() => ({
...defaultGetCases,
selectedCases: useGetCasesMockState.data.cases,
}));
useDeleteCasesMock
.mockReturnValueOnce({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: false,
})
.mockReturnValue({
...defaultDeleteCases,
isDisplayConfirmDeleteModal: true,
});
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
wrapper
.find('[data-test-subj="case-table-bulk-actions"] button')
.first()
.simulate('click');
wrapper
.find('[data-test-subj="cases-bulk-delete-button"]')
.first()
.simulate('click');
expect(handleToggleModal).toBeCalled();
wrapper
.find(
'[data-test-subj="confirm-delete-case-modal"] [data-test-subj="confirmModalConfirmButton"]'
)
.last()
.simulate('click');
expect(handleOnDeleteConfirm.mock.calls[0][0]).toStrictEqual(
useGetCasesMockState.data.cases.map(theCase => theCase.id)
);
});
it('Bulk close status update', () => {
useGetCasesMock.mockImplementation(() => ({
...defaultGetCases,
selectedCases: useGetCasesMockState.data.cases,
}));
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
wrapper
.find('[data-test-subj="case-table-bulk-actions"] button')
.first()
.simulate('click');
wrapper
.find('[data-test-subj="cases-bulk-close-button"]')
.first()
.simulate('click');
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed');
});
it('Bulk open status update', () => {
useGetCasesMock.mockImplementation(() => ({
...defaultGetCases,
selectedCases: useGetCasesMockState.data.cases,
filterOptions: {
...defaultGetCases.filterOptions,
status: 'closed',
},
}));
const wrapper = mount(
<TestProviders>
<AllCases />
</TestProviders>
);
wrapper
.find('[data-test-subj="case-table-bulk-actions"] button')
.first()
.simulate('click');
wrapper
.find('[data-test-subj="cases-bulk-open-button"]')
.first()
.simulate('click');
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open');
});
it('isDeleted is true, refetch', () => {
useDeleteCasesMock.mockImplementation(() => ({
...defaultDeleteCases,
isDeleted: true,
}));
mount(
<TestProviders>
<AllCases />
</TestProviders>
);
expect(refetchCases).toBeCalled();
expect(fetchCasesStatus).toBeCalled();
expect(dispatchResetIsDeleted).toBeCalled();
});
it('isUpdated is true, refetch', () => {
useUpdateCasesMock.mockImplementation(() => ({
...defaultUpdateCases,
isUpdated: true,
}));
mount(
<TestProviders>
<AllCases />
</TestProviders>
);
expect(refetchCases).toBeCalled();
expect(fetchCasesStatus).toBeCalled();
expect(dispatchResetIsUpdated).toBeCalled();
});
});

View file

@ -43,6 +43,7 @@ import { OpenClosedStats } from '../open_closed_stats';
import { getActions } from './actions';
import { CasesTableFilters } from './table_filters';
import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case';
const CONFIGURE_CASES_URL = getConfigureCasesUrl();
const CREATE_CASE_URL = getCreateCaseUrl();
@ -106,13 +107,20 @@ export const AllCases = React.memo(() => {
isDisplayConfirmDeleteModal,
} = useDeleteCases();
const { dispatchResetIsUpdated, isUpdated, updateBulkStatus } = useUpdateCases();
useEffect(() => {
if (isDeleted) {
refetchCases(filterOptions, queryParams);
fetchCasesStatus();
dispatchResetIsDeleted();
}
}, [isDeleted, filterOptions, queryParams]);
if (isUpdated) {
refetchCases(filterOptions, queryParams);
fetchCasesStatus();
dispatchResetIsUpdated();
}
}, [isDeleted, isUpdated, filterOptions, queryParams]);
const [deleteThisCase, setDeleteThisCase] = useState({
title: '',
@ -135,36 +143,38 @@ export const AllCases = React.memo(() => {
[deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal]
);
const toggleDeleteModal = useCallback(
(deleteCase: Case) => {
handleToggleModal();
setDeleteThisCase(deleteCase);
},
[isDisplayConfirmDeleteModal]
);
const toggleDeleteModal = useCallback((deleteCase: Case) => {
handleToggleModal();
setDeleteThisCase(deleteCase);
}, []);
const toggleBulkDeleteModal = useCallback(
(deleteCases: string[]) => {
handleToggleModal();
setDeleteBulk(deleteCases);
const toggleBulkDeleteModal = useCallback((deleteCases: string[]) => {
handleToggleModal();
setDeleteBulk(deleteCases);
}, []);
const handleUpdateCaseStatus = useCallback(
(status: string) => {
updateBulkStatus(selectedCases, status);
},
[isDisplayConfirmDeleteModal]
[selectedCases]
);
const selectedCaseIds = useMemo(
(): string[] =>
selectedCases.reduce((arr: string[], caseObj: Case) => [...arr, caseObj.id], []),
(): string[] => selectedCases.map((caseObj: Case) => caseObj.id),
[selectedCases]
);
const getBulkItemsPopoverContent = useCallback(
(closePopover: () => void) => (
<EuiContextMenuPanel
data-test-subj="cases-bulk-actions"
items={getBulkItems({
caseStatus: filterOptions.status,
closePopover,
deleteCasesAction: toggleBulkDeleteModal,
selectedCaseIds,
caseStatus: filterOptions.status,
updateCaseStatus: handleUpdateCaseStatus,
})}
/>
),
@ -322,7 +332,7 @@ export const AllCases = React.memo(() => {
</UtilityBar>
<EuiBasicTable
columns={memoizedGetCasesColumns}
data-test-subj="all-cases-table"
data-test-subj="cases-table"
isSelectable
itemId="id"
items={data.cases}

View file

@ -12,8 +12,10 @@ export const NO_CASES = i18n.translate('xpack.siem.case.caseTable.noCases.title'
defaultMessage: 'No Cases',
});
export const NO_CASES_BODY = i18n.translate('xpack.siem.case.caseTable.noCases.body', {
defaultMessage: 'Create a new case to see it displayed in the case workflow table.',
defaultMessage:
'There are no cases to display. Please create a new case or change your filter settings above.',
});
export const ADD_NEW_CASE = i18n.translate('xpack.siem.case.caseTable.addNewCase', {
defaultMessage: 'Add New Case',
});

View file

@ -9,47 +9,52 @@ import { EuiContextMenuItem } from '@elastic/eui';
import * as i18n from './translations';
interface GetBulkItems {
caseStatus: string;
closePopover: () => void;
deleteCasesAction: (cases: string[]) => void;
selectedCaseIds: string[];
caseStatus: string;
updateCaseStatus: (status: string) => void;
}
export const getBulkItems = ({
deleteCasesAction,
closePopover,
caseStatus,
closePopover,
deleteCasesAction,
selectedCaseIds,
updateCaseStatus,
}: GetBulkItems) => {
return [
caseStatus === 'open' ? (
<EuiContextMenuItem
data-test-subj="cases-bulk-close-button"
key={i18n.BULK_ACTION_CLOSE_SELECTED}
icon="magnet"
disabled={true} // TO DO
onClick={async () => {
onClick={() => {
closePopover();
updateCaseStatus('closed');
}}
>
{i18n.BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>
) : (
<EuiContextMenuItem
data-test-subj="cases-bulk-open-button"
key={i18n.BULK_ACTION_OPEN_SELECTED}
icon="magnet"
disabled={true} // TO DO
onClick={() => {
closePopover();
updateCaseStatus('open');
}}
>
{i18n.BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
),
<EuiContextMenuItem
data-test-subj="cases-bulk-delete-button"
key={i18n.BULK_ACTION_DELETE_SELECTED}
icon="trash"
disabled={selectedCaseIds.length === 0}
onClick={async () => {
onClick={() => {
closePopover();
deleteCasesAction(selectedCaseIds);
}}

View file

@ -16,7 +16,7 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate(
export const BULK_ACTION_OPEN_SELECTED = i18n.translate(
'xpack.siem.case.caseTable.bulkActions.openSelectedTitle',
{
defaultMessage: 'Open selected',
defaultMessage: 'Reopen selected',
}
);

View file

@ -5,6 +5,7 @@
*/
import React from 'react';
import { Router } from 'react-router-dom';
import { mount } from 'enzyme';
import { CaseComponent } from './';
import { caseProps, caseClosedProps, data, dataClosed } from './__mock__';
@ -12,6 +13,27 @@ import { TestProviders } from '../../../../mock';
import { useUpdateCase } from '../../../../containers/case/use_update_case';
jest.mock('../../../../containers/case/use_update_case');
const useUpdateCaseMock = useUpdateCase as jest.Mock;
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const location = {
pathname: '/network',
search: '',
state: '',
hash: '',
};
const mockHistory = {
length: 2,
location,
action: pop,
push: jest.fn(),
replace: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
block: jest.fn(),
createHref: jest.fn(),
listen: jest.fn(),
};
describe('CaseView ', () => {
const updateCaseProperty = jest.fn();
@ -42,7 +64,9 @@ describe('CaseView ', () => {
it('should render CaseComponent', () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
</Router>
</TestProviders>
);
expect(
@ -83,6 +107,7 @@ describe('CaseView ', () => {
.prop('raw')
).toEqual(data.description);
});
it('should show closed indicators in header when case is closed', () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
@ -90,7 +115,9 @@ describe('CaseView ', () => {
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseClosedProps} />
<Router history={mockHistory}>
<CaseComponent {...caseClosedProps} />
</Router>
</TestProviders>
);
expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false);
@ -111,7 +138,9 @@ describe('CaseView ', () => {
it('should dispatch update state when button is toggled', () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
</Router>
</TestProviders>
);
@ -128,7 +157,9 @@ describe('CaseView ', () => {
it('should render comments', () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
</Router>
</TestProviders>
);
expect(

View file

@ -23,6 +23,7 @@ import { getTypedPayload } from '../../../../containers/case/utils';
import { WhitePageWrapper } from '../wrappers';
import { useBasePath } from '../../../../lib/kibana';
import { CaseStatus } from '../case_status';
import { SpyRoute } from '../../../../utils/route/spy_routes';
interface Props {
caseId: string;
@ -93,6 +94,8 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) =>
const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]);
const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]);
const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]);
const caseStatusData = useMemo(
() =>
caseData.status === 'open'
@ -179,6 +182,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) =>
</EuiFlexGroup>
</MyWrapper>
</WhitePageWrapper>
<SpyRoute state={spyState} />
</>
);
});

View file

@ -12,6 +12,7 @@ import { useUpdateComment } from '../../../../containers/case/use_update_comment
import { UserActionItem } from './user_action_item';
import { UserActionMarkdown } from './user_action_markdown';
import { AddComment } from '../add_comment';
import { useCurrentUser } from '../../../../lib/kibana';
export interface UserActionTreeProps {
data: Case;
@ -20,14 +21,14 @@ export interface UserActionTreeProps {
}
const DescriptionId = 'description';
const NewId = 'newComent';
const NewId = 'newComment';
export const UserActionTree = React.memo(
({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => {
const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment(
caseData.comments
);
const currentUser = useCurrentUser();
const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]);
const handleManageMarkdownEditId = useCallback(
@ -112,10 +113,10 @@ export const UserActionTree = React.memo(
id={NewId}
isEditable={true}
isLoading={isLoadingIds.includes(NewId)}
fullName="to be determined"
fullName={currentUser != null ? currentUser.fullName : ''}
markdown={MarkdownNewComment}
onEdit={handleManageMarkdownEditId.bind(null, NewId)}
userName="to be determined"
userName={currentUser != null ? currentUser.username : ''}
/>
</>
);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import React from 'react';
import styled, { css } from 'styled-components';
@ -48,6 +48,12 @@ const UserActionItemContainer = styled(EuiFlexGroup)`
margin-right: ${theme.eui.euiSize};
vertical-align: top;
}
.userAction_loadingAvatar {
position: relative;
margin-right: ${theme.eui.euiSizeXL};
top: ${theme.eui.euiSizeM};
left: ${theme.eui.euiSizeS};
}
.userAction__title {
padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL};
background: ${theme.eui.euiColorLightestShade};
@ -74,7 +80,11 @@ export const UserActionItem = ({
}: UserActionItemProps) => (
<UserActionItemContainer gutterSize={'none'}>
<EuiFlexItem data-test-subj={`user-action-${id}-avatar`} grow={false}>
<UserActionAvatar name={fullName ?? userName} />
{fullName.length > 0 || userName.length > 0 ? (
<UserActionAvatar name={fullName ?? userName} />
) : (
<EuiLoadingSpinner className="userAction_loadingAvatar" />
)}
</EuiFlexItem>
<EuiFlexItem data-test-subj={`user-action-${id}`}>
{isEditable && markdown}

View file

@ -28,7 +28,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => {
breadcrumb = [
...breadcrumb,
{
text: params.detailName,
text: params.state?.caseTitle ?? '',
href: getCaseDetailsUrl(params.detailName),
},
];

View file

@ -27,21 +27,24 @@ import {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '../../../../plugins/triggers_actions_ui/public';
import { SecurityPluginSetup } from '../../../../plugins/security/public';
export { AppMountParameters, CoreSetup, CoreStart, PluginInitializerContext };
export interface SetupPlugins {
home: HomePublicPluginSetup;
usageCollection: UsageCollectionSetup;
security: SecurityPluginSetup;
triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup;
usageCollection: UsageCollectionSetup;
}
export interface StartPlugins {
data: DataPublicPluginStart;
embeddable: EmbeddableStart;
inspector: InspectorStart;
newsfeed?: NewsfeedStart;
uiActions: UiActionsStart;
security: SecurityPluginSetup;
triggers_actions_ui: TriggersAndActionsUIPublicPluginStart;
uiActions: UiActionsStart;
}
export type StartServices = CoreStart & StartPlugins;
@ -61,6 +64,8 @@ export class Plugin implements IPlugin<Setup, Start> {
public setup(core: CoreSetup, plugins: SetupPlugins) {
initTelemetry(plugins.usageCollection, this.id);
const security = plugins.security;
core.application.register({
id: this.id,
title: this.name,
@ -69,8 +74,7 @@ export class Plugin implements IPlugin<Setup, Start> {
const { renderApp } = await import('./app');
plugins.triggers_actions_ui.actionTypeRegistry.register(serviceNowActionType());
return renderApp(coreStart, startPlugins as StartPlugins, params);
return renderApp(coreStart, { ...startPlugins, security } as StartPlugins, params);
},
});

View file

@ -39,12 +39,13 @@ export const SpyRouteComponent = memo<SpyRouteProps & { location: H.Location }>(
dispatch({
type: 'updateRouteWithOutSearch',
route: {
pageName,
detailName,
tabName,
pathName: pathname,
history,
flowTarget,
history,
pageName,
pathName: pathname,
state,
tabName,
},
});
setIsInitializing(false);
@ -52,13 +53,14 @@ export const SpyRouteComponent = memo<SpyRouteProps & { location: H.Location }>(
dispatch({
type: 'updateRoute',
route: {
pageName,
detailName,
tabName,
search,
pathName: pathname,
history,
flowTarget,
history,
pageName,
pathName: pathname,
search,
state,
tabName,
},
});
}
@ -67,14 +69,14 @@ export const SpyRouteComponent = memo<SpyRouteProps & { location: H.Location }>(
dispatch({
type: 'updateRoute',
route: {
pageName,
detailName,
tabName,
search,
pathName: pathname,
history,
flowTarget,
history,
pageName,
pathName: pathname,
search,
state,
tabName,
},
});
}