From 462b774b279ae8aae0b7ddeb870025d3e2b5dbf1 Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Mon, 7 Nov 2022 11:19:01 -0300 Subject: [PATCH] [FIX] Adjusted form validation to disallow duplicated emails (#27037) --- .../client/components/CustomFieldsForm.js | 11 +- .../sidebar/sections/OmnichannelSection.tsx | 7 +- .../contacts/contextualBar/ContactNewEdit.js | 255 ----------------- .../contacts/contextualBar/ContactNewEdit.tsx | 262 ++++++++++++++++++ .../e2e/omnichannel-contact-center.spec.ts | 226 +++++++++++++++ .../page-objects/omnichannel-contacts-list.ts | 34 +++ .../e2e/page-objects/omnichannel-info.ts | 13 + .../omnichannel-manage-contact.ts | 45 +++ .../e2e/page-objects/omnichannel-section.ts | 25 ++ apps/meteor/tests/e2e/utils/test.ts | 12 +- 10 files changed, 624 insertions(+), 266 deletions(-) delete mode 100644 apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js create mode 100644 apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx create mode 100644 apps/meteor/tests/e2e/omnichannel-contact-center.spec.ts create mode 100644 apps/meteor/tests/e2e/page-objects/omnichannel-contacts-list.ts create mode 100644 apps/meteor/tests/e2e/page-objects/omnichannel-info.ts create mode 100644 apps/meteor/tests/e2e/page-objects/omnichannel-manage-contact.ts create mode 100644 apps/meteor/tests/e2e/page-objects/omnichannel-section.ts diff --git a/apps/meteor/client/components/CustomFieldsForm.js b/apps/meteor/client/components/CustomFieldsForm.js index 5fc7af74187..b4c5d131716 100644 --- a/apps/meteor/client/components/CustomFieldsForm.js +++ b/apps/meteor/client/components/CustomFieldsForm.js @@ -113,7 +113,7 @@ const CustomFieldsAssembler = ({ formValues, formHandlers, customFields, ...prop }); export default function CustomFieldsForm({ - jsonCustomFields = undefined, + jsonCustomFields = {}, customFieldsData, setCustomFieldsData, onLoadFields = () => {}, @@ -129,7 +129,7 @@ export default function CustomFieldsForm({ } }); - const hasCustomFields = Boolean(Object.values(customFields).length); + const hasCustomFields = useMemo(() => Object.values(customFields).length > 0, [customFields]); const defaultFields = useMemo( () => Object.entries(customFields).reduce((data, [key, value]) => { @@ -142,11 +142,14 @@ export default function CustomFieldsForm({ const { values, handlers } = useForm({ ...defaultFields, ...customFieldsData }); useEffect(() => { - onLoadFields(hasCustomFields); + onLoadFields?.(hasCustomFields); + }, [onLoadFields, hasCustomFields]); + + useEffect(() => { if (hasCustomFields) { setCustomFieldsData(values); } - }, [hasCustomFields, onLoadFields, setCustomFieldsData, values]); + }, [hasCustomFields, setCustomFieldsData, values]); if (!hasCustomFields) { return null; diff --git a/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx b/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx index 96a70c8ffbc..15d17047bd9 100644 --- a/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx +++ b/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx @@ -41,7 +41,12 @@ const OmnichannelSection = (props: typeof Box): ReactElement => { {isCallEnabled && } {hasPermissionToSeeContactCenter && ( - handleRoute('directory')} /> + handleRoute('directory')} + /> )} {isCallReady && } diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js deleted file mode 100644 index 97503037619..00000000000 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js +++ /dev/null @@ -1,255 +0,0 @@ -import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { useState, useMemo, useEffect } from 'react'; - -import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; -import { validateEmail } from '../../../../../../lib/emailValidator'; -import CustomFieldsForm from '../../../../../components/CustomFieldsForm'; -import VerticalBar from '../../../../../components/VerticalBar'; -import { AsyncStatePhase } from '../../../../../hooks/useAsyncState'; -import { useComponentDidUpdate } from '../../../../../hooks/useComponentDidUpdate'; -import { useEndpointData } from '../../../../../hooks/useEndpointData'; -import { useForm } from '../../../../../hooks/useForm'; -import { createToken } from '../../../../../lib/utils/createToken'; -import { useFormsSubscription } from '../../../additionalForms'; -import { FormSkeleton } from '../../Skeleton'; - -const initialValues = { - token: '', - name: '', - email: '', - phone: '', - username: '', -}; - -const getInitialValues = (data) => { - if (!data) { - return initialValues; - } - - const { - contact: { name, token, phone, visitorEmails, livechatData, contactManager }, - } = data; - - return { - token: token ?? '', - name: name ?? '', - email: visitorEmails ? visitorEmails[0].address : '', - phone: phone ? phone[0].phoneNumber : '', - livechatData: livechatData ?? {}, - username: contactManager?.username ?? '', - }; -}; - -function ContactNewEdit({ id, data, close }) { - const t = useTranslation(); - - const canViewCustomFields = () => hasAtLeastOnePermission(['view-livechat-room-customfields', 'edit-livechat-room-customfields']); - - const initialValue = getInitialValues(data); - - const { username: initialUsername } = initialValue; - - const { values, handlers, hasUnsavedChanges: hasUnsavedChangesContact } = useForm(initialValue); - - const eeForms = useFormsSubscription(); - - const { useContactManager = () => {} } = eeForms; - - const ContactManager = useContactManager(); - - const { handleName, handleEmail, handlePhone, handleUsername } = handlers; - const { token, name, email, phone, username } = values; - - const { - values: valueCustom, - handlers: handleValueCustom, - hasUnsavedChanges: hasUnsavedChangesCustomFields, - } = useForm({ - livechatData: values.livechatData, - }); - - const { handleLivechatData } = handleValueCustom; - const { livechatData } = valueCustom; - - const [nameError, setNameError] = useState(); - const [emailError, setEmailError] = useState(); - const [phoneError, setPhoneError] = useState(); - const [customFieldsError, setCustomFieldsError] = useState([]); - const [userId, setUserId] = useState('no-agent-selected'); - - const { value: allCustomFields, phase: state } = useEndpointData('/v1/livechat/custom-fields'); - - const jsonConverterToValidFormat = (customFields) => { - const jsonObj = {}; - customFields.forEach(({ _id, label, visibility, options, scope, defaultValue, required }) => { - (visibility === 'visible') & (scope === 'visitor') && - (jsonObj[_id] = { - label, - type: options ? 'select' : 'text', - required, - defaultValue, - options: options && options.split(',').map((item) => item.trim()), - }); - }); - return jsonObj; - }; - - const jsonCustomField = useMemo( - () => (allCustomFields && allCustomFields.customFields ? jsonConverterToValidFormat(allCustomFields.customFields) : {}), - [allCustomFields], - ); - - const saveContact = useEndpoint('POST', '/v1/omnichannel/contact'); - const emailAlreadyExistsAction = useEndpoint('GET', '/v1/omnichannel/contact.search'); - const phoneAlreadyExistsAction = useEndpoint('GET', '/v1/omnichannel/contact.search'); - const getUserData = useEndpoint('GET', '/v1/users.info'); - - const checkEmailExists = useMutableCallback(async () => { - if (!validateEmail(email)) { - return; - } - const { contact } = await emailAlreadyExistsAction({ email }); - if (!contact || (id && contact._id === id)) { - return setEmailError(null); - } - setEmailError(t('Email_already_exists')); - }); - - const checkPhoneExists = useMutableCallback(async () => { - if (!phone) { - return; - } - const { contact } = await phoneAlreadyExistsAction({ phone }); - if (!contact || (id && contact._id === id)) { - return setPhoneError(null); - } - setPhoneError(t('Phone_already_exists')); - }); - - const dispatchToastMessage = useToastMessageDispatch(); - - useComponentDidUpdate(() => { - setNameError(!name ? t('The_field_is_required', t('Name')) : ''); - }, [t, name]); - useComponentDidUpdate(() => { - setEmailError(email && !validateEmail(email) ? t('Validate_email_address') : null); - }, [t, email]); - useComponentDidUpdate(() => { - !phone && setPhoneError(null); - }, [phone]); - - useEffect(() => { - if (!initialUsername) { - return; - } - - getUserData({ username: initialUsername }).then(({ user }) => { - setUserId(user._id); - }); - }, [getUserData, initialUsername]); - - const handleContactManagerChange = useMutableCallback(async (userId) => { - setUserId(userId); - if (userId === 'no-agent-selected') { - handleUsername(''); - return; - } - - getUserData({ userId }).then(({ user }) => { - handleUsername(user.username); - }); - }); - - const handleSave = useMutableCallback(async (e) => { - e.preventDefault(); - let error = false; - if (!name) { - setNameError(t('The_field_is_required', 'name')); - error = true; - } - if (email && !validateEmail(email)) { - setEmailError(t('Validate_email_address')); - error = true; - } - - if (error) { - return; - } - - const payload = { - name, - phone, - email, - customFields: livechatData || {}, - token: token || createToken(), - ...(username && { contactManager: { username } }), - ...(id && { _id: id }), - }; - - try { - await saveContact(payload); - dispatchToastMessage({ type: 'success', message: t('Saved') }); - close(); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - const formIsValid = - (hasUnsavedChangesContact || hasUnsavedChangesCustomFields) && name && !emailError && !phoneError && customFieldsError.length === 0; - - if ([state].includes(AsyncStatePhase.LOADING)) { - return ; - } - - return ( - <> - - - {t('Name')}* - - - - {nameError} - - - {t('Email')} - - - - {t(emailError)} - - - {t('Phone')} - - - - {t(phoneError)} - - {canViewCustomFields() && allCustomFields && ( - - )} - {ContactManager && } - - - - - - - - - ); -} - -export default ContactNewEdit; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx new file mode 100644 index 00000000000..27cb9daeba0 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx @@ -0,0 +1,262 @@ +import { ILivechatCustomField, ILivechatVisitor, Serialized } from '@rocket.chat/core-typings'; +import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React, { useState, useEffect, ReactElement } from 'react'; +import { useController, useForm } from 'react-hook-form'; +import { debounce } from 'underscore'; + +import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; +import { validateEmail } from '../../../../../../lib/emailValidator'; +import CustomFieldsForm from '../../../../../components/CustomFieldsForm'; +import VerticalBar from '../../../../../components/VerticalBar'; +import { createToken } from '../../../../../lib/utils/createToken'; +import { useFormsSubscription } from '../../../additionalForms'; +import { FormSkeleton } from '../../Skeleton'; + +type ContactNewEditProps = { + id: string; + data: { contact: Serialized | null }; + close(): void; +}; + +type ContactFormData = { + token: string; + name: string; + email: string; + phone: string; + username: string; + customFields: Record; +}; + +type CustomFieldsMetadata = Record< + string, + { + label: string; + type: 'select' | 'text'; + required?: boolean; + defaultValue?: unknown; + options?: string[]; + } +>; + +const DEFAULT_VALUES = { + token: '', + name: '', + email: '', + phone: '', + username: '', + customFields: {}, +}; + +const getInitialValues = (data: ContactNewEditProps['data']): ContactFormData => { + if (!data) { + return DEFAULT_VALUES; + } + + const { name, token, phone, visitorEmails, livechatData, contactManager } = data.contact ?? {}; + + return { + token: token ?? '', + name: name ?? '', + email: visitorEmails ? visitorEmails[0].address : '', + phone: phone ? phone[0].phoneNumber : '', + customFields: livechatData ?? {}, + username: contactManager?.username ?? '', + }; +}; + +const formatCustomFieldsMetadata = (customFields: Serialized[]): CustomFieldsMetadata => + customFields + .filter(({ visibility, scope }) => visibility === 'visible' && scope === 'visitor') + .reduce((obj, { _id, label, options, defaultValue, required }) => { + obj[_id] = { + label, + type: options ? 'select' : 'text', + required, + defaultValue, + options: options?.split(',').map((item) => item.trim()), + }; + return obj; + }, {} as CustomFieldsMetadata); + +export const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement => { + const t = useTranslation(); + + const canViewCustomFields = (): boolean => + hasAtLeastOnePermission(['view-livechat-room-customfields', 'edit-livechat-room-customfields']); + + const { useContactManager } = useFormsSubscription(); + + const ContactManager = useContactManager?.(); + + const [userId, setUserId] = useState('no-agent-selected'); + const dispatchToastMessage = useToastMessageDispatch(); + const saveContact = useEndpoint('POST', '/v1/omnichannel/contact'); + const getContactBy = useEndpoint('GET', '/v1/omnichannel/contact.search'); + const getUserData = useEndpoint('GET', '/v1/users.info'); + const getCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields'); + + const { data: customFieldsMetadata = {}, isLoading: isLoadingCustomFields } = useQuery(['contact-json-custom-fields'], async () => { + const rawFields = await getCustomFields(); + return formatCustomFieldsMetadata(rawFields?.customFields); + }); + + const initialValue = getInitialValues(data); + const { username: initialUsername } = initialValue; + + const { + register, + formState: { errors, isValid: isFormValid, isDirty }, + control, + setValue, + getValues, + handleSubmit, + trigger, + } = useForm({ + mode: 'onSubmit', + reValidateMode: 'onSubmit', + defaultValues: initialValue, + }); + + const { + field: { onChange: handleLivechatData, value: customFields }, + } = useController({ + name: 'customFields', + control, + }); + + const [customFieldsErrors, setCustomFieldsErrors] = useState([]); + const isValid = isDirty && isFormValid && customFieldsErrors.length === 0; + + useEffect(() => { + if (!initialUsername) { + return; + } + + getUserData({ username: initialUsername }).then(({ user }) => { + setUserId(user._id); + }); + }, [getUserData, initialUsername]); + + const isEmailValid = async (email: string): Promise => { + if (email === initialValue.email) { + return true; + } + + if (!validateEmail(email)) { + return t('error-invalid-email-address'); + } + + const { contact } = await getContactBy({ email }); + return !contact || contact._id === id || t('Email_already_exists'); + }; + + const isPhoneValid = async (phone: string): Promise => { + if (!phone || initialValue.phone === phone) { + return true; + } + + const { contact } = await getContactBy({ phone }); + return !contact || contact._id === id || t('Phone_already_exists'); + }; + + const isNameValid = (v: string): string | boolean => (!v.trim() ? t('The_field_is_required', t('Name')) : true); + + const handleContactManagerChange = async (userId: string): Promise => { + setUserId(userId); + + if (userId === 'no-agent-selected') { + setValue('username', ''); + return; + } + + const { user } = await getUserData({ userId }); + setValue('username', user.username || ''); + }; + + const validate = (fieldName: keyof ContactFormData): (() => void) => debounce(() => trigger(fieldName), 500); + + const handleSave = async (): Promise => { + const { name, phone, email, customFields, username, token } = getValues(); + + const payload = { + name, + phone, + email, + customFields, + token: token || createToken(), + ...(username && { contactManager: { username } }), + ...(id && { _id: id }), + }; + + try { + await saveContact(payload); + dispatchToastMessage({ type: 'success', message: t('Saved') }); + close(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }; + + if (isLoadingCustomFields) { + return ; + } + + return ( + <> + + + {t('Name')}* + + + + {errors.name?.message} + + + {t('Email')} + + + + {errors.email?.message} + + + {t('Phone')} + + + + {errors.phone?.message} + + {canViewCustomFields() && customFields && ( + + )} + {ContactManager && } + + + + + + + + + ); +}; + +export default ContactNewEdit; diff --git a/apps/meteor/tests/e2e/omnichannel-contact-center.spec.ts b/apps/meteor/tests/e2e/omnichannel-contact-center.spec.ts new file mode 100644 index 00000000000..09f92da299e --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel-contact-center.spec.ts @@ -0,0 +1,226 @@ +import faker from '@faker-js/faker'; + +import { OmnichannelContacts } from './page-objects/omnichannel-contacts-list'; +import { OmnichannelSection } from './page-objects/omnichannel-section'; +import { test, expect } from './utils/test'; +import { createToken } from '../../client/lib/utils/createToken'; + +const createContact = (generateToken = false) => ({ + id: null, + name: `${faker.name.firstName()} ${faker.name.lastName()}`, + email: faker.internet.email().toLowerCase(), + phone: faker.phone.phoneNumber('+############'), + token: generateToken ? createToken() : null, + customFields: {}, +}); + +const NEW_CONTACT = createContact(); +const EDIT_CONTACT = createContact(); +const EXISTING_CONTACT = createContact(true); + +const URL = { + contactCenter: '/omnichannel-directory/contacts', + newContact: '/omnichannel-directory/contacts/new', + get editContact() { + return `${this.contactCenter}/edit/${NEW_CONTACT.id}`; + }, + get contactInfo() { + return `${this.contactCenter}/info/${NEW_CONTACT.id}`; + }, +}; + +const ERROR = { + nameRequired: 'The field Name is required.', + invalidEmail: 'Invalid email address', + existingEmail: 'Email already exists', + existingPhone: 'Phone already exists', +}; + +test.use({ storageState: 'admin-session.json' }); + +test.describe('Omnichannel Contact Center', () => { + let poContacts: OmnichannelContacts; + let poOmniSection: OmnichannelSection; + + test.beforeAll(async ({ api }) => { + // Add a contact + const { id: _, ...data } = EXISTING_CONTACT; + await api.post('/omnichannel/contact', data); + }); + + test.afterAll(async ({ api }) => { + // Remove added contacts + await api.delete('/livechat/visitor', { token: EXISTING_CONTACT.token }); + await api.delete('/livechat/visitor', { token: NEW_CONTACT.token }); + }); + + test.beforeEach(async ({ page }) => { + poContacts = new OmnichannelContacts(page); + poOmniSection = new OmnichannelSection(page); + }); + + test.afterEach(async ({ api }) => { + await api + .get('/omnichannel/contact.search', { phone: NEW_CONTACT.phone }) + .then((res) => res.json()) + .then((res) => { + NEW_CONTACT.token = res.contact?.token; + NEW_CONTACT.id = res.contact?._id; + }); + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await poOmniSection.btnContactCenter.click(); + await page.waitForURL(URL.contactCenter); + }); + + test('Add new contact', async ({ page }) => { + await test.step('cancel button', async () => { + await poContacts.btnNewContact.click(); + await page.waitForURL(URL.newContact); + await expect(poContacts.newContact.newContactTitle).toBeVisible(); + await poContacts.newContact.btnCancel.click(); + await page.waitForURL(URL.contactCenter); + await expect(poContacts.newContact.newContactTitle).not.toBeVisible(); + }); + + await test.step('open contextual bar', async () => { + await poContacts.btnNewContact.click(); + await page.waitForURL(URL.newContact); + await expect(poContacts.newContact.newContactTitle).toBeVisible(); + await expect(poContacts.newContact.btnSave).toBeDisabled(); + }); + + await test.step('input name', async () => { + await poContacts.newContact.inputName.type(NEW_CONTACT.name); + }); + + await test.step('validate email format', async () => { + await poContacts.newContact.inputEmail.type('invalidemail'); + await expect(poContacts.newContact.errorMessage(ERROR.invalidEmail)).toBeVisible(); + }); + + await test.step('validate existing email', async () => { + await poContacts.newContact.inputEmail.selectText(); + await poContacts.newContact.inputEmail.type(EXISTING_CONTACT.email); + await expect(poContacts.newContact.errorMessage(ERROR.existingEmail)).toBeVisible(); + await expect(poContacts.newContact.btnSave).toBeDisabled(); + }); + + await test.step('input email', async () => { + await poContacts.newContact.inputEmail.selectText(); + await poContacts.newContact.inputEmail.type(NEW_CONTACT.email); + await expect(poContacts.newContact.errorMessage(ERROR.invalidEmail)).not.toBeVisible(); + await expect(poContacts.newContact.errorMessage(ERROR.existingEmail)).not.toBeVisible(); + }); + + await test.step('validate existing phone ', async () => { + await poContacts.newContact.inputPhone.type(EXISTING_CONTACT.phone); + await expect(poContacts.newContact.errorMessage(ERROR.existingPhone)).toBeVisible(); + await expect(poContacts.newContact.btnSave).toBeDisabled(); + }); + + await test.step('input phone ', async () => { + await poContacts.newContact.inputPhone.selectText(); + await poContacts.newContact.inputPhone.type(NEW_CONTACT.phone); + await expect(poContacts.newContact.errorMessage(ERROR.existingPhone)).not.toBeVisible(); + }); + + await test.step('save new contact ', async () => { + await expect(poContacts.newContact.btnSave).toBeEnabled(); + await poContacts.newContact.btnSave.click(); + await page.waitForURL(URL.contactCenter); + await expect(poContacts.toastSuccess).toBeVisible(); + + await poContacts.inputSearch.type(NEW_CONTACT.name); + await expect(poContacts.findRowByName(NEW_CONTACT.name)).toBeVisible(); + }); + }); + + test('Edit new contact', async ({ page }) => { + await test.step('search contact and open contextual bar', async () => { + await poContacts.inputSearch.type(NEW_CONTACT.name); + const row = poContacts.findRowByName(NEW_CONTACT.name); + await expect(row).toBeVisible(); + await row.click(); + await page.waitForURL(URL.contactInfo); + }); + + await test.step('cancel button', async () => { + await poContacts.contactInfo.btnEdit.click(); + await page.waitForURL(URL.editContact); + await poContacts.contactInfo.btnCancel.click(); + await page.waitForURL(URL.contactInfo); + }); + + await test.step('edit contact', async () => { + poContacts.contactInfo.btnEdit.click(); + await page.waitForURL(URL.editContact); + }); + + await test.step('initial values', async () => { + await expect(poContacts.contactInfo.inputName).toHaveValue(NEW_CONTACT.name); + await expect(poContacts.contactInfo.inputEmail).toHaveValue(NEW_CONTACT.email); + await expect(poContacts.contactInfo.inputPhone).toHaveValue(NEW_CONTACT.phone); + }); + + await test.step('validate email format', async () => { + await poContacts.contactInfo.inputEmail.selectText(); + await poContacts.contactInfo.inputEmail.type('invalidemail'); + await expect(poContacts.contactInfo.errorMessage(ERROR.invalidEmail)).toBeVisible(); + }); + + await test.step('validate existing email', async () => { + await poContacts.contactInfo.inputEmail.selectText(); + await poContacts.contactInfo.inputEmail.type(EXISTING_CONTACT.email); + await expect(poContacts.contactInfo.errorMessage(ERROR.existingEmail)).toBeVisible(); + await expect(poContacts.contactInfo.btnSave).toBeDisabled(); + }); + + await test.step('input email', async () => { + await poContacts.contactInfo.inputEmail.selectText(); + await poContacts.contactInfo.inputEmail.type(EDIT_CONTACT.email); + await expect(poContacts.contactInfo.errorMessage(ERROR.invalidEmail)).not.toBeVisible(); + await expect(poContacts.contactInfo.errorMessage(ERROR.existingEmail)).not.toBeVisible(); + await expect(poContacts.contactInfo.btnSave).toBeEnabled(); + }); + + await test.step('validate existing phone ', async () => { + await poContacts.contactInfo.inputPhone.selectText(); + await poContacts.contactInfo.inputPhone.type(EXISTING_CONTACT.phone); + await expect(poContacts.contactInfo.errorMessage(ERROR.existingPhone)).toBeVisible(); + await expect(poContacts.contactInfo.btnSave).toBeDisabled(); + }); + + await test.step('input phone ', async () => { + await poContacts.contactInfo.inputPhone.selectText(); + await poContacts.contactInfo.inputPhone.type(EDIT_CONTACT.phone); + await expect(poContacts.contactInfo.errorMessage(ERROR.existingPhone)).not.toBeVisible(); + await expect(poContacts.contactInfo.btnSave).toBeEnabled(); + }); + + await test.step('validate name is required', async () => { + await poContacts.contactInfo.inputName.selectText(); + await poContacts.contactInfo.inputName.type(' '); + + await expect(poContacts.contactInfo.btnSave).toBeEnabled(); + await poContacts.contactInfo.btnSave.click(); + await expect(poContacts.contactInfo.errorMessage(ERROR.nameRequired)).toBeVisible(); + }); + + await test.step('edit name', async () => { + await poContacts.contactInfo.inputName.selectText(); + await poContacts.contactInfo.inputName.type(EDIT_CONTACT.name); + }); + + await test.step('save new contact ', async () => { + await poContacts.contactInfo.btnSave.click(); + await expect(poContacts.toastSuccess).toBeVisible(); + + await poContacts.inputSearch.selectText(); + await poContacts.inputSearch.type(EDIT_CONTACT.name); + await expect(poContacts.findRowByName(EDIT_CONTACT.name)).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 new file mode 100644 index 00000000000..0805b8f5e25 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-contacts-list.ts @@ -0,0 +1,34 @@ +import type { Locator, Page } from '@playwright/test'; + +import { OmnichannelContactInfo } from './omnichannel-info'; +import { OmnichannelManageContact } from './omnichannel-manage-contact'; + +export class OmnichannelContacts { + private readonly page: Page; + + readonly newContact: OmnichannelManageContact; + + readonly contactInfo: OmnichannelContactInfo; + + constructor(page: Page) { + this.page = page; + this.newContact = new OmnichannelManageContact(page); + this.contactInfo = new OmnichannelContactInfo(page); + } + + get btnNewContact(): Locator { + return this.page.locator('button >> text="New Contact"'); + } + + get inputSearch(): Locator { + return this.page.locator('input[placeholder="Search"]'); + } + + findRowByName(contactName: string) { + return this.page.locator(`td >> text="${contactName}"`); + } + + get toastSuccess(): Locator { + return this.page.locator('.rcx-toastbar.rcx-toastbar--success'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-info.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-info.ts new file mode 100644 index 00000000000..a56c2e54f1e --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-info.ts @@ -0,0 +1,13 @@ +import type { Locator } from '@playwright/test'; + +import { OmnichannelManageContact } from './omnichannel-manage-contact'; + +export class OmnichannelContactInfo extends OmnichannelManageContact { + get btnEdit(): Locator { + return this.page.locator('role=button[name="Edit"]'); + } + + get btnCall(): Locator { + return this.page.locator('role=button[name=Call"]'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-manage-contact.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-manage-contact.ts new file mode 100644 index 00000000000..cfae81ec608 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-manage-contact.ts @@ -0,0 +1,45 @@ +import type { Locator, Page } from '@playwright/test'; + +export class OmnichannelManageContact { + protected readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get newContactTitle(): Locator { + return this.page.locator('h3 >> text="New Contact"'); + } + + get editContactTitle(): Locator { + return this.page.locator('h3 >> text="Edit Contact Profile"'); + } + + get inputName(): Locator { + return this.page.locator('input[name=name]'); + } + + get inputEmail(): Locator { + return this.page.locator('input[name=email]'); + } + + get inputPhone(): Locator { + return this.page.locator('input[name=phone]'); + } + + get inputContactManager(): Locator { + return this.page.locator('input[name=contactManager]'); + } + + get btnSave(): Locator { + return this.page.locator('button >> text="Save"'); + } + + get btnCancel(): Locator { + return this.page.locator('button >> text="Cancel"'); + } + + errorMessage(message: string): Locator { + return this.page.locator(`.rcx-field__error >> text="${message}"`); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts new file mode 100644 index 00000000000..87a34a66356 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-section.ts @@ -0,0 +1,25 @@ +import type { Locator, Page } from '@playwright/test'; + +export class OmnichannelSection { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get element(): Locator { + return this.page.locator('div[data-qa-id="omncSection"]'); + } + + get btnVoipToggle(): Locator { + return this.page.locator('role=button[name="Enable/Disable VoIP"]'); + } + + get btnDialpad(): Locator { + return this.page.locator('role=button[name="Open Dialpad"]'); + } + + get btnContactCenter(): Locator { + return this.page.locator('role=button[name="Contact Center"]'); + } +} diff --git a/apps/meteor/tests/e2e/utils/test.ts b/apps/meteor/tests/e2e/utils/test.ts index 53da3cfaf61..ebef919905c 100644 --- a/apps/meteor/tests/e2e/utils/test.ts +++ b/apps/meteor/tests/e2e/utils/test.ts @@ -13,10 +13,10 @@ export type AnyObj = { [key: string]: any }; export type BaseTest = { api: { - get(uri: string, prefix?: string): Promise; + get(uri: string, params?: AnyObj, prefix?: string): Promise; post(uri: string, data: AnyObj, prefix?: string): Promise; put(uri: string, data: AnyObj, prefix?: string): Promise; - delete(uri: string, prefix?: string): Promise; + delete(uri: string, params?: AnyObj, prefix?: string): Promise; }; }; @@ -61,8 +61,8 @@ export const test = baseTest.extend({ }; await use({ - get(uri: string, prefix = API_PREFIX) { - return request.get(BASE_URL + prefix + uri, { headers }); + get(uri: string, params?: AnyObj, prefix = API_PREFIX) { + return request.get(BASE_URL + prefix + uri, { headers, params }); }, post(uri: string, data: AnyObj, prefix = API_PREFIX) { return request.post(BASE_URL + prefix + uri, { headers, data }); @@ -70,8 +70,8 @@ export const test = baseTest.extend({ put(uri: string, data: AnyObj, prefix = API_PREFIX) { return request.put(BASE_URL + prefix + uri, { headers, data }); }, - delete(uri: string, prefix = API_PREFIX) { - return request.delete(BASE_URL + prefix + uri, { headers }); + delete(uri: string, params?: AnyObj, prefix = API_PREFIX) { + return request.delete(BASE_URL + prefix + uri, { headers, params }); }, }); },