Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-dolphins-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixed omnichannel contact form asynchronous validations
10 changes: 9 additions & 1 deletion apps/meteor/app/livechat/server/lib/Contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,24 @@ export const Contacts = {
livechatData[cf._id] = cfValue;
}

const fieldsToRemove = {
// if field is explicitely set to empty string, remove
...(phone === '' && { phone: 1 }),
...(visitorEmail === '' && { visitorEmails: 1 }),
...(!contactManager?.username && { contactManager: 1 }),
};

const updateUser: { $set: MatchKeysAndValues<ILivechatVisitor>; $unset?: OnlyFieldsOfType<ILivechatVisitor> } = {
$set: {
token,
name,
livechatData,
// if phone has some value, set
...(phone && { phone: [{ phoneNumber: phone }] }),
...(visitorEmail && { visitorEmails: [{ address: visitorEmail }] }),
...(contactManager?.username && { contactManager: { username: contactManager.username } }),
},
...(!contactManager?.username && { $unset: { contactManager: 1 } }),
...(Object.keys(fieldsToRemove).length && { $unset: fieldsToRemove }),
};

await LivechatVisitors.updateOne({ _id: contactId }, updateUser);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useForm } from 'react-hook-form';

import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client';
import { validateEmail } from '../../../../../../lib/emailValidator';
import { withDebouncing } from '../../../../../../lib/utils/highOrderFunctions';
import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../../../components/Contextualbar';
import { CustomFieldsForm } from '../../../../../components/CustomFieldsFormV2';
import { createToken } from '../../../../../lib/utils/createToken';
Expand Down Expand Up @@ -82,19 +81,17 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement

const {
register,
formState: { errors, isValid: isFormValid, isDirty },
formState: { errors, isValid, isDirty },
control,
setValue,
handleSubmit,
trigger,
setError,
} = useForm<ContactFormData>({
mode: 'onSubmit',
reValidateMode: 'onSubmit',
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: initialValue,
});

const isValid = isDirty && isFormValid;

useEffect(() => {
if (!initialUsername) {
return;
Expand All @@ -105,29 +102,29 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement
});
}, [getUserData, initialUsername]);

const isEmailValid = async (email: string): Promise<boolean | string> => {
if (email === initialValue.email) {
const validateEmailFormat = (email: string): boolean | string => {
if (!email || 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');
return true;
};

const isPhoneValid = async (phone: string): Promise<boolean | string> => {
if (!phone || initialValue.phone === phone) {
const validateContactField = async (name: 'phone' | 'email', value: string, optional = true) => {
if ((optional && !value) || value === initialValue[name]) {
return true;
}

const { contact } = await getContactBy({ phone });
return !contact || contact._id === id || t('Phone_already_exists');
const query = { [name]: value } as Record<'phone' | 'email', string>;
const { contact } = await getContactBy(query);
return !contact || contact._id === id;
};

const isNameValid = (v: string): string | boolean => (!v.trim() ? t('The_field_is_required', t('Name')) : true);
const validateName = (v: string): string | boolean => (!v.trim() ? t('The_field_is_required', t('Name')) : true);

const handleContactManagerChange = async (userId: string): Promise<void> => {
setUserId(userId);
Expand All @@ -141,9 +138,21 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement
setValue('username', user.username || '', { shouldDirty: true });
};

const validate = (fieldName: keyof ContactFormData): (() => void) => withDebouncing({ wait: 500 })(() => trigger(fieldName));
const validateAsync = async ({ phone = '', email = '' } = {}) => {
const isEmailValid = await validateContactField('email', email);
const isPhoneValid = await validateContactField('phone', phone);

!isEmailValid && setError('email', { message: t('Email_already_exists') });
!isPhoneValid && setError('phone', { message: t('Phone_already_exists') });

return isEmailValid && isPhoneValid;
};

const handleSave = async (data: ContactFormData): Promise<void> => {
if (!(await validateAsync(data))) {
return;
}

const { name, phone, email, customFields, username, token } = data;

const payload = {
Expand Down Expand Up @@ -175,29 +184,21 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement
<Field>
<Field.Label>{t('Name')}*</Field.Label>
<Field.Row>
<TextInput {...register('name', { validate: isNameValid })} error={errors.name?.message} flexGrow={1} />
<TextInput {...register('name', { validate: validateName })} error={errors.name?.message} flexGrow={1} />
</Field.Row>
<Field.Error>{errors.name?.message}</Field.Error>
</Field>
<Field>
<Field.Label>{t('Email')}</Field.Label>
<Field.Row>
<TextInput
{...register('email', { validate: isEmailValid, onChange: validate('email') })}
error={errors.email?.message}
flexGrow={1}
/>
<TextInput {...register('email', { validate: validateEmailFormat })} error={errors.email?.message} flexGrow={1} />
</Field.Row>
<Field.Error>{errors.email?.message}</Field.Error>
</Field>
<Field>
<Field.Label>{t('Phone')}</Field.Label>
<Field.Row>
<TextInput
{...register('phone', { validate: isPhoneValid, onChange: validate('phone') })}
error={errors.phone?.message}
flexGrow={1}
/>
<TextInput {...register('phone')} error={errors.phone?.message} flexGrow={1} />
</Field.Row>
<Field.Error>{errors.phone?.message}</Field.Error>
</Field>
Expand All @@ -209,7 +210,7 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement
<Button flexGrow={1} onClick={close}>
{t('Cancel')}
</Button>
<Button mie='none' type='submit' onClick={handleSubmit(handleSave)} flexGrow={1} disabled={!isValid} primary>
<Button mie='none' type='submit' onClick={handleSubmit(handleSave)} flexGrow={1} disabled={!isValid || !isDirty} primary>
{t('Save')}
</Button>
</ButtonGroup>
Expand Down
76 changes: 48 additions & 28 deletions apps/meteor/tests/e2e/omnichannel-contact-center.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,26 @@ test.describe('Omnichannel Contact Center', () => {
await expect(poContacts.newContact.errorMessage(ERROR.invalidEmail)).toBeVisible();
});

await test.step('validate existing email', async () => {
await test.step('input 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 test.step('input existing phone ', async () => {
await poContacts.newContact.inputPhone.selectText();
await poContacts.newContact.inputPhone.type(EXISTING_CONTACT.phone);
await expect(poContacts.newContact.errorMessage(ERROR.existingPhone)).not.toBeVisible();
});

await test.step('run async validations ', async () => {
await expect(poContacts.newContact.btnSave).toBeEnabled();
await poContacts.newContact.btnSave.click();

await expect(poContacts.newContact.errorMessage(ERROR.existingEmail)).toBeVisible();
await expect(poContacts.newContact.btnSave).toBeDisabled();

await expect(poContacts.newContact.errorMessage(ERROR.existingPhone)).toBeVisible();
await expect(poContacts.newContact.btnSave).toBeDisabled();
});
Expand All @@ -128,6 +132,13 @@ test.describe('Omnichannel Contact Center', () => {
await expect(poContacts.newContact.errorMessage(ERROR.existingPhone)).not.toBeVisible();
});

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('save new contact ', async () => {
await expect(poContacts.newContact.btnSave).toBeEnabled();
await poContacts.newContact.btnSave.click();
Expand Down Expand Up @@ -172,49 +183,58 @@ test.describe('Omnichannel Contact Center', () => {
await expect(poContacts.contactInfo.errorMessage(ERROR.invalidEmail)).toBeVisible();
});

await test.step('validate existing email', async () => {
await test.step('input 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 test.step('input 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 expect(poContacts.contactInfo.btnSave).not.toBeEnabled();
});

await test.step('edit name', async () => {
await poContacts.contactInfo.inputName.selectText();
await poContacts.contactInfo.inputName.type(EDIT_CONTACT.name);
});

await test.step('run async validations ', async () => {
await expect(poContacts.newContact.btnSave).toBeEnabled();
await poContacts.newContact.btnSave.click();

await expect(poContacts.newContact.errorMessage(ERROR.existingEmail)).toBeVisible();
await expect(poContacts.newContact.btnSave).toBeDisabled();

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(EDIT_CONTACT.phone);
await expect(poContacts.newContact.errorMessage(ERROR.existingPhone)).not.toBeVisible();
});

await test.step('input email', async () => {
await poContacts.newContact.inputEmail.selectText();
await poContacts.newContact.inputEmail.type(EDIT_CONTACT.email);
await expect(poContacts.newContact.errorMessage(ERROR.invalidEmail)).not.toBeVisible();
await expect(poContacts.newContact.errorMessage(ERROR.existingEmail)).not.toBeVisible();
});

await test.step('save new contact ', async () => {
await poContacts.contactInfo.btnSave.click();
await expect(poContacts.toastSuccess).toBeVisible();
Expand Down