+
({
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',
},
],
});
diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js b/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js
index 5a50bd440..59de35564 100644
--- a/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js
+++ b/app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js
@@ -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',
diff --git a/app/javascript/dashboard/composables/spec/useFileUpload.spec.js b/app/javascript/dashboard/composables/spec/useFileUpload.spec.js
new file mode 100644
index 000000000..27e17dc43
--- /dev/null
+++ b/app/javascript/dashboard/composables/spec/useFileUpload.spec.js
@@ -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();
+ });
+});
diff --git a/app/javascript/dashboard/composables/useFileUpload.js b/app/javascript/dashboard/composables/useFileUpload.js
new file mode 100644
index 000000000..dd6779250
--- /dev/null
+++ b/app/javascript/dashboard/composables/useFileUpload.js
@@ -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,
+ };
+};
diff --git a/app/javascript/dashboard/i18n/locale/en/components.json b/app/javascript/dashboard/i18n/locale/en/components.json
index cafedd5fa..0bbc0c65a 100644
--- a/app/javascript/dashboard/i18n/locale/en/components.json
+++ b/app/javascript/dashboard/i18n/locale/en/components.json
@@ -12,7 +12,8 @@
},
"DROPDOWN_MENU": {
"SEARCH_PLACEHOLDER": "Search...",
- "EMPTY_STATE": "No results found."
+ "EMPTY_STATE": "No results found.",
+ "SEARCHING": "Searching..."
},
"DIALOG": {
"BUTTONS": {
diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json
index e6bfe7cbd..3826823ed 100644
--- a/app/javascript/dashboard/i18n/locale/en/contact.json
+++ b/app/javascript/dashboard/i18n/locale/en/contact.json
@@ -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": {
diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue
index 3b3199afe..97f24741f 100644
--- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue
+++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue
@@ -178,7 +178,6 @@ export default {
{
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"
>
{
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,
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js
index 80fe44beb..e8da81482 100644
--- a/app/javascript/dashboard/store/modules/contacts/actions.js
+++ b/app/javascript/dashboard/store/modules/contacts/actions.js
@@ -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) {