feat(v4): Compose a new conversation from a phone number. (#10568)

This commit is contained in:
Sivin Varghese
2024-12-17 18:07:58 +05:30
committed by GitHub
parent 96ae298464
commit 6b348da807
6 changed files with 528 additions and 78 deletions

View File

@@ -1,9 +1,11 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { INPUT_TYPES } from 'dashboard/components-next/taginput/helper/tagInputHelper.js';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
@@ -42,15 +44,19 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const inputType = ref(INPUT_TYPES.EMAIL);
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
@@ -80,6 +86,14 @@ const errorClass = computed(() => {
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: '';
});
const handleInput = value => {
// Update input type based on whether input starts with '+'
// If it does, set input type to 'tel'
// Otherwise, set input type to 'email'
inputType.value = value.startsWith('+') ? INPUT_TYPES.TEL : INPUT_TYPES.EMAIL;
emit('searchContacts', value);
};
</script>
<template>
@@ -126,11 +140,11 @@ const errorClass = computed(() => {
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
type="email"
:type="inputType"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
@input="emit('searchContacts', $event)"
@input="handleInput"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"

View File

@@ -193,10 +193,12 @@ export const searchContacts = async ({ keys, query }) => {
return filteredPayload || [];
};
export const createNewContact = async email => {
export const createNewContact = async input => {
const payload = {
name: getCapitalizedNameFromEmail(email),
email,
name: input.startsWith('+')
? input.slice(1) // Remove the '+' prefix if it exists
: getCapitalizedNameFromEmail(input),
...(input.startsWith('+') ? { phone_number: input } : { email: input }),
};
const {

View File

@@ -429,6 +429,28 @@ describe('composeConversationHelper', () => {
email: 'john@example.com',
});
});
it('creates new contact with phone number', async () => {
const mockContact = {
id: 1,
name: '919999999999',
phone_number: '+919999999999',
};
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('+919999999999');
expect(result).toEqual({
id: 1,
name: '919999999999',
phoneNumber: '+919999999999',
});
expect(ContactAPI.create).toHaveBeenCalledWith({
name: '919999999999',
phone_number: '+919999999999',
});
});
});
describe('fetchContactableInboxes', () => {

View File

@@ -1,16 +1,24 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
MODE,
INPUT_TYPES,
getValidationRules,
checkTagTypeValidity,
buildTagMenuItems,
canAddTag,
findMatchingMenuItem,
} from './helper/tagInputHelper';
const props = defineProps({
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'text' },
type: { type: String, default: INPUT_TYPES.TEXT },
isLoading: { type: Boolean, default: false },
menuItems: {
type: Array,
@@ -23,8 +31,8 @@ const props = defineProps({
showDropdown: { type: Boolean, default: false },
mode: {
type: String,
default: 'multiple',
validator: value => ['single', 'multiple'].includes(value),
default: MODE.MULTIPLE,
validator: value => [MODE.SINGLE, MODE.MULTIPLE].includes(value),
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
@@ -45,22 +53,17 @@ const modelValue = defineModel({
default: () => [],
});
const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(true);
const rules = computed(() => ({
newTag: props.type === 'email' ? { email } : {},
}));
const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag });
const isNewTagInValidType = computed(() => v$.value.$invalid);
const isNewTagInValidType = computed(() =>
checkTagTypeValidity(props.type, newTag.value, v$.value)
);
const showInput = computed(() =>
props.mode === MODE.SINGLE
@@ -74,68 +77,49 @@ const showDropdownMenu = computed(() =>
: props.showDropdown
);
const filteredMenuItems = computed(() => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
const availableMenuItems = props.menuItems.filter(
item => !tags.value.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length &&
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
return [
{
label: trimmedNewTag,
value: trimmedNewTag,
email: trimmedNewTag,
thumbnail: { name: trimmedNewTag, src: '' },
action: 'create',
},
];
}
return availableMenuItems;
});
const emitDataOnAdd = emailValue => {
const matchingMenuItem = props.menuItems.find(
item => item.email === emailValue
);
const filteredMenuItems = computed(() =>
buildTagMenuItems({
mode: props.mode,
tags: tags.value,
menuItems: props.menuItems,
newTag: newTag.value,
isLoading: props.isLoading,
type: props.type,
isNewTagInValidType: isNewTagInValidType.value,
})
);
const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
return matchingMenuItem
? emit('add', { email: emailValue, ...matchingMenuItem })
: emit('add', { value: emailValue, action: 'create' });
? emit('add', { value: value, ...matchingMenuItem })
: emit('add', { value: value, action: 'create' });
};
const updateValueAndFocus = value => {
tags.value.push(value);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const addTag = async () => {
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
if (!canAddTag(props.mode, tags.value.length)) {
newTag.value = '';
return;
}
if (props.type === 'email' || props.allowCreate) {
if (
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
props.allowCreate
) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
tags.value.push(trimmedTag);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
updateValueAndFocus(trimmedTag);
};
const removeTag = index => {
@@ -144,19 +128,25 @@ const removeTag = index => {
emit('remove');
};
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
const handleDropdownAction = async ({
email: emailAddress,
phoneNumber,
...rest
}) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return;
if (props.type === 'email' && props.showDropdown) {
newTag.value = emailAddress;
if (!(await v$.value.$validate())) return;
emit('add', { email: emailAddress, ...rest });
}
const isEmail = props.type === 'email';
newTag.value = isEmail ? emailAddress : phoneNumber;
tags.value.push(emailAddress);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
if (!(await v$.value.$validate())) return;
const payload = isEmail
? { email: emailAddress, ...rest }
: { phoneNumber, ...rest };
emit('add', payload);
updateValueAndFocus(emailAddress);
};
const handleFocus = () => {
@@ -226,7 +216,6 @@ const handleBlur = e => emit('blur', e);
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
class="w-full"
:focus-on-mount="focusOnMount"

View File

@@ -0,0 +1,299 @@
import {
validatePhoneNumber,
formatPhoneNumber,
buildTagMenuItems,
MODE,
INPUT_TYPES,
getValidationRules,
validateAndFormatNewTag,
createNewTagMenuItem,
canAddTag,
findMatchingMenuItem,
} from '../tagInputHelper';
import { email } from '@vuelidate/validators';
describe('tagInputHelper', () => {
describe('validatePhoneNumber', () => {
it('returns true for empty value', () => {
expect(validatePhoneNumber('')).toBe(true);
});
it('validates correct phone number', () => {
expect(validatePhoneNumber('+918283838283')).toBe(true);
});
it('validates correct phone number id + is present and number is not valid', () => {
expect(validatePhoneNumber('+91828383834283')).toBe(false);
});
it('validates correct phone number if + is not present', () => {
expect(validatePhoneNumber('91828383834283')).toBe(false);
});
it('invalidates incorrect phone number', () => {
expect(validatePhoneNumber('invalid')).toBe(false);
});
it('handles null value', () => {
expect(validatePhoneNumber(null)).toBe(true);
});
});
describe('formatPhoneNumber', () => {
it('formats valid phone number', () => {
const result = formatPhoneNumber('+918283838283');
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('+91 82838 38283');
});
it('handles invalid phone number', () => {
const result = formatPhoneNumber('invalid');
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe('invalid');
});
it('handles error case', () => {
const result = formatPhoneNumber(null);
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe(null);
});
});
describe('getValidationRules', () => {
it('returns email validation for email type', () => {
const rules = getValidationRules(INPUT_TYPES.EMAIL);
expect(rules.newTag).toHaveProperty('email');
expect(rules.newTag).not.toHaveProperty('isValidPhone');
expect(rules.newTag.email).toBe(email);
});
it('returns phone validation for tel type', () => {
const rules = getValidationRules(INPUT_TYPES.TEL);
expect(rules.newTag).toHaveProperty('isValidPhone');
expect(rules.newTag).not.toHaveProperty('email');
expect(rules.newTag.isValidPhone).toBe(validatePhoneNumber);
});
it('returns empty rules for text type', () => {
const rules = getValidationRules(INPUT_TYPES.TEXT);
expect(Object.keys(rules.newTag)).toHaveLength(0);
});
});
describe('validateAndFormatNewTag', () => {
it('validates and formats email tag', () => {
const result = validateAndFormatNewTag(
'test@example.com',
INPUT_TYPES.EMAIL,
false
);
expect(result).toEqual({
isValid: true,
formattedValue: 'test@example.com',
});
});
it('validates and formats phone tag', () => {
const result = validateAndFormatNewTag(
'+918283838283',
INPUT_TYPES.TEL,
false
);
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('+91 82838 38283');
});
it('handles invalid email', () => {
const result = validateAndFormatNewTag(
'test@example.com',
INPUT_TYPES.EMAIL,
true
);
expect(result.isValid).toBe(false);
expect(result.formattedValue).toBe('test@example.com');
});
it('handles text type', () => {
const result = validateAndFormatNewTag(
'sample text',
INPUT_TYPES.TEXT,
false
);
expect(result.isValid).toBe(true);
expect(result.formattedValue).toBe('sample text');
});
});
describe('createNewTagMenuItem', () => {
it('creates email menu item', () => {
const result = createNewTagMenuItem(
'test@example.com',
'test@example.com',
INPUT_TYPES.EMAIL
);
expect(result).toEqual({
label: 'test@example.com',
value: 'test@example.com',
email: 'test@example.com',
thumbnail: { name: 'test@example.com', src: '' },
action: 'create',
});
});
it('creates phone menu item', () => {
const result = createNewTagMenuItem(
'+91 82838 38283',
'+918283838283',
INPUT_TYPES.TEL
);
expect(result).toEqual({
label: '+91 82838 38283',
value: '+918283838283',
phoneNumber: '+918283838283',
thumbnail: { name: '+91 82838 38283', src: '' },
action: 'create',
});
});
it('creates text menu item', () => {
const result = createNewTagMenuItem(
'sample text',
'sample text',
INPUT_TYPES.TEXT
);
expect(result).toEqual({
label: 'sample text',
value: 'sample text',
thumbnail: { name: 'sample text', src: '' },
action: 'create',
});
});
});
describe('buildTagMenuItems', () => {
const baseParams = {
mode: MODE.MULTIPLE,
tags: [],
menuItems: [],
newTag: '',
isLoading: false,
type: INPUT_TYPES.TEXT,
isNewTagInValidType: false,
};
it('returns empty array in single mode with existing tag', () => {
const result = buildTagMenuItems({
...baseParams,
mode: MODE.SINGLE,
tags: ['existing'],
});
expect(result).toEqual([]);
});
it('filters out existing tags', () => {
const result = buildTagMenuItems({
...baseParams,
menuItems: [
{ label: 'item1', value: '1' },
{ label: 'item2', value: '2' },
],
tags: ['item1'],
});
expect(result).toHaveLength(1);
expect(result[0].label).toBe('item2');
});
it('creates new email item when valid', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
menuItems: [],
});
expect(result[0]).toMatchObject({
label: 'test@example.com',
email: 'test@example.com',
action: 'create',
});
});
it('creates new phone item when valid', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.TEL,
newTag: '+918283838283',
menuItems: [],
});
expect(result[0]).toMatchObject({
value: '+918283838283',
label: '+91 82838 38283',
action: 'create',
});
});
it('returns empty array when loading', () => {
const result = buildTagMenuItems({
...baseParams,
isLoading: true,
newTag: 'test',
});
expect(result).toEqual([]);
});
it('returns empty array for invalid tag', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'invalid-email',
isNewTagInValidType: true,
});
expect(result).toEqual([]);
});
it('returns available menu items when no new tag', () => {
const menuItems = [
{ label: 'item1', value: '1' },
{ label: 'item2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
});
expect(result).toEqual(menuItems);
});
});
describe('canAddTag', () => {
it('prevents adding tags in single mode when tag exists', () => {
expect(canAddTag(MODE.SINGLE, 1)).toBe(false);
expect(canAddTag(MODE.SINGLE, 0)).toBe(true);
});
it('allows adding tags in multiple mode', () => {
expect(canAddTag(MODE.MULTIPLE, 1)).toBe(true);
expect(canAddTag(MODE.MULTIPLE, 0)).toBe(true);
});
});
describe('findMatchingMenuItem', () => {
const menuItems = [
{ email: 'test1@example.com', label: 'Test 1' },
{ email: 'test2@example.com', label: 'Test 2' },
];
it('finds matching menu item by email', () => {
const result = findMatchingMenuItem(menuItems, 'test1@example.com');
expect(result).toEqual(menuItems[0]);
});
it('returns undefined when no match found', () => {
const result = findMatchingMenuItem(menuItems, 'nonexistent@example.com');
expect(result).toBeUndefined();
});
it('handles empty menu items', () => {
const result = findMatchingMenuItem([], 'test@example.com');
expect(result).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,124 @@
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js';
import { email } from '@vuelidate/validators';
export const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
export const INPUT_TYPES = {
EMAIL: 'email',
TEL: 'tel',
TEXT: 'text',
};
export const validatePhoneNumber = value => {
if (!value) return true;
try {
return isValidPhoneNumber(value);
} catch (error) {
return false;
}
};
export const formatPhoneNumber = value => {
try {
const phoneNumber = parsePhoneNumber(value);
return {
isValid: phoneNumber?.isValid() || false,
formattedValue: phoneNumber?.formatInternational() || value,
};
} catch (error) {
return { isValid: false, formattedValue: value };
}
};
export const getValidationRules = type => ({
newTag: {
...(type === INPUT_TYPES.EMAIL ? { email } : {}),
...(type === INPUT_TYPES.TEL ? { isValidPhone: validatePhoneNumber } : {}),
},
});
export const checkTagTypeValidity = (type, value, v$) => {
if (type === INPUT_TYPES.TEL) {
return !validatePhoneNumber(value);
}
return v$.$invalid;
};
export const validateAndFormatNewTag = (
trimmedNewTag,
type,
isNewTagInValidType
) => {
let isValid = true;
let formattedValue = trimmedNewTag;
if (type === INPUT_TYPES.EMAIL) {
isValid = !isNewTagInValidType;
} else if (type === INPUT_TYPES.TEL) {
const { isValid: phoneValid, formattedValue: phoneFormatted } =
formatPhoneNumber(trimmedNewTag);
isValid = phoneValid;
formattedValue = phoneFormatted;
}
return { isValid, formattedValue };
};
export const createNewTagMenuItem = (formattedValue, trimmedNewTag, type) => ({
label: formattedValue,
value: trimmedNewTag,
...(type === INPUT_TYPES.EMAIL ? { email: trimmedNewTag } : {}),
...(type === INPUT_TYPES.TEL ? { phoneNumber: trimmedNewTag } : {}),
thumbnail: { name: formattedValue, src: '' },
action: 'create',
});
export const buildTagMenuItems = ({
mode,
tags,
menuItems,
newTag,
isLoading,
type,
isNewTagInValidType,
}) => {
if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = menuItems.filter(
item => !tags.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Validation passes (email/phone) and There are no menu items available
const trimmedNewTag = newTag?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.includes(trimmedNewTag) &&
!isLoading &&
!availableMenuItems.length;
if (shouldShowTypedValue) {
const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag,
type,
isNewTagInValidType
);
if (isValid) {
return [createNewTagMenuItem(formattedValue, trimmedNewTag, type)];
}
}
return availableMenuItems;
};
export const canAddTag = (mode, tagsLength) =>
!(mode === MODE.SINGLE && tagsLength >= 1);
export const findMatchingMenuItem = (menuItems, value) =>
menuItems.find(item => item.email === value);