mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 20:18:08 +00:00
fix: New compose conversation form (#10548)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user