diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/ContactItemMenu.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/ContactItemMenu.tsx new file mode 100644 index 0000000000000..a89bfcaaa1cc4 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/contacts/ContactItemMenu.tsx @@ -0,0 +1,61 @@ +import type { ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import { useRouter, useSetModal, usePermission } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import RemoveContactModal from './RemoveContactModal'; + +type ContactItemMenuProps = { + _id: string; + name: string; + channels: ILivechatContactChannel[]; +}; + +const ContactItemMenu = ({ _id, name, channels }: ContactItemMenuProps): ReactElement => { + const { t } = useTranslation(); + const setModal = useSetModal(); + const router = useRouter(); + + const canEditContact = usePermission('update-livechat-contact'); + const canDeleteContact = usePermission('delete-livechat-contact'); + + const handleContactEdit = useEffectEvent((): void => + router.navigate({ + pattern: '/omnichannel-directory/:tab?/:context?/:id?', + params: { + tab: 'contacts', + context: 'edit', + id: _id, + }, + }), + ); + + const handleContactRemoval = useEffectEvent(() => { + setModal( setModal(null)} />); + }); + + const menuOptions: GenericMenuItemProps[] = [ + { + id: 'edit', + icon: 'edit', + content: t('Edit'), + onClick: () => handleContactEdit(), + disabled: !canEditContact, + }, + { + id: 'delete', + icon: 'trash', + content: t('Delete'), + onClick: () => handleContactRemoval(), + variant: 'danger', + disabled: !canDeleteContact, + }, + ]; + + return ; +}; + +export default ContactItemMenu; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx index 2035de375c9f3..7a2b58f58e91c 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTable.tsx @@ -88,6 +88,7 @@ function ContactTable() { {t('Last_Chat')} {isCallReady && } + ); diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTableRow.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTableRow.tsx index 78304d132a135..fa5e898207a9a 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/ContactTableRow.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/ContactTableRow.tsx @@ -3,6 +3,7 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings'; import { useRoute } from '@rocket.chat/ui-contexts'; +import ContactItemMenu from './ContactItemMenu'; import { GenericTableCell, GenericTableRow } from '../../../../components/GenericTable'; import { OmnichannelRoomIcon } from '../../../../components/RoomIcon/OmnichannelRoomIcon'; import { useIsCallReady } from '../../../../contexts/CallContext'; @@ -63,6 +64,9 @@ const ContactTableRow = ({ _id, name, phones, contactManager, lastChat, channels )} + + + ); }; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/RemoveContactModal.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/RemoveContactModal.tsx new file mode 100644 index 0000000000000..f56c5f274b834 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/contacts/RemoveContactModal.tsx @@ -0,0 +1,79 @@ +import { Box, Input } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ReactElement, ChangeEvent } from 'react'; +import { useState, useId } from 'react'; +import { useTranslation } from 'react-i18next'; + +type RemoveContactModalProps = { + _id: string; + name: string; + channelsCount: number; + onClose: () => void; +}; + +const RemoveContactModal = ({ _id, name, channelsCount, onClose }: RemoveContactModalProps): ReactElement => { + const { t } = useTranslation(); + const [text, setText] = useState(''); + + const queryClient = useQueryClient(); + const removeContact = useEndpoint('POST', '/v1/omnichannel/contacts.delete'); + const dispatchToast = useToastMessageDispatch(); + const contactDeleteModalId = useId(); + + const handleSubmit = useEffectEvent((event: ChangeEvent): void => { + event.preventDefault(); + removeContactMutation.mutate(); + }); + + const confirmationText = t('Delete').toLowerCase(); + + const removeContactMutation = useMutation({ + mutationFn: () => removeContact({ contactId: _id }), + onSuccess: async () => { + dispatchToast({ type: 'success', message: t('Contact_has_been_deleted') }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['current-contacts'], + }), + queryClient.invalidateQueries({ + queryKey: ['getContactsByIds', _id], + }), + ]); + onClose(); + }, + onError: (error) => { + dispatchToast({ type: 'error', message: error }); + onClose(); + }, + }); + + return ( + } + onCancel={onClose} + confirmText={t('Delete')} + title={t('Delete_Contact')} + onClose={onClose} + variant='danger' + confirmDisabled={text !== confirmationText} + > + + {t('Are_you_sure_delete_contact', { contactName: name, channelsCount, confirmationText })} + + + ) => setText(event.currentTarget.value)} + /> + + + ); +}; + +export default RemoveContactModal; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts index 9c624b0c49bfc..430c3a683f9d8 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center.spec.ts @@ -25,6 +25,13 @@ const EXISTING_CONTACT = { phones: [faker.phone.number('+############')], token: undefined, }; +const DELETE_CONTACT = { + id: undefined, + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number('+############')], +}; + const NEW_CUSTOM_FIELD = { searchable: true, field: 'hiddenCustomField', @@ -63,8 +70,8 @@ test.describe('Omnichannel Contact Center', () => { let poOmniSection: OmnichannelSection; test.beforeAll(async ({ api }) => { - // Add a contact - await api.post('/omnichannel/contacts', EXISTING_CONTACT); + // Add contacts + await Promise.all([api.post('/omnichannel/contacts', EXISTING_CONTACT), api.post('/omnichannel/contacts', DELETE_CONTACT)]); if (IS_EE) { await api.post('/livechat/custom.field', NEW_CUSTOM_FIELD); @@ -72,9 +79,8 @@ test.describe('Omnichannel Contact Center', () => { }); test.afterAll(async ({ api }) => { - // Remove added contact - await api.delete(`/livechat/visitor/${NEW_CONTACT.token}`); - await api.delete(`/livechat/visitor/${EXISTING_CONTACT.token}`); + // Remove added contacts + await Promise.all([api.delete(`/livechat/visitor/${NEW_CONTACT.token}`), api.delete(`/livechat/visitor/${EXISTING_CONTACT.token}`)]); if (IS_EE) { await api.post('method.call/livechat:removeCustomField', { message: NEW_CUSTOM_FIELD.field }); @@ -262,4 +268,33 @@ test.describe('Omnichannel Contact Center', () => { await expect(poContacts.findRowByName(EDIT_CONTACT.name)).toBeVisible(); }); }); + + test('Delete a contact', async () => { + await test.step('Find contact and open modal', async () => { + await poContacts.inputSearch.fill(DELETE_CONTACT.name); + await poContacts.findRowMenu(DELETE_CONTACT.name).click(); + await poContacts.findMenuItem('Delete').click(); + }); + + await test.step('Fill confirmation and delete contact', async () => { + await expect(poContacts.deleteContactModal).toBeVisible(); + await expect(poContacts.btnDeleteContact).toBeDisabled(); + + // Fills the input with the wrong confirmation + await poContacts.inputDeleteContactConfirmation.fill('wrong'); + await expect(poContacts.btnDeleteContact).toBeDisabled(); + + // Fills the input correctly + await poContacts.inputDeleteContactConfirmation.fill('delete'); + await expect(poContacts.btnDeleteContact).toBeEnabled(); + await poContacts.btnDeleteContact.click(); + + await expect(poContacts.deleteContactModal).not.toBeVisible(); + }); + + await test.step('Confirm contact removal', async () => { + await poContacts.inputSearch.fill(DELETE_CONTACT.name); + await expect(poContacts.findRowByName(DELETE_CONTACT.name)).not.toBeVisible(); + }); + }); }); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-contacts-list.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-contacts-list.ts index daad632bf490f..5a288ccf3e639 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-contacts-list.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-contacts-list.ts @@ -25,7 +25,27 @@ export class OmnichannelContacts { } findRowByName(contactName: string) { - return this.page.locator(`td >> text="${contactName}"`); + return this.page.locator('tr', { has: this.page.locator(`td >> text="${contactName}"`) }); + } + + findRowMenu(contactName: string): Locator { + return this.findRowByName(contactName).getByRole('button', { name: 'More Actions' }); + } + + findMenuItem(name: string): Locator { + return this.page.getByRole('menuitem', { name }); + } + + get deleteContactModal(): Locator { + return this.page.getByRole('dialog', { name: 'Delete Contact' }); + } + + get inputDeleteContactConfirmation(): Locator { + return this.deleteContactModal.getByRole('textbox', { name: 'Confirm contact removal' }); + } + + get btnDeleteContact(): Locator { + return this.deleteContactModal.getByRole('button', { name: 'Delete' }); } get btnFilters(): Locator { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 8295458f0e346..0f9666fe4bf2a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -667,6 +667,7 @@ "Are_you_sure_you_want_to_disable_Facebook_integration": "Are you sure you want to disable Facebook integration?", "Are_you_sure_you_want_to_pin_this_message": "Are you sure you want to pin this message?", "Are_you_sure_you_want_to_reset_the_name_of_all_priorities": "Are you sure you want to reset the name of all priorities?", + "Are_you_sure_delete_contact": "Are you sure you want to delete {{contactName}} and all {{channelsCount}} of their conversation history? To confirm, type '{{confirmationText}}' in the field below.", "Ask_enable_advanced_contact_profile": "Ask your workspace admin to enable advanced contact profile", "Asset_preview": "Asset preview", "Assets": "Assets", @@ -1134,6 +1135,7 @@ "Confirm_new_workspace_description": "Identification data and cloud connection data will be reset.

Warning: License can be affected if changing workspace URL.", "Confirm_password": "Confirm password", "Confirm_your_password": "Confirm your password", + "Confirm_contact_removal": "Confirm Contact Removal", "Confirmation": "Confirmation", "Conflicts_found": "Conflicts found", "Connect": "Connect", @@ -1158,6 +1160,7 @@ "Contact_email": "Contact email", "Contact_has_been_created": "Contact has been created", "Contact_has_been_updated": "Contact has been updated", + "Contact_has_been_deleted": "Contact has been deleted", "Contact_history_is_preserved": "Contact history is preserved", "Contact_identification": "Contact identification", "Contact_not_found": "Contact not found", @@ -1593,6 +1596,7 @@ "Default_value": "Default value", "Delete": "Delete", "Delete_Department?": "Delete Department?", + "Delete_Contact": "Delete Contact", "Delete_File_Warning": "Deleting a file will delete it forever. This cannot be undone.", "Delete_Role_Warning": "This cannot be undone", "Delete_Role_Warning_Not_Enterprise": "This cannot be undone. You won't be able to create a new custom role, since that feature is no longer available for your current plan.", @@ -6045,6 +6049,9 @@ "error-channels-setdefault-missing-default-param": "The bodyParam 'default' is required", "error-comment-is-required": "Comment is required", "error-contact-sent-last-message-so-cannot-place-on-hold": "You cannot place chat on-hold, when the Contact has sent the last message", + "error-contact-not-found": "Contact not found.", + "error-contact-has-open-rooms": "Cannot delete contact with open rooms.", + "error-contact-something-went-wrong": "Something went wrong while deleting the contact.", "error-could-not-change-email": "Could not change email", "error-could-not-change-name": "Could not change name", "error-could-not-change-username": "Could not change username",