Skip to content

Commit

Permalink
[Cases] Improve bulk actions (#142150)
Browse files Browse the repository at this point in the history
* Use react query for delete cases

* Convert delete to react query

* Convert update to react query

* Convert use_get_cases_status to react query

* Convert use_get_cases_metrics to react query

* Refresh metrics and statuses

* Show loading when updating cases

* Create query key builder

* Improve refreshing logic

* Improve delete messages

* Fix types and tests

* Improvements

* PR feedback

* Fix bug

* Refactor actions

* Add status actions

* Change status to panel

* Add status column

* Improvements

* Fix tests & types

* Remove comment

* Improve e2e tests

* Add unit tests

* Add permissions

* Fix delete e2e

* Disable statuses

* Fix i18n

* PR feedback

* Disable actions when cases are selected

* Improve modal tests

* Disables checkbox on read only

* PR feedback
  • Loading branch information
cnasikas authored Oct 5, 2022
1 parent bb8a3c3 commit 3469d64
Show file tree
Hide file tree
Showing 43 changed files with 3,276 additions and 1,179 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/cases/public/common/mock/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const noUpdateCasesPermissions = () => buildCasesPermissions({ update: fa
export const noPushCasesPermissions = () => buildCasesPermissions({ push: false });
export const noDeleteCasesPermissions = () => buildCasesPermissions({ delete: false });
export const writeCasesPermissions = () => buildCasesPermissions({ read: false });
export const onlyDeleteCasesPermission = () =>
buildCasesPermissions({ read: false, create: false, update: false, delete: true, push: false });

export const buildCasesPermissions = (overrides: Partial<Omit<CasesPermissions, 'all'>> = {}) => {
const create = overrides.create ?? true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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 { i18n } from '@kbn/i18n';
export { DELETED_CASES } from '../../../common/translations';

export const BULK_ACTION_DELETE_LABEL = i18n.translate(
'xpack.cases.caseTable.bulkActions.deleteCases',
{
defaultMessage: 'Delete cases',
}
);

export const DELETE_ACTION_LABEL = i18n.translate('xpack.cases.caseTable.action.deleteCase', {
defaultMessage: 'Delete case',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* 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 { AppMockRenderer, createAppMockRenderer } from '../../../common/mock';
import { act, renderHook } from '@testing-library/react-hooks';
import { useDeleteAction } from './use_delete_action';

import * as api from '../../../containers/api';
import { basicCase } from '../../../containers/mock';

jest.mock('../../../containers/api');

describe('useDeleteAction', () => {
let appMockRender: AppMockRenderer;
const onAction = jest.fn();
const onActionSuccess = jest.fn();

beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});

it('renders an action with one case', async () => {
const { result } = renderHook(
() => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);

expect(result.current.getAction([basicCase])).toMatchInlineSnapshot(`
Object {
"data-test-subj": "cases-bulk-action-delete",
"disabled": false,
"icon": <EuiIcon
color="danger"
size="m"
type="trash"
/>,
"key": "cases-bulk-action-delete",
"name": <EuiTextColor
color="danger"
>
Delete case
</EuiTextColor>,
"onClick": [Function],
}
`);
});

it('renders an action with multiple cases', async () => {
const { result } = renderHook(
() => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);

expect(result.current.getAction([basicCase, basicCase])).toMatchInlineSnapshot(`
Object {
"data-test-subj": "cases-bulk-action-delete",
"disabled": false,
"icon": <EuiIcon
color="danger"
size="m"
type="trash"
/>,
"key": "cases-bulk-action-delete",
"name": <EuiTextColor
color="danger"
>
Delete cases
</EuiTextColor>,
"onClick": [Function],
}
`);
});

it('deletes the selected cases', async () => {
const deleteSpy = jest.spyOn(api, 'deleteCases');

const { result, waitFor } = renderHook(
() => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);

const action = result.current.getAction([basicCase]);

act(() => {
action.onClick();
});

expect(onAction).toHaveBeenCalled();
expect(result.current.isModalVisible).toBe(true);

act(() => {
result.current.onConfirmDeletion();
});

await waitFor(() => {
expect(result.current.isModalVisible).toBe(false);
expect(onActionSuccess).toHaveBeenCalled();
expect(deleteSpy).toHaveBeenCalledWith(['basic-case-id'], expect.anything());
});
});

it('closes the modal', async () => {
const { result, waitFor } = renderHook(
() => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);

const action = result.current.getAction([basicCase]);

act(() => {
action.onClick();
});

expect(result.current.isModalVisible).toBe(true);

act(() => {
result.current.onCloseModal();
});

await waitFor(() => {
expect(result.current.isModalVisible).toBe(false);
});
});

it('shows the success toaster correctly when delete one case', async () => {
const { result, waitFor } = renderHook(
() => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);

const action = result.current.getAction([basicCase]);

act(() => {
action.onClick();
});

act(() => {
result.current.onConfirmDeletion();
});

await waitFor(() => {
expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith(
'Deleted case'
);
});
});

it('shows the success toaster correctly when delete multiple case', async () => {
const { result, waitFor } = renderHook(
() => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }),
{
wrapper: appMockRender.AppWrapper,
}
);

const action = result.current.getAction([basicCase, basicCase]);

act(() => {
action.onClick();
});

act(() => {
result.current.onConfirmDeletion();
});

await waitFor(() => {
expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith(
'Deleted 2 cases'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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, { useCallback, useState } from 'react';
import { EuiIcon, EuiTextColor, useEuiTheme } from '@elastic/eui';
import { Case } from '../../../../common';
import { useDeleteCases } from '../../../containers/use_delete_cases';

import * as i18n from './translations';
import { UseActionProps } from '../types';
import { useCasesContext } from '../../cases_context/use_cases_context';

const getDeleteActionTitle = (totalCases: number): string =>
totalCases > 1 ? i18n.BULK_ACTION_DELETE_LABEL : i18n.DELETE_ACTION_LABEL;

export const useDeleteAction = ({ onAction, onActionSuccess, isDisabled }: UseActionProps) => {
const euiTheme = useEuiTheme();
const { permissions } = useCasesContext();
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const [caseToBeDeleted, setCaseToBeDeleted] = useState<Case[]>([]);
const canDelete = permissions.delete;
const isActionDisabled = isDisabled || !canDelete;

const onCloseModal = useCallback(() => setIsModalVisible(false), []);
const openModal = useCallback(
(selectedCases: Case[]) => {
onAction();
setIsModalVisible(true);
setCaseToBeDeleted(selectedCases);
},
[onAction]
);

const { mutate: deleteCases } = useDeleteCases();

const onConfirmDeletion = useCallback(() => {
onCloseModal();
deleteCases(
{
caseIds: caseToBeDeleted.map(({ id }) => id),
successToasterTitle: i18n.DELETED_CASES(caseToBeDeleted.length),
},
{ onSuccess: onActionSuccess }
);
}, [deleteCases, onActionSuccess, onCloseModal, caseToBeDeleted]);

const color = isActionDisabled ? euiTheme.euiTheme.colors.disabled : 'danger';

const getAction = (selectedCases: Case[]) => {
return {
name: <EuiTextColor color={color}>{getDeleteActionTitle(selectedCases.length)}</EuiTextColor>,
onClick: () => openModal(selectedCases),
disabled: isActionDisabled,
'data-test-subj': 'cases-bulk-action-delete',
icon: <EuiIcon type="trash" size="m" color={color} />,
key: 'cases-bulk-action-delete',
};
};

return { getAction, isModalVisible, onConfirmDeletion, onCloseModal, canDelete };
};

export type UseDeleteAction = ReturnType<typeof useDeleteAction>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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 { i18n } from '@kbn/i18n';
export { MARK_CASE_IN_PROGRESS, OPEN_CASE, CLOSE_CASE } from '../../../common/translations';

export const CLOSED_CASES = ({
totalCases,
caseTitle,
}: {
totalCases: number;
caseTitle?: string;
}) =>
i18n.translate('xpack.cases.containers.closedCases', {
values: { caseTitle, totalCases },
defaultMessage: 'Closed {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}',
});

export const REOPENED_CASES = ({
totalCases,
caseTitle,
}: {
totalCases: number;
caseTitle?: string;
}) =>
i18n.translate('xpack.cases.containers.reopenedCases', {
values: { caseTitle, totalCases },
defaultMessage: 'Opened {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}',
});

export const MARK_IN_PROGRESS_CASES = ({
totalCases,
caseTitle,
}: {
totalCases: number;
caseTitle?: string;
}) =>
i18n.translate('xpack.cases.containers.markInProgressCases', {
values: { caseTitle, totalCases },
defaultMessage:
'Marked {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}} as in progress',
});

export const BULK_ACTION_STATUS_CLOSE = i18n.translate(
'xpack.cases.caseTable.bulkActions.status.close',
{
defaultMessage: 'Close selected',
}
);

export const BULK_ACTION_STATUS_OPEN = i18n.translate(
'xpack.cases.caseTable.bulkActions.status.open',
{
defaultMessage: 'Open selected',
}
);

export const BULK_ACTION_STATUS_IN_PROGRESS = i18n.translate(
'xpack.cases.caseTable.bulkActions.status.inProgress',
{
defaultMessage: 'Mark in progress',
}
);
Loading

0 comments on commit 3469d64

Please sign in to comment.