mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
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 { 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 ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// buttonLabel: {
|
||||
// type: String,
|
||||
// default: '',
|
||||
// },
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
// 'message',
|
||||
'goToContactsList',
|
||||
]);
|
||||
const emit = defineEmits(['goToContactsList']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
@@ -62,7 +60,11 @@ const handleBreadcrumbClick = () => {
|
||||
:items="breadcrumbItems"
|
||||
@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>
|
||||
</header>
|
||||
|
||||
@@ -41,11 +41,11 @@ const FORM_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',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
};
|
||||
|
||||
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 ContactSortMenu from './components/ContactSortMenu.vue';
|
||||
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: {
|
||||
@@ -18,10 +19,10 @@ defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// buttonLabel: {
|
||||
// type: String,
|
||||
// default: '',
|
||||
// },
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: 'last_activity_at',
|
||||
@@ -48,7 +49,6 @@ const emit = defineEmits([
|
||||
'search',
|
||||
'filter',
|
||||
'update:sort',
|
||||
// 'message',
|
||||
'add',
|
||||
'import',
|
||||
'export',
|
||||
@@ -131,9 +131,12 @@ const emit = defineEmits([
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<!-- TODO: Add this when we enabling message feature -->
|
||||
<!-- <div class="w-px h-4 bg-n-strong" /> -->
|
||||
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -55,7 +55,7 @@ const handleContactAction = ({ action }) => {
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
: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)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
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">
|
||||
<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 { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
// import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
@@ -110,19 +110,10 @@ const onClickInsertEmoji = emoji => {
|
||||
emit('insertEmoji', emoji);
|
||||
};
|
||||
|
||||
const useFileUpload = () => {
|
||||
// Empty function for testing purposes
|
||||
// TODO: Will use useFileUpload composable later
|
||||
return {
|
||||
onFileUpload: () => {},
|
||||
};
|
||||
};
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: props.isTwilioSmsInbox,
|
||||
attachFile: ({ blob, file }) => {
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
reader.onloadend = () => {
|
||||
|
||||
@@ -141,7 +141,11 @@ const isAnyDropdownActive = computed(() => {
|
||||
});
|
||||
|
||||
const handleContactSearch = value => {
|
||||
emit('searchContacts', value);
|
||||
showContactsDropdown.value = true;
|
||||
emit('searchContacts', {
|
||||
keys: ['email', 'phone_number', 'name'],
|
||||
query: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
@@ -156,12 +160,12 @@ const handleDropdownUpdate = (type, value) => {
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showCcEmailsDropdown.value = true;
|
||||
emit('searchContacts', value);
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showBccEmailsDropdown.value = true;
|
||||
emit('searchContacts', value);
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
};
|
||||
|
||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
@@ -250,7 +254,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
|
||||
<template>
|
||||
<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
|
||||
:contacts="contacts"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: {
|
||||
type: Array,
|
||||
@@ -43,20 +42,19 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'setSelectedContact',
|
||||
'clearSelectedContact',
|
||||
'updateDropdown',
|
||||
]);
|
||||
|
||||
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactsList = computed(() => {
|
||||
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
|
||||
id,
|
||||
label: `${name} (${email})`,
|
||||
label: email ? `${name} (${email})` : name,
|
||||
value: id,
|
||||
thumbnail: { name, src: thumbnail },
|
||||
...rest,
|
||||
@@ -69,13 +67,19 @@ const contactsList = computed(() => {
|
||||
const selectedContactLabel = computed(() => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
|
||||
<div class="flex items-baseline w-full gap-3 min-h-7">
|
||||
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.LABEL') }}
|
||||
{{ t(`${i18nPrefix}.LABEL`) }}
|
||||
</label>
|
||||
|
||||
<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"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING')
|
||||
}}
|
||||
{{ t(`${i18nPrefix}.CONTACT_CREATING`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -95,9 +97,7 @@ const selectedContactLabel = computed(() => {
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{
|
||||
isCreatingContact
|
||||
? t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.CONTACT_CREATING'
|
||||
)
|
||||
? t(`${i18nPrefix}.CONTACT_CREATING`)
|
||||
: selectedContactLabel
|
||||
}}
|
||||
</span>
|
||||
@@ -112,11 +112,7 @@ const selectedContactLabel = computed(() => {
|
||||
</div>
|
||||
<TagInput
|
||||
v-else
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.TAG_INPUT_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
|
||||
mode="single"
|
||||
:menu-items="contactsList"
|
||||
:show-dropdown="showContactsDropdown"
|
||||
@@ -125,12 +121,8 @@ const selectedContactLabel = computed(() => {
|
||||
allow-create
|
||||
type="email"
|
||||
class="flex-1 min-h-7"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
@focus="emit('updateDropdown', 'contacts', true)"
|
||||
:class="errorClass"
|
||||
focus-on-mount
|
||||
@input="emit('searchContacts', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'contacts', false)"
|
||||
@add="emit('setSelectedContact', $event)"
|
||||
|
||||
@@ -54,7 +54,7 @@ const targetInboxLabel = computed(() => {
|
||||
v-if="targetInbox"
|
||||
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 }}
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
export const convertChannelTypeToLabel = channelType => {
|
||||
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 = (
|
||||
attachedFiles,
|
||||
directUploadsEnabled
|
||||
@@ -116,3 +130,57 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
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 ContactAPI from 'dashboard/api/contacts';
|
||||
import * as helpers from '../composeConversationHelper';
|
||||
|
||||
vi.mock('dashboard/api/contacts');
|
||||
|
||||
describe('composeConversationHelper', () => {
|
||||
describe('convertChannelTypeToLabel', () => {
|
||||
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', () => {
|
||||
it('prepares direct upload files', () => {
|
||||
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>
|
||||
<button
|
||||
v-for="item in filteredMenuItems"
|
||||
:key="item.action"
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
: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="{
|
||||
'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 { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarNotificationBell from './SidebarNotificationBell.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
|
||||
const emit = defineEmits([
|
||||
'openNotificationPanel',
|
||||
'closeKeyShortcutModal',
|
||||
'openKeyShortcutModal',
|
||||
'showCreateAccountModal',
|
||||
@@ -27,7 +27,6 @@ const { accountScopedRoute } = useAccount();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
const enableNewConversation = false;
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
@@ -481,7 +480,7 @@ const menuItems = computed(() => {
|
||||
<div class="flex gap-2 px-2">
|
||||
<RouterLink
|
||||
: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-grow text-left">
|
||||
@@ -493,14 +492,17 @@ const menuItems = computed(() => {
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="enableNewConversation"
|
||||
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
|
||||
/>
|
||||
</button>
|
||||
<ComposeConversation align-position="right">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||
@@ -518,12 +520,6 @@ const menuItems = computed(() => {
|
||||
<SidebarProfileMenu
|
||||
@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>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -120,7 +120,7 @@ const allowedMenuItems = computed(() => {
|
||||
</div>
|
||||
</button>
|
||||
</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 />
|
||||
<DropdownSeparator />
|
||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||
|
||||
@@ -216,9 +216,11 @@ const handleBlur = e => emit('blur', e);
|
||||
@click.stop="removeTag(index)"
|
||||
/>
|
||||
</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
|
||||
v-if="showInput"
|
||||
ref="tagInputRef"
|
||||
v-model="newTag"
|
||||
:placeholder="placeholder"
|
||||
|
||||
@@ -3,10 +3,12 @@ import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const contacts = accountId => ({
|
||||
parentNav: 'contacts',
|
||||
routes: [
|
||||
'contacts_dashboard',
|
||||
'contacts_dashboard_index',
|
||||
'contacts_dashboard_segments_index',
|
||||
'contacts_dashboard_labels_index',
|
||||
'contacts_edit',
|
||||
'contacts_segments_dashboard',
|
||||
'contacts_labels_dashboard',
|
||||
'contacts_edit_segment',
|
||||
'contacts_edit_label',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
@@ -14,7 +16,7 @@ const contacts = accountId => ({
|
||||
label: 'ALL_CONTACTS',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/contacts`),
|
||||
toStateName: 'contacts_dashboard',
|
||||
toStateName: 'contacts_dashboard_index',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ const primaryMenuItems = accountId => [
|
||||
label: 'CONTACTS',
|
||||
featureFlag: FEATURE_FLAGS.CRM,
|
||||
toState: frontendURL(`accounts/${accountId}/contacts`),
|
||||
toStateName: 'contacts_dashboard',
|
||||
toStateName: 'contacts_dashboard_index',
|
||||
},
|
||||
{
|
||||
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": {
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"EMPTY_STATE": "No results found."
|
||||
"EMPTY_STATE": "No results found.",
|
||||
"SEARCHING": "Searching..."
|
||||
},
|
||||
"DIALOG": {
|
||||
"BUTTONS": {
|
||||
|
||||
@@ -393,6 +393,7 @@
|
||||
"SEARCH_TITLE": "Search contacts",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message",
|
||||
"SEND_MESSAGE": "Send message",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "Contacts"
|
||||
},
|
||||
@@ -666,7 +667,7 @@
|
||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||
"CONTACT_SELECTOR": {
|
||||
"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..."
|
||||
},
|
||||
"INBOX_SELECTOR": {
|
||||
@@ -677,9 +678,9 @@
|
||||
"SUBJECT_LABEL": "Subject :",
|
||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||
"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_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"
|
||||
},
|
||||
"MESSAGE_EDITOR": {
|
||||
|
||||
@@ -178,7 +178,6 @@ export default {
|
||||
<NextSidebar
|
||||
v-if="showNextSidebar"
|
||||
@toggle-account-modal="toggleAccountModal"
|
||||
@open-notification-panel="openNotificationPanel"
|
||||
@open-key-shortcut-modal="toggleKeyShortcutModal"
|
||||
@close-key-shortcut-modal="closeKeyShortcutModal"
|
||||
@show-create-account-modal="openCreateAccountModal"
|
||||
|
||||
@@ -63,6 +63,10 @@ const goToContactsList = () => {
|
||||
const fetchActiveContact = async () => {
|
||||
if (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"
|
||||
>
|
||||
<ContactsDetailsLayout
|
||||
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
|
||||
:button-label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||
:selected-contact="selectedContact"
|
||||
is-detail-view
|
||||
:show-pagination-footer="false"
|
||||
|
||||
@@ -138,7 +138,7 @@ export default {
|
||||
},
|
||||
selectedInbox: {
|
||||
get() {
|
||||
const inboxList = this.contact.contactableInboxes || [];
|
||||
const inboxList = this.contact.contact_inboxes || [];
|
||||
return (
|
||||
inboxList.find(inbox => {
|
||||
return inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id;
|
||||
@@ -152,7 +152,7 @@ export default {
|
||||
},
|
||||
},
|
||||
showNoInboxAlert() {
|
||||
if (!this.contact.contactableInboxes) {
|
||||
if (!this.contact.contact_inboxes) {
|
||||
return false;
|
||||
}
|
||||
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
|
||||
@@ -166,7 +166,7 @@ export default {
|
||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||
},
|
||||
inboxes() {
|
||||
const inboxList = this.contact.contactableInboxes || [];
|
||||
const inboxList = this.contact.contact_inboxes || [];
|
||||
return inboxList.map(inbox => ({
|
||||
...inbox.inbox,
|
||||
sourceId: inbox.source_id,
|
||||
|
||||
@@ -205,8 +205,8 @@ export const actions = {
|
||||
try {
|
||||
const response = await ContactAPI.getContactableInboxes(id);
|
||||
const contact = {
|
||||
id,
|
||||
contactableInboxes: response.data.payload,
|
||||
id: Number(id),
|
||||
contact_inboxes: response.data.payload,
|
||||
};
|
||||
commit(types.SET_CONTACT_ITEM, contact);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user