From 35bef21f83dfcaf31239c454013af311e7bef41d Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 15:42:11 +0530
Subject: [PATCH] feat: add media view for contacts (#14393)
---
app/javascript/dashboard/api/contacts.js | 6 +
.../Contacts/ContactsDetailsLayout.vue | 18 +-
.../ContactCustomAttributes.vue | 2 +-
.../ContactsSidebar/ContactHistory.vue | 2 +-
.../Contacts/ContactsSidebar/ContactMedia.vue | 108 +++++
.../Contacts/ContactsSidebar/ContactMerge.vue | 2 +-
.../Contacts/ContactsSidebar/ContactNotes.vue | 2 +-
.../SharedAttachments/Files.vue | 175 ++++++++
.../SharedAttachments/Media.vue | 305 +++++++++++++
.../components-next/message/constants.js | 6 +
.../dashboard/i18n/locale/en/contact.json | 1 +
.../i18n/locale/en/conversation.json | 3 +-
.../contacts/pages/ContactManageView.vue | 9 +-
.../dashboard/conversation/SharedFiles.vue | 399 ++----------------
.../store/modules/contacts/actions.js | 17 +-
.../store/modules/contacts/getters.js | 1 +
.../store/modules/contacts/mutations.js | 10 +-
.../modules/specs/contacts/actions.spec.js | 44 ++
.../modules/specs/contacts/getters.spec.js | 18 +
.../modules/specs/contacts/mutations.spec.js | 44 ++
.../dashboard/store/mutation-types.js | 1 +
21 files changed, 788 insertions(+), 385 deletions(-)
create mode 100644 app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue
create mode 100644 app/javascript/dashboard/components-next/SharedAttachments/Files.vue
create mode 100644 app/javascript/dashboard/components-next/SharedAttachments/Media.vue
diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js
index c39a4cf9d..0b32c0bc2 100644
--- a/app/javascript/dashboard/api/contacts.js
+++ b/app/javascript/dashboard/api/contacts.js
@@ -40,6 +40,12 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
+ getAttachments(contactId, page = 1) {
+ return axios.get(`${this.url}/${contactId}/attachments`, {
+ params: { page },
+ });
+ }
+
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
index 351cc7071..44fafb6c5 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
@@ -128,9 +128,14 @@ const closeMobileSidebar = () => {
@@ -179,9 +184,14 @@ const closeMobileSidebar = () => {
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
index 039d2c709..1a9246e27 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
-
+
+import { computed, onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRoute, useRouter } from 'vue-router';
+import { useStore, useMapGetter } from 'dashboard/composables/store';
+import {
+ MEDIA_TYPES,
+ NON_FILE_TYPES,
+} from 'dashboard/components-next/message/constants';
+
+import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
+import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
+import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
+import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
+
+const MEDIA_PEEK_LIMIT = 12;
+const FILES_PEEK_LIMIT = 6;
+
+const route = useRoute();
+const router = useRouter();
+const store = useStore();
+const { t } = useI18n();
+
+const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
+const uiFlags = useMapGetter('contacts/getUIFlags');
+
+const attachments = computed(() =>
+ attachmentsByContact.value(route.params.contactId)
+);
+const isFetching = computed(() => uiFlags.value.isFetchingAttachments);
+
+const hasContent = computed(() =>
+ attachments.value.some(
+ a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
+ )
+);
+
+const mediaAttachments = computed(() =>
+ attachments.value
+ .filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
+ .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
+);
+
+const showGallery = ref(false);
+const selectedAttachment = ref(null);
+
+const onMediaSelect = attachment => {
+ selectedAttachment.value = attachment;
+ showGallery.value = true;
+};
+
+const onFileSelect = attachment => {
+ if (attachment.data_url) {
+ window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
+ }
+};
+
+const onJumpToMessage = attachment => {
+ if (!attachment.conversation_id || !attachment.message_id) return;
+ router.push({
+ name: 'inbox_conversation',
+ params: {
+ accountId: route.params.accountId,
+ conversation_id: attachment.conversation_id,
+ },
+ query: { messageId: attachment.message_id },
+ });
+};
+
+onMounted(() => {
+ store.dispatch('contacts/fetchAttachments', route.params.contactId);
+});
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue
index d569ae900..1d019ff80 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue
@@ -103,7 +103,7 @@ const onMergeContacts = async () => {
-
+
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue
index 789aba1c8..218a1293f 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue
@@ -55,7 +55,7 @@ useKeyboardEvents(keyboardEvents);
-
+
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useAlert } from 'dashboard/composables';
+import { formatBytes } from 'shared/helpers/FileHelper';
+import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
+import { downloadFile } from '@chatwoot/utils';
+import {
+ MEDIA_TYPES,
+ NON_FILE_TYPES,
+} from 'dashboard/components-next/message/constants';
+
+import FileIcon from 'next/icon/FileIcon.vue';
+import NextButton from 'dashboard/components-next/button/Button.vue';
+
+const props = defineProps({
+ attachments: { type: Array, default: () => [] },
+ peekLimit: { type: Number, default: 0 },
+ showJumpToMessage: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['select', 'jumpToMessage']);
+
+const { t } = useI18n();
+
+const fileAttachments = computed(() =>
+ [...props.attachments]
+ .filter(
+ a =>
+ a.data_url &&
+ !MEDIA_TYPES.includes(a.file_type) &&
+ !NON_FILE_TYPES.includes(a.file_type)
+ )
+ .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
+);
+
+const showAll = ref(false);
+const downloadingId = ref(null);
+
+const isPeekable = computed(() => props.peekLimit > 0);
+
+const visibleFiles = computed(() => {
+ if (!isPeekable.value || showAll.value) return fileAttachments.value;
+ return fileAttachments.value.slice(0, props.peekLimit);
+});
+
+const fileNameFromUrl = url => {
+ if (!url) return '';
+ const name = url.split('/').pop();
+ return name ? decodeURIComponent(name) : '';
+};
+
+const displayName = attachment =>
+ fileNameFromUrl(attachment.data_url) ||
+ t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
+
+const displaySize = attachment => {
+ if (attachment.file_size) return formatBytes(attachment.file_size);
+ if (attachment.extension) return attachment.extension.toUpperCase();
+ return '—';
+};
+
+const displayTime = attachment => {
+ if (!attachment.created_at) return '';
+ return shortTimestamp(dynamicTime(attachment.created_at), true);
+};
+
+const onActivate = attachment => emit('select', attachment);
+
+const onDownloadFile = async attachment => {
+ const { id, file_type: type, data_url: url, extension } = attachment;
+ try {
+ downloadingId.value = id;
+ await downloadFile({ url, type, extension });
+ } catch (error) {
+ useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
+ } finally {
+ downloadingId.value = null;
+ }
+};
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
+
+ {{ fileAttachments.length }}
+
+
+
+
+
+ -
+
+
+
+
+
+ {{ displayName(attachment) }}
+
+
+ {{ displaySize(attachment) }}
+
+ · {{ displayTime(attachment) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/SharedAttachments/Media.vue b/app/javascript/dashboard/components-next/SharedAttachments/Media.vue
new file mode 100644
index 000000000..6a3875583
--- /dev/null
+++ b/app/javascript/dashboard/components-next/SharedAttachments/Media.vue
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
+
+ {{ mediaAttachments.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayDuration(attachment) }}
+
+
+
+ {{ displayTime(attachment) }}
+
+
+
+
+
+
+
+
+
+ {{
+ t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
+ count: overflow,
+ })
+ }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js
index 4f8b4f23c..517c083a1 100644
--- a/app/javascript/dashboard/components-next/message/constants.js
+++ b/app/javascript/dashboard/components-next/message/constants.js
@@ -78,6 +78,12 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.IG_REEL,
];
+export const NON_FILE_TYPES = [
+ ATTACHMENT_TYPES.LOCATION,
+ ATTACHMENT_TYPES.FALLBACK,
+ ATTACHMENT_TYPES.CONTACT,
+];
+
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json
index 8d0b73dfc..1a10b253f 100644
--- a/app/javascript/dashboard/i18n/locale/en/contact.json
+++ b/app/javascript/dashboard/i18n/locale/en/contact.json
@@ -511,6 +511,7 @@
"ATTRIBUTES": "Attributes",
"HISTORY": "History",
"NOTES": "Notes",
+ "MEDIA": "Media",
"MERGE": "Merge"
},
"HISTORY": {
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 3eb83fd31..045b8d0d9 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -401,7 +401,8 @@
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
- "UNTITLED_FILE": "Untitled file"
+ "UNTITLED_FILE": "Untitled file",
+ "JUMP_TO_MESSAGE": "Jump to message"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue
index fe23e23d3..05142bb82 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue
+++ b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue
@@ -11,6 +11,7 @@ import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDeta
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
+import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
@@ -40,6 +41,7 @@ const CONTACT_TABS_OPTIONS = [
{ key: 'ATTRIBUTES', value: 'attributes' },
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
+ { key: 'MEDIA', value: 'media' },
{ key: 'MERGE', value: 'merge' },
];
@@ -149,8 +151,8 @@ onMounted(() => {
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
-
-
+
+
{
@tab-changed="handleTabChange"
/>
+
+
{
/>
+
- [...allAttachments.value].sort(
- (a, b) => (b.created_at || 0) - (a.created_at || 0)
- )
-);
+const { t } = useI18n();
const mediaAttachments = computed(() =>
- sortedAttachments.value.filter(a => MEDIA_TYPES.includes(a.file_type))
+ allAttachments.value
+ .filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
+ .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
-const fileAttachments = computed(() =>
- sortedAttachments.value.filter(
- a => !MEDIA_TYPES.includes(a.file_type) && a.data_url
+const hasContent = computed(() =>
+ allAttachments.value.some(
+ a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
)
);
-const showAllMedia = ref(false);
-const showAllFiles = ref(false);
-
-const visibleMedia = computed(() =>
- showAllMedia.value
- ? mediaAttachments.value
- : mediaAttachments.value.slice(0, MEDIA_PEEK_LIMIT)
-);
-
-const visibleFiles = computed(() =>
- showAllFiles.value
- ? fileAttachments.value
- : fileAttachments.value.slice(0, FILES_PEEK_LIMIT)
-);
-
-const mediaOverflow = computed(() => {
- const total = mediaAttachments.value.length;
- return total > MEDIA_PEEK_LIMIT ? total - (MEDIA_PEEK_LIMIT - 1) : 0;
-});
-
const showGallery = ref(false);
const selectedAttachment = ref(null);
-const downloadingId = ref(null);
-const fileNameFromUrl = url => {
- if (!url) return '';
- const name = url.split('/').pop();
- return name ? decodeURIComponent(name) : '';
-};
-
-const onDownloadFile = async attachment => {
- const { id, file_type: type, data_url: url, extension } = attachment;
- try {
- downloadingId.value = id;
- await downloadFile({ url, type, extension });
- } catch (error) {
- useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
- } finally {
- downloadingId.value = null;
- }
-};
-
-const isVideoType = type =>
- [ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
-
-const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
-const isPlayableType = type => isVideoType(type) || isAudioType(type);
-
-const durations = ref({});
-
-const onLoadedMetadata = (attachment, event) => {
- const seconds = event.target?.duration;
- if (Number.isFinite(seconds) && seconds > 0) {
- durations.value[attachment.id] = seconds;
- }
-};
-
-const displayDuration = attachment => {
- const seconds = durations.value[attachment.id];
- return seconds ? formatDuration(Math.round(seconds)) : '';
-};
-
-const isOverflowTile = index =>
- !showAllMedia.value &&
- mediaOverflow.value > 0 &&
- index === MEDIA_PEEK_LIMIT - 1;
-
-const onTileActivate = (attachment, index) => {
- if (isOverflowTile(index)) {
- showAllMedia.value = true;
- return;
- }
+const onMediaSelect = attachment => {
selectedAttachment.value = attachment;
showGallery.value = true;
};
-const failedThumbs = ref(new Set());
-const failedPreviews = ref(new Set());
-
-const imagePreviewSrc = ({
- id,
- file_type: type,
- thumb_url: thumbUrl,
- data_url: dataUrl,
-}) => {
- const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
- if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
- if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
- return null;
-};
-
-const onPreviewError = ({
- id,
- file_type: type,
- thumb_url: thumbUrl,
- data_url: dataUrl,
-}) => {
- const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
- if (
- canRetryWithFull &&
- (type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
- ) {
- failedThumbs.value.add(id);
- return;
+const onFileSelect = attachment => {
+ if (attachment.data_url) {
+ window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
}
- failedPreviews.value.add(id);
-};
-
-const hasPreview = attachment =>
- !!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
-const hasVideoPreview = attachment =>
- isVideoType(attachment.file_type) &&
- attachment.data_url &&
- !failedPreviews.value.has(attachment.id);
-
-const fallbackIcon = type => {
- if (type === ATTACHMENT_TYPES.AUDIO) return 'i-lucide-music';
- if (isVideoType(type)) return 'i-lucide-video';
- return 'i-lucide-image';
-};
-
-const displayName = attachment =>
- fileNameFromUrl(attachment.data_url) ||
- t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
-
-const displaySize = attachment => {
- if (attachment.file_size) return formatBytes(attachment.file_size);
- if (attachment.extension) return attachment.extension.toUpperCase();
- return '—';
-};
-
-const displayTime = attachment => {
- if (!attachment.created_at) return '';
- return shortTimestamp(dynamicTime(attachment.created_at), true);
};
-
+
-
+
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
-
-
-
-
- {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
-
- {{ mediaAttachments.length }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ displayDuration(attachment) }}
-
-
-
- {{ displayTime(attachment) }}
-
-
-
-
-
-
-
- {{
- t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
- count: mediaOverflow,
- })
- }}
-
-
-
-
-
-
-
-
-
- {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
-
- {{ fileAttachments.length }}
-
-
-
-
-
-
-
+
+
+
+
{
const formData = new FormData();
@@ -114,6 +114,19 @@ export const actions = {
}
},
+ fetchAttachments: async ({ commit }, id) => {
+ commit(types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true });
+ try {
+ const response = await ContactAPI.getAttachments(id);
+ commit(types.SET_CONTACT_ATTACHMENTS, {
+ id,
+ data: response.data.payload,
+ });
+ } finally {
+ commit(types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false });
+ }
+ },
+
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
const { avatar, customAttributes, ...paramsToDecamelize } = contactParams;
const decamelizedContactParams = {
diff --git a/app/javascript/dashboard/store/modules/contacts/getters.js b/app/javascript/dashboard/store/modules/contacts/getters.js
index 5a75ac091..425ba9449 100644
--- a/app/javascript/dashboard/store/modules/contacts/getters.js
+++ b/app/javascript/dashboard/store/modules/contacts/getters.js
@@ -24,6 +24,7 @@ export const getters = {
stopPaths: ['custom_attributes'],
});
},
+ getContactAttachments: $state => id => $state.records[id]?.attachments || [],
getMeta: $state => {
return $state.meta;
},
diff --git a/app/javascript/dashboard/store/modules/contacts/mutations.js b/app/javascript/dashboard/store/modules/contacts/mutations.js
index 5eef7e2b3..5fbd04ed1 100644
--- a/app/javascript/dashboard/store/modules/contacts/mutations.js
+++ b/app/javascript/dashboard/store/modules/contacts/mutations.js
@@ -58,7 +58,15 @@ export const mutations = {
},
[types.EDIT_CONTACT]: ($state, data) => {
- $state.records[data.id] = data;
+ const existingAttachments = $state.records[data.id]?.attachments;
+ $state.records[data.id] = existingAttachments
+ ? { ...data, attachments: existingAttachments }
+ : data;
+ },
+
+ [types.SET_CONTACT_ATTACHMENTS]: ($state, { id, data }) => {
+ if (!$state.records[id]) $state.records[id] = {};
+ $state.records[id].attachments = data;
},
[types.DELETE_CONTACT]: ($state, id) => {
diff --git a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
index 00b7bb83b..bb2f0393b 100644
--- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
@@ -438,4 +438,48 @@ describe('#actions', () => {
]);
});
});
+
+ describe('#fetchAttachments', () => {
+ const attachments = [
+ { id: 11, message_id: 21, file_type: 'image' },
+ { id: 12, message_id: 22, file_type: 'file' },
+ ];
+
+ it('fetches and stores attachments on the contact record', async () => {
+ axios.get.mockResolvedValue({ data: { payload: attachments } });
+ const state = { records: { 1: { id: 1 } } };
+ await actions.fetchAttachments({ commit, state }, 1);
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true }],
+ [types.SET_CONTACT_ATTACHMENTS, { id: 1, data: attachments }],
+ [types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false }],
+ ]);
+ });
+
+ it('refetches even when attachments are already cached', async () => {
+ axios.get.mockResolvedValue({ data: { payload: attachments } });
+ const state = {
+ records: { 1: { id: 1, attachments: [{ id: 99 }] } },
+ };
+ await actions.fetchAttachments({ commit, state }, 1);
+ expect(axios.get).toHaveBeenCalled();
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true }],
+ [types.SET_CONTACT_ATTACHMENTS, { id: 1, data: attachments }],
+ [types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false }],
+ ]);
+ });
+
+ it('clears the loading flag and rethrows when the API errors', async () => {
+ axios.get.mockRejectedValue(new Error('Network error'));
+ const state = { records: { 1: { id: 1 } } };
+ await expect(
+ actions.fetchAttachments({ commit, state }, 1)
+ ).rejects.toThrow('Network error');
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: true }],
+ [types.SET_CONTACT_UI_FLAG, { isFetchingAttachments: false }],
+ ]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js
index 38973ec9d..260ca8f2a 100644
--- a/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js
@@ -50,4 +50,22 @@ describe('#getters', () => {
};
expect(getters.getAppliedContactFilters(state)).toEqual(filters);
});
+
+ describe('getContactAttachments', () => {
+ it('returns the attachments stored on the contact record', () => {
+ const data = [{ id: 11, file_type: 'image' }];
+ const state = { records: { 1: { id: 1, attachments: data } } };
+ expect(getters.getContactAttachments(state)(1)).toEqual(data);
+ });
+
+ it('returns an empty array when the contact has no cached attachments', () => {
+ const state = { records: { 1: { id: 1 } } };
+ expect(getters.getContactAttachments(state)(1)).toEqual([]);
+ });
+
+ it('returns an empty array when the contact is not in the store', () => {
+ const state = { records: {} };
+ expect(getters.getContactAttachments(state)(99)).toEqual([]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js
index 4eb40ff61..962cc6ea4 100644
--- a/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js
@@ -63,6 +63,21 @@ describe('#mutations', () => {
1: { id: 1, name: 'contact2', email: 'contact2@chatwoot.com' },
});
});
+
+ it('preserves a cached attachments list across edits', () => {
+ const attachments = [{ id: 11, file_type: 'image' }];
+ const state = {
+ records: {
+ 1: { id: 1, name: 'contact1', attachments },
+ },
+ };
+ mutations[types.EDIT_CONTACT](state, { id: 1, name: 'contact2' });
+ expect(state.records[1]).toEqual({
+ id: 1,
+ name: 'contact2',
+ attachments,
+ });
+ });
});
describe('#SET_CONTACT_FILTERS', () => {
@@ -102,4 +117,33 @@ describe('#mutations', () => {
expect(state.appliedFilters).toEqual([]);
});
});
+
+ describe('#SET_CONTACT_ATTACHMENTS', () => {
+ it('attaches the list to the existing contact record', () => {
+ const state = { records: { 1: { id: 1, name: 'Sivin' } } };
+ const data = [{ id: 11, file_type: 'image' }];
+ mutations[types.SET_CONTACT_ATTACHMENTS](state, { id: 1, data });
+ expect(state.records[1]).toEqual({
+ id: 1,
+ name: 'Sivin',
+ attachments: data,
+ });
+ });
+
+ it('creates a record shell when the contact is not yet loaded', () => {
+ const state = { records: {} };
+ const data = [{ id: 12, file_type: 'file' }];
+ mutations[types.SET_CONTACT_ATTACHMENTS](state, { id: 5, data });
+ expect(state.records[5]).toEqual({ attachments: data });
+ });
+
+ it('replaces an existing attachment list', () => {
+ const state = {
+ records: { 1: { id: 1, attachments: [{ id: 99 }] } },
+ };
+ const data = [{ id: 11 }];
+ mutations[types.SET_CONTACT_ATTACHMENTS](state, { id: 1, data });
+ expect(state.records[1].attachments).toEqual(data);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 1597b7ea6..059d9636e 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -139,6 +139,7 @@ export default {
SET_CONTACT_META: 'SET_CONTACT_META',
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
+ SET_CONTACT_ATTACHMENTS: 'SET_CONTACT_ATTACHMENTS',
SET_CONTACTS: 'SET_CONTACTS',
APPEND_CONTACTS: 'APPEND_CONTACTS',
CLEAR_CONTACTS: 'CLEAR_CONTACTS',