fix: New compose conversation form (#10548)

This commit is contained in:
Sivin Varghese
2024-12-06 15:40:06 +05:30
committed by GitHub
parent afb3e3e649
commit 1b430ffae2
9 changed files with 150 additions and 35 deletions

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, useSlots } from 'vue'; import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue'; import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
@@ -21,6 +22,9 @@ const emit = defineEmits(['goToContactsList']);
const { t } = useI18n(); const { t } = useI18n();
const slots = useSlots(); const slots = useSlots();
const route = useRoute();
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => { const selectedContactName = computed(() => {
return props.selectedContact?.name; return props.selectedContact?.name;
@@ -60,7 +64,7 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems" :items="breadcrumbItems"
@click="handleBreadcrumbClick" @click="handleBreadcrumbClick"
/> />
<ComposeConversation> <ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" /> <Button :label="buttonLabel" size="sm" @click="toggle" />
</template> </template>

View File

@@ -2,7 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
@@ -21,9 +21,12 @@ const props = defineProps({
type: String, type: String,
default: 'left', default: 'left',
}, },
contactId: {
type: String,
default: null,
},
}); });
const route = useRoute();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
@@ -44,8 +47,8 @@ const uiFlags = useMapGetter('contactConversations/getUIFlags');
const directUploadsEnabled = computed( const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled () => globalConfig.value.directUploadsEnabled
); );
const contactId = computed(() => route.params.contactId || null);
const activeContact = computed(() => contactById.value(contactId.value)); const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => { const composePopoverClass = computed(() => {
return props.alignPosition === 'right' return props.alignPosition === 'right'
@@ -149,7 +152,7 @@ const toggle = () => {
watch( watch(
activeContact, activeContact,
() => { () => {
if (activeContact.value && contactId.value) { if (activeContact.value && props.contactId) {
// Add null check for contactInboxes // Add null check for contactInboxes
const contactInboxes = activeContact.value?.contactInboxes || []; const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = { selectedContact.value = {
@@ -177,7 +180,10 @@ useKeyboardEvents(keyboardEvents);
</script> </script>
<template> <template>
<div class="relative z-40"> <div
v-on-click-outside="() => (showComposeNewConversation = false)"
class="relative z-40"
>
<slot <slot
name="trigger" name="trigger"
:is-open="showComposeNewConversation" :is-open="showComposeNewConversation"

View File

@@ -65,7 +65,14 @@ const contactsList = computed(() => {
}); });
const selectedContactLabel = computed(() => { const selectedContactLabel = computed(() => {
return `${props.selectedContact?.name} (${props.selectedContact?.email})`; const { name, email = '', phoneNumber = '' } = props.selectedContact || {};
if (email) {
return `${name} (${email})`;
}
if (phoneNumber) {
return `${name} (${phoneNumber})`;
}
return name || '';
}); });
const errorClass = computed(() => { const errorClass = computed(() => {

View File

@@ -154,7 +154,12 @@ export const searchContacts = async ({ keys, query }) => {
'name', 'name',
generateContactQuery({ keys, query }) generateContactQuery({ keys, query })
); );
return camelcaseKeys(payload, { deep: true }); const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
}; };
export const createNewContact = async email => { export const createNewContact = async email => {

View File

@@ -297,6 +297,70 @@ describe('composeConversationHelper', () => {
}); });
}); });
it('searches contacts and returns only contacts with email or phone number', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
{
id: 2,
name: 'Jane Doe',
email: null,
phone_number: null,
created_at: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phone_number: null,
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phoneNumber: null,
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('handles empty search results', async () => { it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({ ContactAPI.filter.mockResolvedValue({
data: { payload: [] }, data: { payload: [] },
@@ -310,6 +374,8 @@ describe('composeConversationHelper', () => {
const mockPayload = [ const mockPayload = [
{ {
id: 1, id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [ contact_inboxes: [
{ {
inbox_id: 1, inbox_id: 1,
@@ -332,6 +398,8 @@ describe('composeConversationHelper', () => {
expect(result).toEqual([ expect(result).toEqual([
{ {
id: 1, id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [ contactInboxes: [
{ {
inboxId: 1, inboxId: 1,

View File

@@ -95,7 +95,7 @@ onMounted(() => {
rounded-full rounded-full
/> />
</slot> </slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" /> <Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span> <span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{ <span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label item.label

View File

@@ -81,17 +81,19 @@ const filteredMenuItems = computed(() => {
item => !tags.value.includes(item.label) item => !tags.value.includes(item.label)
); );
// Only show typed value as suggestion if: // Show typed value as suggestion only if:
// 1. There's a value being typed // 1. There's a value being typed
// 2. The value isn't already in the tags // 2. The value isn't already in the tags
// 3. There are no menu items available // 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value.trim(); const trimmedNewTag = newTag.value?.trim();
if ( const shouldShowTypedValue =
trimmedNewTag && trimmedNewTag &&
!tags.value.includes(trimmedNewTag) && !tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length && !availableMenuItems.length &&
!props.isLoading (props.type === 'email' ? !isNewTagInValidType.value : true);
) {
if (shouldShowTypedValue) {
return [ return [
{ {
label: trimmedNewTag, label: trimmedNewTag,
@@ -117,7 +119,7 @@ const emitDataOnAdd = emailValue => {
}; };
const addTag = async () => { const addTag = async () => {
const trimmedTag = newTag.value.trim(); const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return; if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) { if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
@@ -185,7 +187,7 @@ watch(
watch( watch(
() => newTag.value, () => newTag.value,
async newValue => { async newValue => {
if (props.type === 'email' && newValue.trim()?.length > 2) { if (props.type === 'email' && newValue?.trim()?.length > 2) {
await v$.value.$validate(); await v$.value.$validate();
} }
} }

View File

@@ -119,6 +119,13 @@ export default {
this.menuItem.toStateName === 'settings_applications' this.menuItem.toStateName === 'settings_applications'
); );
}, },
isContactsDefaultRoute() {
return (
this.menuItem.toStateName === 'contacts_dashboard_index' &&
(this.$store.state.route.name === 'contacts_dashboard_index' ||
this.$store.state.route.name === 'contacts_edit')
);
},
isCurrentRoute() { isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName); return this.$store.state.route.name.includes(this.menuItem.toStateName);
}, },
@@ -130,6 +137,7 @@ export default {
this.isAllConversations || this.isAllConversations ||
this.isMentions || this.isMentions ||
this.isUnattended || this.isUnattended ||
this.isContactsDefaultRoute ||
this.isCurrentRoute this.isCurrentRoute
) { ) {
return 'bg-woot-25 dark:bg-slate-800 text-woot-500 dark:text-woot-500 hover:text-woot-500 dark:hover:text-woot-500 active-view'; return 'bg-woot-25 dark:bg-slate-800 text-woot-500 dark:text-woot-500 hover:text-woot-500 dark:hover:text-woot-500 active-view';
@@ -242,7 +250,7 @@ export default {
</span> </span>
</router-link> </router-link>
<ul v-if="hasSubMenu" class="reset-base list-none"> <ul v-if="hasSubMenu" class="list-none reset-base">
<SecondaryChildNavItem <SecondaryChildNavItem
v-for="child in menuItem.children" v-for="child in menuItem.children"
:key="child.id" :key="child.id"

View File

@@ -11,18 +11,31 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms', SMS: 'Channel::Sms',
}; };
const INBOX_ICON_MAP = { const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global', [INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger', [INBOX_TYPES.FB]: 'i-ri-messenger-fill',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x', [INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp', [INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-fill',
[INBOX_TYPES.API]: 'i-ri-cloudy', [INBOX_TYPES.API]: 'i-ri-cloudy-fill',
[INBOX_TYPES.EMAIL]: 'i-ri-mail', [INBOX_TYPES.EMAIL]: 'i-ri-mail-fill',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram', [INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line', [INBOX_TYPES.LINE]: 'i-ri-line-fill',
}; };
const DEFAULT_ICON = 'i-ri-chat-1'; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.WEB]: 'i-ri-global-line',
[INBOX_TYPES.FB]: 'i-ri-messenger-line',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-line',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-line',
[INBOX_TYPES.API]: 'i-ri-cloudy-line',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-line',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
export const getInboxSource = (type, phoneNumber, inbox) => { export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) { switch (type) {
@@ -111,15 +124,17 @@ export const getInboxClassByType = (type, phoneNumber) => {
}; };
export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => { export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => {
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
// Special case for Twilio (whatsapp and sms) // Special case for Twilio (whatsapp and sms)
if (type === INBOX_TYPES.TWILIO) { if (type === INBOX_TYPES.TWILIO && phoneNumber?.startsWith('whatsapp')) {
return phoneNumber?.startsWith('whatsapp') return iconMap[INBOX_TYPES.WHATSAPP];
? `i-ri-whatsapp-${variant}`
: `i-ri-chat-1-${variant}`;
} }
const baseIcon = INBOX_ICON_MAP[type] ?? DEFAULT_ICON; return iconMap[type] ?? defaultIcon;
return `${baseIcon}-${variant}`;
}; };
export const getInboxWarningIconClass = (type, reauthorizationRequired) => { export const getInboxWarningIconClass = (type, reauthorizationRequired) => {