feat: Attachment preview
This commit is contained in:
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createNewContact,
|
||||
@@ -115,6 +116,17 @@ watch(
|
||||
onMounted(() => {
|
||||
onContactSearch('');
|
||||
});
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
if (showComposeNewConversation.value) {
|
||||
showComposeNewConversation.value = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+14
-10
@@ -5,12 +5,17 @@ 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 { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachedFiles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isWhatsappInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -59,7 +64,6 @@ const { t } = useI18n();
|
||||
|
||||
const uploadAttachment = ref(null);
|
||||
const isEmojiPickerOpen = ref(false);
|
||||
const attachedFiles = ref([]);
|
||||
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
@@ -112,12 +116,17 @@ const { onFileUpload } = useFileUpload({
|
||||
thumb: reader.result,
|
||||
blobSignedId: blob?.signed_id,
|
||||
};
|
||||
|
||||
attachedFiles.value = [...attachedFiles.value, newFile];
|
||||
emit('attachFile', attachedFiles.value);
|
||||
emit('attachFile', [...props.attachedFiles, newFile]);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const keyboardEvents = {
|
||||
Enter: {
|
||||
action: () => !props.isWhatsappInbox && emit('sendMessage'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -167,12 +176,7 @@ const { onFileUpload } = useFileUpload({
|
||||
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"
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { fileNameWithEllipsis } from 'shared/helpers/FileHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:attachments']);
|
||||
|
||||
const isTypeImage = file => {
|
||||
const type = file.content_type || file.type;
|
||||
return type.includes('image');
|
||||
};
|
||||
|
||||
const filteredImageAttachments = computed(() => {
|
||||
return props.attachments.filter(attachment =>
|
||||
isTypeImage(attachment.resource)
|
||||
);
|
||||
});
|
||||
|
||||
const filteredNonImageAttachments = computed(() => {
|
||||
return props.attachments.filter(
|
||||
attachment => !isTypeImage(attachment.resource)
|
||||
);
|
||||
});
|
||||
|
||||
const removeAttachment = id => {
|
||||
const updatedAttachments = props.attachments.filter(
|
||||
attachment => attachment.resource.id !== id
|
||||
);
|
||||
emit('update:attachments', updatedAttachments);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div
|
||||
v-if="filteredImageAttachments.length > 0"
|
||||
class="flex flex-wrap gap-3"
|
||||
>
|
||||
<div
|
||||
v-for="attachment in filteredImageAttachments"
|
||||
:key="attachment.id"
|
||||
class="relative group/image w-[72px] h-[72px]"
|
||||
>
|
||||
<img
|
||||
class="object-cover w-[72px] h-[72px] rounded-lg"
|
||||
:src="attachment.thumb"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
|
||||
@click="removeAttachment(attachment.resource.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredNonImageAttachments.length > 0"
|
||||
class="flex flex-wrap gap-3"
|
||||
>
|
||||
<div
|
||||
v-for="attachment in filteredNonImageAttachments"
|
||||
:key="attachment.id"
|
||||
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ fileNameWithEllipsis(attachment.resource) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="shrink-0 !h-5 !w-5"
|
||||
@click="removeAttachment(attachment.resource.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+12
@@ -23,6 +23,7 @@ import EmailOptions from './EmailOptions.vue';
|
||||
import MessageEditor from './MessageEditor.vue';
|
||||
import ActionButtons from './ActionButtons.vue';
|
||||
import InboxEmptyState from './InboxEmptyState.vue';
|
||||
import AttachmentPreviews from './AttachmentPreviews.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: {
|
||||
@@ -203,15 +204,18 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
emit('updateTargetInbox', { ...rest });
|
||||
showInboxesDropdown.value = false;
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -345,9 +349,17 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
v-model="state.message"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
|
||||
+10
-2
@@ -17,6 +17,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasAttachments: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -25,7 +29,11 @@ const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isEmailOrWebWidgetInbox" class="flex-1 h-full min-h-[200px]">
|
||||
<div
|
||||
v-if="isEmailOrWebWidgetInbox"
|
||||
class="flex-1 h-full"
|
||||
:class="!hasAttachments && 'min-h-[200px]'"
|
||||
>
|
||||
<Editor
|
||||
:model-value="modelValue"
|
||||
:placeholder="
|
||||
@@ -41,7 +49,7 @@ const { t } = useI18n();
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex-1 h-full min-h-[200px]">
|
||||
<div v-else class="flex-1 h-full" :class="!hasAttachments && 'min-h-[200px]'">
|
||||
<TextArea
|
||||
:model-value="modelValue"
|
||||
:placeholder="
|
||||
|
||||
@@ -19,3 +19,19 @@ export const checkFileSizeLimit = (file, maximumUploadLimit) => {
|
||||
const fileSizeInMB = fileSizeInMegaBytes(fileSize);
|
||||
return fileSizeInMB <= maximumUploadLimit;
|
||||
};
|
||||
|
||||
export const fileNameWithEllipsis = (file, maxLength = 26, ellipsis = '…') => {
|
||||
const fullName = file?.filename ?? file?.name ?? 'Untitled';
|
||||
|
||||
const dotIndex = fullName.lastIndexOf('.');
|
||||
if (dotIndex === -1) return fullName;
|
||||
|
||||
const [name, extension] = [
|
||||
fullName.slice(0, dotIndex),
|
||||
fullName.slice(dotIndex),
|
||||
];
|
||||
|
||||
if (name.length <= maxLength) return fullName;
|
||||
|
||||
return `${name.slice(0, maxLength)}${ellipsis}${extension}`;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
formatBytes,
|
||||
fileSizeInMegaBytes,
|
||||
checkFileSizeLimit,
|
||||
fileNameWithEllipsis,
|
||||
} from '../FileHelper';
|
||||
|
||||
describe('#File Helpers', () => {
|
||||
@@ -35,4 +36,51 @@ describe('#File Helpers', () => {
|
||||
expect(checkFileSizeLimit({ file: { size: 199154 } }, 40)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileNameWithEllipsis', () => {
|
||||
it('should return original filename if name length is within limit', () => {
|
||||
const file = { name: 'document.pdf' };
|
||||
expect(fileNameWithEllipsis(file)).toBe('document.pdf');
|
||||
});
|
||||
|
||||
it('should truncate filename if it exceeds max length', () => {
|
||||
const file = { name: 'very-long-filename-that-needs-truncating.pdf' };
|
||||
expect(fileNameWithEllipsis(file)).toBe(
|
||||
'very-long-filename-that-ne….pdf'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle files without extension', () => {
|
||||
const file = { name: 'README' };
|
||||
expect(fileNameWithEllipsis(file)).toBe('README');
|
||||
});
|
||||
|
||||
it('should handle files with multiple dots', () => {
|
||||
const file = { name: 'archive.tar.gz' };
|
||||
expect(fileNameWithEllipsis(file)).toBe('archive.tar.gz');
|
||||
});
|
||||
|
||||
it('should handle hidden files', () => {
|
||||
const file = { name: '.gitignore' };
|
||||
expect(fileNameWithEllipsis(file)).toBe('.gitignore');
|
||||
});
|
||||
|
||||
it('should handle both filename and name properties', () => {
|
||||
const file = {
|
||||
filename: 'from-filename.pdf',
|
||||
name: 'from-name.pdf',
|
||||
};
|
||||
expect(fileNameWithEllipsis(file)).toBe('from-filename.pdf');
|
||||
});
|
||||
|
||||
it('should handle special characters', () => {
|
||||
const file = { name: 'résumé-2023_final-version.doc' };
|
||||
expect(fileNameWithEllipsis(file)).toBe('résumé-2023_final-version.doc');
|
||||
});
|
||||
|
||||
it('should handle very short filenames', () => {
|
||||
const file = { name: 'a.txt' };
|
||||
expect(fileNameWithEllipsis(file)).toBe('a.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user