[Cases] Copy case ID to clipboard (#148962)

Fixes #148085

## Summary

Users want to refer to a case by its UUID in external messaging
applications like Slack.

They are now able to copy the UUID from the cases table or the case view
page.

The ability to search for cases by UUID will be developed in #148084.

## Screenshots

### All Cases View
<img width="791" alt="Screenshot 2023-01-16 at 11 15 14"
src="https://user-images.githubusercontent.com/1533137/212663742-37e3c7df-e983-4ae7-ab04-2ed4326bba03.png">
<img width="1569" alt="Screenshot 2023-01-16 at 11 16 21"
src="https://user-images.githubusercontent.com/1533137/212663745-12941766-fa90-498b-9e61-10ae30f709f6.png">

### Case Detail View

<img width="1398" alt="Screenshot 2023-01-16 at 12 07 10"
src="https://user-images.githubusercontent.com/1533137/212664134-3bc98d20-726e-4dad-9f1b-e08104e5c836.png">
<img width="1391" alt="Screenshot 2023-01-16 at 12 07 16"
src="https://user-images.githubusercontent.com/1533137/212664140-0b750e0a-7fbc-4b53-8d81-251aa183401e.png">


### 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)

### Release notes

Users can now click a button on Case Detail and All Cases List to copy a
case's UUID to the clipboard.
This commit is contained in:
Antonio 2023-01-16 21:41:35 +01:00 committed by GitHub
parent 801fed27dd
commit c594f606e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 228 additions and 4 deletions

View file

@ -21,6 +21,14 @@ export const DELETE_CASE = (quantity: number = 1) =>
defaultMessage: `Delete {quantity, plural, =1 {case} other {{quantity} cases}}`,
});
export const COPY_ID_ACTION_LABEL = i18n.translate('xpack.cases.caseView.copyID', {
defaultMessage: 'Copy Case ID',
});
export const COPY_ID_ACTION_SUCCESS = i18n.translate('xpack.cases.caseView.copyIDSuccess', {
defaultMessage: 'Copied Case ID to clipboard',
});
export const NAME = i18n.translate('xpack.cases.caseView.name', {
defaultMessage: 'Name',
});

View file

@ -0,0 +1,91 @@
/*
* 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 { AppMockRenderer } from '../../../common/mock';
import { createAppMockRenderer } from '../../../common/mock';
import { renderHook } from '@testing-library/react-hooks';
import { useCopyIDAction } from './use_copy_id_action';
import { basicCase } from '../../../containers/mock';
jest.mock('../../../containers/api');
describe('useCopyIDAction', () => {
let appMockRender: AppMockRenderer;
const onActionSuccess = jest.fn();
const originalClipboard = global.window.navigator.clipboard;
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
},
writable: true,
});
});
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
});
});
it('renders a copy ID action with one case', async () => {
const { result } = renderHook(() => useCopyIDAction({ onActionSuccess }), {
wrapper: appMockRender.AppWrapper,
});
expect(result.current.getAction(basicCase)).toMatchInlineSnapshot(`
Object {
"data-test-subj": "cases-action-copy-id",
"icon": <EuiIcon
size="m"
type="copyClipboard"
/>,
"key": "cases-action-copy-id",
"name": <EuiTextColor>
Copy Case ID
</EuiTextColor>,
"onClick": [Function],
}
`);
});
it('copies the id of the selected case to the clipboard', async () => {
const { result, waitFor } = renderHook(() => useCopyIDAction({ onActionSuccess }), {
wrapper: appMockRender.AppWrapper,
});
const action = result.current.getAction(basicCase);
action.onClick();
await waitFor(() => {
expect(onActionSuccess).toHaveBeenCalled();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(basicCase.id);
});
});
it('shows the success toaster correctly when copying the case id', async () => {
const { result, waitFor } = renderHook(() => useCopyIDAction({ onActionSuccess }), {
wrapper: appMockRender.AppWrapper,
});
const action = result.current.getAction(basicCase);
action.onClick();
await waitFor(() => {
expect(onActionSuccess).toHaveBeenCalled();
expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith(
'Copied Case ID to clipboard'
);
});
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 { EuiIcon, EuiTextColor } from '@elastic/eui';
import * as i18n from '../../../common/translations';
import { useCasesToast } from '../../../common/use_cases_toast';
import type { Case } from '../../../../common';
import type { UseCopyIDActionProps } from '../types';
export const useCopyIDAction = ({ onActionSuccess }: UseCopyIDActionProps) => {
const { showSuccessToast } = useCasesToast();
const getAction = (selectedCase: Case) => {
return {
name: <EuiTextColor>{i18n.COPY_ID_ACTION_LABEL}</EuiTextColor>,
onClick: () => {
navigator.clipboard.writeText(selectedCase.id).then(() => {
onActionSuccess();
showSuccessToast(i18n.COPY_ID_ACTION_SUCCESS);
});
},
'data-test-subj': 'cases-action-copy-id',
icon: <EuiIcon type="copyClipboard" size="m" />,
key: 'cases-action-copy-id',
};
};
return { getAction };
};
export type UseCopyIDAction = ReturnType<typeof useCopyIDAction>;

View file

@ -13,6 +13,8 @@ export interface UseActionProps {
isDisabled: boolean;
}
export type UseCopyIDActionProps = Pick<UseActionProps, 'onActionSuccess'>;
export interface ItemsSelectionState {
selectedItems: string[];
unSelectedItems: string[];

View file

@ -73,6 +73,7 @@ describe('useActions', () => {
expect(res.getByText('Actions')).toBeInTheDocument();
expect(res.getByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument();
expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument();
expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument();
});
});
@ -143,6 +144,40 @@ describe('useActions', () => {
});
});
it('copies the case id to the clipboard', async () => {
const originalClipboard = global.window.navigator.clipboard;
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
},
writable: true,
});
const { result } = renderHook(() => useActions({ disableActions: false }), {
wrapper: appMockRender.AppWrapper,
});
const comp = result.current.actions!.render(basicCase) as React.ReactElement;
const res = appMockRender.render(comp);
userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`));
await waitFor(() => {
expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument();
});
userEvent.click(res.getByTestId('cases-action-copy-id'), undefined, {
skipPointerEventsCheck: true,
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(basicCase.id);
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
});
});
describe('Modals', () => {
it('delete a case', async () => {
const deleteSpy = jest.spyOn(api, 'deleteCases');
@ -304,6 +339,7 @@ describe('useActions', () => {
expect(res.getByTestId(`case-action-severity-panel-${basicCase.id}`)).toBeInTheDocument();
expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument();
expect(res.getByTestId(`actions-separator-${basicCase.id}`)).toBeInTheDocument();
expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument();
});
});
@ -321,6 +357,7 @@ describe('useActions', () => {
await waitFor(() => {
expect(res.getByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument();
expect(res.getByTestId(`case-action-severity-panel-${basicCase.id}`)).toBeInTheDocument();
expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument();
expect(res.queryByTestId('cases-bulk-action-delete')).toBeFalsy();
expect(res.queryByTestId(`actions-separator-${basicCase.id}`)).toBeFalsy();
});
@ -340,6 +377,7 @@ describe('useActions', () => {
await waitFor(() => {
expect(res.queryByTestId(`case-action-status-panel-${basicCase.id}`)).toBeFalsy();
expect(res.queryByTestId(`case-action-severity-panel-${basicCase.id}`)).toBeFalsy();
expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument();
expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument();
expect(res.queryByTestId(`actions-separator-${basicCase.id}`)).toBeFalsy();
});

View file

@ -27,6 +27,7 @@ import { useTagsAction } from '../actions/tags/use_tags_action';
import { EditTagsFlyout } from '../actions/tags/edit_tags_flyout';
import { useAssigneesAction } from '../actions/assignees/use_assignees_action';
import { EditAssigneesFlyout } from '../actions/assignees/edit_assignees_flyout';
import { useCopyIDAction } from '../actions/copy_id/use_copy_id_action';
const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }> = ({
theCase,
@ -43,6 +44,10 @@ const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }
onActionSuccess: refreshCases,
});
const copyIDAction = useCopyIDAction({
onActionSuccess: closePopover,
});
const statusAction = useStatusAction({
isDisabled: false,
onAction: closePopover,
@ -126,6 +131,8 @@ const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }
mainPanelItems.push(assigneesAction.getAction([theCase]));
}
mainPanelItems.push(copyIDAction.getAction(theCase));
if (canDelete) {
mainPanelItems.push(deleteAction.getAction([theCase]));
}
@ -146,13 +153,14 @@ const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }
return panelsToBuild;
}, [
assigneesAction,
canDelete,
canUpdate,
copyIDAction,
deleteAction,
severityAction,
statusAction,
tagsAction,
assigneesAction,
theCase,
]);

View file

@ -55,6 +55,32 @@ describe('CaseView actions', () => {
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy();
});
it('clicking copyClipboard icon copies case id', () => {
const originalClipboard = global.window.navigator.clipboard;
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
},
writable: true,
});
const wrapper = mount(
<TestProviders>
<Actions {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="property-actions-ellipses"]').first().simulate('click');
wrapper.find('button[data-test-subj="property-actions-copyClipboard"]').simulate('click');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(basicCase.id);
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
});
});
it('does not show trash icon when user does not have deletion privileges', () => {
const wrapper = mount(
<TestProviders permissions={noDeleteCasesPermissions()}>
@ -63,7 +89,9 @@ describe('CaseView actions', () => {
);
expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy();
expect(wrapper.find('button[data-test-subj="property-actions-ellipses"]').exists()).toBeFalsy();
wrapper.find('button[data-test-subj="property-actions-ellipses"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="property-actions-trash"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="property-actions-copyClipboard"]').exists()).toBeTruthy();
});
it('toggle delete modal and confirm', async () => {

View file

@ -16,6 +16,7 @@ import type { Case } from '../../../common/ui/types';
import type { CaseService } from '../../containers/use_get_case_user_actions';
import { useAllCasesNavigation } from '../../common/navigation';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useCasesToast } from '../../common/use_cases_toast';
interface CaseViewActions {
caseData: Case;
@ -26,6 +27,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
const { mutate: deleteCases } = useDeleteCases();
const { navigateToAllCases } = useAllCasesNavigation();
const { permissions } = useCasesContext();
const { showSuccessToast } = useCasesToast();
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const openModal = useCallback(() => {
@ -38,6 +40,14 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
const propertyActions = useMemo(
() => [
{
iconType: 'copyClipboard',
label: i18n.COPY_ID_ACTION_LABEL,
onClick: () => {
navigator.clipboard.writeText(caseData.id);
showSuccessToast(i18n.COPY_ID_ACTION_SUCCESS);
},
},
...(permissions.delete
? [
{
@ -57,7 +67,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({ caseData, currentExternal
]
: []),
],
[permissions.delete, openModal, currentExternalIncident]
[permissions.delete, openModal, currentExternalIncident, caseData.id, showSuccessToast]
);
const onConfirmDeletion = useCallback(() => {

View file

@ -233,8 +233,10 @@ describe('CaseActionBar', () => {
</TestProviders>
);
expect(queryByTestId('property-actions-ellipses')).not.toBeInTheDocument();
userEvent.click(screen.getByTestId('property-actions-ellipses'));
expect(queryByText('Delete case')).not.toBeInTheDocument();
expect(queryByTestId('property-actions-trash')).not.toBeInTheDocument();
expect(queryByTestId('property-actions-copyClipboard')).toBeInTheDocument();
});
it('should show the the delete item in the menu when the user does have delete privileges', () => {