chore: Created file upload composable and use it in compose view

This commit is contained in:
iamsivin
2024-11-15 22:12:16 +05:30
parent b97ee9675e
commit e3602acf68
4 changed files with 362 additions and 65 deletions
@@ -85,6 +85,9 @@ const inboxTypes = computed(() => ({
isEmailOrWebWidget:
targetInbox.value?.channelType === INBOX_TYPES.EMAIL ||
targetInbox.value?.channelType === INBOX_TYPES.WEB,
isTwilioSMS:
targetInbox.value?.channelType === INBOX_TYPES.TWILIO &&
targetInbox.value?.medium === 'sms',
}));
const whatsappMessageTemplates = computed(() =>
@@ -102,7 +105,12 @@ const validationRules = computed(() => ({
subject: { required: requiredIf(inboxTypes.value.isEmail) },
}));
const v$ = useVuelidate(validationRules, state);
const v$ = useVuelidate(validationRules, {
selectedContact,
targetInbox,
message: computed(() => state.message),
subject: computed(() => state.subject),
});
const validationStates = computed(() => ({
isContactInvalid:
@@ -222,6 +230,10 @@ const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
const clearForm = () => {
Object.assign(state, {
message: '',
@@ -300,76 +312,74 @@ watch(
<template>
<div
class="divide-y divide-n-strong absolute right-0 w-[670px] mt-2 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="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"
>
<div class="flex flex-col divide-y divide-n-strong">
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxSelector
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<InboxSelector
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:show-bcc-input="showBccInput"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@toggle-bcc="toggleBccInput"
@update-dropdown="handleDropdownUpdate"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:show-bcc-input="showBccInput"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@toggle-bcc="toggleBccInput"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="!inboxTypes.isWhatsapp"
v-model="state.message"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
/>
</div>
<MessageEditor
v-if="!inboxTypes.isWhatsapp"
v-model="state.message"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
/>
<ActionButtons
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-inbox="inboxTypes.isTwilio"
:is-api-inbox="inboxTypes.isApi"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
:disable-send-button="isCreating"
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
/>
</div>
</template>
@@ -3,6 +3,9 @@ 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 { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import FileUpload from 'vue-upload-component';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
@@ -10,11 +13,15 @@ import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
isWhatsappInbox: {
type: Boolean,
required: true,
default: false,
},
isEmailOrWebWidgetInbox: {
type: Boolean,
required: true,
default: false,
},
isTwilioSmsInbox: {
type: Boolean,
default: false,
},
messageTemplates: {
type: Array,
@@ -22,7 +29,7 @@ const props = defineProps({
},
channelType: {
type: String,
required: true,
default: '',
},
isLoading: {
type: Boolean,
@@ -41,11 +48,14 @@ const emit = defineEmits([
'insertEmoji',
'addSignature',
'removeSignature',
'attachFile',
]);
const { t } = useI18n();
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const attachedFiles = ref([]);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
@@ -83,6 +93,27 @@ const toggleMessageSignature = () => {
const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
const newFile = {
resource: blob || file,
isPrivate: false,
thumb: reader.result,
blobSignedId: blob?.signed_id,
};
attachedFiles.value = [...attachedFiles.value, newFile];
emit('attachFile', attachedFiles.value);
};
},
});
</script>
<template>
@@ -113,13 +144,32 @@ const onClickInsertEmoji = emoji => {
:on-click="onClickInsertEmoji"
/>
</div>
<Button
<FileUpload
v-if="isEmailOrWebWidgetInbox"
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10"
/>
ref="uploadAttachment"
input-id="composeNewConversationAttachment"
:size="4096 * 4096"
:accept="ALLOWED_FILE_TYPES"
multiple
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
@input-file="onFileUpload"
>
<Button
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10 relative"
>
<span
v-if="attachedFiles.length > 0"
class="absolute top-0 right-0 rounded-full size-2.5 bg-n-brand"
/>
</Button>
</FileUpload>
<Button
v-if="isEmailOrWebWidgetInbox"
icon="i-lucide-type"
@@ -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();
});
});
@@ -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,
};
};