Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54a1db2e0a | ||
|
|
c20fcb5529 | ||
|
|
a989c3976b | ||
|
|
116ed54c7e | ||
|
|
cfa0bb953b | ||
|
|
b6a14bda48 |
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
badges: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const iconByType = {
|
||||
text: 'i-lucide-align-justify',
|
||||
checkbox: 'i-lucide-circle-check-big',
|
||||
list: 'i-lucide-list',
|
||||
date: 'i-lucide-calendar',
|
||||
link: 'i-lucide-link',
|
||||
number: 'i-lucide-hash',
|
||||
};
|
||||
|
||||
const attributeIcon = computed(() => {
|
||||
const typeKey = props.attribute.type?.toLowerCase();
|
||||
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.label }}
|
||||
</h4>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon :icon="attributeIcon" class="size-4" />
|
||||
<span class="text-sm">{{ attribute.type }}</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-n-weak" />
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<AttributeBadge
|
||||
v-for="badge in badges"
|
||||
:key="badge.type"
|
||||
:type="badge.type"
|
||||
/>
|
||||
<div
|
||||
v-if="badges.length > 0"
|
||||
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-pencil-line"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
@click="emit('edit', attribute)"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
@click="emit('delete', attribute)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ attribute.attribute_description || attribute.description || '' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'resolution',
|
||||
validator: value => ['pre-chat', 'resolution'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const attributeConfig = {
|
||||
'pre-chat': {
|
||||
colorClass: 'text-n-blue-11',
|
||||
icon: 'i-lucide-message-circle',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||
},
|
||||
resolution: {
|
||||
colorClass: 'text-n-teal-11',
|
||||
icon: 'i-lucide-circle-check-big',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||
},
|
||||
};
|
||||
const config = computed(
|
||||
() => attributeConfig[props.type] || attributeConfig.resolution
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||
>
|
||||
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||
<span class="text-xs" :class="config.colorClass">{{
|
||||
t(config.labelKey)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -25,7 +24,6 @@ defineOptions({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
@@ -47,7 +45,7 @@ const isBannerClosed = computed(() => {
|
||||
});
|
||||
|
||||
const showYearInReviewMenuItem = computed(() => {
|
||||
return route.query['year-in-review'] === 'true' && isBannerClosed.value;
|
||||
return isBannerClosed.value;
|
||||
});
|
||||
|
||||
const openYearInReviewModal = () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
@@ -9,7 +8,6 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const yearInReviewBannerImage =
|
||||
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const getters = useStoreGetters();
|
||||
@@ -31,10 +29,7 @@ const isBannerClosed = computed(() => {
|
||||
});
|
||||
|
||||
const shouldShowBanner = computed(
|
||||
() =>
|
||||
route.query['year-in-review'] === 'true' &&
|
||||
!isBannerClosed.value &&
|
||||
!isACustomBrandedInstance.value
|
||||
() => !isBannerClosed.value && !isACustomBrandedInstance.value
|
||||
);
|
||||
|
||||
const openModal = () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import YearInReviewAPI from 'dashboard/api/yearInReview';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { YEAR_IN_REVIEW_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import IntroSlide from './slides/IntroSlide.vue';
|
||||
import ConversationsSlide from './slides/ConversationsSlide.vue';
|
||||
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
|
||||
@@ -108,6 +110,7 @@ const fetchYearInReviewData = async () => {
|
||||
|
||||
const nextSlide = () => {
|
||||
if (currentSlide.value < 4) {
|
||||
useTrack(YEAR_IN_REVIEW_EVENTS.NEXT_CLICKED);
|
||||
if (!hasConversations.value && currentSlide.value === 1) {
|
||||
currentSlide.value = 4;
|
||||
} else {
|
||||
@@ -141,6 +144,7 @@ const close = () => {
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
useTrack(YEAR_IN_REVIEW_EVENTS.MODAL_OPENED);
|
||||
isOpen.value = true;
|
||||
fetchYearInReviewData();
|
||||
playDrumroll();
|
||||
@@ -151,6 +155,7 @@ const currentSlideBackground = computed(
|
||||
);
|
||||
|
||||
const shareCurrentSlide = async () => {
|
||||
useTrack(YEAR_IN_REVIEW_EVENTS.SHARE_CLICKED);
|
||||
showShareModal.value = true;
|
||||
nextTick(() => {
|
||||
if (shareModalRef.value) {
|
||||
|
||||
@@ -131,3 +131,9 @@ export const LINEAR_EVENTS = Object.freeze({
|
||||
LINK_ISSUE: 'Linked a linear issue',
|
||||
UNLINK_ISSUE: 'Unlinked a linear issue',
|
||||
});
|
||||
|
||||
export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
MODAL_OPENED: 'Year in Review: Modal opened',
|
||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
}
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AddAttribute from './AddAttribute.vue';
|
||||
import CustomAttribute from './CustomAttribute.vue';
|
||||
import EditAttribute from './EditAttribute.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import {
|
||||
useStoreGetters,
|
||||
useStore,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
const { currentAccount } = useAccount();
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const showAddPopup = ref(false);
|
||||
const [showAddPopup, toggleAddPopup] = useToggle(false);
|
||||
const selectedTabIndex = ref(0);
|
||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
const [showEditPopup, toggleEditPopup] = useToggle(false);
|
||||
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
|
||||
const selectedAttribute = ref({});
|
||||
|
||||
const openAddPopup = () => {
|
||||
showAddPopup.value = true;
|
||||
toggleAddPopup(true);
|
||||
};
|
||||
const hideAddPopup = () => {
|
||||
showAddPopup.value = false;
|
||||
toggleAddPopup(false);
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
toggleEditPopup(false);
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
const closeDelete = () => {
|
||||
toggleDeletePopup(false);
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
@@ -37,6 +59,10 @@ const tabs = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const tabsForTabBar = computed(() =>
|
||||
tabs.value.map(tab => ({ label: tab.name, key: tab.key }))
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
});
|
||||
@@ -49,9 +75,75 @@ const attributes = computed(() =>
|
||||
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
||||
);
|
||||
|
||||
const onClickTabChange = index => {
|
||||
selectedTabIndex.value = index;
|
||||
const onClickTabChange = tab => {
|
||||
selectedTabIndex.value = tab.key;
|
||||
};
|
||||
|
||||
const handleEditAttribute = attribute => {
|
||||
selectedAttribute.value = attribute;
|
||||
toggleEditPopup(true);
|
||||
};
|
||||
|
||||
const handleDeleteAttribute = attribute => {
|
||||
selectedAttribute.value = attribute;
|
||||
toggleDeletePopup(true);
|
||||
};
|
||||
|
||||
const confirmDeleteAttribute = async () => {
|
||||
try {
|
||||
await store.dispatch('attributes/delete', selectedAttribute.value.id);
|
||||
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||
closeDelete();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const requiredAttributeKeys = computed(
|
||||
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
|
||||
const hasPreChatBadge = attribute => {
|
||||
return (inboxes.value || []).some(inbox => {
|
||||
const fields =
|
||||
inbox?.pre_chat_form_options?.pre_chat_fields ||
|
||||
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
|
||||
[];
|
||||
return fields.some(field => field.name === attribute.attribute_key);
|
||||
});
|
||||
};
|
||||
|
||||
const buildBadges = attribute => {
|
||||
const badges = [];
|
||||
if (hasPreChatBadge(attribute)) {
|
||||
badges.push({
|
||||
type: 'pre-chat',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attribute.attribute_model === 'conversation_attribute' &&
|
||||
requiredAttributeKeys.value.includes(attribute.attribute_key)
|
||||
) {
|
||||
badges.push({
|
||||
type: 'resolution',
|
||||
});
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const derivedAttributes = computed(() =>
|
||||
attributes.value.map(attribute => ({
|
||||
...attribute,
|
||||
label: attribute.attribute_display_name,
|
||||
type: attribute.attribute_display_type,
|
||||
value: attribute.attribute_key,
|
||||
badges: buildBadges(attribute),
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -77,27 +169,25 @@ const onClickTabChange = index => {
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #preBody>
|
||||
<woot-tabs
|
||||
class="font-medium [&_ul]:p-0 mb-4"
|
||||
:index="selectedTabIndex"
|
||||
@change="onClickTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.key"
|
||||
:index="index"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
is-compact
|
||||
/>
|
||||
</woot-tabs>
|
||||
</template>
|
||||
<template #body>
|
||||
<CustomAttribute
|
||||
:key="attributeModel"
|
||||
:attribute-model="attributeModel"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<TabBar
|
||||
:tabs="tabsForTabBar"
|
||||
:initial-active-tab="selectedTabIndex"
|
||||
class="max-w-xl"
|
||||
@tab-changed="onClickTabChange"
|
||||
/>
|
||||
<div class="grid gap-3">
|
||||
<AttributeListItem
|
||||
v-for="attribute in derivedAttributes"
|
||||
:key="attribute.id"
|
||||
:attribute="attribute"
|
||||
:badges="attribute.badges"
|
||||
@edit="handleEditAttribute"
|
||||
@delete="handleDeleteAttribute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<AddAttribute
|
||||
v-if="showAddPopup"
|
||||
@@ -105,5 +195,34 @@ const onClickTabChange = index => {
|
||||
:on-close="hideAddPopup"
|
||||
:selected-attribute-model-tab="selectedTabIndex"
|
||||
/>
|
||||
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
|
||||
<EditAttribute
|
||||
:selected-attribute="selectedAttribute"
|
||||
:is-updating="uiFlags.isUpdating"
|
||||
@on-close="hideEditPopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-confirm-delete-modal
|
||||
v-if="showDeletePopup"
|
||||
v-model:show="showDeletePopup"
|
||||
:title="
|
||||
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
|
||||
attributeName: selectedAttribute.attribute_display_name,
|
||||
})
|
||||
"
|
||||
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
|
||||
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
|
||||
selectedAttribute.attribute_display_name || ''
|
||||
}`"
|
||||
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
|
||||
:confirm-value="selectedAttribute.attribute_display_name"
|
||||
:confirm-place-holder-text="
|
||||
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
|
||||
attributeName: selectedAttribute.attribute_display_name,
|
||||
})
|
||||
"
|
||||
@on-confirm="confirmDeleteAttribute"
|
||||
@on-close="closeDelete"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
@@ -76,7 +76,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
||||
welcomeDescription: chatwootSettings.welcomeDescription || '',
|
||||
availableMessage: chatwootSettings.availableMessage || '',
|
||||
unavailableMessage: chatwootSettings.unavailableMessage || '',
|
||||
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
|
||||
enableFileUpload: chatwootSettings.enableFileUpload,
|
||||
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
|
||||
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useAttachments } from '../composables/useAttachments';
|
||||
|
||||
export default {
|
||||
components: { FluentIcon, FileUpload, Spinner },
|
||||
@@ -20,13 +21,16 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
return { canHandleAttachments };
|
||||
},
|
||||
data() {
|
||||
return { isUploading: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
}),
|
||||
fileUploadSizeLimit() {
|
||||
return resolveMaximumFileUploadSize(
|
||||
@@ -46,7 +50,7 @@ export default {
|
||||
methods: {
|
||||
handleClipboardPaste(e) {
|
||||
// If file picker is not enabled, do not allow paste
|
||||
if (!this.shouldShowFilePicker) return;
|
||||
if (!this.canHandleAttachments) return;
|
||||
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
// items is a DataTransferItemList object which does not have forEach method
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
|
||||
import ChatAttachmentButton from 'widget/components/ChatAttachment.vue';
|
||||
import ChatSendButton from 'widget/components/ChatSendButton.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import { useAttachments } from '../composables/useAttachments';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
|
||||
@@ -18,7 +18,6 @@ export default {
|
||||
FluentIcon,
|
||||
ResizableTextArea,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
onSendMessage: {
|
||||
type: Function,
|
||||
@@ -29,6 +28,18 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
canHandleAttachments,
|
||||
shouldShowEmojiPicker,
|
||||
hasEmojiPickerEnabled,
|
||||
} = useAttachments();
|
||||
return {
|
||||
canHandleAttachments,
|
||||
shouldShowEmojiPicker,
|
||||
hasEmojiPickerEnabled,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userInput: '',
|
||||
@@ -41,15 +52,10 @@ export default {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
|
||||
}),
|
||||
showAttachment() {
|
||||
return (
|
||||
this.shouldShowFilePicker &&
|
||||
this.hasAttachmentsEnabled &&
|
||||
this.userInput.length === 0
|
||||
);
|
||||
return this.canHandleAttachments && this.userInput.length === 0;
|
||||
},
|
||||
showSendButton() {
|
||||
return this.userInput.length > 0;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAttachments } from '../useAttachments';
|
||||
import { useStore } from 'vuex';
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Mock Vue's useStore
|
||||
vi.mock('vuex', () => ({
|
||||
useStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Vue's computed
|
||||
vi.mock('vue', () => ({
|
||||
computed: vi.fn(fn => ({ value: fn() })),
|
||||
}));
|
||||
|
||||
describe('useAttachments', () => {
|
||||
let mockStore;
|
||||
let mockGetters;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset window.chatwootWebChannel
|
||||
delete window.chatwootWebChannel;
|
||||
|
||||
// Create mock store
|
||||
mockGetters = {};
|
||||
mockStore = {
|
||||
getters: mockGetters,
|
||||
};
|
||||
vi.mocked(useStore).mockReturnValue(mockStore);
|
||||
|
||||
// Mock computed to return a reactive-like object
|
||||
vi.mocked(computed).mockImplementation(fn => ({ value: fn() }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('shouldShowFilePicker', () => {
|
||||
it('returns value from store getter', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = true;
|
||||
|
||||
const { shouldShowFilePicker } = useAttachments();
|
||||
|
||||
expect(shouldShowFilePicker.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns undefined when not set in store', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
|
||||
const { shouldShowFilePicker } = useAttachments();
|
||||
|
||||
expect(shouldShowFilePicker.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAttachmentsEnabled', () => {
|
||||
it('returns true when attachments are enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments', 'emoji'],
|
||||
};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when attachments are not enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji'],
|
||||
};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when channel config has no enabled features', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: [],
|
||||
};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when channel config is missing', () => {
|
||||
window.chatwootWebChannel = undefined;
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when enabledFeatures is missing', () => {
|
||||
window.chatwootWebChannel = {};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canHandleAttachments', () => {
|
||||
beforeEach(() => {
|
||||
// Set up a default channel config
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
});
|
||||
|
||||
it('prioritizes SDK flag when explicitly set to true', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = true;
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(true);
|
||||
});
|
||||
|
||||
it('prioritizes SDK flag when explicitly set to false', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = false;
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to inbox settings when SDK flag is undefined', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to inbox settings when SDK flag is undefined and attachments disabled', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji'],
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('prioritizes SDK false over inbox settings true', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = false;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('prioritizes SDK true over inbox settings false', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = true;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji'], // no attachments
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasEmojiPickerEnabled', () => {
|
||||
it('returns true when emoji picker is enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji_picker', 'attachments'],
|
||||
};
|
||||
|
||||
const { hasEmojiPickerEnabled } = useAttachments();
|
||||
|
||||
expect(hasEmojiPickerEnabled.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when emoji picker is not enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
|
||||
const { hasEmojiPickerEnabled } = useAttachments();
|
||||
|
||||
expect(hasEmojiPickerEnabled.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowEmojiPicker', () => {
|
||||
it('returns value from store getter', () => {
|
||||
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
|
||||
|
||||
const { shouldShowEmojiPicker } = useAttachments();
|
||||
|
||||
expect(shouldShowEmojiPicker.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration test', () => {
|
||||
it('returns all expected properties', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments', 'emoji_picker'],
|
||||
};
|
||||
|
||||
const result = useAttachments();
|
||||
|
||||
expect(result).toHaveProperty('shouldShowFilePicker');
|
||||
expect(result).toHaveProperty('shouldShowEmojiPicker');
|
||||
expect(result).toHaveProperty('hasAttachmentsEnabled');
|
||||
expect(result).toHaveProperty('hasEmojiPickerEnabled');
|
||||
expect(result).toHaveProperty('canHandleAttachments');
|
||||
expect(Object.keys(result)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export function useAttachments() {
|
||||
const store = useStore();
|
||||
|
||||
const shouldShowFilePicker = computed(
|
||||
() => store.getters['appConfig/getShouldShowFilePicker']
|
||||
);
|
||||
|
||||
const shouldShowEmojiPicker = computed(
|
||||
() => store.getters['appConfig/getShouldShowEmojiPicker']
|
||||
);
|
||||
|
||||
const hasAttachmentsEnabled = computed(() => {
|
||||
const channelConfig = window.chatwootWebChannel;
|
||||
return channelConfig?.enabledFeatures?.includes('attachments') || false;
|
||||
});
|
||||
|
||||
const hasEmojiPickerEnabled = computed(() => {
|
||||
const channelConfig = window.chatwootWebChannel;
|
||||
return channelConfig?.enabledFeatures?.includes('emoji_picker') || false;
|
||||
});
|
||||
|
||||
const canHandleAttachments = computed(() => {
|
||||
// If enableFileUpload was explicitly set via SDK, prioritize that
|
||||
if (shouldShowFilePicker.value !== undefined) {
|
||||
return shouldShowFilePicker.value;
|
||||
}
|
||||
|
||||
// Otherwise, fall back to inbox settings only
|
||||
return hasAttachmentsEnabled.value;
|
||||
});
|
||||
|
||||
return {
|
||||
shouldShowFilePicker,
|
||||
shouldShowEmojiPicker,
|
||||
hasAttachmentsEnabled,
|
||||
hasEmojiPickerEnabled,
|
||||
canHandleAttachments,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const state = {
|
||||
welcomeDescription: '',
|
||||
availableMessage: '',
|
||||
unavailableMessage: '',
|
||||
enableFileUpload: true,
|
||||
enableFileUpload: undefined,
|
||||
enableEmojiPicker: true,
|
||||
enableEndConversation: true,
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export const actions = {
|
||||
welcomeDescription = '',
|
||||
availableMessage = '',
|
||||
unavailableMessage = '',
|
||||
enableFileUpload = true,
|
||||
enableFileUpload = undefined,
|
||||
enableEmojiPicker = true,
|
||||
enableEndConversation = true,
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class Imap::ImapMailbox
|
||||
auto_reply: @processed_mail.auto_reply?,
|
||||
mail_subject: @processed_mail.subject,
|
||||
initiated_at: {
|
||||
timestamp: Time.now.utc
|
||||
timestamp: @processed_mail.date&.utc || Time.now.utc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module MailboxHelper
|
||||
private
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
def create_message
|
||||
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
|
||||
return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
|
||||
@@ -13,6 +14,7 @@ module MailboxHelper
|
||||
message_type: 'incoming',
|
||||
content_type: 'incoming_email',
|
||||
source_id: processed_mail.message_id,
|
||||
created_at: processed_mail.date&.utc || Time.current,
|
||||
content_attributes: {
|
||||
email: processed_mail.serialized_data,
|
||||
cc_email: processed_mail.cc,
|
||||
@@ -20,6 +22,7 @@ module MailboxHelper
|
||||
}
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
def add_attachments_to_message
|
||||
return if @message.blank?
|
||||
|
||||
Reference in New Issue
Block a user