mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
801fed27dd
commit
c594f606e8
9 changed files with 228 additions and 4 deletions
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>;
|
|
@ -13,6 +13,8 @@ export interface UseActionProps {
|
|||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export type UseCopyIDActionProps = Pick<UseActionProps, 'onActionSuccess'>;
|
||||
|
||||
export interface ItemsSelectionState {
|
||||
selectedItems: string[];
|
||||
unSelectedItems: string[];
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue