Merge branch 'develop' into chore-replace-availability-mixin-with-composable-CW-3472

This commit is contained in:
Fayaz Ahmed
2024-08-27 12:29:45 +05:30
committed by GitHub
1151 changed files with 11214 additions and 6383 deletions
+3 -7
View File
@@ -10,7 +10,6 @@ import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import {
@@ -32,9 +31,6 @@ export default {
UpgradeBanner,
PendingEmailVerificationBanner,
},
mixins: [rtlMixin],
data() {
return {
showAddAccountModal: false,
@@ -46,6 +42,7 @@ export default {
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
@@ -102,7 +99,6 @@ export default {
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
@@ -124,8 +120,8 @@ export default {
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
@@ -10,7 +10,6 @@ import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
@@ -42,7 +41,7 @@ export default {
IntersectionObserver,
VirtualList,
},
mixins: [conversationMixin, filterMixin],
mixins: [filterMixin],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -142,7 +141,7 @@ export default {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationListRef);
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
@@ -906,7 +905,6 @@ export default {
v-if="!hasAppliedFiltersOrActiveFolders"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
class="tab--chat-type"
@chatTabChange="updateAssigneeTab"
/>
@@ -994,14 +992,4 @@ export default {
.conversations-list {
@apply overflow-hidden hover:overflow-y-auto;
}
.tab--chat-type {
@apply py-0 px-4;
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components/ui/Banner.vue';
import accountMixin from 'dashboard/mixins/account';
const EMPTY_SUBSCRIPTION_INFO = {
status: null,
@@ -11,10 +11,13 @@ const EMPTY_SUBSCRIPTION_INFO = {
export default {
components: { Banner },
mixins: [accountMixin],
setup() {
const { isAdmin } = useAdmin();
const { accountId } = useAccount();
return {
accountId,
isAdmin,
};
},
@@ -1,12 +1,10 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import { useAlert } from 'dashboard/composables';
export default {
components: { Banner },
mixins: [accountMixin],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -1,12 +1,17 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
mixins: [accountMixin],
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
@@ -13,13 +13,12 @@ import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const resolveActionsRef = ref(null);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
@@ -131,17 +130,14 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents, resolveActionsRef);
useKeyboardEvents(keyboardEvents);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div
ref="resolveActionsRef"
class="relative flex items-center justify-end resolve-actions"
>
<div class="relative flex items-center justify-end resolve-actions">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -1,5 +1,4 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -8,7 +7,10 @@ import { useRoute, useRouter } from 'dashboard/composables/route';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import { routesWithPermissions } from '../../routes';
import { hasPermissions } from '../../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../../helper/permissionsHelper';
export default {
components: {
@@ -26,7 +28,6 @@ export default {
},
},
setup(props, { emit }) {
const sidebarRef = ref(null);
const route = useRoute();
const router = useRouter();
@@ -64,11 +65,10 @@ export default {
action: () => navigateToRoute('agent_list'),
},
};
useKeyboardEvents(keyboardEvents, sidebarRef);
useKeyboardEvents(keyboardEvents);
return {
toggleKeyShortcutModal,
sidebarRef,
};
},
data() {
@@ -113,7 +113,10 @@ export default {
return getSidebarItems(this.accountId);
},
primaryMenuItems() {
const userPermissions = this.currentUser.permissions;
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem => {
const isAvailableForTheUser = hasPermissions(
@@ -195,7 +198,7 @@ export default {
</script>
<template>
<aside ref="sidebarRef" class="flex h-full">
<aside class="flex h-full">
<PrimarySidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
@@ -4,7 +4,10 @@ import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { hasPermissions } from '../../../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../../../helper/permissionsHelper';
import { routesWithPermissions } from '../../../routes';
export default {
@@ -59,7 +62,10 @@ export default {
accessibleMenuItems() {
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
menuItem => {
const { permissions: userPermissions = [] } = this.currentUser;
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
return hasPermissions(
routesWithPermissions[menuItem.toStateName],
userPermissions
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import configMixin from 'shared/mixins/configMixin';
import { useConfig } from 'dashboard/composables/useConfig';
import {
getInboxClassByType,
getInboxWarningIconClass,
@@ -16,7 +16,6 @@ import Policy from '../../policy.vue';
export default {
components: { SecondaryChildNavItem, Policy },
mixins: [configMixin],
props: {
menuItem: {
type: Object,
@@ -25,8 +24,10 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const { isEnterprise } = useConfig();
return {
isAdmin,
isEnterprise,
};
},
computed: {
+14 -5
View File
@@ -1,7 +1,11 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
import { hasPermissions } from '../helper/permissionsHelper';
import {
getUserPermissions,
hasPermissions,
} from '../helper/permissionsHelper';
const props = defineProps({
permissions: {
type: Array,
@@ -10,12 +14,17 @@ const props = defineProps({
});
const getters = useStoreGetters();
const user = getters.getCurrentUser.value;
const hasPermission = computed(() =>
hasPermissions(props.permissions, user.permissions)
);
const user = computed(() => getters.getCurrentUser.value);
const accountId = computed(() => getters.getCurrentAccountId.value);
const userPermissions = computed(() => {
return getUserPermissions(user.value, accountId.value);
});
const hasPermission = computed(() => {
return hasPermissions(props.permissions, userPermissions.value);
});
</script>
<!-- eslint-disable vue/no-root-v-if -->
<template>
<div v-if="hasPermission">
<slot />
@@ -4,10 +4,10 @@ import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
export default {
@@ -16,17 +16,17 @@ export default {
AICTAModal,
AIAssistanceCTAButton,
},
mixins: [aiMixin],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const aiAssistanceButtonRef = ref(null);
const initialMessage = ref('');
const initializeMessage = draftMessage => {
initialMessage.value = draftMessage;
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
@@ -39,15 +39,17 @@ export default {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, aiAssistanceButtonRef);
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
aiAssistanceButtonRef,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
@@ -118,7 +120,7 @@ export default {
</script>
<template>
<div ref="aiAssistanceButtonRef">
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@@ -1,20 +1,23 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
export default {
components: {
AILoader,
},
mixins: [aiMixin, messageFormatterMixin],
props: {
aiOption: {
type: String,
required: true,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
},
data() {
return {
generatedContent: '',
@@ -3,16 +3,16 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import aiMixin from 'dashboard/mixins/aiMixin';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
mixins: [aiMixin],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$ };
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import wootConstants from 'dashboard/constants/globals';
@@ -16,8 +16,6 @@ const props = defineProps({
const emit = defineEmits(['chatTabChange']);
const chatTypeTabsRef = ref(null);
const activeTabIndex = computed(() => {
return props.items.findIndex(item => item.key === props.activeTab);
});
@@ -39,18 +37,31 @@ const keyboardEvents = {
},
},
};
useKeyboardEvents(keyboardEvents, chatTypeTabsRef);
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div ref="chatTypeTabsRef">
<woot-tabs :index="activeTabIndex" @change="onTabChange">
<woot-tabs-item
v-for="item in items"
:key="item.key"
:name="item.name"
:count="item.count"
/>
</woot-tabs>
</div>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
v-for="item in items"
:key="item.key"
:name="item.name"
:count="item.count"
/>
</woot-tabs>
</template>
<style scoped lang="scss">
.tab--chat-type {
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -18,8 +18,6 @@ const props = defineProps({
const emit = defineEmits(['add', 'remove']);
const labelSelectorWrapRef = ref(null);
const { isAdmin } = useAdmin();
const showSearchDropdownLabel = ref(false);
@@ -57,15 +55,11 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents, labelSelectorWrapRef);
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
ref="labelSelectorWrapRef"
v-on-clickaway="closeDropdownLabel"
class="relative leading-6"
>
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in savedLabels"
@@ -1,5 +1,4 @@
<script>
import { ref, watchEffect, computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
@@ -116,35 +115,28 @@ export default {
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const uploadRef = ref(null);
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadTriggerButton = computed(() => {
if (uploadRef.value) {
return uploadRef.value.$children[1].$el;
}
return null;
});
const keyboardEvents = {
'Alt+KeyA': {
action: () => {
uploadTriggerButton.value.click();
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
};
watchEffect(() => {
useKeyboardEvents(keyboardEvents, uploadTriggerButton);
});
useKeyboardEvents(keyboardEvents);
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
uploadRef,
};
},
computed: {
@@ -269,7 +261,6 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
@@ -1,5 +1,4 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
export default {
@@ -23,8 +22,6 @@ export default {
},
},
setup(props, { emit }) {
const replyTopRef = ref(null);
const setReplyMode = mode => {
emit('setReplyMode', mode);
};
@@ -44,12 +41,11 @@ export default {
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, replyTopRef);
useKeyboardEvents(keyboardEvents);
return {
handleReplyClick,
handleNoteClick,
replyTopRef,
};
},
computed: {
@@ -76,10 +72,7 @@ export default {
</script>
<template>
<div
ref="replyTopRef"
class="flex justify-between bg-black-50 dark:bg-slate-800"
>
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
<div class="button-group">
<woot-button
variant="clear"
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import conversationMixin from '../../../mixins/conversations';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
@@ -24,7 +24,7 @@ export default {
PriorityMark,
SLACardLabel,
},
mixins: [inboxMixin, conversationMixin],
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
@@ -118,7 +118,7 @@ export default {
},
lastMessageInChat() {
return this.lastMessage(this.chat);
return getLastMessage(this.chat);
},
inbox() {
@@ -316,7 +316,11 @@ export default {
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
<CardLabels
:conversation-id="chat.id"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -1,8 +1,6 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
@@ -24,7 +22,7 @@ export default {
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin],
mixins: [inboxMixin],
props: {
chat: {
type: Object,
@@ -44,18 +42,12 @@ export default {
},
},
setup(props, { emit }) {
const conversationHeaderActionsRef = ref(null);
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
return {
conversationHeaderActionsRef,
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
@@ -183,7 +175,6 @@ export default {
</div>
<div
ref="conversationHeaderActionsRef"
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import accountMixin from 'dashboard/mixins/account';
import { useAccount } from 'dashboard/composables/useAccount';
import OnboardingView from '../OnboardingView.vue';
import EmptyStateMessage from './EmptyStateMessage.vue';
@@ -10,7 +10,6 @@ export default {
OnboardingView,
EmptyStateMessage,
},
mixins: [accountMixin],
props: {
isOnExpandedLayout: {
type: Boolean,
@@ -19,8 +18,12 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
computed: {
@@ -44,7 +47,7 @@ export default {
return this.$t('CONVERSATION.404');
},
newInboxURL() {
return this.addAccountScoping('settings/inboxes/new');
return this.accountScopedUrl('settings/inboxes/new');
},
emptyClassName() {
if (
@@ -1,5 +1,5 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import BubbleActions from './bubble/Actions.vue';
import BubbleContact from './bubble/Contact.vue';
import BubbleFile from './bubble/File.vue';
@@ -39,7 +39,6 @@ export default {
InstagramStoryReply,
Spinner,
},
mixins: [messageFormatterMixin],
props: {
data: {
type: Object,
@@ -74,6 +73,12 @@ export default {
default: () => ({}),
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
showContextMenu: false,
@@ -1,11 +1,10 @@
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
name: 'MessagePreview',
mixins: [messageFormatterMixin],
props: {
message: {
type: Object,
@@ -20,6 +19,12 @@ export default {
default: '',
},
},
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
@@ -1,7 +1,9 @@
<script>
import { ref } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
// components
import ReplyBox from './ReplyBox.vue';
@@ -13,17 +15,17 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
// mixins
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -38,7 +40,7 @@ export default {
Banner,
ConversationLabelSuggestion,
},
mixins: [conversationMixin, inboxMixin, configMixin, aiMixin],
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -50,8 +52,8 @@ export default {
},
},
setup() {
const conversationFooterRef = ref(null);
const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
@@ -67,13 +69,24 @@ export default {
},
};
useKeyboardEvents(keyboardEvents, conversationFooterRef);
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
return {
conversationFooterRef,
isEnterprise,
isPopOutReplyBox,
closePopOutReplyBox,
showPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
};
},
data() {
@@ -138,14 +151,14 @@ export default {
}
return messages;
},
getReadMessages() {
return this.readMessages(
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
getUnReadMessages() {
return this.unReadMessages(
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
@@ -468,7 +481,7 @@ export default {
</li>
</transition>
<Message
v-for="message in getReadMessages"
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
@@ -493,7 +506,7 @@ export default {
</span>
</li>
<Message
v-for="message in getUnReadMessages"
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
@@ -514,7 +527,6 @@ export default {
/>
</ul>
<div
ref="conversationFooterRef"
class="conversation-footer"
:class="{ 'modal-mask': isPopOutReplyBox }"
>
@@ -7,7 +7,7 @@ import {
CMD_MUTE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
CMD_UNMUTE_CONVERSATION,
} from '../../../routes/dashboard/commands/commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
export default {
components: {
@@ -0,0 +1,54 @@
<script setup>
defineProps({
imageSrc: {
type: String,
default: '',
},
imageAlt: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
to: {
type: String,
default: '',
},
linkText: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="h-full w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-white/10 rounded-lg p-4 flex flex-col"
>
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
<div class="mt-auto">
<p
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
>
{{ title }}
</p>
<p class="text-slate-600 dark:text-slate-400 text-sm">
{{ description }}
</p>
<router-link
v-if="to"
:to="{ name: to }"
class="no-underline text-woot-500 text-sm font-medium"
>
<span>{{ linkText }}</span>
<span>{{ `` }}</span>
</router-link>
</div>
</div>
</template>
@@ -1,159 +1,80 @@
<script>
import { mapGetters } from 'vuex';
import accountMixin from '../../../mixins/account';
<script setup>
import OnboardingFeatureCard from './OnboardingFeatureCard.vue';
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters } from 'dashboard/composables/store';
export default {
mixins: [accountMixin],
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
newInboxURL() {
return this.addAccountScoping('settings/inboxes/new');
},
newAgentURL() {
return this.addAccountScoping('settings/agents/list');
},
newLabelsURL() {
return this.addAccountScoping('settings/labels/list');
},
},
};
const getters = useStoreGetters();
const { t } = useI18n();
const globalConfig = computed(() => getters['globalConfig/get'].value);
const currentUser = computed(() => getters.getCurrentUser.value);
const greetingMessage = computed(() => {
const hours = new Date().getHours();
let translationKey;
if (hours < 12) {
translationKey = 'ONBOARDING.GREETING_MORNING';
} else if (hours < 18) {
translationKey = 'ONBOARDING.GREETING_AFTERNOON';
} else {
translationKey = 'ONBOARDING.GREETING_EVENING';
}
return t(translationKey, {
name: currentUser.value.name,
installationName: globalConfig.value.installationName,
});
});
</script>
<template>
<div class="onboarding-wrap">
<div class="onboarding">
<div class="scroll-wrap">
<div class="features-item">
<h1 class="text-2xl text-slate-900 dark:text-slate-50">
<span>{{
$t('ONBOARDING.TITLE', {
installationName: globalConfig.installationName,
})
}}</span>
</h1>
<p class="intro-body">
{{
$t('ONBOARDING.DESCRIPTION', {
installationName: globalConfig.installationName,
})
}}
</p>
<p v-if="globalConfig.installationName === 'Chatwoot'">
<a
href="https://www.chatwoot.com/changelog"
target="_blank"
rel="noopener nofollow noreferrer"
class="onboarding--link"
>
{{ $t('ONBOARDING.READ_LATEST_UPDATES') }}
</a>
<span>🎉</span>
</p>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">💬</span>
<span class="conversation--title">{{
$t('ONBOARDING.ALL_CONVERSATION.TITLE')
}}</span>
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION') }}
</p>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">👥</span>
{{ $t('ONBOARDING.TEAM_MEMBERS.TITLE') }}
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION') }}
</p>
<router-link :to="newAgentURL" class="onboarding--link">
{{ $t('ONBOARDING.TEAM_MEMBERS.NEW_LINK') }}
</router-link>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">📥</span>{{ $t('ONBOARDING.INBOXES.TITLE') }}
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.INBOXES.DESCRIPTION') }}
</p>
<router-link :to="newInboxURL" class="onboarding--link">
{{ $t('ONBOARDING.INBOXES.NEW_LINK') }}
</router-link>
</div>
<div class="features-item">
<h2 class="text-lg text-black-900 dark:text-slate-200">
<span class="emoji">🔖</span>{{ $t('ONBOARDING.LABELS.TITLE') }}
</h2>
<p class="intro-body">
{{ $t('ONBOARDING.LABELS.DESCRIPTION') }}
</p>
<router-link :to="newLabelsURL" class="onboarding--link">
{{ $t('ONBOARDING.LABELS.NEW_LINK') }}
</router-link>
</div>
</div>
<div
class="min-h-screen max-w-4xl mx-auto grid grid-cols-1 xl:grid-cols-2 grid-rows-[auto_1fr_1fr] gap-4 p-8 w-full font-inter overflow-auto"
>
<div class="col-span-full self-start">
<p
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
>
{{ greetingMessage }}
</p>
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-base">
{{
$t('ONBOARDING.DESCRIPTION', {
installationName: globalConfig.installationName,
})
}}
</p>
</div>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/omnichannel-inbox.png"
image-alt="Omnichannel"
to="settings_inbox_new"
:title="$t('ONBOARDING.ALL_CONVERSATION.TITLE')"
:description="$t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION')"
:link-text="$t('ONBOARDING.ALL_CONVERSATION.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/teams.png"
image-alt="Teams"
to="settings_teams_new"
:title="$t('ONBOARDING.TEAM_MEMBERS.TITLE')"
:description="$t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION')"
:link-text="$t('ONBOARDING.TEAM_MEMBERS.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/canned-responses.png"
image-alt="Canned responses"
to="canned_list"
:title="$t('ONBOARDING.CANNED_RESPONSES.TITLE')"
:description="$t('ONBOARDING.CANNED_RESPONSES.DESCRIPTION')"
:link-text="$t('ONBOARDING.CANNED_RESPONSES.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/labels.png"
image-alt="Labels"
to="labels_list"
:title="$t('ONBOARDING.LABELS.TITLE')"
:description="$t('ONBOARDING.LABELS.DESCRIPTION')"
:link-text="$t('ONBOARDING.LABELS.NEW_LINK')"
/>
</div>
</template>
<style lang="scss" scoped>
.onboarding-wrap {
display: flex;
font-size: var(--font-size-small);
justify-content: center;
overflow: auto;
text-align: left;
}
.onboarding {
height: 100vh;
overflow: auto;
}
.scroll-wrap {
padding: var(--space-larger) 8.5rem;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
.features-item {
margin-bottom: var(--space-large);
}
.conversation--title {
margin-left: var(--space-minus-smaller);
}
.page-title {
font-size: var(--font-size-big);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-one);
}
.block-title {
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-smaller);
margin-left: var(--space-minus-large);
}
.intro-body {
margin-bottom: var(--space-small);
line-height: 1.5;
}
.onboarding--link {
color: var(--w-500);
font-weight: var(--font-weight-medium);
text-decoration: underline;
}
.emoji {
width: var(--space-large);
display: inline-block;
}
</style>
@@ -17,7 +17,6 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -31,7 +30,6 @@ import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
@@ -62,13 +60,7 @@ export default {
MessageSignatureMissingAlert,
ArticleSearchPopover,
},
mixins: [
inboxMixin,
messageFormatterMixin,
rtlMixin,
fileUploadMixin,
keyboardEventListenerMixins,
],
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
popoutReplyBox: {
type: Boolean,
@@ -121,6 +113,7 @@ export default {
},
computed: {
...mapGetters({
isRTL: 'accounts/isRTL',
currentChat: 'getSelectedChat',
messageSignature: 'getMessageSignature',
currentUser: 'getCurrentUser',
@@ -307,7 +300,7 @@ export default {
if (this.isOnExpandedLayout || this.popoutReplyBox) {
return 'emoji-dialog--expanded';
}
if (this.isRTLView) {
if (this.isRTL) {
return 'emoji-dialog--rtl';
}
return '';
@@ -40,7 +40,6 @@ const onSelect = () => {
};
useKeyboardNavigableList({
elementRef: tagAgentsRef,
items,
onSelect,
adjustScroll,
@@ -2,6 +2,7 @@
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
@@ -42,6 +43,9 @@ export default {
isAudio() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
},
timeStampURL() {
return timeStampAppendedURL(this.dataUrl);
},
attachmentTypeClasses() {
return {
image: this.isImage,
@@ -108,7 +112,7 @@ export default {
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
<source :src="`${dataUrl}?t=${Date.now()}`" />
<source :src="timeStampURL" />
</audio>
<GalleryView
v-if="show"
@@ -35,7 +35,6 @@ const ALLOWED_FILE_TYPES = {
const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1;
const galleryViewRef = ref(null);
const zoomScale = ref(1);
const activeAttachment = ref({});
const activeFileType = ref('');
@@ -202,7 +201,7 @@ const keyboardEvents = {
},
},
};
useKeyboardEvents(keyboardEvents, galleryViewRef);
useKeyboardEvents(keyboardEvents);
onMounted(() => {
setImageAndVideoSrc(props.attachment);
@@ -218,7 +217,6 @@ onMounted(() => {
:on-close="onClose"
>
<div
ref="galleryViewRef"
v-on-clickaway="onClose"
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
@click="onClose"
@@ -1,9 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
components: {
@@ -11,7 +14,6 @@ export default {
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [agentMixin],
props: {
chatId: {
type: Number,
@@ -112,13 +114,19 @@ export default {
labels: 'labels/getLabels',
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const filteredAgents = this.sortedAgentsByAvailability(
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
this.currentUser,
this.currentAccountId
);
const filteredAgents = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
@@ -2,7 +2,9 @@
// components
import WootButton from '../../../ui/WootButton.vue';
import Avatar from '../../Avatar.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
// composables
import { useAI } from 'dashboard/composables/useAI';
// store & api
import { mapGetters } from 'vuex';
@@ -18,7 +20,6 @@ export default {
Avatar,
WootButton,
},
mixins: [aiMixin],
props: {
suggestedLabels: {
type: Array,
@@ -30,6 +31,11 @@ export default {
default: () => [],
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
return { isAIIntegrationEnabled };
},
data() {
return {
isDismissed: false,
@@ -41,7 +47,11 @@ export default {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat',
}),
conversationId() {
return this.currentChat?.id;
},
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
@@ -162,7 +172,7 @@ export default {
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
class="label-suggestion--option"
class="label-suggestion--option !px-0"
@click="pushOrAddLabel(label.title)"
>
<woot-label
@@ -6,7 +6,7 @@ import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
@@ -1,23 +1,26 @@
<script>
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
export default {
mixins: [conversationLabelMixin],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationId: {
type: Number,
conversationLabels: {
type: Array,
required: true,
},
// conversationLabels prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
conversationLabels: {
type: String,
required: false,
default: '',
},
},
setup(props) {
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.conversationLabels.includes(title)
);
});
return {
activeLabels,
};
},
data() {
return {
@@ -80,7 +83,7 @@ export default {
<slot name="before" />
<woot-label
v-for="(label, index) in activeLabels"
:key="label.id"
:key="label ? label.id : index"
:title="label.title"
:description="label.description"
:color="label.color"
@@ -48,11 +48,7 @@ const loadLinkedIssue = async () => {
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
);
useAlert(errorMessage);
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
}
};
@@ -39,7 +39,6 @@ const onSelect = () => {
};
useKeyboardNavigableList({
elementRef: mentionsListContainerRef,
items: computed(() => props.items),
onSelect,
adjustScroll,
@@ -0,0 +1,197 @@
export const mockAssignableAgents = [
{
id: 1,
account_id: 1,
availability_status: 'online',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'administrator',
thumbnail: '',
},
];
export const mockCurrentChat = {
meta: {
sender: {
additional_attributes: {},
availability_status: 'offline',
email: null,
id: 212,
name: 'Chatwoot',
phone_number: null,
identifier: null,
thumbnail: '',
custom_attributes: {},
last_activity_at: 1723553344,
created_at: 1722588710,
},
channel: 'Channel::WebWidget',
assignee: {
id: 1,
account_id: 1,
availability_status: 'online',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'administrator',
thumbnail: '',
},
hmac_verified: false,
},
id: 138,
messages: [
{
id: 3348,
content: 'Hello, how can I assist you today?',
account_id: 1,
inbox_id: 1,
conversation_id: 138,
message_type: 1,
created_at: 1724398739,
updated_at: '2024-08-23T07:38:59.763Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Hello, how can I assist you today?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1724398739,
contact_inbox: {
source_id: '5e57317d-053b-4a72-8292-a25b9f29c401',
},
},
sender: {
id: 1,
name: 'John Doe',
available_name: 'John Doe',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
],
account_id: 1,
uuid: '69dd6922-2f0c-4317-8796-bbeb3679cead',
additional_attributes: {
browser: {
device_name: 'Unknown',
browser_name: 'Chrome',
platform_name: 'macOS',
browser_version: '127.0.0.0',
platform_version: '10.15.7',
},
referer: 'http://chatwoot.com/widget_tests?dark_mode=auto',
initiated_at: {
timestamp: 'Fri Aug 02 2024 15:21:18 GMT+0530 (India Standard Time)',
},
browser_language: 'en',
},
agent_last_seen_at: 1724400730,
assignee_last_seen_at: 1724400686,
can_reply: true,
contact_last_seen_at: 1723553351,
custom_attributes: {},
inbox_id: 1,
labels: ['billing'],
muted: false,
snoozed_until: null,
status: 'open',
created_at: 1722592278,
timestamp: 1724398739,
first_reply_created_at: 1722592316,
unread_count: 0,
last_non_activity_message: {},
last_activity_at: 1724398739,
priority: null,
waiting_since: 0,
sla_policy_id: 10,
applied_sla: {
id: 143,
sla_id: 10,
sla_status: 'missed',
created_at: 1722592279,
updated_at: 1722874214,
sla_description: '',
sla_name: 'Hacker SLA',
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 240,
sla_only_during_business_hours: false,
sla_resolution_time_threshold: 259200,
},
sla_events: [
{
id: 270,
event_type: 'nrt',
meta: {
message_id: 2743,
},
updated_at: 1722592819,
created_at: 1722592819,
},
{
id: 275,
event_type: 'rt',
meta: {},
updated_at: 1722852322,
created_at: 1722852322,
},
],
allMessagesLoaded: false,
dataFetched: true,
};
export const mockTeamsList = [
{
id: 5,
name: 'design',
description: 'design team',
allow_auto_assign: true,
account_id: 1,
is_member: false,
},
];
export const mockActiveLabels = [
{
id: 16,
title: 'billing',
description: '',
color: '#D8EA19',
show_on_sidebar: true,
},
];
export const mockInactiveLabels = [
{
id: 2,
title: 'Feature Request',
description: '',
color: '#D8EA19',
show_on_sidebar: true,
},
];
export const MOCK_FEATURE_FLAGS = {
CRM: 'crm',
AGENT_MANAGEMENT: 'agent_management',
TEAM_MANAGEMENT: 'team_management',
INBOX_MANAGEMENT: 'inbox_management',
REPORTS: 'reports',
LABELS: 'labels',
CANNED_RESPONSES: 'canned_responses',
INTEGRATIONS: 'integrations',
};
@@ -0,0 +1,83 @@
import { useAppearanceHotKeys } from '../useAppearanceHotKeys';
import { useI18n } from 'dashboard/composables/useI18n';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
vi.mock('dashboard/composables/useI18n');
vi.mock('shared/helpers/localStorage');
vi.mock('dashboard/helper/themeHelper.js');
describe('useAppearanceHotKeys', () => {
beforeEach(() => {
useI18n.mockReturnValue({
t: vi.fn(key => key),
});
window.matchMedia = vi.fn().mockReturnValue({ matches: false });
});
it('should return goToAppearanceHotKeys computed property', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
expect(goToAppearanceHotKeys.value).toBeDefined();
});
it('should have the correct number of appearance options', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
expect(goToAppearanceHotKeys.value.length).toBe(4); // 1 parent + 3 theme options
});
it('should have the correct parent option', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const parentOption = goToAppearanceHotKeys.value.find(
option => option.id === 'appearance_settings'
);
expect(parentOption).toBeDefined();
expect(parentOption.children.length).toBe(3);
});
it('should have the correct theme options', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const themeOptions = goToAppearanceHotKeys.value.filter(
option => option.parent === 'appearance_settings'
);
expect(themeOptions.length).toBe(3);
expect(themeOptions.map(option => option.id)).toEqual([
'light',
'dark',
'auto',
]);
});
it('should call setAppearance when a theme option is selected', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const lightThemeOption = goToAppearanceHotKeys.value.find(
option => option.id === 'light'
);
lightThemeOption.handler();
expect(LocalStorage.set).toHaveBeenCalledWith(
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
'light'
);
expect(setColorTheme).toHaveBeenCalledWith(false);
});
it('should handle system dark mode preference', () => {
window.matchMedia = vi.fn().mockReturnValue({ matches: true });
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const autoThemeOption = goToAppearanceHotKeys.value.find(
option => option.id === 'auto'
);
autoThemeOption.handler();
expect(LocalStorage.set).toHaveBeenCalledWith(
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
'auto'
);
expect(setColorTheme).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,102 @@
import { useBulkActionsHotKeys } from '../useBulkActionsHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('shared/helpers/mitt');
describe('useBulkActionsHotKeys', () => {
let store;
beforeEach(() => {
store = {
getters: {
'bulkActions/getSelectedConversationIds': [],
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
emitter.emit = vi.fn();
});
it('should return bulk actions when conversations are selected', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
expect(bulkActionsHotKeys.value.length).toBeGreaterThan(0);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
});
it('should include snooze options in bulk actions', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const snoozeAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_snooze_conversation'
);
expect(snoozeAction).toBeDefined();
expect(snoozeAction.children).toEqual(
Object.values(wootConstants.SNOOZE_OPTIONS)
);
});
it('should create handlers for reopen and resolve actions', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const reopenAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_reopen_conversation'
);
const resolveAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_resolve_conversation'
);
expect(reopenAction.handler).toBeDefined();
expect(resolveAction.handler).toBeDefined();
reopenAction.handler();
expect(emitter.emit).toHaveBeenCalledWith(
'CMD_BULK_ACTION_REOPEN_CONVERSATION'
);
resolveAction.handler();
expect(emitter.emit).toHaveBeenCalledWith(
'CMD_BULK_ACTION_RESOLVE_CONVERSATION'
);
});
it('should return an empty array when no conversations are selected', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
expect(bulkActionsHotKeys.value).toEqual([]);
});
});
@@ -0,0 +1,204 @@
import { useConversationHotKeys } from '../useConversationHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
mockAssignableAgents,
mockCurrentChat,
mockTeamsList,
mockActiveLabels,
mockInactiveLabels,
} from './fixtures';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useAI');
vi.mock('dashboard/composables/useAgentsList');
describe('useConversationHotKeys', () => {
let store;
beforeEach(() => {
store = {
dispatch: vi.fn(),
getters: {
getSelectedChat: mockCurrentChat,
'draftMessages/getReplyEditorMode': REPLY_EDITOR_MODES.REPLY,
getContextMenuChatId: null,
'teams/getTeams': mockTeamsList,
'draftMessages/get': vi.fn(),
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRoute.mockReturnValue({ name: 'inbox_conversation' });
useConversationLabels.mockReturnValue({
activeLabels: { value: mockActiveLabels },
inactiveLabels: { value: mockInactiveLabels },
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
useAgentsList.mockReturnValue({
agentsList: { value: [] },
assignableAgents: { value: mockAssignableAgents },
});
});
it('should return the correct computed properties', () => {
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value).toBeDefined();
});
it('should generate conversation hot keys', () => {
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
});
it('should include AI assist actions when AI integration is enabled', () => {
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
);
expect(aiAssistAction).toBeDefined();
});
it('should not include AI assist actions when AI integration is disabled', () => {
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
);
expect(aiAssistAction).toBeUndefined();
});
it('should dispatch actions when handlers are called', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignAgentAction = conversationHotKeys.value.find(
action => action.id === 'assign_an_agent'
);
expect(assignAgentAction).toBeDefined();
if (assignAgentAction && assignAgentAction.children) {
const childAction = conversationHotKeys.value.find(
action => action.id === assignAgentAction.children[0]
);
if (childAction && childAction.handler) {
childAction.handler({ agentInfo: { id: 2 } });
expect(store.dispatch).toHaveBeenCalledWith('assignAgent', {
conversationId: 1,
agentId: 2,
});
}
}
});
it('should return snooze actions when in snooze context', () => {
store.getters.getContextMenuChatId = 1;
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useRoute.mockReturnValue({ name: 'inbox_conversation' });
const { conversationHotKeys } = useConversationHotKeys();
const snoozeAction = conversationHotKeys.value.find(action =>
action.id.includes('snooze_conversation')
);
expect(snoozeAction).toBeDefined();
});
it('should return the correct label actions when there are active labels', () => {
const { conversationHotKeys } = useConversationHotKeys();
const addLabelAction = conversationHotKeys.value.find(
action => action.id === 'add_a_label_to_the_conversation'
);
const removeLabelAction = conversationHotKeys.value.find(
action => action.id === 'remove_a_label_to_the_conversation'
);
expect(addLabelAction).toBeDefined();
expect(removeLabelAction).toBeDefined();
});
it('should return only add label actions when there are no active labels', () => {
useConversationLabels.mockReturnValue({
activeLabels: { value: [] },
inactiveLabels: { value: [{ title: 'inactive_label' }] },
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
const { conversationHotKeys } = useConversationHotKeys();
const addLabelAction = conversationHotKeys.value.find(
action => action.id === 'add_a_label_to_the_conversation'
);
const removeLabelAction = conversationHotKeys.value.find(
action => action.id === 'remove_a_label_to_the_conversation'
);
expect(addLabelAction).toBeDefined();
expect(removeLabelAction).toBeUndefined();
});
it('should return the correct team assignment actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignTeamAction = conversationHotKeys.value.find(
action => action.id === 'assign_a_team'
);
expect(assignTeamAction).toBeDefined();
expect(assignTeamAction.children.length).toBe(mockTeamsList.length);
});
it('should return the correct priority assignment actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignPriorityAction = conversationHotKeys.value.find(
action => action.id === 'assign_priority'
);
expect(assignPriorityAction).toBeDefined();
expect(assignPriorityAction.children.length).toBe(4);
});
it('should return the correct conversation additional actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const muteAction = conversationHotKeys.value.find(
action => action.id === 'mute_conversation'
);
const sendTranscriptAction = conversationHotKeys.value.find(
action => action.id === 'send_transcript'
);
expect(muteAction).toBeDefined();
expect(sendTranscriptAction).toBeDefined();
});
it('should return unmute action when conversation is muted', () => {
store.getters.getSelectedChat = { ...mockCurrentChat, muted: true };
const { conversationHotKeys } = useConversationHotKeys();
const unmuteAction = conversationHotKeys.value.find(
action => action.id === 'unmute_conversation'
);
expect(unmuteAction).toBeDefined();
});
it('should not return conversation hot keys when not in conversation or inbox route', () => {
useRoute.mockReturnValue({ name: 'some_other_route' });
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value.length).toBe(0);
});
});
@@ -0,0 +1,188 @@
import { useGoToCommandHotKeys } from '../useGoToCommandHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRouter } from 'dashboard/composables/route';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { MOCK_FEATURE_FLAGS } from './fixtures';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/composables/useAdmin');
vi.mock('dashboard/helper/URLHelper');
const mockRoutes = [
{ path: 'accounts/:accountId/dashboard', name: 'dashboard' },
{
path: 'accounts/:accountId/contacts',
name: 'contacts',
featureFlag: MOCK_FEATURE_FLAGS.CRM,
},
{
path: 'accounts/:accountId/settings/agents/list',
name: 'agent_settings',
featureFlag: MOCK_FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
path: 'accounts/:accountId/settings/teams/list',
name: 'team_settings',
featureFlag: MOCK_FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
path: 'accounts/:accountId/settings/inboxes/list',
name: 'inbox_settings',
featureFlag: MOCK_FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{ path: 'accounts/:accountId/profile/settings', name: 'profile_settings' },
{ path: 'accounts/:accountId/notifications', name: 'notifications' },
{
path: 'accounts/:accountId/reports/overview',
name: 'reports_overview',
featureFlag: MOCK_FEATURE_FLAGS.REPORTS,
},
{
path: 'accounts/:accountId/settings/labels/list',
name: 'label_settings',
featureFlag: MOCK_FEATURE_FLAGS.LABELS,
},
{
path: 'accounts/:accountId/settings/canned-response/list',
name: 'canned_responses',
featureFlag: MOCK_FEATURE_FLAGS.CANNED_RESPONSES,
},
{
path: 'accounts/:accountId/settings/applications',
name: 'applications',
featureFlag: MOCK_FEATURE_FLAGS.INTEGRATIONS,
},
];
describe('useGoToCommandHotKeys', () => {
let store;
beforeEach(() => {
store = {
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn().mockReturnValue(true),
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRouter.mockReturnValue({ push: vi.fn() });
useAdmin.mockReturnValue({ isAdmin: { value: true } });
frontendURL.mockImplementation(url => url);
});
it('should return goToCommandHotKeys computed property', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
expect(goToCommandHotKeys.value).toBeDefined();
expect(goToCommandHotKeys.value.length).toBeGreaterThan(0);
});
it('should filter commands based on feature flags', () => {
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(
(accountId, flag) => flag !== MOCK_FEATURE_FLAGS.CRM
);
const { goToCommandHotKeys } = useGoToCommandHotKeys();
mockRoutes.forEach(route => {
const command = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes(route.name)
);
if (route.featureFlag === MOCK_FEATURE_FLAGS.CRM) {
expect(command).toBeUndefined();
} else if (!route.featureFlag) {
expect(command).toBeDefined();
}
});
});
it('should filter commands for non-admin users', () => {
useAdmin.mockReturnValue({ isAdmin: { value: false } });
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const adminOnlyCommands = goToCommandHotKeys.value.filter(
cmd =>
cmd.id.includes('agent_settings') ||
cmd.id.includes('team_settings') ||
cmd.id.includes('inbox_settings')
);
expect(adminOnlyCommands.length).toBe(0);
});
it('should include commands for both admin and agent roles when user is admin', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const adminCommand = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes('agent_settings')
);
const agentCommand = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes('profile_settings')
);
expect(adminCommand).toBeDefined();
expect(agentCommand).toBeDefined();
});
it('should translate section and title for each command', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
expect(useI18n().t).toHaveBeenCalledWith(
expect.stringContaining('COMMAND_BAR.SECTIONS.')
);
expect(useI18n().t).toHaveBeenCalledWith(
expect.stringContaining('COMMAND_BAR.COMMANDS.')
);
expect(command.section).toBeDefined();
expect(command.title).toBeDefined();
});
});
it('should call router.push with correct URL when handler is called', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
command.handler();
expect(useRouter().push).toHaveBeenCalledWith(expect.any(String));
});
});
it('should use current account ID in the path', () => {
store.getters.getCurrentAccountId = 42;
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
command.handler();
expect(useRouter().push).toHaveBeenCalledWith(
expect.stringContaining('42')
);
});
});
it('should include icon for each command', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
expect(command.icon).toBeDefined();
});
});
it('should return commands for all enabled features', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const enabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
);
expect(enabledFeatureCommands.length).toBeGreaterThan(0);
});
it('should not return commands for disabled features', () => {
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(() => false);
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const disabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
);
expect(disabledFeatureCommands.length).toBe(0);
});
});
@@ -0,0 +1,37 @@
import { useInboxHotKeys } from '../useInboxHotKeys';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/helper/routeHelpers');
vi.mock('shared/helpers/mitt');
describe('useInboxHotKeys', () => {
beforeEach(() => {
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRoute.mockReturnValue({ name: 'inbox_dashboard' });
isAInboxViewRoute.mockReturnValue(true);
});
it('should return inbox hot keys when on an inbox view route', () => {
const { inboxHotKeys } = useInboxHotKeys();
expect(inboxHotKeys.value.length).toBeGreaterThan(0);
expect(inboxHotKeys.value[0].id).toBe('snooze_notification');
});
it('should return an empty array when not on an inbox view route', () => {
isAInboxViewRoute.mockReturnValue(false);
const { inboxHotKeys } = useInboxHotKeys();
expect(inboxHotKeys.value).toEqual([]);
});
it('should have the correct structure for snooze actions', () => {
const { inboxHotKeys } = useInboxHotKeys();
const snoozeNotificationAction = inboxHotKeys.value.find(
action => action.id === 'snooze_notification'
);
expect(snoozeNotificationAction).toBeDefined();
});
});
@@ -0,0 +1,70 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import {
ICON_APPEARANCE,
ICON_LIGHT_MODE,
ICON_DARK_MODE,
ICON_SYSTEM_MODE,
} from 'dashboard/helper/commandbar/icons';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
const getThemeOptions = t => [
{
key: 'light',
label: t('COMMAND_BAR.COMMANDS.LIGHT_MODE'),
icon: ICON_LIGHT_MODE,
},
{
key: 'dark',
label: t('COMMAND_BAR.COMMANDS.DARK_MODE'),
icon: ICON_DARK_MODE,
},
{
key: 'auto',
label: t('COMMAND_BAR.COMMANDS.SYSTEM_MODE'),
icon: ICON_SYSTEM_MODE,
},
];
const setAppearance = theme => {
LocalStorage.set(LOCAL_STORAGE_KEYS.COLOR_SCHEME, theme);
const isOSOnDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
setColorTheme(isOSOnDarkMode);
};
export function useAppearanceHotKeys() {
const { t } = useI18n();
const themeOptions = computed(() => getThemeOptions(t));
const goToAppearanceHotKeys = computed(() => {
const options = themeOptions.value.map(theme => ({
id: theme.key,
title: theme.label,
parent: 'appearance_settings',
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: theme.icon,
handler: () => {
setAppearance(theme.key);
},
}));
return [
{
id: 'appearance_settings',
title: t('COMMAND_BAR.COMMANDS.CHANGE_APPEARANCE'),
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: ICON_APPEARANCE,
children: options.map(option => option.id),
},
...options,
];
});
return {
goToAppearanceHotKeys,
};
}
@@ -0,0 +1,89 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useMapGetter } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import {
ICON_SNOOZE_CONVERSATION,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/icons';
import { emitter } from 'shared/helpers/mitt';
import { createSnoozeHandlers } from 'dashboard/helper/commandbar/actions';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const createEmitHandler = event => () => emitter.emit(event);
const SNOOZE_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
'bulk_action_snooze_conversation',
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
),
];
const RESOLVED_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION,
handler: createEmitHandler(CMD_BULK_ACTION_REOPEN_CONVERSATION),
},
];
const OPEN_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION,
handler: createEmitHandler(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
},
];
export function useBulkActionsHotKeys() {
const { t } = useI18n();
const selectedConversations = useMapGetter(
'bulkActions/getSelectedConversationIds'
);
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
}));
};
const bulkActionsHotKeys = computed(() => {
let actions = [];
if (selectedConversations.value.length > 0) {
actions = [
...SNOOZE_CONVERSATION_BULK_ACTIONS,
...RESOLVED_CONVERSATION_BULK_ACTIONS,
...OPEN_CONVERSATION_BULK_ACTIONS,
];
}
return prepareActions(actions);
});
return {
bulkActionsHotKeys,
};
}
@@ -0,0 +1,408 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'dashboard/composables/route';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import wootConstants from 'dashboard/constants/globals';
import {
ICON_ADD_LABEL,
ICON_ASSIGN_AGENT,
ICON_ASSIGN_PRIORITY,
ICON_ASSIGN_TEAM,
ICON_REMOVE_LABEL,
ICON_PRIORITY_URGENT,
ICON_PRIORITY_HIGH,
ICON_PRIORITY_LOW,
ICON_PRIORITY_MEDIUM,
ICON_PRIORITY_NONE,
ICON_AI_ASSIST,
ICON_AI_SUMMARY,
ICON_AI_SHORTEN,
ICON_AI_EXPAND,
ICON_AI_GRAMMAR,
} from 'dashboard/helper/commandbar/icons';
import {
OPEN_CONVERSATION_ACTIONS,
SNOOZE_CONVERSATION_ACTIONS,
RESOLVED_CONVERSATION_ACTIONS,
SEND_TRANSCRIPT_ACTION,
UNMUTE_ACTION,
MUTE_ACTION,
} from 'dashboard/helper/commandbar/actions';
import {
isAConversationRoute,
isAInboxViewRoute,
} from 'dashboard/helper/routeHelpers';
const prepareActions = (actions, t) => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
}));
};
const createPriorityOptions = (t, currentPriority) => {
return [
{
label: t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
icon: ICON_PRIORITY_NONE,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
icon: ICON_PRIORITY_URGENT,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
icon: ICON_PRIORITY_HIGH,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
icon: ICON_PRIORITY_MEDIUM,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
icon: ICON_PRIORITY_LOW,
},
].filter(item => item.key !== currentPriority);
};
const createNonDraftMessageAIAssistActions = (t, replyMode) => {
if (replyMode === REPLY_EDITOR_MODES.REPLY) {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPLY_SUGGESTION'),
key: 'reply_suggestion',
icon: ICON_AI_ASSIST,
},
];
}
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: ICON_AI_SUMMARY,
},
];
};
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.FIX_SPELLING_GRAMMAR'),
key: 'fix_spelling_grammar',
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
icon: ICON_AI_ASSIST,
},
];
};
export function useConversationHotKeys() {
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { agentsList } = useAgentsList();
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const contextMenuChatId = useMapGetter('getContextMenuChatId');
const teams = useMapGetter('teams/getTeams');
const getDraftMessage = useMapGetter('draftMessages/get');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = computed(() => getDraftMessage.value(draftKey.value));
const hasAnAssignedTeam = computed(() => !!currentChat.value?.meta?.team);
const teamsList = computed(() => {
if (hasAnAssignedTeam.value) {
return [{ id: 0, name: t('TEAMS_SETTINGS.LIST.NONE') }, ...teams.value];
}
return teams.value;
});
const onChangeAssignee = action => {
store.dispatch('assignAgent', {
conversationId: currentChat.value.id,
agentId: action.agentInfo.id,
});
};
const onChangePriority = action => {
store.dispatch('assignPriority', {
conversationId: currentChat.value.id,
priority: action.priority.key,
});
};
const onChangeTeam = action => {
store.dispatch('assignTeam', {
conversationId: currentChat.value.id,
teamId: action.teamInfo.id,
});
};
const statusActions = computed(() => {
const isOpen = currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
const isSnoozed =
currentChat.value?.status === wootConstants.STATUS_TYPE.SNOOZED;
const isResolved =
currentChat.value?.status === wootConstants.STATUS_TYPE.RESOLVED;
let actions = [];
if (isOpen) {
actions = [...OPEN_CONVERSATION_ACTIONS, ...SNOOZE_CONVERSATION_ACTIONS];
} else if (isResolved || isSnoozed) {
actions = RESOLVED_CONVERSATION_ACTIONS;
}
return prepareActions(actions, t);
});
const priorityOptions = computed(() =>
createPriorityOptions(t, currentChat.value?.priority)
);
const assignAgentActions = computed(() => {
const agentOptions = agentsList.value.map(agent => ({
id: `agent-${agent.id}`,
title: agent.name,
parent: 'assign_an_agent',
section: t('COMMAND_BAR.SECTIONS.CHANGE_ASSIGNEE'),
agentInfo: agent,
icon: ICON_ASSIGN_AGENT,
handler: onChangeAssignee,
}));
return [
{
id: 'assign_an_agent',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_AN_AGENT'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_AGENT,
children: agentOptions.map(option => option.id),
},
...agentOptions,
];
});
const assignPriorityActions = computed(() => {
const options = priorityOptions.value.map(priority => ({
id: `priority-${priority.key}`,
title: priority.label,
parent: 'assign_priority',
section: t('COMMAND_BAR.SECTIONS.CHANGE_PRIORITY'),
priority: priority,
icon: priority.icon,
handler: onChangePriority,
}));
return [
{
id: 'assign_priority',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_PRIORITY'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_PRIORITY,
children: options.map(option => option.id),
},
...options,
];
});
const assignTeamActions = computed(() => {
const teamOptions = teamsList.value.map(team => ({
id: `team-${team.id}`,
title: team.name,
parent: 'assign_a_team',
section: t('COMMAND_BAR.SECTIONS.CHANGE_TEAM'),
teamInfo: team,
icon: ICON_ASSIGN_TEAM,
handler: onChangeTeam,
}));
return [
{
id: 'assign_a_team',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_A_TEAM'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_TEAM,
children: teamOptions.map(option => option.id),
},
...teamOptions,
];
});
const addLabelActions = computed(() => {
const availableLabels = inactiveLabels.value.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'add_a_label_to_the_conversation',
section: t('COMMAND_BAR.SECTIONS.ADD_LABEL'),
icon: ICON_ADD_LABEL,
handler: action => addLabelToConversation({ title: action.id }),
}));
return [
...availableLabels,
{
id: 'add_a_label_to_the_conversation',
title: t('COMMAND_BAR.COMMANDS.ADD_LABELS_TO_CONVERSATION'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ADD_LABEL,
children: inactiveLabels.value.map(label => label.title),
},
];
});
const removeLabelActions = computed(() => {
const activeLabelsComputed = activeLabels.value.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'remove_a_label_to_the_conversation',
section: t('COMMAND_BAR.SECTIONS.REMOVE_LABEL'),
icon: ICON_REMOVE_LABEL,
handler: action => removeLabelFromConversation(action.id),
}));
return [
...activeLabelsComputed,
{
id: 'remove_a_label_to_the_conversation',
title: t('COMMAND_BAR.COMMANDS.REMOVE_LABEL_FROM_CONVERSATION'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_REMOVE_LABEL,
children: activeLabels.value.map(label => label.title),
},
];
});
const labelActions = computed(() => {
if (activeLabels.value.length) {
return [...addLabelActions.value, ...removeLabelActions.value];
}
return addLabelActions.value;
});
const conversationAdditionalActions = computed(() => {
return prepareActions(
[
currentChat.value.muted ? UNMUTE_ACTION : MUTE_ACTION,
SEND_TRANSCRIPT_ACTION,
],
t
);
});
const AIAssistActions = computed(() => {
const aiOptions = draftMessage.value
? createDraftMessageAIAssistActions(t)
: createNonDraftMessageAIAssistActions(t, replyMode.value);
const options = aiOptions.map(item => ({
id: `ai-assist-${item.key}`,
title: item.label,
parent: 'ai_assist',
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item,
icon: item.icon,
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
}));
return [
{
id: 'ai_assist',
title: t('COMMAND_BAR.COMMANDS.AI_ASSIST'),
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
icon: ICON_AI_ASSIST,
children: options.map(option => option.id),
},
...options,
];
});
const isConversationOrInboxRoute = computed(() => {
return isAConversationRoute(route.name) || isAInboxViewRoute(route.name);
});
const shouldShowSnoozeOption = computed(() => {
return (
isAConversationRoute(route.name, true, false) && contextMenuChatId.value
);
});
const getDefaultConversationHotKeys = computed(() => {
const defaultConversationHotKeys = [
...statusActions.value,
...conversationAdditionalActions.value,
...assignAgentActions.value,
...assignTeamActions.value,
...labelActions.value,
...assignPriorityActions.value,
];
if (isAIIntegrationEnabled.value) {
return [...defaultConversationHotKeys, ...AIAssistActions.value];
}
return defaultConversationHotKeys;
});
const conversationHotKeys = computed(() => {
if (shouldShowSnoozeOption.value) {
return prepareActions(SNOOZE_CONVERSATION_ACTIONS, t);
}
if (isConversationOrInboxRoute.value) {
return getDefaultConversationHotKeys.value;
}
return [];
});
return {
conversationHotKeys,
};
}
@@ -1,3 +1,8 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'dashboard/composables/route';
import { useAdmin } from 'dashboard/composables/useAdmin';
import {
ICON_ACCOUNT_SETTINGS,
ICON_AGENT_REPORTS,
@@ -14,11 +19,9 @@ import {
ICON_TEAM_REPORTS,
ICON_USER_PROFILE,
ICON_CONVERSATION_REPORTS,
} from './CommandBarIcons';
import { frontendURL } from '../../../helper/URLHelper';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { FEATURE_FLAGS } from '../../../featureFlags';
} from 'dashboard/helper/commandbar/icons';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const GO_TO_COMMANDS = [
{
@@ -172,45 +175,45 @@ const GO_TO_COMMANDS = [
},
];
export default {
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
goToCommandHotKeys() {
let commands = GO_TO_COMMANDS.filter(cmd => {
if (cmd.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
cmd.featureFlag
);
}
return true;
});
export function useGoToCommandHotKeys() {
const { t } = useI18n();
const router = useRouter();
const { isAdmin } = useAdmin();
if (!this.isAdmin) {
commands = commands.filter(command => command.role.includes('agent'));
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledOnAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const openRoute = url => {
router.push(frontendURL(url));
};
const goToCommandHotKeys = computed(() => {
let commands = GO_TO_COMMANDS.filter(cmd => {
if (cmd.featureFlag) {
return isFeatureEnabledOnAccount.value(
currentAccountId.value,
cmd.featureFlag
);
}
return true;
});
return commands.map(command => ({
id: command.id,
section: this.$t(command.section),
title: this.$t(command.title),
icon: command.icon,
handler: () => this.openRoute(command.path(this.accountId)),
}));
},
},
methods: {
openRoute(url) {
this.$router.push(frontendURL(url));
},
},
};
if (!isAdmin.value) {
commands = commands.filter(command => command.role.includes('agent'));
}
return commands.map(command => ({
id: command.id,
section: t(command.section),
title: t(command.title),
icon: command.icon,
handler: () => openRoute(command.path(currentAccountId.value)),
}));
});
return {
goToCommandHotKeys,
};
}
@@ -1,13 +1,19 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
import { ICON_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/icons';
import { emitter } from 'shared/helpers/mitt';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const createSnoozeHandler = option => () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, option);
const INBOX_SNOOZE_EVENTS = [
{
id: 'snooze_notification',
@@ -21,8 +27,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
handler: createSnoozeHandler(SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
@@ -30,8 +35,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
@@ -39,8 +43,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
@@ -48,8 +51,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
@@ -57,26 +59,30 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export default {
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
export function useInboxHotKeys() {
const { t } = useI18n();
const route = useRoute();
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: action.section ? t(action.section) : undefined,
}));
};
const inboxHotKeys = computed(() => {
if (isAInboxViewRoute(route.name)) {
return prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
});
return {
inboxHotKeys,
};
}
@@ -0,0 +1,63 @@
import { allAgentsData } from 'dashboard/helper/specs/fixtures/agentFixtures';
export { allAgentsData };
export const formattedAgentsData = [
{
account_id: 0,
confirmed: true,
email: 'None',
id: 0,
name: 'None',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
@@ -0,0 +1,37 @@
export const summary = {
avg_first_response_time: '198.6666666666667',
avg_resolution_time: '208.3333333333333',
conversations_count: 5000,
incoming_messages_count: 5,
outgoing_messages_count: 3,
previous: {
avg_first_response_time: '89.0',
avg_resolution_time: '145.0',
conversations_count: 4,
incoming_messages_count: 5,
outgoing_messages_count: 4,
resolutions_count: 0,
},
resolutions_count: 3,
};
export const botSummary = {
bot_resolutions_count: 10,
bot_handoffs_count: 20,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 5,
},
};
export const report = {
data: [
{ value: '0.00', timestamp: 1647541800, count: 0 },
{ value: '0.00', timestamp: 1647628200, count: 0 },
{ value: '0.00', timestamp: 1647714600, count: 0 },
{ value: '0.00', timestamp: 1647801000, count: 0 },
{ value: '0.01', timestamp: 1647887400, count: 4 },
{ value: '0.00', timestamp: 1647973800, count: 0 },
{ value: '0.00', timestamp: 1648060200, count: 0 },
],
};
@@ -0,0 +1,119 @@
import { useAI } from '../useAI';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from '../useI18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('../useI18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useAI', () => {
const mockStore = {
dispatch: vi.fn(),
};
const mockGetters = {
'integrations/getUIFlags': { value: { isFetching: false } },
'draftMessages/get': { value: () => 'Draft message' },
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useStoreGetters.mockReturnValue(mockGetters);
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useTrack.mockReturnValue(vi.fn());
useI18n.mockReturnValue({ t: vi.fn() });
useAlert.mockReturnValue(vi.fn());
});
it('initializes computed properties correctly', async () => {
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
useAI();
expect(uiFlags.value).toEqual({ isFetching: false });
expect(appIntegrations.value).toEqual([]);
expect(currentChat.value).toEqual({ id: '123' });
expect(replyMode.value).toBe('reply');
expect(draftMessage.value).toBe('Draft message');
});
it('fetches integrations if required', async () => {
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations if already loaded', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [{ id: 'openai' }],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('records analytics correctly', async () => {
const mockTrack = vi.fn();
useTrack.mockReturnValue(mockTrack);
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [
{ id: 'openai', hooks: [{ id: 'hook1' }] },
],
getSelectedChat: { id: '123' },
};
return { value: mockValues[getter] };
});
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
expect(result).toEqual(['label1', 'label2']);
});
});
@@ -0,0 +1,37 @@
import { ref } from 'vue';
import { describe, it, expect, vi } from 'vitest';
import { useAccount } from '../useAccount';
import { useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useAccount', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
getCurrentAccountId: ref(123),
});
});
it('returns accountId as a computed property', () => {
const { accountId } = useAccount();
expect(accountId.value).toBe(123);
});
it('generates account-scoped URLs correctly', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('settings/inbox/new');
expect(result).toBe('/app/accounts/123/settings/inbox/new');
});
it('handles URLs with leading slash', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('users');
expect(result).toBe('/app/accounts/123/users');
});
it('handles empty URL', () => {
const { accountScopedUrl } = useAccount();
const result = accountScopedUrl('');
expect(result).toBe('/app/accounts/123/');
});
});
@@ -0,0 +1,91 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/agentHelper');
const mockUseMapGetter = (overrides = {}) => {
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
};
const mergedGetters = { ...defaultGetters, ...overrides };
useMapGetter.mockImplementation(getter => mergedGetters[getter]);
};
describe('useAgentsList', () => {
beforeEach(() => {
vi.clearAllMocks();
agentHelper.getAgentsByUpdatedPresence.mockImplementation(agents => agents);
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
formattedAgentsData.slice(1)
);
agentHelper.getCombinedAgents.mockImplementation(
(agents, includeNone, isAgentSelected) => {
if (includeNone && isAgentSelected) {
return [agentHelper.createNoneAgent, ...agents];
}
return agents;
}
);
mockUseMapGetter();
});
it('returns agentsList and assignableAgents', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(agentsList.value).toEqual([
agentHelper.createNoneAgent,
...formattedAgentsData.slice(1),
]);
});
it('includes None agent when includeNoneAgent is true', () => {
const { agentsList } = useAgentsList(true);
expect(agentsList.value[0]).toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length);
});
it('excludes None agent when includeNoneAgent is false', () => {
const { agentsList } = useAgentsList(false);
expect(agentsList.value[0]).not.toEqual(agentHelper.createNoneAgent);
expect(agentsList.value.length).toBe(formattedAgentsData.length - 1);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
});
it('handles missing inbox_id', () => {
mockUseMapGetter({
getSelectedChat: ref({ meta: { assignee: true } }),
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
});
});
@@ -0,0 +1,51 @@
import { useConfig } from '../useConfig';
describe('useConfig', () => {
const originalChatwootConfig = window.chatwootConfig;
beforeEach(() => {
window.chatwootConfig = {
hostURL: 'https://example.com',
vapidPublicKey: 'vapid-key',
enabledLanguages: ['en', 'fr'],
isEnterprise: 'true',
enterprisePlanName: 'enterprise',
};
});
afterEach(() => {
window.chatwootConfig = originalChatwootConfig;
});
it('returns the correct configuration values', () => {
const config = useConfig();
expect(config.hostURL).toBe('https://example.com');
expect(config.vapidPublicKey).toBe('vapid-key');
expect(config.enabledLanguages).toEqual(['en', 'fr']);
expect(config.isEnterprise).toBe(true);
expect(config.enterprisePlanName).toBe('enterprise');
});
it('handles missing configuration values', () => {
window.chatwootConfig = {};
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
it('handles undefined window.chatwootConfig', () => {
window.chatwootConfig = undefined;
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { useConversationLabels } from '../useConversationLabels';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useConversationLabels', () => {
let store;
let getters;
beforeEach(() => {
store = {
getters: {
'conversationLabels/getConversationLabels': vi.fn(),
},
dispatch: vi.fn(),
};
getters = {
getSelectedChat: { value: { id: 1 } },
'labels/getLabels': {
value: [
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
{ id: 3, title: 'Label 3' },
],
},
};
useStore.mockReturnValue(store);
useStoreGetters.mockReturnValue(getters);
});
it('should return the correct computed properties', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
useConversationLabels();
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
expect(activeLabels.value).toEqual([
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
]);
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
});
it('should update labels correctly', async () => {
const { onUpdateLabels } = useConversationLabels();
await onUpdateLabels(['Label 1', 'Label 3']);
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 3'],
});
});
it('should add a label to the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
]);
const { addLabelToConversation } = useConversationLabels();
addLabelToConversation({ title: 'Label 2' });
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 2'],
});
});
it('should remove a label from the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { removeLabelFromConversation } = useConversationLabels();
removeLabelFromConversation('Label 2');
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1'],
});
});
});
@@ -0,0 +1,56 @@
import { useIntegrationHook } from '../useIntegrationHook';
import { useMapGetter } from 'dashboard/composables/store';
import { nextTick } from 'vue';
vi.mock('dashboard/composables/store');
describe('useIntegrationHook', () => {
let integrationGetter;
beforeEach(() => {
integrationGetter = vi.fn();
useMapGetter.mockReturnValue({ value: integrationGetter });
});
it('should return the correct computed properties', async () => {
const mockIntegration = {
id: 1,
hook_type: 'inbox',
hooks: ['hook1', 'hook2'],
allow_multiple_hooks: true,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(1);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('multiple');
expect(hook.isIntegrationMultiple.value).toBe(true);
expect(hook.isIntegrationSingle.value).toBe(false);
expect(hook.isHookTypeInbox.value).toBe(true);
expect(hook.hasConnectedHooks.value).toBe(true);
});
it('should handle single integration type correctly', async () => {
const mockIntegration = {
id: 2,
hook_type: 'channel',
hooks: [],
allow_multiple_hooks: false,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(2);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('single');
expect(hook.isIntegrationMultiple.value).toBe(false);
expect(hook.isIntegrationSingle.value).toBe(true);
expect(hook.isHookTypeInbox.value).toBe(false);
expect(hook.hasConnectedHooks.value).toBe(false);
});
});
@@ -1,4 +1,3 @@
import { unref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
describe('useKeyboardEvents', () => {
@@ -11,15 +10,13 @@ describe('useKeyboardEvents', () => {
});
it('should set up listeners on mount and remove them on unmount', async () => {
const el = document.createElement('div');
const elRef = unref({ value: el });
const events = {
'ALT+KeyL': () => {},
};
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events, elRef);
useKeyboardEvents(events);
mountedMock();
unmountedMock();
@@ -9,7 +9,6 @@ vi.mock('../useKeyboardEvents', () => ({
}));
describe('useKeyboardNavigableList', () => {
let elementRef;
let items;
let onSelect;
let adjustScroll;
@@ -18,7 +17,6 @@ describe('useKeyboardNavigableList', () => {
const createMockEvent = () => ({ preventDefault: vi.fn() });
beforeEach(() => {
elementRef = ref(null);
items = ref(['item1', 'item2', 'item3']);
onSelect = vi.fn();
adjustScroll = vi.fn();
@@ -28,7 +26,6 @@ describe('useKeyboardNavigableList', () => {
it('should return moveSelectionUp and moveSelectionDown functions', () => {
const result = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -43,7 +40,6 @@ describe('useKeyboardNavigableList', () => {
it('should move selection up correctly', () => {
const { moveSelectionUp } = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -65,7 +61,6 @@ describe('useKeyboardNavigableList', () => {
it('should move selection down correctly', () => {
const { moveSelectionDown } = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -87,7 +82,6 @@ describe('useKeyboardNavigableList', () => {
it('should call adjustScroll after moving selection', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -103,7 +97,6 @@ describe('useKeyboardNavigableList', () => {
it('should include Enter key handler when onSelect is provided', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -118,7 +111,6 @@ describe('useKeyboardNavigableList', () => {
it('should not include Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
elementRef,
items,
adjustScroll,
selectedIndex,
@@ -131,7 +123,6 @@ describe('useKeyboardNavigableList', () => {
it('should not trigger onSelect when items are empty', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
elementRef,
items: ref([]),
onSelect,
adjustScroll,
@@ -145,23 +136,18 @@ describe('useKeyboardNavigableList', () => {
it('should call useKeyboardEvents with correct parameters', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(useKeyboardEvents).toHaveBeenCalledWith(
expect.any(Object),
elementRef
);
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
});
// Keyboard event handlers
it('should handle ArrowUp key', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -178,7 +164,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle Control+KeyP', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -195,7 +180,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle ArrowDown key', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -212,7 +196,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle Control+KeyN', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -229,7 +212,6 @@ describe('useKeyboardNavigableList', () => {
it('should handle Enter key when onSelect is provided', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -245,7 +227,6 @@ describe('useKeyboardNavigableList', () => {
it('should not have Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
elementRef,
items,
adjustScroll,
selectedIndex,
@@ -257,7 +238,6 @@ describe('useKeyboardNavigableList', () => {
it('should set allowOnFocusedInput to true for all key handlers', () => {
useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -0,0 +1,61 @@
import { ref } from 'vue';
import { useReportMetrics } from '../useReportMetrics';
import { useMapGetter } from 'dashboard/composables/store';
import { summary, botSummary } from './fixtures/reportFixtures';
vi.mock('dashboard/composables/store');
vi.mock('@chatwoot/utils', () => ({
formatTime: vi.fn(time => `formatted_${time}`),
}));
describe('useReportMetrics', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockReturnValue(ref(summary));
});
it('calculates trend correctly', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('conversations_count')).toBe(124900);
expect(calculateTrend('incoming_messages_count')).toBe(0);
expect(calculateTrend('avg_first_response_time')).toBe(123);
});
it('returns 0 for trend when previous value is not available', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('non_existent_key')).toBe(0);
});
it('identifies average metric types correctly', () => {
const { isAverageMetricType } = useReportMetrics();
expect(isAverageMetricType('avg_first_response_time')).toBe(true);
expect(isAverageMetricType('avg_resolution_time')).toBe(true);
expect(isAverageMetricType('reply_time')).toBe(true);
expect(isAverageMetricType('conversations_count')).toBe(false);
});
it('displays metrics correctly for account', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('conversations_count')).toBe('5,000');
expect(displayMetric('incoming_messages_count')).toBe('5');
});
it('displays the metric for bot', () => {
const customKey = 'getBotSummary';
useMapGetter.mockReturnValue(ref(botSummary));
const { displayMetric } = useReportMetrics(customKey);
expect(displayMetric('bot_resolutions_count')).toBe('10');
expect(displayMetric('bot_handoffs_count')).toBe('20');
});
it('handles non-existent metrics', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('non_existent_key')).toBe('0');
});
});
@@ -17,7 +17,7 @@ export const useStoreGetters = () => {
);
};
export const mapGetter = key => {
export const useMapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};
@@ -0,0 +1,204 @@
import { computed, onMounted } from 'vue';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from './useI18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index);
};
/**
* A composable function for AI-related operations in the dashboard.
* @returns {Object} An object containing AI-related methods and computed properties.
*/
export function useAI() {
const store = useStore();
const getters = useStoreGetters();
const track = useTrack();
const { t } = useI18n();
/**
* Computed property for UI flags.
* @type {import('vue').ComputedRef<Object>}
*/
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
/**
* Computed property for the AI integration.
* @type {import('vue').ComputedRef<Object|undefined>}
*/
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
/**
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
/**
* Computed property to check if label suggestion feature is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return settings.label_suggestion;
}
return false;
});
/**
* Computed property to check if app integrations are being fetched.
* @type {import('vue').ComputedRef<boolean>}
*/
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
/**
* Computed property for the hook ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* Computed property for the draft key.
* @type {import('vue').ComputedRef<string>}
*/
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
/**
* Computed property for the draft message.
* @type {import('vue').ComputedRef<string>}
*/
const draftMessage = computed(() =>
getters['draftMessages/get'].value(draftKey.value)
);
/**
* Fetches integrations if they haven't been loaded yet.
* @returns {Promise<void>}
*/
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
track(event, {
type,
...payload,
});
}
};
/**
* Fetches label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const fetchLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
*/
const processEvent = async (type = 'rephrase') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
draftMessage,
uiFlags,
appIntegrations,
currentChat,
replyMode,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
isFetchingAppIntegrations,
fetchIntegrationsIfRequired,
recordAnalytics,
fetchLabelSuggestions,
processEvent,
};
}
@@ -0,0 +1,30 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for account-related operations.
* @returns {Object} An object containing account-related properties and methods.
*/
export function useAccount() {
const getters = useStoreGetters();
/**
* Computed property for the current account ID.
* @type {import('vue').ComputedRef<number>}
*/
const accountId = computed(() => getters.getCurrentAccountId.value);
/**
* Generates an account-scoped URL.
* @param {string} url - The URL to be scoped to the account.
* @returns {string} The account-scoped URL.
*/
const accountScopedUrl = url => {
return `/app/accounts/${accountId.value}/${url}`;
};
return {
accountId,
accountScopedUrl,
};
}
@@ -0,0 +1,57 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
getCombinedAgents,
} from 'dashboard/helper/agentHelper';
/**
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
/**
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
currentAccountId.value
);
const filteredAgentsByAvailability = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return getCombinedAgents(
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected.value
);
});
return {
agentsList,
assignableAgents,
};
}
@@ -0,0 +1,46 @@
/**
* A function that provides access to various configuration values.
* @returns {Object} An object containing configuration values.
*/
export function useConfig() {
const config = window.chatwootConfig || {};
/**
* The host URL of the Chatwoot instance.
* @type {string|undefined}
*/
const hostURL = config.hostURL;
/**
* The VAPID public key for web push notifications.
* @type {string|undefined}
*/
const vapidPublicKey = config.vapidPublicKey;
/**
* An array of enabled languages in the Chatwoot instance.
* @type {string[]|undefined}
*/
const enabledLanguages = config.enabledLanguages;
/**
* Indicates whether the current instance is an enterprise version.
* @type {boolean}
*/
const isEnterprise = config.isEnterprise === 'true';
/**
* The name of the enterprise plan, if applicable.
* Returns "community" or "enterprise"
* @type {string|undefined}
*/
const enterprisePlanName = config.enterprisePlanName;
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
};
}
@@ -0,0 +1,101 @@
import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for managing conversation labels
* @returns {Object} An object containing methods and computed properties for conversation labels
*/
export function useConversationLabels() {
const store = useStore();
const getters = useStoreGetters();
/**
* The currently selected chat
* @type {import('vue').ComputedRef<Object>}
*/
const currentChat = computed(() => getters.getSelectedChat.value);
/**
* The ID of the current conversation
* @type {import('vue').ComputedRef<number|null>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* All labels available for the account
* @type {import('vue').ComputedRef<Array>}
*/
const accountLabels = computed(() => getters['labels/getLabels'].value);
/**
* Labels currently saved to the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const savedLabels = computed(() => {
return store.getters['conversationLabels/getConversationLabels'](
conversationId.value
);
});
/**
* Labels currently active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const activeLabels = computed(() =>
accountLabels.value.filter(({ title }) => savedLabels.value.includes(title))
);
/**
* Labels available but not active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const inactiveLabels = computed(() =>
accountLabels.value.filter(
({ title }) => !savedLabels.value.includes(title)
)
);
/**
* Updates the labels for the current conversation
* @param {string[]} selectedLabels - Array of label titles to be set for the conversation
* @returns {Promise<void>}
*/
const onUpdateLabels = async selectedLabels => {
await store.dispatch('conversationLabels/update', {
conversationId: conversationId.value,
labels: selectedLabels,
});
};
/**
* Adds a label to the current conversation
* @param {Object} value - The label object to be added
* @param {string} value.title - The title of the label to be added
*/
const addLabelToConversation = value => {
const result = activeLabels.value.map(item => item.title);
result.push(value.title);
onUpdateLabels(result);
};
/**
* Removes a label from the current conversation
* @param {string} value - The title of the label to be removed
*/
const removeLabelFromConversation = value => {
const result = activeLabels.value
.map(label => label.title)
.filter(label => label !== value);
onUpdateLabels(result);
};
return {
accountLabels,
savedLabels,
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
onUpdateLabels,
};
}
@@ -0,0 +1,68 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
/**
* Composable for managing integration hooks
* @param {string|number} integrationId - The ID of the integration
* @returns {Object} An object containing computed properties for the integration
*/
export const useIntegrationHook = integrationId => {
const integrationGetter = useMapGetter('integrations/getIntegration');
/**
* The integration object
* @type {import('vue').ComputedRef<Object>}
*/
const integration = computed(() => {
return integrationGetter.value(integrationId);
});
/**
* Whether the integration hook type is 'inbox'
* @type {import('vue').ComputedRef<boolean>}
*/
const isHookTypeInbox = computed(() => {
return integration.value.hook_type === 'inbox';
});
/**
* Whether the integration has any connected hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const hasConnectedHooks = computed(() => {
return !!integration.value.hooks.length;
});
/**
* The type of integration: 'multiple' or 'single'
* @type {import('vue').ComputedRef<string>}
*/
const integrationType = computed(() => {
return integration.value.allow_multiple_hooks ? 'multiple' : 'single';
});
/**
* Whether the integration allows multiple hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationMultiple = computed(() => {
return integrationType.value === 'multiple';
});
/**
* Whether the integration allows only a single hook
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationSingle = computed(() => {
return integrationType.value === 'single';
});
return {
integration,
integrationType,
isIntegrationMultiple,
isIntegrationSingle,
isHookTypeInbox,
hasConnectedHooks,
};
};
@@ -1,4 +1,3 @@
import { onMounted, onBeforeUnmount, unref } from 'vue';
import {
isActiveElementTypeable,
isEscape,
@@ -7,8 +6,7 @@ import {
} from 'shared/helpers/KeyboardHelpers';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { createKeybindingsHandler } from 'tinykeys';
const keyboardListenerMap = new WeakMap();
import { onUnmounted, onMounted } from 'vue';
/**
* Determines if the keyboard event should be ignored based on the element type and handler settings.
@@ -69,49 +67,24 @@ async function wrapEventsInKeybindingsHandler(events) {
return wrappedEvents;
}
/**
* Sets up keyboard event listeners on the specified element.
* @param {Element} root - The DOM element to attach listeners to.
* @param {Object} events - The events to listen for.
*/
const setupListeners = (root, events) => {
if (root instanceof Element && events) {
const keydownHandler = createKeybindingsHandler(events);
document.addEventListener('keydown', keydownHandler);
keyboardListenerMap.set(root, keydownHandler);
}
};
/**
* Removes keyboard event listeners from the specified element.
* @param {Element} root - The DOM element to remove listeners from.
*/
const removeListeners = root => {
// In the future, let's use the abort controller to remove the listeners
// https://caniuse.com/abortcontroller
if (root instanceof Element) {
const handlerToRemove = keyboardListenerMap.get(root);
document.removeEventListener('keydown', handlerToRemove);
keyboardListenerMap.delete(root);
}
};
/**
* Vue composable to handle keyboard events with support for different keyboard layouts.
* @param {Object} keyboardEvents - The keyboard events to handle.
* @param {ref} elRef - A Vue ref to the element to attach the keyboard events to.
*/
export function useKeyboardEvents(keyboardEvents, elRef = null) {
export async function useKeyboardEvents(keyboardEvents) {
let abortController = new AbortController();
onMounted(async () => {
const el = unref(elRef);
const getKeyboardEvents = () => keyboardEvents || null;
const events = getKeyboardEvents();
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
setupListeners(el, wrappedEvents);
if (!keyboardEvents) return;
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
const keydownHandler = createKeybindingsHandler(wrappedEvents);
document.addEventListener('keydown', keydownHandler, {
signal: abortController.signal,
});
});
onBeforeUnmount(() => {
const el = unref(elRef);
removeListeners(el);
onUnmounted(() => {
abortController.abort();
});
}
@@ -84,7 +84,6 @@ const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
* }} An object containing functions to move the selection up and down.
*/
export function useKeyboardNavigableList({
elementRef,
items,
onSelect,
adjustScroll,
@@ -109,7 +108,7 @@ export function useKeyboardNavigableList({
items
);
useKeyboardEvents(keyboardEvents, elementRef);
useKeyboardEvents(keyboardEvents);
return {
moveSelectionUp,
@@ -0,0 +1,57 @@
import { useMapGetter } from 'dashboard/composables/store';
import { formatTime } from '@chatwoot/utils';
/**
* A composable function for report metrics calculations and display.
*
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
* @returns {Object} An object containing utility functions for report metrics.
*/
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
const accountSummary = useMapGetter(accountSummaryKey);
/**
* Calculates the trend percentage for a given metric.
*
* @param {string} key - The key of the metric to calculate trend for.
* @returns {number} The calculated trend percentage, rounded to the nearest integer.
*/
const calculateTrend = key => {
if (!accountSummary.value.previous[key]) return 0;
const diff = accountSummary.value[key] - accountSummary.value.previous[key];
return Math.round((diff / accountSummary.value.previous[key]) * 100);
};
/**
* Checks if a given metric key represents an average metric type.
*
* @param {string} key - The key of the metric to check.
* @returns {boolean} True if the metric is an average type, false otherwise.
*/
const isAverageMetricType = key => {
return [
'avg_first_response_time',
'avg_resolution_time',
'reply_time',
].includes(key);
};
/**
* Formats and displays a metric value based on its type.
*
* @param {string} key - The key of the metric to display.
* @returns {string} The formatted metric value as a string.
*/
const displayMetric = key => {
if (isAverageMetricType(key)) {
return formatTime(accountSummary.value[key]);
}
return Number(accountSummary.value[key] || '').toLocaleString();
};
return {
calculateTrend,
isAverageMetricType,
displayMetric,
};
}
@@ -108,3 +108,12 @@ export const hasValidAvatarUrl = avatarUrl => {
return false;
}
};
export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
return url.toString();
};
@@ -0,0 +1,83 @@
/**
* Default agent object representing 'None'
* @type {Object}
*/
export const createNoneAgent = {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
/**
* Filters and sorts agents by availability status
* @param {Array} agents - List of agents
* @param {string} availability - Availability status to filter by
* @returns {Array} Filtered and sorted list of agents
*/
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
};
/**
* Sorts agents by availability status: online, busy, then offline
* @param {Array} agents - List of agents
* @returns {Array} Sorted list of agents
*/
export const getSortedAgentsByAvailability = agents => {
const onlineAgents = getAgentsByAvailability(agents, 'online');
const busyAgents = getAgentsByAvailability(agents, 'busy');
const offlineAgents = getAgentsByAvailability(agents, 'offline');
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
return filteredAgents;
};
/**
* Updates the availability status of the current user based on the current account
* @param {Array} agents - List of agents
* @param {Object} currentUser - Current user object
* @param {number} currentAccountId - ID of the current account
* @returns {Array} Updated list of agents with dynamic presence
*/
// Here we are updating the availability status of the current user dynamically
// based on the current account availability status
export const getAgentsByUpdatedPresence = (
agents,
currentUser,
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id
? {
...item,
availability_status: currentUser.accounts.find(
account => account.id === currentAccountId
).availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
};
/**
* Combines the filtered agents with the 'None' agent option if applicable.
*
* @param {Array} filteredAgentsByAvailability - The list of agents sorted by availability.
* @param {boolean} includeNoneAgent - Whether to include the 'None' agent option.
* @param {boolean} isAgentSelected - Whether an agent is currently selected.
* @returns {Array} The combined list of agents, potentially including the 'None' agent.
*/
export const getCombinedAgents = (
filteredAgentsByAvailability,
includeNoneAgent,
isAgentSelected
) => {
return [
...(includeNoneAgent && isAgentSelected ? [createNoneAgent] : []),
...filteredAgentsByAvailability,
];
};
@@ -8,7 +8,7 @@ import {
CMD_SEND_TRANSCRIPT,
CMD_SNOOZE_CONVERSATION,
CMD_UNMUTE_CONVERSATION,
} from './commandBarBusEvents';
} from 'dashboard/helper/commandbar/events';
import {
ICON_MUTE_CONVERSATION,
@@ -17,7 +17,7 @@ import {
ICON_SEND_TRANSCRIPT,
ICON_SNOOZE_CONVERSATION,
ICON_UNMUTE_CONVERSATION,
} from './CommandBarIcons';
} from 'dashboard/helper/commandbar/icons';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -46,6 +46,7 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
@@ -0,0 +1,94 @@
/**
* Determines the last non-activity message between store and API messages.
* @param {Object} messageInStore - The last non-activity message from the store.
* @param {Object} messageFromAPI - The last non-activity message from the API.
* @returns {Object} The latest non-activity message.
*/
const getLastNonActivityMessage = (messageInStore, messageFromAPI) => {
// If both API value and store value for last non activity message
// are available, then return the latest one.
if (messageInStore && messageFromAPI) {
return messageInStore.created_at >= messageFromAPI.created_at
? messageInStore
: messageFromAPI;
}
// Otherwise, return whichever is available
return messageInStore || messageFromAPI;
};
/**
* Filters out duplicate source messages from an array of messages.
* @param {Array} messages - The array of messages to filter.
* @returns {Array} An array of messages without duplicates.
*/
export const filterDuplicateSourceMessages = (messages = []) => {
const messagesWithoutDuplicates = [];
// We cannot use Map or any short hand method as it returns the last message with the duplicate ID
// We should return the message with smaller id when there is a duplicate
messages.forEach(m1 => {
if (m1.source_id) {
const index = messagesWithoutDuplicates.findIndex(
m2 => m1.source_id === m2.source_id
);
if (index < 0) {
messagesWithoutDuplicates.push(m1);
}
} else {
messagesWithoutDuplicates.push(m1);
}
});
return messagesWithoutDuplicates;
};
/**
* Retrieves the last message from a conversation, prioritizing non-activity messages.
* @param {Object} m - The conversation object containing messages.
* @returns {Object} The last message of the conversation.
*/
export const getLastMessage = m => {
const lastMessageIncludingActivity = m.messages[m.messages.length - 1];
const nonActivityMessages = m.messages.filter(
message => message.message_type !== 2
);
const lastNonActivityMessageInStore =
nonActivityMessages[nonActivityMessages.length - 1];
const lastNonActivityMessageFromAPI = m.last_non_activity_message;
// If API value and store value for last non activity message
// is empty, then return the last activity message
if (!lastNonActivityMessageInStore && !lastNonActivityMessageFromAPI) {
return lastMessageIncludingActivity;
}
return getLastNonActivityMessage(
lastNonActivityMessageInStore,
lastNonActivityMessageFromAPI
);
};
/**
* Filters messages that have been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of read messages.
*/
export const getReadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 <= agentLastSeenAt * 1000
);
};
/**
* Filters messages that have not been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of unread messages.
*/
export const getUnreadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 > agentLastSeenAt * 1000
);
};
@@ -1,5 +1,6 @@
const FEATURE_HELP_URLS = {
agent_bots: 'https://chwt.app/hc/agent-bots',
agents: 'https://chwt.app/hc/agents',
audit_logs: 'https://chwt.app/hc/audit-logs',
campaigns: 'https://chwt.app/hc/campaigns',
canned_responses: 'https://chwt.app/hc/canned',
@@ -10,6 +11,7 @@ const FEATURE_HELP_URLS = {
help_center: 'https://chwt.app/hc/help-center',
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
message_reply_to: 'https://chwt.app/hc/reply-to',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
@@ -7,6 +7,15 @@ export const hasPermissions = (
);
};
export const getCurrentAccount = ({ accounts } = {}, accountId = null) => {
return accounts.find(account => Number(account.id) === Number(accountId));
};
export const getUserPermissions = (user, accountId) => {
const currentAccount = getCurrentAccount(user, accountId) || {};
return currentAccount.permissions || [];
};
const isPermissionsPresentInRoute = route =>
route.meta && route.meta.permissions;
@@ -1,9 +1,8 @@
import { hasPermissions } from './permissionsHelper';
// eslint-disable-next-line default-param-last
export const getCurrentAccount = ({ accounts } = {}, accountId) => {
return accounts.find(account => account.id === accountId);
};
import {
hasPermissions,
getUserPermissions,
getCurrentAccount,
} from './permissionsHelper';
export const routeIsAccessibleFor = (route, userPermissions = []) => {
const { meta: { permissions: routePermissions = [] } = {} } = route;
@@ -19,7 +18,9 @@ const validateActiveAccountRoutes = (to, user) => {
return accountDashboardURL;
}
const isAccessible = routeIsAccessibleFor(to, user.permissions);
const userPermissions = getUserPermissions(user, to.params.accountId);
const isAccessible = routeIsAccessibleFor(to, userPermissions);
// If the route is not accessible for the user, return to dashboard screen
return isAccessible ? null : accountDashboardURL;
};
@@ -5,6 +5,7 @@ import {
conversationListPageURL,
getArticleSearchURL,
hasValidAvatarUrl,
timeStampAppendedURL,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -190,4 +191,51 @@ describe('#URL Helpers', () => {
expect(hasValidAvatarUrl()).toBe(false);
});
});
describe('timeStampAppendedURL', () => {
const FIXED_TIMESTAMP = 1234567890000;
beforeEach(() => {
vi.spyOn(Date, 'now').mockImplementation(() => FIXED_TIMESTAMP);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should append timestamp to a URL without query parameters', () => {
const input = 'https://example.com/audio.mp3';
const expected = `https://example.com/audio.mp3?t=${FIXED_TIMESTAMP}`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should append timestamp to a URL with existing query parameters', () => {
const input = 'https://example.com/audio.mp3?volume=50';
const expected = `https://example.com/audio.mp3?volume=50&t=${FIXED_TIMESTAMP}`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should not append timestamp if it already exists', () => {
const input = 'https://example.com/audio.mp3?t=9876543210';
expect(timeStampAppendedURL(input)).toBe(input);
});
it('should handle URLs with hash fragments', () => {
const input = 'https://example.com/audio.mp3#section1';
const expected = `https://example.com/audio.mp3?t=${FIXED_TIMESTAMP}#section1`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should handle complex URLs', () => {
const input =
'https://example.com/path/to/audio.mp3?key1=value1&key2=value2#fragment';
const expected = `https://example.com/path/to/audio.mp3?key1=value1&key2=value2&t=${FIXED_TIMESTAMP}#fragment`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should throw an error for invalid URLs', () => {
const input = 'not a valid url';
expect(() => timeStampAppendedURL(input)).toThrow();
});
});
});
@@ -0,0 +1,131 @@
import {
getAgentsByAvailability,
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
getCombinedAgents,
createNoneAgent,
} from '../agentHelper';
import {
allAgentsData,
onlineAgentsData,
busyAgentsData,
offlineAgentsData,
sortedByAvailability,
formattedAgentsByPresenceOnline,
formattedAgentsByPresenceOffline,
} from 'dashboard/helper/specs/fixtures/agentFixtures';
describe('agentHelper', () => {
describe('getAgentsByAvailability', () => {
it('returns agents by availability', () => {
expect(getAgentsByAvailability(allAgentsData, 'online')).toEqual(
onlineAgentsData
);
expect(getAgentsByAvailability(allAgentsData, 'busy')).toEqual(
busyAgentsData
);
expect(getAgentsByAvailability(allAgentsData, 'offline')).toEqual(
offlineAgentsData
);
});
});
describe('getSortedAgentsByAvailability', () => {
it('returns sorted agents by availability', () => {
expect(getSortedAgentsByAvailability(allAgentsData)).toEqual(
sortedByAvailability
);
});
it('returns an empty array when given an empty input', () => {
expect(getSortedAgentsByAvailability([])).toEqual([]);
});
it('maintains the order of agents with the same availability status', () => {
const result = getSortedAgentsByAvailability(allAgentsData);
expect(result[2].name).toBe('Honey Bee');
expect(result[3].name).toBe('Samuel Keta');
});
});
describe('getAgentsByUpdatedPresence', () => {
it('returns agents with updated presence', () => {
const currentUser = {
id: 1,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence(
formattedAgentsByPresenceOnline,
currentUser,
currentAccountId
)
).toEqual(formattedAgentsByPresenceOffline);
});
it('does not modify other agents presence', () => {
const currentUser = {
id: 2,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence(
formattedAgentsByPresenceOnline,
currentUser,
currentAccountId
)
).toEqual(formattedAgentsByPresenceOnline);
});
it('handles empty agent list', () => {
const currentUser = {
id: 1,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence([], currentUser, currentAccountId)
).toEqual([]);
});
});
describe('getCombinedAgents', () => {
it('includes None agent when includeNoneAgent is true and isAgentSelected is true', () => {
const result = getCombinedAgents(sortedByAvailability, true, true);
expect(result).toEqual([createNoneAgent, ...sortedByAvailability]);
expect(result.length).toBe(sortedByAvailability.length + 1);
expect(result[0]).toEqual(createNoneAgent);
});
it('excludes None agent when includeNoneAgent is false', () => {
const result = getCombinedAgents(sortedByAvailability, false, true);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
expect(result[0]).not.toEqual(createNoneAgent);
});
it('excludes None agent when isAgentSelected is false', () => {
const result = getCombinedAgents(sortedByAvailability, true, false);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
expect(result[0]).not.toEqual(createNoneAgent);
});
it('returns only filtered agents when both includeNoneAgent and isAgentSelected are false', () => {
const result = getCombinedAgents(sortedByAvailability, false, false);
expect(result).toEqual(sortedByAvailability);
expect(result.length).toBe(sortedByAvailability.length);
});
it('handles empty filteredAgentsByAvailability array', () => {
const result = getCombinedAgents([], true, true);
expect(result).toEqual([createNoneAgent]);
expect(result.length).toBe(1);
});
});
});
@@ -0,0 +1,101 @@
import {
filterDuplicateSourceMessages,
getLastMessage,
getReadMessages,
getUnreadMessages,
} from '../conversationHelper';
import {
conversationData,
lastMessageData,
readMessagesData,
unReadMessagesData,
} from './fixtures/conversationFixtures';
describe('conversationHelper', () => {
describe('#filterDuplicateSourceMessages', () => {
it('returns messages without duplicate source_id and all messages without source_id', () => {
const input = [
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_1', id: 5 },
{ source_id: 'wa_1', id: 6 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_2', id: 8 },
{ source_id: 'wa_3', id: 9 },
];
const expected = [
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_3', id: 9 },
];
expect(filterDuplicateSourceMessages(input)).toEqual(expected);
});
});
describe('#readMessages', () => {
it('should return read messages if conversation is passed', () => {
expect(
getReadMessages(
conversationData.messages,
conversationData.agent_last_seen_at
)
).toEqual(readMessagesData);
});
});
describe('#unReadMessages', () => {
it('should return unread messages if conversation is passed', () => {
expect(
getUnreadMessages(
conversationData.messages,
conversationData.agent_last_seen_at
)
).toEqual(unReadMessagesData);
});
});
describe('#lastMessage', () => {
it("should return last activity message if both api and store doesn't have other messages", () => {
const testConversation = {
messages: [conversationData.messages[0]],
last_non_activity_message: null,
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[0]
);
});
it('should return message from store if store has latest message', () => {
const testConversation = {
messages: [],
last_non_activity_message: lastMessageData,
};
expect(getLastMessage(testConversation)).toEqual(lastMessageData);
});
it('should return last non activity message from store if api value is empty', () => {
const testConversation = {
messages: [conversationData.messages[0], conversationData.messages[1]],
last_non_activity_message: null,
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[1]
);
});
it("should return last non activity message from store if store doesn't have any messages", () => {
const testConversation = {
messages: [conversationData.messages[1], conversationData.messages[2]],
last_non_activity_message: conversationData.messages[0],
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[1]
);
});
});
});
@@ -0,0 +1,184 @@
export const allAgentsData = [
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
];
export const onlineAgentsData = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
];
export const busyAgentsData = [
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
];
export const offlineAgentsData = [
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
export const sortedByAvailability = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
export const formattedAgentsByPresenceOnline = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
];
export const formattedAgentsByPresenceOffline = [
{
account_id: 1,
availability_status: 'offline',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
];
@@ -0,0 +1,185 @@
export const conversationData = {
meta: {
sender: {
additional_attributes: {
created_at_ip: '127.0.0.1',
},
availability_status: 'offline',
email: null,
id: 5017687,
name: 'long-flower-143',
phone_number: null,
thumbnail: '',
custom_attributes: {},
},
channel: 'Channel::WebWidget',
assignee: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'muhsin@chatwoot.com',
available_name: 'Muhsin Keloth',
id: 21,
name: 'Muhsin Keloth',
role: 'administrator',
thumbnail: 'http://example.com/image.png',
},
},
id: 5815,
messages: [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
],
inbox_id: 37,
status: 'open',
muted: false,
can_reply: true,
timestamp: 1621144123,
contact_last_seen_at: 0,
agent_last_seen_at: 1621144123,
unread_count: 0,
additional_attributes: {
browser: {
device_name: 'Unknown',
browser_name: 'Chrome',
platform_name: 'macOS',
browser_version: '90.0.4430.212',
platform_version: '10.15.7',
},
widget_language: null,
browser_language: 'en',
},
account_id: 1,
labels: [],
};
export const lastMessageData = {
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
};
export const readMessagesData = [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
];
export const unReadMessagesData = [
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
];
@@ -1,8 +1,31 @@
import {
buildPermissionsFromRouter,
getCurrentAccount,
getUserPermissions,
hasPermissions,
} from '../permissionsHelper';
describe('#getCurrentAccount', () => {
it('should return the current account', () => {
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
});
});
describe('#getUserPermissions', () => {
it('should return the correct permissions', () => {
const user = {
accounts: [
{ id: 1, permissions: ['conversations_manage'] },
{ id: 3, permissions: ['contacts_manage'] },
],
};
expect(getUserPermissions(user, 1)).toEqual(['conversations_manage']);
expect(getUserPermissions(user, '3')).toEqual(['contacts_manage']);
expect(getUserPermissions(user, 2)).toEqual([]);
});
});
describe('hasPermissions', () => {
it('returns true if permission is present', () => {
expect(
@@ -1,19 +1,11 @@
import {
getConversationDashboardRoute,
getCurrentAccount,
isAConversationRoute,
routeIsAccessibleFor,
validateLoggedInRoutes,
isAInboxViewRoute,
} from '../routeHelpers';
describe('#getCurrentAccount', () => {
it('should return the current account', () => {
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
});
});
describe('#routeIsAccessibleFor', () => {
it('should return the correct access', () => {
let route = { meta: { permissions: ['administrator'] } };
@@ -18,6 +18,7 @@
"AND": "AND",
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -3,7 +3,8 @@
"HEADER": "Agents",
"HEADER_BTN_TXT": "Add Agent",
"LOADING": "Fetching Agent List",
"SIDEBAR_TXT": "<p><b>Agents</b></p> <p> An <b>Agent</b> is a member of your Customer Support team. </p><p> Agents will be able to view and reply to messages from your users. The list shows all agents currently in your account. </p><p> Click on <b>Add Agent</b> to add a new agent. Agent you add will receive an email with a confirmation link to activate their account, after which they can access Chatwoot and respond to messages. </p><p> Access to Chatwoot's features are based on following roles. </p><p> <b>Agent</b> - Agents with this role can only access inboxes, reports and conversations. They can assign conversations to other agents or themselves and resolve conversations.</p><p> <b>Administrator</b> - Administrator will have access to all Chatwoot features enabled for your account, including settings, along with all of a normal agents' privileges.</p>",
"DESCRIPTION": "An agent is a member of your customer support team who can view and respond to user messages. The list below shows all the agents in your account.",
"LEARN_MORE": "Learn about user roles",
"AGENT_TYPES": {
"ADMINISTRATOR": "Administrator",
"AGENT": "Agent"
@@ -3,6 +3,8 @@
"HEADER": "Audit Logs",
"HEADER_BTN_TXT": "Add Audit Logs",
"LOADING": "Fetching Audit Logs",
"DESCRIPTION": "Audit Logs maintain a record of activities in your account, allowing you to track and audit your account, team, or services.",
"LEARN_MORE": "Learn more about audit logs",
"SEARCH_404": "There are no items matching this query",
"SIDEBAR_TXT": "<p><b>Audit Logs</b> </p><p> Audit Logs are trails for events and actions in a Chatwoot System. </p>",
"LIST": {
@@ -29,7 +31,8 @@
"ADD": "%{agentName} invited %{invitee} to the account as an %{role}",
"EDIT": {
"SELF": "%{agentName} changed their %{attributes} to %{values}",
"OTHER": "%{agentName} changed %{attributes} of %{user} to %{values}"
"OTHER": "%{agentName} changed %{attributes} of %{user} to %{values}",
"DELETED": "%{agentName} changed %{attributes} of a deleted user to %{values}"
}
},
"INBOX": {
@@ -93,7 +93,9 @@
"ACTION": {
"DELETE_MESSAGE": "You need to have atleast one action to save",
"TEAM_MESSAGE_INPUT_PLACEHOLDER": "Enter your message here",
"TEAM_DROPDOWN_PLACEHOLDER": "Select teams"
"TEAM_DROPDOWN_PLACEHOLDER": "Select teams",
"EMAIL_INPUT_PLACEHOLDER": "Enter email",
"URL_INPUT_PLACEHOLDER": "Enter URL"
},
"TOGGLE": {
"ACTIVATION_TITLE": "Activate Automation Rule",
@@ -113,6 +115,15 @@
"LABEL_UPLOADING": "Uploading...",
"LABEL_UPLOADED": "Successfully Uploaded",
"LABEL_UPLOAD_FAILED": "Upload Failed"
},
"ERRORS": {
"ATTRIBUTE_KEY_REQUIRED": "Attribute key is required",
"FILTER_OPERATOR_REQUIRED": "Filter operator is required",
"VALUE_REQUIRED": "Value is required",
"VALUE_MUST_BE_BETWEEN_1_AND_998": "Value must be between 1 and 998",
"ACTION_PARAMETERS_REQUIRED": "Action parameters are required",
"ATLEAST_ONE_CONDITION_REQUIRED": "At least one condition is required",
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
}
}
}
@@ -7,6 +7,7 @@
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"YES": "Yes",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully.",
@@ -1,10 +1,11 @@
{
"CANNED_MGMT": {
"HEADER": "Canned Responses",
"LEARN_MORE": "Learn more about canned responses",
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"SEARCH_404": "There are no items matching this query.",
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are pre-written reply templates that help you quickly respond to a conversation. To insert a canned response during a chat, agents can type a short code preceded by a '/' character. </p><p> You can manage your canned responses from this page or create new ones using the \"Add canned response\" button.</p><p>Open the <a target=\"_blank\" href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677501325-how-to-create-saved-reply-templates-with-canned-responses\">Canned Responses handbook</a> in another tab for a helping hand.</p><p>Also, check out the all-new <a href=\"https://www.chatwoot.com/tools/canned-responses-library\" target=\"_blank\">Canned Responses Library</a>.</p>",
"LIST": {
"404": "There are no canned responses available in this account.",
"TITLE": "Manage canned responses",
@@ -381,6 +381,9 @@
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contacts, try again!"
},
"DROPDOWN_ITEM": {
"ID": "(ID: {identifier})"
}
}
}
@@ -142,7 +142,8 @@
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"CLICK_HERE": "Click here to update"
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
"REPLYBOX": {
"REPLY": "Reply",
@@ -240,25 +241,29 @@
"ONBOARDING": {
"TITLE": "Hey 👋, Welcome to %{installationName}!",
"DESCRIPTION": "Thanks for signing up. We want you to get the most out of %{installationName}. Here are a few things you can do in %{installationName} to make the experience delightful.",
"GREETING_MORNING": "👋 Good morning, %{name}. Welcome to %{installationName}.",
"GREETING_AFTERNOON": "👋 Good afternoon, %{name}. Welcome to %{installationName}.",
"GREETING_EVENING": "👋 Good evening, %{name}. Welcome to %{installationName}.",
"READ_LATEST_UPDATES": "Read our latest updates",
"ALL_CONVERSATION": {
"TITLE": "All your conversations in one place",
"DESCRIPTION": "View all the conversations from your customers in one single dashboard. You can filter the conversations by the incoming channel, label and status."
"DESCRIPTION": "View all the conversations from your customers in one single dashboard. You can filter the conversations by the incoming channel, label and status.",
"NEW_LINK": "Click here to create an inbox"
},
"TEAM_MEMBERS": {
"TITLE": "Invite your team members",
"DESCRIPTION": "Since you are getting ready to talk to your customer, bring in your teammates to assist you. You can invite your teammates by adding their email addresses to the agent list.",
"NEW_LINK": "Click here to invite a team member"
},
"INBOXES": {
"TITLE": "Connect Inboxes",
"DESCRIPTION": "Connect various channels through which your customers would be talking to you. It can be a website live-chat, your Facebook page or even your WhatsApp number.",
"NEW_LINK": "Click here to create an inbox"
},
"LABELS": {
"TITLE": "Organize conversations with labels",
"DESCRIPTION": "Labels provide an easier way to categorize your conversation. Create some labels like #support-enquiry, #billing-question etc., so that you can use them in a conversation later.",
"NEW_LINK": "Click here to create tags"
},
"CANNED_RESPONSES": {
"TITLE": "Create canned responses",
"DESCRIPTION": "Pre-written quick reply templates help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a response.",
"NEW_LINK": "Click here to create a canned response"
}
},
"CONVERSATION_SIDEBAR": {
@@ -1,5 +1,9 @@
{
"GENERAL": {
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items"
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items",
"PHONE_INPUT": {
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
}
}
}
@@ -481,6 +481,7 @@
"DESCRIPTION": "Use the portal as a headless CMS with third party front-end frameworks using our APIs."
}
}
}
},
"LOADING": "Loading..."
}
}
@@ -4,6 +4,12 @@
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",
"LOADING": "Fetching integrations",
"CAPTAIN": {
"DISABLED": "Captain is not enabled on your account.",
"CLICK_HERE_TO_CONFIGURE": "Click here to configure",
"LOADING_CONSOLE": "Loading Captain Console...",
"FAILED_TO_LOAD_CONSOLE": "Failed to load Captain Console. Please refresh and try again."
},
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"FORM": {
@@ -3,8 +3,9 @@
"HEADER": "Labels",
"HEADER_BTN_TXT": "Add label",
"LOADING": "Fetching labels",
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
"LEARN_MORE": "Learn more about labels",
"SEARCH_404": "There are no items matching this query",
"SIDEBAR_TXT": "<p><b>Labels</b> <p>Labels help you to categorize conversations and prioritize them. You can assign label to a conversation from the sidepanel. <br /><br />Labels are tied to the account and can be used to create custom workflows in your organization. You can assign custom color to a label, it makes it easier to identify the label. You will be able to display the label on the sidebar to filter the conversations easily.</p>",
"LIST": {
"404": "There are no labels available in this account.",
"TITLE": "Manage labels",
@@ -49,7 +50,8 @@
"DISMISS": "Dismiss",
"ADD_SELECTED_LABELS": "Add selected labels",
"ADD_SELECTED_LABEL": "Add selected label",
"ADD_ALL_LABELS": "Add all labels"
"ADD_ALL_LABELS": "Add all labels",
"SUGGESTED_LABELS": "Suggested labels"
},
"ADD": {
"TITLE": "Add label",
@@ -73,6 +73,15 @@
"BUTTON_TOOLTIP": "Execute",
"PREVIEW": "Preview Macro",
"EXECUTED_SUCCESSFULLY": "Macro executed successfully"
},
"ERRORS": {
"ATTRIBUTE_KEY_REQUIRED": "Attribute key is required",
"FILTER_OPERATOR_REQUIRED": "Filter operator is required",
"VALUE_REQUIRED": "Value is required",
"VALUE_MUST_BE_BETWEEN_1_AND_998": "Value must be between 1 and 998",
"ACTION_PARAMETERS_REQUIRED": "Action parameters are required",
"ATLEAST_ONE_CONDITION_REQUIRED": "At least one condition is required",
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
}
}
}
@@ -481,7 +481,8 @@
"HEADER": "Conversation Traffic",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "%{count} conversation",
"CONVERSATIONS": "%{count} conversations"
"CONVERSATIONS": "%{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents",
@@ -536,6 +537,7 @@
"LABELS": "Label",
"TEAMS": "Team"
},
"WITH": "with",
"METRICS": {
"HIT_RATE": {
"LABEL": "Hit Rate",
@@ -11,6 +11,7 @@
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"SEARCHING_DATA": "Searching",
"EMPTY_STATE": "No %{item} found for query '%{query}'",
"EMPTY_STATE_FULL": "No results found for query '%{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus",
@@ -239,6 +239,7 @@
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
"CAPTAIN": "Captain",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",

Some files were not shown because too many files have changed in this diff Show More