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

View File

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

View File

@@ -65,7 +65,14 @@ const contactsList = 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(() => {

View File

@@ -154,7 +154,12 @@ export const searchContacts = async ({ keys, query }) => {
'name',
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 => {

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 () => {
ContactAPI.filter.mockResolvedValue({
data: { payload: [] },
@@ -310,6 +374,8 @@ describe('composeConversationHelper', () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [
{
inbox_id: 1,
@@ -332,6 +398,8 @@ describe('composeConversationHelper', () => {
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [
{
inboxId: 1,

View File

@@ -95,7 +95,7 @@ onMounted(() => {
rounded-full
/>
</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.label" class="min-w-0 text-sm truncate">{{
item.label

View File

@@ -81,17 +81,19 @@ const filteredMenuItems = computed(() => {
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
// 2. The value isn't already in the tags
// 3. There are no menu items available
const trimmedNewTag = newTag.value.trim();
if (
// 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.isLoading
) {
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
return [
{
label: trimmedNewTag,
@@ -117,7 +119,7 @@ const emitDataOnAdd = emailValue => {
};
const addTag = async () => {
const trimmedTag = newTag.value.trim();
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
@@ -185,7 +187,7 @@ watch(
watch(
() => newTag.value,
async newValue => {
if (props.type === 'email' && newValue.trim()?.length > 2) {
if (props.type === 'email' && newValue?.trim()?.length > 2) {
await v$.value.$validate();
}
}

View File

@@ -119,6 +119,13 @@ export default {
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() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
@@ -130,6 +137,7 @@ export default {
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isContactsDefaultRoute ||
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';
@@ -242,7 +250,7 @@ export default {
</span>
</router-link>
<ul v-if="hasSubMenu" class="reset-base list-none">
<ul v-if="hasSubMenu" class="list-none reset-base">
<SecondaryChildNavItem
v-for="child in menuItem.children"
:key="child.id"

View File

@@ -11,18 +11,31 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
};
const INBOX_ICON_MAP = {
[INBOX_TYPES.WEB]: 'i-ri-global',
[INBOX_TYPES.FB]: 'i-ri-messenger',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp',
[INBOX_TYPES.API]: 'i-ri-cloudy',
[INBOX_TYPES.EMAIL]: 'i-ri-mail',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram',
[INBOX_TYPES.LINE]: 'i-ri-line',
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-fill',
[INBOX_TYPES.API]: 'i-ri-cloudy-fill',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-fill',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[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) => {
switch (type) {
@@ -111,15 +124,17 @@ export const getInboxClassByType = (type, phoneNumber) => {
};
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)
if (type === INBOX_TYPES.TWILIO) {
return phoneNumber?.startsWith('whatsapp')
? `i-ri-whatsapp-${variant}`
: `i-ri-chat-1-${variant}`;
if (type === INBOX_TYPES.TWILIO && phoneNumber?.startsWith('whatsapp')) {
return iconMap[INBOX_TYPES.WHATSAPP];
}
const baseIcon = INBOX_ICON_MAP[type] ?? DEFAULT_ICON;
return `${baseIcon}-${variant}`;
return iconMap[type] ?? defaultIcon;
};
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {