feat(v4): Compose new conversation without multiple clicks (#10545)
--------- Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
@@ -2,24 +2,22 @@
|
|||||||
import { computed, useSlots } from 'vue';
|
import { computed, useSlots } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
// 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';
|
||||||
|
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// buttonLabel: {
|
buttonLabel: {
|
||||||
// type: String,
|
type: String,
|
||||||
// default: '',
|
default: '',
|
||||||
// },
|
},
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits(['goToContactsList']);
|
||||||
// 'message',
|
|
||||||
'goToContactsList',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
@@ -62,7 +60,11 @@ const handleBreadcrumbClick = () => {
|
|||||||
:items="breadcrumbItems"
|
:items="breadcrumbItems"
|
||||||
@click="handleBreadcrumbClick"
|
@click="handleBreadcrumbClick"
|
||||||
/>
|
/>
|
||||||
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
|
<ComposeConversation>
|
||||||
|
<template #trigger="{ toggle }">
|
||||||
|
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||||
|
</template>
|
||||||
|
</ComposeConversation>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ const FORM_CONFIG = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SOCIAL_CONFIG = {
|
const SOCIAL_CONFIG = {
|
||||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
|
||||||
GITHUB: 'i-ri-github-fill',
|
|
||||||
INSTAGRAM: 'i-ri-instagram-line',
|
|
||||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||||
|
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||||
|
INSTAGRAM: 'i-ri-instagram-line',
|
||||||
TWITTER: 'i-ri-twitter-x-fill',
|
TWITTER: 'i-ri-twitter-x-fill',
|
||||||
|
GITHUB: 'i-ri-github-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState = {
|
const defaultState = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
|||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import ContactSortMenu from './components/ContactSortMenu.vue';
|
import ContactSortMenu from './components/ContactSortMenu.vue';
|
||||||
import ContactMoreActions from './components/ContactMoreActions.vue';
|
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||||
|
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
showSearch: {
|
showSearch: {
|
||||||
@@ -18,10 +19,10 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
// buttonLabel: {
|
buttonLabel: {
|
||||||
// type: String,
|
type: String,
|
||||||
// default: '',
|
default: '',
|
||||||
// },
|
},
|
||||||
activeSort: {
|
activeSort: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'last_activity_at',
|
default: 'last_activity_at',
|
||||||
@@ -48,7 +49,6 @@ const emit = defineEmits([
|
|||||||
'search',
|
'search',
|
||||||
'filter',
|
'filter',
|
||||||
'update:sort',
|
'update:sort',
|
||||||
// 'message',
|
|
||||||
'add',
|
'add',
|
||||||
'import',
|
'import',
|
||||||
'export',
|
'export',
|
||||||
@@ -131,9 +131,12 @@ const emit = defineEmits([
|
|||||||
@export="emit('export')"
|
@export="emit('export')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: Add this when we enabling message feature -->
|
<div class="w-px h-4 bg-n-strong" />
|
||||||
<!-- <div class="w-px h-4 bg-n-strong" /> -->
|
<ComposeConversation>
|
||||||
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
|
<template #trigger="{ toggle }">
|
||||||
|
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||||
|
</template>
|
||||||
|
</ComposeConversation>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const handleContactAction = ({ action }) => {
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-if="showActionsDropdown"
|
v-if="showActionsDropdown"
|
||||||
:menu-items="contactMenuItems"
|
:menu-items="contactMenuItems"
|
||||||
class="right-0 mt-1 w-52 top-full"
|
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
|
||||||
@action="handleContactAction($event)"
|
@action="handleContactAction($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const handleOrderChange = value => {
|
|||||||
<div
|
<div
|
||||||
v-if="isMenuOpen"
|
v-if="isMenuOpen"
|
||||||
v-on-clickaway="() => (isMenuOpen = false)"
|
v-on-clickaway="() => (isMenuOpen = false)"
|
||||||
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="text-sm text-n-slate-12">
|
<span class="text-sm text-n-slate-12">
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script setup>
|
||||||
|
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 { useAlert } from 'dashboard/composables';
|
||||||
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import {
|
||||||
|
searchContacts,
|
||||||
|
createNewContact,
|
||||||
|
fetchContactableInboxes,
|
||||||
|
processContactableInboxes,
|
||||||
|
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||||
|
|
||||||
|
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
alignPosition: {
|
||||||
|
type: String,
|
||||||
|
default: 'left',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const contacts = ref([]);
|
||||||
|
const selectedContact = ref(null);
|
||||||
|
const targetInbox = ref(null);
|
||||||
|
const isCreatingContact = ref(false);
|
||||||
|
const isFetchingInboxes = ref(false);
|
||||||
|
const isSearching = ref(false);
|
||||||
|
const showComposeNewConversation = ref(false);
|
||||||
|
|
||||||
|
const contactById = useMapGetter('contacts/getContactById');
|
||||||
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
const globalConfig = useMapGetter('globalConfig/get');
|
||||||
|
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 composePopoverClass = computed(() => {
|
||||||
|
return props.alignPosition === 'right'
|
||||||
|
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||||
|
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
|
||||||
|
});
|
||||||
|
|
||||||
|
const onContactSearch = debounce(
|
||||||
|
async query => {
|
||||||
|
isSearching.value = true;
|
||||||
|
contacts.value = [];
|
||||||
|
try {
|
||||||
|
contacts.value = await searchContacts(query);
|
||||||
|
isSearching.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetContacts = () => {
|
||||||
|
contacts.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||||
|
let contact;
|
||||||
|
if (action === 'create') {
|
||||||
|
isCreatingContact.value = true;
|
||||||
|
try {
|
||||||
|
contact = await createNewContact(value);
|
||||||
|
isCreatingContact.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
isCreatingContact.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contact = rest;
|
||||||
|
}
|
||||||
|
selectedContact.value = contact;
|
||||||
|
if (contact?.id) {
|
||||||
|
isFetchingInboxes.value = true;
|
||||||
|
try {
|
||||||
|
const contactableInboxes = await fetchContactableInboxes(contact.id);
|
||||||
|
selectedContact.value.contactInboxes = contactableInboxes;
|
||||||
|
isFetchingInboxes.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
isFetchingInboxes.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTargetInbox = inbox => {
|
||||||
|
targetInbox.value = inbox;
|
||||||
|
resetContacts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelectedContact = () => {
|
||||||
|
selectedContact.value = null;
|
||||||
|
targetInbox.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCompose = () => {
|
||||||
|
showComposeNewConversation.value = false;
|
||||||
|
selectedContact.value = null;
|
||||||
|
targetInbox.value = null;
|
||||||
|
resetContacts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||||
|
try {
|
||||||
|
const data = await store.dispatch('contactConversations/create', {
|
||||||
|
params: payload,
|
||||||
|
isFromWhatsApp,
|
||||||
|
});
|
||||||
|
const action = {
|
||||||
|
type: 'link',
|
||||||
|
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
||||||
|
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
||||||
|
};
|
||||||
|
closeCompose();
|
||||||
|
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
||||||
|
return true; // Return success
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error instanceof ExceptionWithMessage
|
||||||
|
? error.data
|
||||||
|
: t('COMPOSE_NEW_CONVERSATION.FORM.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
return false; // Return failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
showComposeNewConversation.value = !showComposeNewConversation.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
activeContact,
|
||||||
|
() => {
|
||||||
|
if (activeContact.value && contactId.value) {
|
||||||
|
// Add null check for contactInboxes
|
||||||
|
const contactInboxes = activeContact.value?.contactInboxes || [];
|
||||||
|
selectedContact.value = {
|
||||||
|
...activeContact.value,
|
||||||
|
contactInboxes: processContactableInboxes(contactInboxes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => resetContacts());
|
||||||
|
|
||||||
|
const keyboardEvents = {
|
||||||
|
Escape: {
|
||||||
|
action: () => {
|
||||||
|
if (showComposeNewConversation.value) {
|
||||||
|
showComposeNewConversation.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative z-40">
|
||||||
|
<slot
|
||||||
|
name="trigger"
|
||||||
|
:is-open="showComposeNewConversation"
|
||||||
|
:toggle="toggle"
|
||||||
|
/>
|
||||||
|
<ComposeNewConversationForm
|
||||||
|
v-if="showComposeNewConversation"
|
||||||
|
:contacts="contacts"
|
||||||
|
:contact-id="contactId"
|
||||||
|
:is-loading="isSearching"
|
||||||
|
:current-user="currentUser"
|
||||||
|
:selected-contact="selectedContact"
|
||||||
|
:target-inbox="targetInbox"
|
||||||
|
:is-creating-contact="isCreatingContact"
|
||||||
|
:is-fetching-inboxes="isFetchingInboxes"
|
||||||
|
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||||
|
:contact-conversations-ui-flags="uiFlags"
|
||||||
|
:contacts-ui-flags="contactsUiFlags"
|
||||||
|
:class="composePopoverClass"
|
||||||
|
@search-contacts="onContactSearch"
|
||||||
|
@reset-contact-search="resetContacts"
|
||||||
|
@update-selected-contact="handleSelectedContact"
|
||||||
|
@update-target-inbox="handleTargetInbox"
|
||||||
|
@clear-selected-contact="clearSelectedContact"
|
||||||
|
@create-conversation="createConversation"
|
||||||
|
@discard="closeCompose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,7 +3,7 @@ import { defineAsyncComponent, ref, computed } from 'vue';
|
|||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
// import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
@@ -110,19 +110,10 @@ const onClickInsertEmoji = emoji => {
|
|||||||
emit('insertEmoji', emoji);
|
emit('insertEmoji', emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useFileUpload = () => {
|
|
||||||
// Empty function for testing purposes
|
|
||||||
// TODO: Will use useFileUpload composable later
|
|
||||||
return {
|
|
||||||
onFileUpload: () => {},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const { onFileUpload } = useFileUpload({
|
const { onFileUpload } = useFileUpload({
|
||||||
isATwilioSMSChannel: props.isTwilioSmsInbox,
|
isATwilioSMSChannel: props.isTwilioSmsInbox,
|
||||||
attachFile: ({ blob, file }) => {
|
attachFile: ({ blob, file }) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(file.file);
|
reader.readAsDataURL(file.file);
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
|
|||||||
@@ -141,7 +141,11 @@ const isAnyDropdownActive = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleContactSearch = value => {
|
const handleContactSearch = value => {
|
||||||
emit('searchContacts', value);
|
showContactsDropdown.value = true;
|
||||||
|
emit('searchContacts', {
|
||||||
|
keys: ['email', 'phone_number', 'name'],
|
||||||
|
query: value,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropdownUpdate = (type, value) => {
|
const handleDropdownUpdate = (type, value) => {
|
||||||
@@ -156,12 +160,12 @@ const handleDropdownUpdate = (type, value) => {
|
|||||||
|
|
||||||
const searchCcEmails = value => {
|
const searchCcEmails = value => {
|
||||||
showCcEmailsDropdown.value = true;
|
showCcEmailsDropdown.value = true;
|
||||||
emit('searchContacts', value);
|
emit('searchContacts', { keys: ['email'], query: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchBccEmails = value => {
|
const searchBccEmails = value => {
|
||||||
showBccEmailsDropdown.value = true;
|
showBccEmailsDropdown.value = true;
|
||||||
emit('searchContacts', value);
|
emit('searchContacts', { keys: ['email'], query: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||||
@@ -250,7 +254,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||||
>
|
>
|
||||||
<ContactSelector
|
<ContactSelector
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
|
|||||||
|
|
||||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
contacts: {
|
contacts: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -43,20 +42,19 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'searchContacts',
|
'searchContacts',
|
||||||
'setSelectedContact',
|
'setSelectedContact',
|
||||||
'clearSelectedContact',
|
'clearSelectedContact',
|
||||||
'updateDropdown',
|
'updateDropdown',
|
||||||
]);
|
]);
|
||||||
|
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const contactsList = computed(() => {
|
const contactsList = computed(() => {
|
||||||
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
|
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
|
||||||
id,
|
id,
|
||||||
label: `${name} (${email})`,
|
label: email ? `${name} (${email})` : name,
|
||||||
value: id,
|
value: id,
|
||||||
thumbnail: { name, src: thumbnail },
|
thumbnail: { name, src: thumbnail },
|
||||||
...rest,
|
...rest,
|
||||||
@@ -69,13 +67,19 @@ const contactsList = computed(() => {
|
|||||||
const selectedContactLabel = computed(() => {
|
const selectedContactLabel = computed(() => {
|
||||||
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
|
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const errorClass = computed(() => {
|
||||||
|
return props.hasErrors
|
||||||
|
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
|
||||||
|
: '';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
|
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
|
||||||
<div class="flex items-baseline w-full gap-3 min-h-7">
|
<div class="flex items-baseline w-full gap-3 min-h-7">
|
||||||
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
|
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
|
||||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.LABEL') }}
|
{{ t(`${i18nPrefix}.LABEL`) }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -83,9 +87,7 @@ const selectedContactLabel = computed(() => {
|
|||||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
|
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
|
||||||
>
|
>
|
||||||
<span class="text-sm truncate text-n-slate-12">
|
<span class="text-sm truncate text-n-slate-12">
|
||||||
{{
|
{{ t(`${i18nPrefix}.CONTACT_CREATING`) }}
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING')
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -95,9 +97,7 @@ const selectedContactLabel = computed(() => {
|
|||||||
<span class="text-sm truncate text-n-slate-12">
|
<span class="text-sm truncate text-n-slate-12">
|
||||||
{{
|
{{
|
||||||
isCreatingContact
|
isCreatingContact
|
||||||
? t(
|
? t(`${i18nPrefix}.CONTACT_CREATING`)
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING'
|
|
||||||
)
|
|
||||||
: selectedContactLabel
|
: selectedContactLabel
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
@@ -112,11 +112,7 @@ const selectedContactLabel = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<TagInput
|
<TagInput
|
||||||
v-else
|
v-else
|
||||||
:placeholder="
|
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
|
||||||
t(
|
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
mode="single"
|
mode="single"
|
||||||
:menu-items="contactsList"
|
:menu-items="contactsList"
|
||||||
:show-dropdown="showContactsDropdown"
|
:show-dropdown="showContactsDropdown"
|
||||||
@@ -125,12 +121,8 @@ const selectedContactLabel = computed(() => {
|
|||||||
allow-create
|
allow-create
|
||||||
type="email"
|
type="email"
|
||||||
class="flex-1 min-h-7"
|
class="flex-1 min-h-7"
|
||||||
:class="
|
:class="errorClass"
|
||||||
hasErrors
|
focus-on-mount
|
||||||
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
@focus="emit('updateDropdown', 'contacts', true)"
|
|
||||||
@input="emit('searchContacts', $event)"
|
@input="emit('searchContacts', $event)"
|
||||||
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
||||||
@add="emit('setSelectedContact', $event)"
|
@add="emit('setSelectedContact', $event)"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const targetInboxLabel = computed(() => {
|
|||||||
v-if="targetInbox"
|
v-if="targetInbox"
|
||||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
|
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-n-slate-12">
|
<span class="text-sm truncate text-n-slate-12">
|
||||||
{{ targetInboxLabel }}
|
{{ targetInboxLabel }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
import ContactAPI from 'dashboard/api/contacts';
|
||||||
|
|
||||||
export const convertChannelTypeToLabel = channelType => {
|
export const convertChannelTypeToLabel = channelType => {
|
||||||
const [, type] = channelType.split('::');
|
const [, type] = channelType.split('::');
|
||||||
@@ -45,6 +47,18 @@ export const buildContactableInboxesList = contactInboxes => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCapitalizedNameFromEmail = email => {
|
||||||
|
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
|
||||||
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const processContactableInboxes = inboxes => {
|
||||||
|
return inboxes.map(inbox => ({
|
||||||
|
...inbox.inbox,
|
||||||
|
sourceId: inbox.sourceId,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
export const prepareAttachmentPayload = (
|
export const prepareAttachmentPayload = (
|
||||||
attachedFiles,
|
attachedFiles,
|
||||||
directUploadsEnabled
|
directUploadsEnabled
|
||||||
@@ -116,3 +130,57 @@ export const prepareWhatsAppMessagePayload = ({
|
|||||||
assigneeId: currentUser.id,
|
assigneeId: currentUser.id,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
||||||
|
return {
|
||||||
|
payload: keys.map(key => {
|
||||||
|
const filterPayload = {
|
||||||
|
attribute_key: key,
|
||||||
|
filter_operator: 'contains',
|
||||||
|
values: [query],
|
||||||
|
attribute_model: 'standard',
|
||||||
|
};
|
||||||
|
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
||||||
|
filterPayload.query_operator = 'or';
|
||||||
|
}
|
||||||
|
return filterPayload;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// API Calls
|
||||||
|
export const searchContacts = async ({ keys, query }) => {
|
||||||
|
const {
|
||||||
|
data: { payload },
|
||||||
|
} = await ContactAPI.filter(
|
||||||
|
undefined,
|
||||||
|
'name',
|
||||||
|
generateContactQuery({ keys, query })
|
||||||
|
);
|
||||||
|
return camelcaseKeys(payload, { deep: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNewContact = async email => {
|
||||||
|
const payload = {
|
||||||
|
name: getCapitalizedNameFromEmail(email),
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
payload: { contact: newContact },
|
||||||
|
},
|
||||||
|
} = await ContactAPI.create(payload);
|
||||||
|
|
||||||
|
return camelcaseKeys(newContact, { deep: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchContactableInboxes = async contactId => {
|
||||||
|
const {
|
||||||
|
data: { payload: inboxes = [] },
|
||||||
|
} = await ContactAPI.getContactableInboxes(contactId);
|
||||||
|
|
||||||
|
const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
|
||||||
|
|
||||||
|
return processContactableInboxes(convertInboxesToCamelKeys);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import ContactAPI from 'dashboard/api/contacts';
|
||||||
import * as helpers from '../composeConversationHelper';
|
import * as helpers from '../composeConversationHelper';
|
||||||
|
|
||||||
|
vi.mock('dashboard/api/contacts');
|
||||||
|
|
||||||
describe('composeConversationHelper', () => {
|
describe('composeConversationHelper', () => {
|
||||||
describe('convertChannelTypeToLabel', () => {
|
describe('convertChannelTypeToLabel', () => {
|
||||||
it('converts channel type with namespace to capitalized label', () => {
|
it('converts channel type with namespace to capitalized label', () => {
|
||||||
@@ -90,6 +93,35 @@ describe('composeConversationHelper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getCapitalizedNameFromEmail', () => {
|
||||||
|
it('extracts and capitalizes name from email', () => {
|
||||||
|
expect(helpers.getCapitalizedNameFromEmail('john.doe@example.com')).toBe(
|
||||||
|
'John.doe'
|
||||||
|
);
|
||||||
|
expect(helpers.getCapitalizedNameFromEmail('jane@example.com')).toBe(
|
||||||
|
'Jane'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processContactableInboxes', () => {
|
||||||
|
it('processes inboxes with correct structure', () => {
|
||||||
|
const inboxes = [
|
||||||
|
{
|
||||||
|
inbox: { id: 1, name: 'Inbox 1' },
|
||||||
|
sourceId: 'source1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = helpers.processContactableInboxes(inboxes);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: 'Inbox 1',
|
||||||
|
sourceId: 'source1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('prepareAttachmentPayload', () => {
|
describe('prepareAttachmentPayload', () => {
|
||||||
it('prepares direct upload files', () => {
|
it('prepares direct upload files', () => {
|
||||||
const files = [{ blobSignedId: 'signed1' }];
|
const files = [{ blobSignedId: 'signed1' }];
|
||||||
@@ -168,4 +200,210 @@ describe('composeConversationHelper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('generateContactQuery', () => {
|
||||||
|
it('generates correct query structure for contact search', () => {
|
||||||
|
const query = 'test@example.com';
|
||||||
|
const expected = {
|
||||||
|
payload: [
|
||||||
|
{
|
||||||
|
attribute_key: 'email',
|
||||||
|
filter_operator: 'contains',
|
||||||
|
values: [query],
|
||||||
|
attribute_model: 'standard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty query', () => {
|
||||||
|
const expected = {
|
||||||
|
payload: [
|
||||||
|
{
|
||||||
|
attribute_key: 'email',
|
||||||
|
filter_operator: 'contains',
|
||||||
|
values: [''],
|
||||||
|
attribute_model: 'standard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
helpers.generateContactQuery({ keys: ['email'], query: '' })
|
||||||
|
).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mutliple keys', () => {
|
||||||
|
const expected = {
|
||||||
|
payload: [
|
||||||
|
{
|
||||||
|
attribute_key: 'email',
|
||||||
|
filter_operator: 'contains',
|
||||||
|
values: ['john'],
|
||||||
|
attribute_model: 'standard',
|
||||||
|
query_operator: 'or',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute_key: 'phone_number',
|
||||||
|
filter_operator: 'contains',
|
||||||
|
values: ['john'],
|
||||||
|
attribute_model: 'standard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
helpers.generateContactQuery({
|
||||||
|
keys: ['email', 'phone_number'],
|
||||||
|
query: 'john',
|
||||||
|
})
|
||||||
|
).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API calls', () => {
|
||||||
|
describe('searchContacts', () => {
|
||||||
|
it('searches contacts and returns camelCase results', async () => {
|
||||||
|
const mockPayload = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
phone_number: '+1234567890',
|
||||||
|
created_at: '2023-01-01',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
ContactAPI.filter.mockResolvedValue({
|
||||||
|
data: { payload: mockPayload },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.searchContacts({
|
||||||
|
keys: ['email'],
|
||||||
|
query: 'john',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
phoneNumber: '+1234567890',
|
||||||
|
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: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.searchContacts('nonexistent');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transforms nested objects to camelCase', async () => {
|
||||||
|
const mockPayload = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
contact_inboxes: [
|
||||||
|
{
|
||||||
|
inbox_id: 1,
|
||||||
|
source_id: 'source1',
|
||||||
|
created_at: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom_attributes: {
|
||||||
|
custom_field_name: 'value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
ContactAPI.filter.mockResolvedValue({
|
||||||
|
data: { payload: mockPayload },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.searchContacts('test');
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
contactInboxes: [
|
||||||
|
{
|
||||||
|
inboxId: 1,
|
||||||
|
sourceId: 'source1',
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customAttributes: {
|
||||||
|
customFieldName: 'value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNewContact', () => {
|
||||||
|
it('creates new contact with capitalized name', async () => {
|
||||||
|
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
||||||
|
ContactAPI.create.mockResolvedValue({
|
||||||
|
data: { payload: { contact: mockContact } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.createNewContact('john@example.com');
|
||||||
|
expect(result).toEqual(mockContact);
|
||||||
|
expect(ContactAPI.create).toHaveBeenCalledWith({
|
||||||
|
name: 'John',
|
||||||
|
email: 'john@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchContactableInboxes', () => {
|
||||||
|
it('fetches and processes contactable inboxes', async () => {
|
||||||
|
const mockInboxes = [
|
||||||
|
{
|
||||||
|
inbox: { id: 1, name: 'Inbox 1' },
|
||||||
|
sourceId: 'source1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ContactAPI.getContactableInboxes.mockResolvedValue({
|
||||||
|
data: { payload: mockInboxes },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.fetchContactableInboxes(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
id: 1,
|
||||||
|
name: 'Inbox 1',
|
||||||
|
sourceId: 'source1',
|
||||||
|
});
|
||||||
|
expect(ContactAPI.getContactableInboxes).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no inboxes found', async () => {
|
||||||
|
ContactAPI.getContactableInboxes.mockResolvedValue({
|
||||||
|
data: { payload: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.fetchContactableInboxes(1);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="item in filteredMenuItems"
|
v-for="(item, index) in filteredMenuItems"
|
||||||
:key="item.action"
|
:key="index"
|
||||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import { useMapGetter } from 'dashboard/composables/store';
|
|||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
|
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||||
import ChannelLeaf from './ChannelLeaf.vue';
|
import ChannelLeaf from './ChannelLeaf.vue';
|
||||||
import SidebarNotificationBell from './SidebarNotificationBell.vue';
|
|
||||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||||
import Logo from 'next/icon/Logo.vue';
|
import Logo from 'next/icon/Logo.vue';
|
||||||
|
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'openNotificationPanel',
|
|
||||||
'closeKeyShortcutModal',
|
'closeKeyShortcutModal',
|
||||||
'openKeyShortcutModal',
|
'openKeyShortcutModal',
|
||||||
'showCreateAccountModal',
|
'showCreateAccountModal',
|
||||||
@@ -27,7 +27,6 @@ const { accountScopedRoute } = useAccount();
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const enableNewConversation = false;
|
|
||||||
|
|
||||||
const toggleShortcutModalFn = show => {
|
const toggleShortcutModalFn = show => {
|
||||||
if (show) {
|
if (show) {
|
||||||
@@ -481,7 +480,7 @@ const menuItems = computed(() => {
|
|||||||
<div class="flex gap-2 px-2">
|
<div class="flex gap-2 px-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'search' }"
|
:to="{ name: 'search' }"
|
||||||
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
||||||
>
|
>
|
||||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
||||||
<span class="flex-grow text-left">
|
<span class="flex-grow text-left">
|
||||||
@@ -493,14 +492,17 @@ const menuItems = computed(() => {
|
|||||||
{{ searchShortcut }}
|
{{ searchShortcut }}
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button
|
<ComposeConversation align-position="right">
|
||||||
v-if="enableNewConversation"
|
<template #trigger="{ toggle }">
|
||||||
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
|
<Button
|
||||||
>
|
icon="i-lucide-pen-line"
|
||||||
<span
|
color="slate"
|
||||||
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
|
size="sm"
|
||||||
/>
|
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||||
</button>
|
@click="toggle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ComposeConversation>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||||
@@ -518,12 +520,6 @@ const menuItems = computed(() => {
|
|||||||
<SidebarProfileMenu
|
<SidebarProfileMenu
|
||||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||||
/>
|
/>
|
||||||
<div v-if="false" class="flex items-center">
|
|
||||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
|
||||||
<SidebarNotificationBell
|
|
||||||
@open-notification-panel="emit('openNotificationPanel')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const allowedMenuItems = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
|
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
|
||||||
<SidebarProfileMenuStatus />
|
<SidebarProfileMenuStatus />
|
||||||
<DropdownSeparator />
|
<DropdownSeparator />
|
||||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||||
|
|||||||
@@ -216,9 +216,11 @@ const handleBlur = e => emit('blur', e);
|
|||||||
@click.stop="removeTag(index)"
|
@click.stop="removeTag(index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full">
|
<div
|
||||||
|
v-if="showInput || showDropdownMenu"
|
||||||
|
class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full"
|
||||||
|
>
|
||||||
<InlineInput
|
<InlineInput
|
||||||
v-if="showInput"
|
|
||||||
ref="tagInputRef"
|
ref="tagInputRef"
|
||||||
v-model="newTag"
|
v-model="newTag"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { frontendURL } from '../../../../helper/URLHelper';
|
|||||||
const contacts = accountId => ({
|
const contacts = accountId => ({
|
||||||
parentNav: 'contacts',
|
parentNav: 'contacts',
|
||||||
routes: [
|
routes: [
|
||||||
'contacts_dashboard',
|
'contacts_dashboard_index',
|
||||||
|
'contacts_dashboard_segments_index',
|
||||||
|
'contacts_dashboard_labels_index',
|
||||||
'contacts_edit',
|
'contacts_edit',
|
||||||
'contacts_segments_dashboard',
|
'contacts_edit_segment',
|
||||||
'contacts_labels_dashboard',
|
'contacts_edit_label',
|
||||||
],
|
],
|
||||||
menuItems: [
|
menuItems: [
|
||||||
{
|
{
|
||||||
@@ -14,7 +16,7 @@ const contacts = accountId => ({
|
|||||||
label: 'ALL_CONTACTS',
|
label: 'ALL_CONTACTS',
|
||||||
hasSubMenu: false,
|
hasSubMenu: false,
|
||||||
toState: frontendURL(`accounts/${accountId}/contacts`),
|
toState: frontendURL(`accounts/${accountId}/contacts`),
|
||||||
toStateName: 'contacts_dashboard',
|
toStateName: 'contacts_dashboard_index',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const primaryMenuItems = accountId => [
|
|||||||
label: 'CONTACTS',
|
label: 'CONTACTS',
|
||||||
featureFlag: FEATURE_FLAGS.CRM,
|
featureFlag: FEATURE_FLAGS.CRM,
|
||||||
toState: frontendURL(`accounts/${accountId}/contacts`),
|
toState: frontendURL(`accounts/${accountId}/contacts`),
|
||||||
toStateName: 'contacts_dashboard',
|
toStateName: 'contacts_dashboard_index',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'arrow-trending-lines',
|
icon: 'arrow-trending-lines',
|
||||||
|
|||||||
146
app/javascript/dashboard/composables/spec/useFileUpload.spec.js
Normal file
146
app/javascript/dashboard/composables/spec/useFileUpload.spec.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useFileUpload } from '../useFileUpload';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DirectUpload } from 'activestorage';
|
||||||
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
|
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/composables', () => ({
|
||||||
|
useAlert: vi.fn(message => message),
|
||||||
|
}));
|
||||||
|
vi.mock('vue-i18n');
|
||||||
|
vi.mock('activestorage');
|
||||||
|
vi.mock('shared/helpers/FileHelper');
|
||||||
|
|
||||||
|
describe('useFileUpload', () => {
|
||||||
|
const mockAttachFile = vi.fn();
|
||||||
|
const mockTranslate = vi.fn();
|
||||||
|
|
||||||
|
const mockFile = {
|
||||||
|
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const getterMap = {
|
||||||
|
getCurrentAccountId: { value: '123' },
|
||||||
|
getCurrentUser: { value: { access_token: 'test-token' } },
|
||||||
|
getSelectedChat: { value: { id: '456' } },
|
||||||
|
'globalConfig/get': { value: { directUploadsEnabled: true } },
|
||||||
|
};
|
||||||
|
return getterMap[getter];
|
||||||
|
});
|
||||||
|
|
||||||
|
useI18n.mockReturnValue({ t: mockTranslate });
|
||||||
|
checkFileSizeLimit.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle direct file upload when enabled', () => {
|
||||||
|
const { onFileUpload } = useFileUpload({
|
||||||
|
isATwilioSMSChannel: false,
|
||||||
|
attachFile: mockAttachFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBlob = { signed_id: 'test-blob' };
|
||||||
|
DirectUpload.mockImplementation(() => ({
|
||||||
|
create: callback => callback(null, mockBlob),
|
||||||
|
}));
|
||||||
|
|
||||||
|
onFileUpload(mockFile);
|
||||||
|
|
||||||
|
expect(DirectUpload).toHaveBeenCalledWith(
|
||||||
|
mockFile.file,
|
||||||
|
'/api/v1/accounts/123/conversations/456/direct_uploads',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(mockAttachFile).toHaveBeenCalledWith({
|
||||||
|
file: mockFile,
|
||||||
|
blob: mockBlob,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle indirect file upload when direct upload is disabled', () => {
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const getterMap = {
|
||||||
|
getCurrentAccountId: { value: '123' },
|
||||||
|
getCurrentUser: { value: { access_token: 'test-token' } },
|
||||||
|
getSelectedChat: { value: { id: '456' } },
|
||||||
|
'globalConfig/get': { value: { directUploadsEnabled: false } },
|
||||||
|
};
|
||||||
|
return getterMap[getter];
|
||||||
|
});
|
||||||
|
|
||||||
|
const { onFileUpload } = useFileUpload({
|
||||||
|
isATwilioSMSChannel: false,
|
||||||
|
attachFile: mockAttachFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
onFileUpload(mockFile);
|
||||||
|
|
||||||
|
expect(DirectUpload).not.toHaveBeenCalled();
|
||||||
|
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show alert when file size exceeds limit', () => {
|
||||||
|
checkFileSizeLimit.mockReturnValue(false);
|
||||||
|
mockTranslate.mockReturnValue('File size exceeds limit');
|
||||||
|
|
||||||
|
const { onFileUpload } = useFileUpload({
|
||||||
|
isATwilioSMSChannel: false,
|
||||||
|
attachFile: mockAttachFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
onFileUpload(mockFile);
|
||||||
|
|
||||||
|
expect(useAlert).toHaveBeenCalledWith('File size exceeds limit');
|
||||||
|
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use different max file size for Twilio SMS channel', () => {
|
||||||
|
const { onFileUpload } = useFileUpload({
|
||||||
|
isATwilioSMSChannel: true,
|
||||||
|
attachFile: mockAttachFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
onFileUpload(mockFile);
|
||||||
|
|
||||||
|
expect(checkFileSizeLimit).toHaveBeenCalledWith(
|
||||||
|
mockFile,
|
||||||
|
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle direct upload errors', () => {
|
||||||
|
const mockError = 'Upload failed';
|
||||||
|
DirectUpload.mockImplementation(() => ({
|
||||||
|
create: callback => callback(mockError, null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { onFileUpload } = useFileUpload({
|
||||||
|
isATwilioSMSChannel: false,
|
||||||
|
attachFile: mockAttachFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
onFileUpload(mockFile);
|
||||||
|
|
||||||
|
expect(useAlert).toHaveBeenCalledWith(mockError);
|
||||||
|
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when file is null', () => {
|
||||||
|
const { onFileUpload } = useFileUpload({
|
||||||
|
isATwilioSMSChannel: false,
|
||||||
|
attachFile: mockAttachFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
onFileUpload(null);
|
||||||
|
|
||||||
|
expect(checkFileSizeLimit).not.toHaveBeenCalled();
|
||||||
|
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||||
|
expect(useAlert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
91
app/javascript/dashboard/composables/useFileUpload.js
Normal file
91
app/javascript/dashboard/composables/useFileUpload.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { DirectUpload } from 'activestorage';
|
||||||
|
import {
|
||||||
|
MAXIMUM_FILE_UPLOAD_SIZE,
|
||||||
|
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
|
||||||
|
} from 'shared/constants/messages';
|
||||||
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for handling file uploads in conversations
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
|
||||||
|
* @param {Function} options.attachFile - Callback function to handle file attachment
|
||||||
|
* @returns {Object} File upload methods and utilities
|
||||||
|
*/
|
||||||
|
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
const globalConfig = useMapGetter('globalConfig/get');
|
||||||
|
|
||||||
|
const maxFileSize = computed(() =>
|
||||||
|
isATwilioSMSChannel
|
||||||
|
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||||
|
: MAXIMUM_FILE_UPLOAD_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDirectFileUpload = file => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (checkFileSizeLimit(file, maxFileSize.value)) {
|
||||||
|
const upload = new DirectUpload(
|
||||||
|
file.file,
|
||||||
|
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||||
|
{
|
||||||
|
directUploadWillCreateBlobWithXHR: xhr => {
|
||||||
|
xhr.setRequestHeader(
|
||||||
|
'api_access_token',
|
||||||
|
currentUser.value.access_token
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
upload.create((error, blob) => {
|
||||||
|
if (error) {
|
||||||
|
useAlert(error);
|
||||||
|
} else {
|
||||||
|
attachFile({ file, blob });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
useAlert(
|
||||||
|
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||||
|
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndirectFileUpload = file => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (checkFileSizeLimit(file, maxFileSize.value)) {
|
||||||
|
attachFile({ file });
|
||||||
|
} else {
|
||||||
|
useAlert(
|
||||||
|
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||||
|
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileUpload = file => {
|
||||||
|
if (globalConfig.value.directUploadsEnabled) {
|
||||||
|
handleDirectFileUpload(file);
|
||||||
|
} else {
|
||||||
|
handleIndirectFileUpload(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onFileUpload,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
},
|
},
|
||||||
"DROPDOWN_MENU": {
|
"DROPDOWN_MENU": {
|
||||||
"SEARCH_PLACEHOLDER": "Search...",
|
"SEARCH_PLACEHOLDER": "Search...",
|
||||||
"EMPTY_STATE": "No results found."
|
"EMPTY_STATE": "No results found.",
|
||||||
|
"SEARCHING": "Searching..."
|
||||||
},
|
},
|
||||||
"DIALOG": {
|
"DIALOG": {
|
||||||
"BUTTONS": {
|
"BUTTONS": {
|
||||||
|
|||||||
@@ -393,6 +393,7 @@
|
|||||||
"SEARCH_TITLE": "Search contacts",
|
"SEARCH_TITLE": "Search contacts",
|
||||||
"SEARCH_PLACEHOLDER": "Search...",
|
"SEARCH_PLACEHOLDER": "Search...",
|
||||||
"MESSAGE_BUTTON": "Message",
|
"MESSAGE_BUTTON": "Message",
|
||||||
|
"SEND_MESSAGE": "Send message",
|
||||||
"BREADCRUMB": {
|
"BREADCRUMB": {
|
||||||
"CONTACTS": "Contacts"
|
"CONTACTS": "Contacts"
|
||||||
},
|
},
|
||||||
@@ -666,7 +667,7 @@
|
|||||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||||
"CONTACT_SELECTOR": {
|
"CONTACT_SELECTOR": {
|
||||||
"LABEL": "To:",
|
"LABEL": "To:",
|
||||||
"TAG_INPUT_PLACEHOLDER": "Type an email address to search for the contact and press Enter",
|
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
|
||||||
"CONTACT_CREATING": "Creating contact..."
|
"CONTACT_CREATING": "Creating contact..."
|
||||||
},
|
},
|
||||||
"INBOX_SELECTOR": {
|
"INBOX_SELECTOR": {
|
||||||
@@ -677,9 +678,9 @@
|
|||||||
"SUBJECT_LABEL": "Subject :",
|
"SUBJECT_LABEL": "Subject :",
|
||||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||||
"CC_LABEL": "Cc:",
|
"CC_LABEL": "Cc:",
|
||||||
"CC_PLACEHOLDER": "Type an email address to search for the contact and press Enter",
|
"CC_PLACEHOLDER": "Search for a contact with their email address",
|
||||||
"BCC_LABEL": "Bcc:",
|
"BCC_LABEL": "Bcc:",
|
||||||
"BCC_PLACEHOLDER": "Type an email address to search for the contact and press Enter",
|
"BCC_PLACEHOLDER": "Search for a contact with their email address",
|
||||||
"BCC_BUTTON": "Bcc"
|
"BCC_BUTTON": "Bcc"
|
||||||
},
|
},
|
||||||
"MESSAGE_EDITOR": {
|
"MESSAGE_EDITOR": {
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ export default {
|
|||||||
<NextSidebar
|
<NextSidebar
|
||||||
v-if="showNextSidebar"
|
v-if="showNextSidebar"
|
||||||
@toggle-account-modal="toggleAccountModal"
|
@toggle-account-modal="toggleAccountModal"
|
||||||
@open-notification-panel="openNotificationPanel"
|
|
||||||
@open-key-shortcut-modal="toggleKeyShortcutModal"
|
@open-key-shortcut-modal="toggleKeyShortcutModal"
|
||||||
@close-key-shortcut-modal="closeKeyShortcutModal"
|
@close-key-shortcut-modal="closeKeyShortcutModal"
|
||||||
@show-create-account-modal="openCreateAccountModal"
|
@show-create-account-modal="openCreateAccountModal"
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ const goToContactsList = () => {
|
|||||||
const fetchActiveContact = async () => {
|
const fetchActiveContact = async () => {
|
||||||
if (route.params.contactId) {
|
if (route.params.contactId) {
|
||||||
store.dispatch('contacts/show', { id: route.params.contactId });
|
store.dispatch('contacts/show', { id: route.params.contactId });
|
||||||
|
await store.dispatch(
|
||||||
|
'contacts/fetchContactableInbox',
|
||||||
|
route.params.contactId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,7 +101,7 @@ onMounted(() => {
|
|||||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
|
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
|
||||||
>
|
>
|
||||||
<ContactsDetailsLayout
|
<ContactsDetailsLayout
|
||||||
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
|
:button-label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||||
:selected-contact="selectedContact"
|
:selected-contact="selectedContact"
|
||||||
is-detail-view
|
is-detail-view
|
||||||
:show-pagination-footer="false"
|
:show-pagination-footer="false"
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default {
|
|||||||
},
|
},
|
||||||
selectedInbox: {
|
selectedInbox: {
|
||||||
get() {
|
get() {
|
||||||
const inboxList = this.contact.contactableInboxes || [];
|
const inboxList = this.contact.contact_inboxes || [];
|
||||||
return (
|
return (
|
||||||
inboxList.find(inbox => {
|
inboxList.find(inbox => {
|
||||||
return inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id;
|
return inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id;
|
||||||
@@ -152,7 +152,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
showNoInboxAlert() {
|
showNoInboxAlert() {
|
||||||
if (!this.contact.contactableInboxes) {
|
if (!this.contact.contact_inboxes) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
|
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
|
||||||
@@ -166,7 +166,7 @@ export default {
|
|||||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||||
},
|
},
|
||||||
inboxes() {
|
inboxes() {
|
||||||
const inboxList = this.contact.contactableInboxes || [];
|
const inboxList = this.contact.contact_inboxes || [];
|
||||||
return inboxList.map(inbox => ({
|
return inboxList.map(inbox => ({
|
||||||
...inbox.inbox,
|
...inbox.inbox,
|
||||||
sourceId: inbox.source_id,
|
sourceId: inbox.source_id,
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ export const actions = {
|
|||||||
try {
|
try {
|
||||||
const response = await ContactAPI.getContactableInboxes(id);
|
const response = await ContactAPI.getContactableInboxes(id);
|
||||||
const contact = {
|
const contact = {
|
||||||
id,
|
id: Number(id),
|
||||||
contactableInboxes: response.data.payload,
|
contact_inboxes: response.data.payload,
|
||||||
};
|
};
|
||||||
commit(types.SET_CONTACT_ITEM, contact);
|
commit(types.SET_CONTACT_ITEM, contact);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user