Merge branch 'develop' into fix/CW-6944
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
@@ -18,7 +20,13 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isBeta: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,9 +45,18 @@ defineProps({
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<Label
|
||||
v-if="isBeta && !isComingSoon"
|
||||
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
|
||||
:label="t('GENERAL.BETA')"
|
||||
color="blue"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-start text-sm">
|
||||
{{ description }}
|
||||
</p>
|
||||
@@ -50,7 +67,7 @@ defineProps({
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
|
||||
>
|
||||
<span class="text-n-slate-12 font-medium text-sm">
|
||||
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||
{{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -77,6 +77,10 @@ const isComingSoon = computed(() => {
|
||||
return ['voice'].includes(key) && !isActive.value;
|
||||
});
|
||||
|
||||
const isBeta = computed(() => {
|
||||
return ['tiktok', 'voice'].includes(props.channel.key);
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
if (isActive.value) {
|
||||
emit('channelItemClick', props.channel.key);
|
||||
@@ -90,6 +94,7 @@ const onItemClick = () => {
|
||||
:description="channel.description"
|
||||
:icon="channel.icon"
|
||||
:is-coming-soon="isComingSoon"
|
||||
:is-beta="isBeta"
|
||||
:disabled="!isActive"
|
||||
@click="onItemClick"
|
||||
/>
|
||||
|
||||
@@ -273,6 +273,10 @@ export default {
|
||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||
},
|
||||
showFileUpload() {
|
||||
const { image_send: imageSend } =
|
||||
this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
|
||||
const tiktokAttachmentSupported = imageSend ?? true;
|
||||
|
||||
return (
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isAFacebookInbox ||
|
||||
@@ -283,7 +287,7 @@ export default {
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAnInstagramChannel ||
|
||||
this.isATiktokChannel
|
||||
(this.isATiktokChannel && tiktokAttachmentSupported)
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -706,6 +710,7 @@ export default {
|
||||
|
||||
// Don't handle paste if editor is disabled
|
||||
if (this.isEditorDisabled) return;
|
||||
if (!this.showFileUpload && !this.isOnPrivateNote) return;
|
||||
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(e.clipboardData.files)
|
||||
@@ -1025,6 +1030,8 @@ export default {
|
||||
});
|
||||
},
|
||||
attachFile({ blob, file }) {
|
||||
if (!this.showFileUpload && !this.isOnPrivateNote) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
reader.onloadend = () => {
|
||||
|
||||
@@ -22,6 +22,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
autoPlay: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
@@ -309,6 +313,7 @@ onMounted(() => {
|
||||
:src="activeAttachment.data_url"
|
||||
controls
|
||||
playsInline
|
||||
:autoplay="autoPlay"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
@click.stop
|
||||
/>
|
||||
@@ -317,6 +322,7 @@ onMounted(() => {
|
||||
v-if="isAudio"
|
||||
:key="activeAttachment.message_id"
|
||||
controls
|
||||
:autoplay="autoPlay"
|
||||
class="w-full max-w-md"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'conversation_info' },
|
||||
{ name: 'contact_attributes' },
|
||||
{ name: 'contact_notes' },
|
||||
{ name: 'shared_files' },
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'linear_issues' },
|
||||
|
||||
@@ -365,7 +365,19 @@
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"MACROS": "Macros",
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
"SHOPIFY_ORDERS": "Shopify Orders",
|
||||
"SHARED_FILES": "Attachments"
|
||||
},
|
||||
"SHARED_FILES": {
|
||||
"EMPTY": "No attachments yet",
|
||||
"DOWNLOAD": "Download file",
|
||||
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
|
||||
"MEDIA_HEADING": "Media",
|
||||
"FILES_HEADING": "Files",
|
||||
"VIEW_ALL": "View all",
|
||||
"SHOW_LESS": "Show less",
|
||||
"MORE_COUNT": "+{count}",
|
||||
"UNTITLED_FILE": "Untitled file"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
|
||||
}
|
||||
},
|
||||
"VIEW": {
|
||||
"TOOLTIP": "View macro"
|
||||
},
|
||||
"EDIT": {
|
||||
"TOOLTIP": "Edit macro",
|
||||
"API": {
|
||||
@@ -66,7 +69,9 @@
|
||||
"LABEL": "Macro Visibility",
|
||||
"GLOBAL": {
|
||||
"LABEL": "Public",
|
||||
"DESCRIPTION": "This macro is available publicly for all agents in this account."
|
||||
"DESCRIPTION": "This macro is available publicly for all agents in this account.",
|
||||
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
|
||||
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
|
||||
},
|
||||
"PERSONAL": {
|
||||
"LABEL": "Private",
|
||||
|
||||
@@ -17,6 +17,7 @@ import ContactInfo from './contact/ContactInfo.vue';
|
||||
import ContactNotes from './contact/ContactNotes.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import SharedFiles from './SharedFiles.vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import MacrosList from './Macros/List.vue';
|
||||
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
|
||||
@@ -297,6 +298,18 @@ onMounted(() => {
|
||||
<ContactNotes :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'shared_files'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHARED_FILES')"
|
||||
:is-open="isContactSidebarItemOpen('is_shared_files_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_shared_files_open', value)
|
||||
"
|
||||
>
|
||||
<SharedFiles />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||
import {
|
||||
dynamicTime,
|
||||
formatDuration,
|
||||
shortTimestamp,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import {
|
||||
ATTACHMENT_TYPES,
|
||||
MEDIA_TYPES,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const MEDIA_PEEK_LIMIT = 6;
|
||||
const FILES_PEEK_LIMIT = 3;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const allAttachments = useMapGetter('getSelectedChatAttachments');
|
||||
const attachmentsLoaded = useMapGetter('getSelectedChatAttachmentsLoaded');
|
||||
|
||||
const sortedAttachments = computed(() =>
|
||||
[...allAttachments.value].sort(
|
||||
(a, b) => (b.created_at || 0) - (a.created_at || 0)
|
||||
)
|
||||
);
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
sortedAttachments.value.filter(a => MEDIA_TYPES.includes(a.file_type))
|
||||
);
|
||||
|
||||
const fileAttachments = computed(() =>
|
||||
sortedAttachments.value.filter(
|
||||
a => !MEDIA_TYPES.includes(a.file_type) && a.data_url
|
||||
)
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 p-2">
|
||||
<div v-if="!attachmentsLoaded" class="flex justify-center p-3">
|
||||
<Spinner class="size-5" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="!mediaAttachments.length && !fileAttachments.length"
|
||||
class="p-3 text-sm text-center text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
||||
</p>
|
||||
|
||||
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ mediaAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="mediaOverflow > 0"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="
|
||||
showAllMedia ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
|
||||
"
|
||||
:label="
|
||||
showAllMedia
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAllMedia = !showAllMedia"
|
||||
/>
|
||||
</header>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="(attachment, index) in visibleMedia"
|
||||
:key="attachment.id"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
|
||||
@click="onTileActivate(attachment, index)"
|
||||
@keydown.enter="onTileActivate(attachment, index)"
|
||||
@keydown.space.prevent="onTileActivate(attachment, index)"
|
||||
>
|
||||
<template v-if="!isOverflowTile(index)">
|
||||
<img
|
||||
v-if="hasPreview(attachment)"
|
||||
:src="imagePreviewSrc(attachment)"
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
:alt="fileNameFromUrl(attachment.data_url)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<video
|
||||
v-else-if="hasVideoPreview(attachment)"
|
||||
:src="`${attachment.data_url}#t=0.1`"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
@error="onPreviewError(attachment)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="fallbackIcon(attachment.file_type)"
|
||||
class="size-6 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
v-if="isAudioType(attachment.file_type) && attachment.data_url"
|
||||
:src="attachment.data_url"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
@loadedmetadata="onLoadedMetadata(attachment, $event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasVideoPreview(attachment)"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-play"
|
||||
class="ms-0.5 size-3.5 text-n-black"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="
|
||||
isPlayableType(attachment.file_type) &&
|
||||
displayDuration(attachment)
|
||||
"
|
||||
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
|
||||
>
|
||||
{{ displayDuration(attachment) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="displayTime(attachment)"
|
||||
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
|
||||
>
|
||||
{{ displayTime(attachment) }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
|
||||
:disabled="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click.stop="onDownloadFile(attachment)"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isOverflowTile(index)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
|
||||
>
|
||||
<span class="text-base font-semibold text-n-slate-12">
|
||||
{{
|
||||
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
|
||||
count: mediaOverflow,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
|
||||
<header class="flex items-center justify-between px-0.5">
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
|
||||
>
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
|
||||
<span
|
||||
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
|
||||
>
|
||||
{{ fileAttachments.length }}
|
||||
</span>
|
||||
</h4>
|
||||
<NextButton
|
||||
v-if="fileAttachments.length > FILES_PEEK_LIMIT"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
trailing-icon
|
||||
:icon="
|
||||
showAllFiles ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
|
||||
"
|
||||
:label="
|
||||
showAllFiles
|
||||
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
|
||||
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
|
||||
"
|
||||
@click="showAllFiles = !showAllFiles"
|
||||
/>
|
||||
</header>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
<li
|
||||
v-for="attachment in visibleFiles"
|
||||
:key="attachment.id"
|
||||
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg hover:bg-n-slate-3 group"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
|
||||
>
|
||||
<FileIcon
|
||||
:file-type="attachment.extension?.toLowerCase() || ''"
|
||||
class="size-4 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
:href="attachment.data_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex-1 min-w-0"
|
||||
:title="displayName(attachment)"
|
||||
>
|
||||
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
|
||||
{{ displayName(attachment) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ displaySize(attachment) }}
|
||||
<template v-if="displayTime(attachment)">
|
||||
· {{ displayTime(attachment) }}
|
||||
</template>
|
||||
</p>
|
||||
</a>
|
||||
<NextButton
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-download"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:is-loading="downloadingId === attachment.id"
|
||||
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
|
||||
@click="onDownloadFile(attachment)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<GalleryView
|
||||
v-if="showGallery && selectedAttachment"
|
||||
v-model:show="showGallery"
|
||||
:attachment="selectedAttachment"
|
||||
:all-attachments="mediaAttachments"
|
||||
auto-play
|
||||
@close="showGallery = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,10 +9,12 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { BaseTable } from 'dashboard/components-next/table';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const showDeleteConfirmationPopup = ref(false);
|
||||
const selectedMacro = ref({});
|
||||
@@ -109,6 +111,7 @@ const tableHeaders = computed(() => {
|
||||
v-for="macro in items"
|
||||
:key="macro.id"
|
||||
:macro="macro"
|
||||
:can-manage-public-macros="isAdmin"
|
||||
@delete="openDeletePopup(macro)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MACRO_ACTION_TYPES } from './constants';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
|
||||
import { useMacros } from 'dashboard/composables/useMacros';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
@@ -18,6 +19,7 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const macro = ref(null);
|
||||
const mode = ref('CREATE');
|
||||
@@ -33,6 +35,9 @@ provide('macroActionTypes', macroActionTypes);
|
||||
|
||||
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
|
||||
const macroId = computed(() => route.params.macroId);
|
||||
const isPublicMacroReadOnly = computed(
|
||||
() => macro.value?.visibility === 'global' && !isAdmin.value
|
||||
);
|
||||
|
||||
const fetchDropdownData = () => {
|
||||
store.dispatch('agents/get');
|
||||
@@ -92,7 +97,7 @@ const initNewMacro = () => {
|
||||
action_params: [],
|
||||
},
|
||||
],
|
||||
visibility: 'global',
|
||||
visibility: isAdmin.value ? 'global' : 'personal',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -110,6 +115,8 @@ watch(
|
||||
);
|
||||
|
||||
const saveMacro = async macroData => {
|
||||
if (isPublicMacroReadOnly.value) return;
|
||||
|
||||
try {
|
||||
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
|
||||
const successMessage =
|
||||
@@ -136,6 +143,8 @@ const saveMacro = async macroData => {
|
||||
<MacroForm
|
||||
v-if="macro && !uiFlags.isFetchingItem"
|
||||
:macro-data="macro"
|
||||
:can-manage-public-macros="isAdmin"
|
||||
:read-only="isPublicMacroReadOnly"
|
||||
@update:macro-data="macro = $event"
|
||||
@submit="saveMacro"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,14 @@ export default {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
canManagePublicMacros: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup() {
|
||||
@@ -112,19 +120,23 @@ export default {
|
||||
<div
|
||||
class="flex-1 w-full h-full max-h-full ltr:pl-12 ltr:pr-6 rtl:pl-6 rtl:pr-12 py-4 overflow-y-auto lg:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size"
|
||||
>
|
||||
<MacroNodes
|
||||
v-model="macro.actions"
|
||||
:files="files"
|
||||
:errors="errors"
|
||||
@add-new-node="appendNode"
|
||||
@delete-node="deleteNode"
|
||||
@reset-action="resetNode"
|
||||
/>
|
||||
<div :inert="readOnly" :class="{ 'opacity-75': readOnly }">
|
||||
<MacroNodes
|
||||
v-model="macro.actions"
|
||||
:files="files"
|
||||
:errors="errors"
|
||||
@add-new-node="appendNode"
|
||||
@delete-node="deleteNode"
|
||||
@reset-action="resetNode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/3 pb-4">
|
||||
<MacroProperties
|
||||
:macro-name="macro.name"
|
||||
:macro-visibility="macro.visibility"
|
||||
:can-manage-public-macros="canManagePublicMacros"
|
||||
:read-only="readOnly"
|
||||
@update:name="updateName"
|
||||
@update:visibility="updateVisibility"
|
||||
@submit="submit"
|
||||
|
||||
@@ -17,8 +17,36 @@ export default {
|
||||
type: String,
|
||||
default: 'global',
|
||||
},
|
||||
canManagePublicMacros: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:name', 'update:visibility', 'submit'],
|
||||
computed: {
|
||||
isPublicVisibilityDisabled() {
|
||||
return !this.canManagePublicMacros;
|
||||
},
|
||||
publicVisibilityDescription() {
|
||||
if (this.readOnly) {
|
||||
return this.$t(
|
||||
'MACROS.EDITOR.VISIBILITY.GLOBAL.EDIT_DISABLED_DESCRIPTION'
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isPublicVisibilityDisabled) {
|
||||
return this.$t(
|
||||
'MACROS.EDITOR.VISIBILITY.GLOBAL.CREATE_DISABLED_DESCRIPTION'
|
||||
);
|
||||
}
|
||||
|
||||
return this.$t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isActive(key) {
|
||||
return this.macroVisibility === key
|
||||
@@ -26,9 +54,14 @@ export default {
|
||||
: 'bg-white dark:bg-n-solid-2 border-n-weak dark:border-n-strong';
|
||||
},
|
||||
onUpdateName(value) {
|
||||
if (this.readOnly) return;
|
||||
|
||||
this.$emit('update:name', value);
|
||||
},
|
||||
onUpdateVisibility(value) {
|
||||
if (this.readOnly) return;
|
||||
if (value === 'global' && this.isPublicVisibilityDisabled) return;
|
||||
|
||||
this.$emit('update:visibility', value);
|
||||
},
|
||||
},
|
||||
@@ -46,6 +79,7 @@ export default {
|
||||
:placeholder="$t('MACROS.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
:error="v$.macro.name.$error ? $t('MACROS.ADD.FORM.NAME.ERROR') : null"
|
||||
:class="{ error: v$.macro.name.$error }"
|
||||
:readonly="readOnly"
|
||||
@update:model-value="onUpdateName"
|
||||
/>
|
||||
</div>
|
||||
@@ -55,8 +89,13 @@ export default {
|
||||
</p>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<button
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
|
||||
type="button"
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start"
|
||||
:class="isActive('global')"
|
||||
:disabled="isPublicVisibilityDisabled || readOnly"
|
||||
:aria-describedby="
|
||||
isPublicVisibilityDisabled ? 'macro-public-visibility-help' : null
|
||||
"
|
||||
@click="onUpdateVisibility('global')"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
|
||||
@@ -69,13 +108,18 @@ export default {
|
||||
class="text-n-brand size-4"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-label-small">
|
||||
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
|
||||
<p
|
||||
id="macro-public-visibility-help"
|
||||
class="text-n-slate-11 text-label-small"
|
||||
>
|
||||
{{ publicVisibilityDescription }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
|
||||
type="button"
|
||||
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start"
|
||||
:class="isActive('personal')"
|
||||
:disabled="readOnly"
|
||||
@click="onUpdateVisibility('personal')"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
|
||||
@@ -111,6 +155,7 @@ export default {
|
||||
solid
|
||||
:label="$t('MACROS.HEADER_BTN_TXT_SAVE')"
|
||||
class="w-full"
|
||||
:disabled="readOnly"
|
||||
@click="$emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
canManagePublicMacros: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
defineEmits(['delete']);
|
||||
const { t } = useI18n();
|
||||
@@ -32,6 +36,14 @@ const visibilityLabel = computed(() => {
|
||||
: 'MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL';
|
||||
return t(i18nKey);
|
||||
});
|
||||
|
||||
const canManageMacro = computed(
|
||||
() => props.canManagePublicMacros || props.macro.visibility !== 'global'
|
||||
);
|
||||
|
||||
const editTooltip = computed(() =>
|
||||
canManageMacro.value ? t('MACROS.EDIT.TOOLTIP') : t('MACROS.VIEW.TOOLTIP')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,13 +97,14 @@ const visibilityLabel = computed(() => {
|
||||
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
|
||||
v-tooltip.top="editTooltip"
|
||||
icon="i-woot-edit-pen"
|
||||
slate
|
||||
sm
|
||||
/>
|
||||
</router-link>
|
||||
<Button
|
||||
v-if="canManageMacro"
|
||||
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import MacroProperties from '../MacroProperties.vue';
|
||||
|
||||
const mountComponent = props =>
|
||||
shallowMount(MacroProperties, {
|
||||
props: {
|
||||
macroName: 'Close conversation',
|
||||
macroVisibility: 'personal',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
v$: {
|
||||
macro: {
|
||||
name: {
|
||||
$error: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
WootInput: true,
|
||||
NextButton: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('MacroProperties.vue', () => {
|
||||
it('allows administrators to select public visibility', async () => {
|
||||
const wrapper = mountComponent({ canManagePublicMacros: true });
|
||||
const publicButton = wrapper.findAll('button')[0];
|
||||
|
||||
await publicButton.trigger('click');
|
||||
|
||||
expect(publicButton.attributes('disabled')).toBeUndefined();
|
||||
expect(wrapper.emitted('update:visibility')?.[0]).toEqual(['global']);
|
||||
});
|
||||
|
||||
it('disables public visibility for agents with helper copy', async () => {
|
||||
const wrapper = mountComponent({ canManagePublicMacros: false });
|
||||
const publicButton = wrapper.findAll('button')[0];
|
||||
|
||||
await publicButton.trigger('click');
|
||||
|
||||
expect(publicButton.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.emitted('update:visibility')).toBeUndefined();
|
||||
expect(wrapper.text()).toContain(
|
||||
'Only administrators can create public macros.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps existing public macros visibly selected when public is disabled', () => {
|
||||
const wrapper = mountComponent({
|
||||
canManagePublicMacros: false,
|
||||
macroVisibility: 'global',
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows existing public macros as read-only for agents', async () => {
|
||||
const wrapper = mountComponent({
|
||||
canManagePublicMacros: false,
|
||||
macroVisibility: 'global',
|
||||
readOnly: true,
|
||||
});
|
||||
const [publicButton, privateButton] = wrapper.findAll('button');
|
||||
|
||||
await privateButton.trigger('click');
|
||||
|
||||
expect(publicButton.attributes('disabled')).toBeDefined();
|
||||
expect(privateButton.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.emitted('update:visibility')).toBeUndefined();
|
||||
expect(wrapper.text()).toContain(
|
||||
'Only administrators can edit public macros.'
|
||||
);
|
||||
});
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import MacrosTableRow from '../MacrosTableRow.vue';
|
||||
|
||||
const macro = visibility => ({
|
||||
id: 1,
|
||||
name: 'Close conversation',
|
||||
visibility,
|
||||
created_by: {
|
||||
available_name: 'Maya Chen',
|
||||
email: 'maya.chen@example.com',
|
||||
},
|
||||
updated_by: {
|
||||
available_name: 'Maya Chen',
|
||||
email: 'maya.chen@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
const mountComponent = props =>
|
||||
shallowMount(MacrosTableRow, {
|
||||
props: {
|
||||
macro: macro('global'),
|
||||
canManagePublicMacros: true,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Avatar: true,
|
||||
BaseTableRow: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
BaseTableCell: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
Button: true,
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('MacrosTableRow.vue', () => {
|
||||
it('shows actions for public macros when public macros can be managed', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps public macros viewable without delete actions when public macros cannot be managed', () => {
|
||||
const wrapper = mountComponent({ canManagePublicMacros: false });
|
||||
|
||||
expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps actions available for personal macros when public macros cannot be managed', () => {
|
||||
const wrapper = mountComponent({
|
||||
macro: macro('personal'),
|
||||
canManagePublicMacros: false,
|
||||
});
|
||||
|
||||
expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,8 @@ const getters = {
|
||||
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
|
||||
return attachments[selectedChatId] || [];
|
||||
},
|
||||
getSelectedChatAttachmentsLoaded: ({ selectedChatId, attachments }) =>
|
||||
selectedChatId !== null && attachments[selectedChatId] !== undefined,
|
||||
getChatListFilters: ({ conversationFilters }) => conversationFilters,
|
||||
getLastEmailInSelectedChat: (stage, _getters) => {
|
||||
const selectedChat = _getters.getSelectedChat;
|
||||
|
||||
@@ -328,6 +328,31 @@ describe('#getters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSelectedChatAttachmentsLoaded', () => {
|
||||
it('returns true when attachments have been fetched for the selected chat', () => {
|
||||
const state = { selectedChatId: 1, attachments: { 1: [] } };
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when the fetched attachment list is non-empty', () => {
|
||||
const state = {
|
||||
selectedChatId: 1,
|
||||
attachments: { 1: [{ id: 1, file_name: 'test' }] },
|
||||
};
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when attachments have not been fetched yet', () => {
|
||||
const state = { selectedChatId: 1, attachments: {} };
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no chat is selected', () => {
|
||||
const state = { selectedChatId: null, attachments: {} };
|
||||
expect(getters.getSelectedChatAttachmentsLoaded(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getContextMenuChatId', () => {
|
||||
it('returns the context menu chat id', () => {
|
||||
const state = { contextMenuChatId: 1 };
|
||||
|
||||
Reference in New Issue
Block a user