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

This commit is contained in:
Fayaz Ahmed
2024-08-09 19:22:42 +05:30
committed by GitHub
42 changed files with 1562 additions and 1312 deletions
@@ -5,7 +5,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core';
const DATE_FORMAT = 'yyyy-MM-dd';
@@ -15,7 +15,6 @@ export default {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
@@ -128,8 +127,7 @@ export default {
required,
regexValidation: value => {
return !(
this.attributeRegex &&
!this.getRegexp(this.attributeRegex).test(value)
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
);
},
},
@@ -1,149 +1,147 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents';
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const resolveActionsRef = ref(null);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
const openDropdown = () => toggleDropdown(true);
const currentChat = computed(() => getters.getSelectedChat.value);
const isOpen = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.OPEN
);
const isPending = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.PENDING
);
const isResolved = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.RESOLVED
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const buttonClass = computed(() => {
if (isPending.value) return 'primary';
if (isOpen.value) return 'success';
if (isResolved.value) return 'warning';
return '';
});
const showAdditionalActions = computed(
() => !isPending.value && !isSnoozed.value
);
const showOpenButton = computed(() => {
return isPending.value || isSnoozed.value;
});
const getConversationParams = () => {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
};
const openSnoozeModal = () => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
};
const toggleStatus = (status, snoozedUntil) => {
closeDropdown();
isLoading.value = true;
store
.dispatch('toggleStatus', {
conversationId: currentChat.value.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
};
const onCmdOpenConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.OPEN);
};
const onCmdResolveConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
};
const keyboardEvents = {
'Alt+KeyM': {
action: () => arrowDownButtonRef.value?.$el.click(),
allowOnFocusedInput: true,
},
mixins: [keyboardEventListenerMixins],
data() {
return {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
isOpen() {
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
},
isPending() {
return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING;
},
isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() {
if (this.isPending) return 'primary';
if (this.isOpen) return 'success';
if (this.isResolved) return 'warning';
return '';
},
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
'Alt+KeyE': {
action: async () => {
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
},
},
mounted() {
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyM': {
action: () => this.$refs.arrowDownButton?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': this.resolveOrToast,
'$mod+Alt+KeyE': async event => {
const { all, activeIndex, lastIndex } = this.getConversationParams();
await this.resolveOrToast();
'$mod+Alt+KeyE': {
action: async event => {
const { all, activeIndex, lastIndex } = getConversationParams();
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
};
},
getConversationParams() {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
},
async resolveOrToast() {
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
onCmdResolveConversation() {
this.toggleStatus(this.STATUS_TYPE.RESOLVED);
},
showOpenButton() {
return this.isResolved || this.isSnoozed;
},
closeDropdown() {
this.showActionsDropdown = false;
},
openDropdown() {
this.showActionsDropdown = true;
},
toggleStatus(status, snoozedUntil) {
this.closeDropdown();
this.isLoading = true;
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
openSnoozeModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
event.preventDefault();
},
},
};
useKeyboardEvents(keyboardEvents, resolveActionsRef);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
ref="resolveActionsRef"
class="relative flex items-center justify-end resolve-actions"
>
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -165,7 +163,7 @@ export default {
:is-loading="isLoading"
@click="onCmdOpenConversation"
>
{{ $t('CONVERSATION.HEADER.REOPEN_ACTION') }}
{{ t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button>
<woot-button
v-else-if="showOpenButton"
@@ -175,11 +173,11 @@ export default {
:is-loading="isLoading"
@click="onCmdOpenConversation"
>
{{ $t('CONVERSATION.HEADER.OPEN_ACTION') }}
{{ t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button>
<woot-button
v-if="showAdditionalActions"
ref="arrowDownButton"
ref="arrowDownButtonRef"
:color-scheme="buttonClass"
:disabled="isLoading"
icon="chevron-down"
@@ -190,7 +188,7 @@ export default {
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
class="dropdown-pane dropdown-pane--open left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem]"
>
<WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending">
@@ -201,7 +199,7 @@ export default {
icon="snooze"
@click="() => openSnoozeModal()"
>
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
{{ t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem v-if="!isPending">
@@ -210,9 +208,9 @@ export default {
color-scheme="secondary"
size="small"
icon="book-clock"
@click="() => toggleStatus(STATUS_TYPE.PENDING)"
@click="() => toggleStatus(wootConstants.STATUS_TYPE.PENDING)"
>
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
{{ t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button>
</WootDropdownItem>
</WootDropdownMenu>
@@ -222,8 +220,6 @@ export default {
<style lang="scss" scoped>
.dropdown-pane {
@apply left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem];
.dropdown-menu__item {
@apply mb-0;
}
@@ -101,6 +101,8 @@ const shouldShowEmptyState = computed(() => {
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
:icon="item.icon"
:icon-color="item.iconColor"
@click="$emit('click', item)"
/>
</slot>
@@ -8,6 +8,14 @@ defineProps({
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
iconColor: {
type: String,
default: '',
},
});
</script>
@@ -20,6 +28,12 @@ defineProps({
@focus="$emit('focus')"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
v-if="icon"
:icon="icon"
size="18"
:style="{ color: iconColor }"
/>
<span
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
>
@@ -1,12 +1,13 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
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 keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
export default {
@@ -15,22 +16,44 @@ export default {
AICTAModal,
AIAssistanceCTAButton,
},
mixins: [aiMixin, keyboardEventListenerMixins],
setup() {
mixins: [aiMixin],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAdmin } = useAdmin();
const aiAssistanceButtonRef = ref(null);
const initialMessage = ref('');
const initializeMessage = draftMessage => {
initialMessage.value = draftMessage;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, aiAssistanceButtonRef);
return {
uiSettings,
updateUISettings,
isAdmin,
aiAssistanceButtonRef,
initialMessage,
initializeMessage,
};
},
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
aiOption: '',
initialMessage: '',
}),
computed: {
...mapGetters({
@@ -56,22 +79,10 @@ export default {
mounted() {
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage;
this.initializeMessage(this.draftMessage);
},
methods: {
getKeyboardEvents() {
return {
'$mod+KeyZ': {
action: () => {
if (this.initialMessage) {
this.$emit('replaceText', this.initialMessage);
this.initialMessage = '';
}
},
},
};
},
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption,
@@ -85,7 +96,7 @@ export default {
is_open_ai_cta_modal_dismissed: true,
});
}
this.initialMessage = this.draftMessage;
this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
@@ -107,7 +118,7 @@ export default {
</script>
<template>
<div v-if="!isFetchingAppIntegrations">
<div ref="aiAssistanceButtonRef">
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@@ -1,54 +1,56 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import wootConstants from 'dashboard/constants/globals';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [keyboardEventListenerMixins],
props: {
items: {
type: Array,
default: () => [],
},
activeTab: {
type: String,
default: wootConstants.ASSIGNEE_TYPE.ME,
},
const props = defineProps({
items: {
type: Array,
default: () => [],
},
computed: {
activeTabIndex() {
return this.items.findIndex(item => item.key === this.activeTab);
},
activeTab: {
type: String,
default: wootConstants.ASSIGNEE_TYPE.ME,
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyN': {
action: () => {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
},
},
};
},
onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) {
this.$emit('chatTabChange', this.items[selectedTabIndex].key);
});
const emit = defineEmits(['chatTabChange']);
const chatTypeTabsRef = ref(null);
const activeTabIndex = computed(() => {
return props.items.findIndex(item => item.key === props.activeTab);
});
const onTabChange = selectedTabIndex => {
if (props.items[selectedTabIndex].key !== props.activeTab) {
emit('chatTabChange', props.items[selectedTabIndex].key);
}
};
const keyboardEvents = {
'Alt+KeyN': {
action: () => {
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
onTabChange(0);
} else {
onTabChange(activeTabIndex.value + 1);
}
},
},
};
useKeyboardEvents(keyboardEvents, chatTypeTabsRef);
</script>
<template>
<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 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>
</template>
@@ -1,83 +1,71 @@
<script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
<script setup>
import { ref, computed } from 'vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
export default {
components: {
AddLabel,
LabelDropdown,
const props = defineProps({
allLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
},
});
mixins: [keyboardEventListenerMixins],
const emit = defineEmits(['add', 'remove']);
props: {
allLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
const labelSelectorWrapRef = ref(null);
const { isAdmin } = useAdmin();
const showSearchDropdownLabel = ref(false);
const selectedLabels = computed(() => {
return props.savedLabels.map(label => label.title);
});
const addItem = label => {
emit('add', label);
};
const removeItem = label => {
emit('remove', label);
};
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
toggleLabels();
e.preventDefault();
},
},
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showSearchDropdownLabel: false,
};
},
computed: {
selectedLabels() {
return this.savedLabels.map(label => label.title);
},
},
methods: {
addItem(label) {
this.$emit('add', label);
},
removeItem(label) {
this.$emit('remove', label);
},
toggleLabels() {
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
},
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
getKeyboardEvents() {
return {
KeyL: {
action: e => {
this.toggleLabels();
e.preventDefault();
},
},
Escape: {
action: () => this.closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
},
Escape: {
action: () => closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, labelSelectorWrapRef);
</script>
<template>
<div v-on-clickaway="closeDropdownLabel" class="label-wrap">
<div
ref="labelSelectorWrapRef"
v-on-clickaway="closeDropdownLabel"
class="relative leading-6"
>
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in savedLabels"
@@ -89,10 +77,10 @@ export default {
variant="smooth"
@click="removeItem"
/>
<div class="dropdown-wrap">
<div class="absolute w-full top-7">
<div
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
class="dropdown-pane"
class="!box-border !w-full dropdown-pane"
>
<LabelDropdown
v-if="showSearchDropdownLabel"
@@ -106,28 +94,3 @@ export default {
</div>
</div>
</template>
<style lang="scss" scoped>
.title-icon {
margin-right: var(--space-smaller);
}
.label-wrap {
position: relative;
line-height: var(--space-medium);
.dropdown-wrap {
display: flex;
position: absolute;
margin-right: var(--space-medium);
top: var(--space-medium);
width: 100%;
left: -1px;
.dropdown-pane {
width: 100%;
box-sizing: border-box;
}
}
}
</style>
@@ -1,8 +1,9 @@
<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';
import * as ActiveStorage from 'activestorage';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
@@ -18,7 +19,7 @@ import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [keyboardEventListenerMixins, inboxMixin],
mixins: [inboxMixin],
props: {
mode: {
type: String,
@@ -115,16 +116,42 @@ 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();
},
allowOnFocusedInput: true,
},
};
watchEffect(() => {
useKeyboardEvents(keyboardEvents, uploadTriggerButton);
});
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
uploadRef,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
@@ -207,21 +234,14 @@ export default {
);
return isFeatEnabled && this.portalSlug;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
},
mounted() {
ActiveStorage.start();
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyA': {
action: () => {
this.$refs.upload.$children[1].$el.click();
},
allowOnFocusedInput: true,
},
};
},
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
@@ -249,7 +269,7 @@ export default {
@click="toggleEmojiPicker"
/>
<FileUpload
ref="upload"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
@@ -330,6 +350,7 @@ export default {
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@@ -337,7 +358,7 @@ export default {
/>
<transition name="modal-fade">
<div
v-show="$refs.upload && $refs.upload.dropActive"
v-show="$refs.uploadRef && $refs.uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
@@ -1,8 +1,9 @@
<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 keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
@@ -23,7 +24,7 @@ export default {
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
mixins: [inboxMixin, agentMixin],
props: {
chat: {
type: Object,
@@ -42,6 +43,20 @@ export default {
default: false,
},
},
setup(props, { emit }) {
const conversationHeaderActionsRef = ref(null);
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
return {
conversationHeaderActionsRef,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -117,16 +132,6 @@ export default {
);
},
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyO': {
action: () => this.$emit('contactPanelToggle'),
},
};
},
},
};
</script>
@@ -178,6 +183,7 @@ 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,4 +1,8 @@
<script>
import { ref } from 'vue';
// composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
// components
import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
@@ -14,7 +18,6 @@ import conversationMixin, {
} from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
@@ -35,13 +38,7 @@ export default {
Banner,
ConversationLabelSuggestion,
},
mixins: [
conversationMixin,
inboxMixin,
keyboardEventListenerMixins,
configMixin,
aiMixin,
],
mixins: [conversationMixin, inboxMixin, configMixin, aiMixin],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -52,7 +49,33 @@ export default {
default: false,
},
},
setup() {
const conversationFooterRef = ref(null);
const isPopOutReplyBox = ref(false);
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
};
const showPopOutReplyBox = () => {
isPopOutReplyBox.value = !isPopOutReplyBox.value;
};
const keyboardEvents = {
Escape: {
action: closePopOutReplyBox,
},
};
useKeyboardEvents(keyboardEvents, conversationFooterRef);
return {
conversationFooterRef,
isPopOutReplyBox,
closePopOutReplyBox,
showPopOutReplyBox,
};
},
data() {
return {
isLoadingPrevious: true,
@@ -60,7 +83,6 @@ export default {
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
isPopoutReplyBox: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
@@ -303,19 +325,6 @@ export default {
});
this.makeMessagesRead();
},
showPopoutReplyBox() {
this.isPopoutReplyBox = !this.isPopoutReplyBox;
},
closePopoutReplyBox() {
this.isPopoutReplyBox = false;
},
getKeyboardEvents() {
return {
Escape: {
action: () => this.closePopoutReplyBox(),
},
};
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
@@ -505,8 +514,9 @@ export default {
/>
</ul>
<div
ref="conversationFooterRef"
class="conversation-footer"
:class="{ 'modal-mask': isPopoutReplyBox }"
:class="{ 'modal-mask': isPopOutReplyBox }"
>
<div
v-if="isAnyoneTyping"
@@ -525,8 +535,8 @@ export default {
</div>
<ReplyBox
:conversation-id="currentChat.id"
:popout-reply-box.sync="isPopoutReplyBox"
@click="showPopoutReplyBox"
:popout-reply-box.sync="isPopOutReplyBox"
@click="showPopOutReplyBox"
/>
</div>
</div>
@@ -1,10 +1,30 @@
<script>
import { mapGetters } from 'vuex';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
const props = defineProps({
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['close']);
const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
@@ -15,194 +35,178 @@ const ALLOWED_FILE_TYPES = {
const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1;
export default {
components: {
Thumbnail,
},
mixins: [keyboardEventListenerMixins],
props: {
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
const galleryViewRef = ref(null);
const zoomScale = ref(1);
const activeAttachment = ref({});
const activeFileType = ref('');
const activeImageIndex = ref(
props.allAttachments.findIndex(
attachment => attachment.message_id === props.attachment.message_id
) || 0
);
const activeImageRotation = ref(0);
const currentUser = computed(() => getters.getCurrentUser.value);
const hasMoreThanOneAttachment = computed(
() => props.allAttachments.length > 1
);
const readableTime = computed(() => {
const { created_at: createdAt } = activeAttachment.value;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
});
const isImage = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
);
const isVideo = computed(
() =>
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
);
const isAudio = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
);
const senderDetails = computed(() => {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = activeAttachment.value?.sender || props.attachment?.sender || {};
const currentUserID = currentUser.value?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
});
const fileNameFromDataUrl = computed(() => {
const { data_url: dataUrl } = activeAttachment.value;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return decodeURIComponent(fileName || '');
});
const imageRotationStyle = computed(() => ({
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
}));
const onClose = () => {
emit('close');
};
const setImageAndVideoSrc = attachment => {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
activeAttachment.value = attachment;
activeFileType.value = type;
};
const onClickChangeAttachment = (attachment, index) => {
if (!attachment) {
return;
}
activeImageIndex.value = index;
setImageAndVideoSrc(attachment);
activeImageRotation.value = 0;
zoomScale.value = 1;
};
const onClickDownload = () => {
const { file_type: type, data_url: url } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
};
const onRotate = type => {
if (!isImage.value) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(activeImageRotation.value) === 360) {
activeImageRotation.value = rotation;
} else {
activeImageRotation.value += rotation;
}
};
const onZoom = scale => {
if (!isImage.value) {
return;
}
const newZoomScale = zoomScale.value + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
zoomScale.value = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
zoomScale.value = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
zoomScale.value = newZoomScale;
};
const onClickZoomImage = () => {
onZoom(0.1);
};
const onWheelImageZoom = e => {
if (!isImage.value) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
onZoom(scale);
};
const keyboardEvents = {
Escape: {
action: () => {
onClose();
},
},
data() {
return {
zoomScale: 1,
activeAttachment: {},
activeFileType: '',
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id
) || 0,
activeImageRotation: 0,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
readableTime() {
const { created_at: createdAt } = this.activeAttachment;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return (
this.activeFileType === ALLOWED_FILE_TYPES.VIDEO ||
this.activeFileType === ALLOWED_FILE_TYPES.IG_REEL
ArrowLeft: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value - 1],
activeImageIndex.value - 1
);
},
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
senderDetails() {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = this.activeAttachment?.sender || this.attachment?.sender || {};
const currentUserID = this.currentUser?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
},
fileNameFromDataUrl() {
const { data_url: dataUrl } = this.activeAttachment;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return decodeURIComponent(fileName || '');
},
imageRotationStyle() {
return {
transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`,
cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
};
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
},
methods: {
onClose() {
this.$emit('close');
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
this.activeImageRotation = 0;
this.zoomScale = 1;
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.activeAttachment = attachment;
this.activeFileType = type;
},
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
},
ArrowLeft: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
},
},
ArrowRight: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
},
},
};
},
onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
},
onRotate(type) {
if (!this.isImage) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(this.activeImageRotation) === 360) {
this.activeImageRotation = rotation;
} else {
this.activeImageRotation += rotation;
}
},
onClickZoomImage() {
this.onZoom(0.1);
},
onZoom(scale) {
if (!this.isImage) {
return;
}
const newZoomScale = this.zoomScale + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
this.zoomScale = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
this.zoomScale = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
this.zoomScale = newZoomScale;
},
onWheelImageZoom(e) {
if (!this.isImage) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
this.onZoom(scale);
ArrowRight: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value + 1],
activeImageIndex.value + 1
);
},
},
};
useKeyboardEvents(keyboardEvents, galleryViewRef);
onMounted(() => {
setImageAndVideoSrc(props.attachment);
});
</script>
<!-- eslint-disable vue/no-mutating-props -->
@@ -214,6 +218,7 @@ export default {
: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"
@@ -62,6 +62,8 @@ const onSearch = async value => {
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
@@ -0,0 +1,175 @@
import { describe, it, expect, vi } from 'vitest';
import { useMacros } from '../useMacros';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
describe('useMacros', () => {
const mockLabels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];
const mockTeams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
const mockAgents = [
{
id: 1,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'agent',
thumbnail: 'https://example.com/image.png',
},
{
id: 9,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'clark@kent.com',
available_name: 'Clark Kent',
name: 'Clark Kent',
role: 'agent',
thumbnail: '',
},
];
beforeEach(() => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
});
});
it('initializes computed properties correctly', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
const { getMacroDropdownValues } = useMacros();
const expectedLabels = mockLabels.map(i => ({
id: i.title,
name: i.title,
}));
expect(getMacroDropdownValues('add_label')).toEqual(expectedLabels);
expect(getMacroDropdownValues('remove_label')).toEqual(expectedLabels);
});
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
});
it('returns an empty array for unknown types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('unknown_type')).toEqual([]);
});
it('handles empty data correctly', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
]);
});
});
@@ -0,0 +1,44 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useMacros = () => {
const getters = useStoreGetters();
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
* @returns {Array} An array of dropdown values
*/
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({
id: i.title,
name: i.title,
}));
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
default:
return [];
}
};
return {
getMacroDropdownValues,
};
};
@@ -1,15 +1,20 @@
{
"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",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"TABLE_HEADER": ["Short code", "Content", "Actions"]
"TABLE_HEADER": [
"Short code",
"Content",
"Actions"
]
},
"ADD": {
"TITLE": "Add canned response",
@@ -1,11 +0,0 @@
export default {
methods: {
getRegexp(regexPatternValue) {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
},
},
};
@@ -1,34 +0,0 @@
import { mapGetters } from 'vuex';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
export default {
computed: {
...mapGetters({
labels: 'labels/getLabels',
teams: 'teams/getTeams',
agents: 'agents/getAgents',
}),
},
methods: {
getDropdownValues(type) {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return this.teams;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...this.agents];
case 'add_label':
case 'remove_label':
return this.labels.map(i => {
return {
id: i.title,
name: i.title,
};
});
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
default:
return [];
}
},
},
};
@@ -1,50 +0,0 @@
import { createWrapper } from '@vue/test-utils';
import macrosMixin from '../macrosMixin';
import Vue from 'vue';
import { teams, labels, agents } from '../../helper/specs/macrosFixtures';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
describe('webhookMixin', () => {
describe('#getEventLabel', () => {
it('returns correct i18n translation:', () => {
const Component = {
render() {},
title: 'MyComponent',
mixins: [macrosMixin],
data: () => {
return {
teams,
labels,
agents,
};
},
methods: {
$t(text) {
return text;
},
},
};
const resolvedLabels = labels.map(i => {
return {
id: i.title,
name: i.title,
};
});
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.getDropdownValues('assign_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('send_email_to_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('add_label')).toEqual(resolvedLabels);
expect(wrapper.vm.getDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
...agents,
]);
expect(wrapper.vm.getDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
expect(wrapper.vm.getDropdownValues()).toEqual([]);
});
});
});
@@ -1,43 +1,34 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
components: {
WootMessageEditor,
},
mixins: [keyboardEventListenerMixins],
data() {
return {
noteContent: '',
};
},
computed: {
buttonDisabled() {
return this.noteContent === '';
},
},
methods: {
getKeyboardEvents() {
return {
'$mod+Enter': {
action: () => this.onAdd(),
allowOnFocusedInput: true,
},
};
},
onAdd() {
if (this.noteContent !== '') {
this.$emit('add', this.noteContent);
}
this.noteContent = '';
},
const emit = defineEmits(['add']);
const addNoteRef = ref(null);
const noteContent = ref('');
const buttonDisabled = computed(() => noteContent.value === '');
const onAdd = () => {
if (noteContent.value !== '') {
emit('add', noteContent.value);
}
noteContent.value = '';
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, addNoteRef);
</script>
<template>
<div
ref="addNoteRef"
class="flex flex-col flex-grow p-4 mb-2 overflow-hidden bg-white border border-solid rounded-md shadow-sm border-slate-75 dark:border-slate-700 dark:bg-slate-900 text-slate-700 dark:text-slate-100"
>
<WootMessageEditor
@@ -0,0 +1,72 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'dashboard/composables/useI18n';
import { useEmitter } from 'dashboard/composables/emitter';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const showCustomSnoozeModal = ref(false);
const selectedChat = computed(() => getters.getSelectedChat.value);
const contextMenuChatId = computed(() => getters.getContextMenuChatId.value);
const toggleStatus = async (status, snoozedUntil) => {
await store.dispatch('toggleStatus', {
conversationId: selectedChat.value?.id || contextMenuChatId.value,
status,
snoozedUntil,
});
store.dispatch('setContextMenuChatId', null);
useAlert(t('CONVERSATION.CHANGE_STATUS'));
};
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
};
const chooseSnoozeTime = customSnoozeTime => {
showCustomSnoozeModal.value = false;
if (customSnoozeTime) {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
};
const hideCustomSnoozeModal = () => {
// if we select custom snooze and the custom snooze modal is open
// Then if the custom snooze modal is closed then set the context menu chat id to null
store.dispatch('setContextMenuChatId', null);
showCustomSnoozeModal.value = false;
};
useEmitter(CMD_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
</script>
<template>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@chooseTime="chooseSnoozeTime"
/>
</woot-modal>
</template>
@@ -1,4 +1,3 @@
<!-- eslint-disable vue/attribute-hyphenation -->
<script>
import '@chatwoot/ninja-keys';
import wootConstants from 'dashboard/constants/globals';
@@ -97,11 +96,12 @@ export default {
};
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
<template>
<ninja-keys
ref="ninjakeys"
no-auto-load-md-icons
hide-breadcrumbs
noAutoLoadMdIcons
hideBreadcrumbs
:placeholder="placeholder"
@selected="onSelected"
@closed="onClosed"
@@ -53,7 +53,7 @@ const INBOX_SNOOZE_EVENTS = [
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
title: 'COMMAND_BAR.COMMANDS.UNTIL_CUSTOM_TIME',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
@@ -1,23 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { getUnixTime } from 'date-fns';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
import PopOverSearch from './search/PopOverSearch.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
export default {
components: {
ChatList,
ConversationBox,
PopOverSearch,
CustomSnoozeModal,
CmdBarConversationSnooze,
},
props: {
inboxId: {
@@ -56,14 +52,12 @@ export default {
data() {
return {
showSearchModal: false,
showCustomSnoozeModal: false,
};
},
computed: {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
contextMenuChatId: 'getContextMenuChatId',
}),
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
@@ -100,10 +94,6 @@ export default {
this.$watch('chatList.length', () => {
this.setActiveChat();
});
this.$emitter.on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
this.$emitter.off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
methods: {
@@ -178,43 +168,6 @@ export default {
closeSearch() {
this.showSearchModal = false;
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
toggleStatus(status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat?.id || this.contextMenuChatId,
status,
snoozedUntil,
})
.then(() => {
this.$store.dispatch('setContextMenuChatId', null);
useAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
});
},
hideCustomSnoozeModal() {
// if we select custom snooze and the custom snooze modal is open
// Then if the custom snooze modal is closed then set the context menu chat id to null
this.$store.dispatch('setContextMenuChatId', null);
this.showCustomSnoozeModal = false;
},
},
};
</script>
@@ -243,15 +196,7 @@ export default {
:is-on-expanded-layout="isOnExpandedLayout"
@contactPanelToggle="onToggleContactPanel"
/>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@chooseTime="chooseSnoozeTime"
/>
</woot-modal>
<CmdBarConversationSnooze />
</section>
</template>
@@ -1,10 +1,11 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
export default {
@@ -14,7 +15,7 @@ export default {
AddLabel,
},
mixins: [conversationLabelMixin, keyboardEventListenerMixins],
mixins: [conversationLabelMixin],
props: {
// conversationId prop is used in /conversation/labelMixin,
// remove this props when refactoring to composable if not needed
@@ -26,14 +27,46 @@ export default {
},
setup() {
const { isAdmin } = useAdmin();
const conversationLabelBoxRef = ref(null);
const showSearchDropdownLabel = ref(false);
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (showSearchDropdownLabel.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationLabelBoxRef);
return {
isAdmin,
conversationLabelBoxRef,
showSearchDropdownLabel,
closeDropdownLabel,
toggleLabels,
};
},
data() {
return {
selectedLabels: [],
showSearchDropdownLabel: false,
};
},
@@ -42,37 +75,11 @@ export default {
conversationUiFlags: 'conversationLabels/getUIFlags',
}),
},
methods: {
toggleLabels() {
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
},
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
getKeyboardEvents() {
return {
KeyL: {
action: e => {
e.preventDefault();
this.toggleLabels();
},
},
Escape: {
action: () => {
if (this.showSearchDropdownLabel) {
this.toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
},
},
};
</script>
<template>
<div class="sidebar-labels-wrap">
<div ref="conversationLabelBoxRef" class="sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
@@ -1,53 +1,51 @@
<script>
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
<script setup>
import { ref, onMounted } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
export default {
name: 'ChatwootSearch',
mixins: [keyboardEventListenerMixins],
props: {
title: {
type: String,
default: 'Chatwoot',
defineProps({
title: {
type: String,
default: 'Chatwoot',
},
});
const emit = defineEmits(['search', 'close']);
const articleSearchHeaderRef = ref(null);
const searchInputRef = ref(null);
const searchQuery = ref('');
onMounted(() => {
searchInputRef.value.focus();
});
const onInput = e => {
emit('search', e.target.value);
};
const onClose = () => {
emit('close');
};
const keyboardEvents = {
Slash: {
action: e => {
e.preventDefault();
searchInputRef.value.focus();
},
},
data() {
return {
searchQuery: '',
isInputFocused: false,
};
},
mounted() {
this.$refs.searchInput.focus();
},
methods: {
onInput(e) {
this.$emit('search', e.target.value);
},
onClose() {
this.$emit('close');
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
},
getKeyboardEvents() {
return {
Slash: {
action: e => {
e.preventDefault();
this.$refs.searchInput.focus();
},
},
};
Escape: {
action: () => {
onClose();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, articleSearchHeaderRef);
</script>
<template>
<div class="flex flex-col py-1">
<div ref="articleSearchHeaderRef" class="flex flex-col py-1">
<div class="flex items-center justify-between py-2 mb-1">
<h3 class="text-base text-slate-900 dark:text-slate-25">
{{ title }}
@@ -68,13 +66,11 @@ export default {
<fluent-icon icon="search" class="" size="18" />
</div>
<input
ref="searchInput"
ref="searchInputRef"
type="text"
:placeholder="$t('HELP_CENTER.ARTICLE_SEARCH.PLACEHOLDER')"
class="block w-full !h-9 ltr:!pl-8 rtl:!pr-8 dark:!bg-slate-700 !bg-slate-25 text-sm rounded-md leading-8 text-slate-700 shadow-sm ring-2 ring-transparent ring-slate-300 border border-solid border-slate-300 placeholder:text-slate-400 focus:border-woot-600 focus:ring-woot-200 !mb-0 focus:bg-slate-25 dark:focus:bg-slate-700 dark:focus:ring-woot-700"
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
</div>
@@ -1,7 +1,6 @@
<script>
import { debounce } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue';
@@ -17,7 +16,7 @@ export default {
SearchResults,
ArticleView,
},
mixins: [portalMixin, keyboardEventListenerMixins],
mixins: [portalMixin],
props: {
selectedPortalSlug: {
type: String,
@@ -114,16 +113,6 @@ export default {
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
this.onClose();
},
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
allowOnFocusedInput: true,
},
};
},
},
};
</script>
@@ -8,12 +8,14 @@ import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
CmdBarConversationSnooze,
},
setup() {
const { uiSettings } = useUISettings();
@@ -209,5 +211,6 @@ export default {
</div>
</div>
<router-view />
<CmdBarConversationSnooze />
</section>
</template>
@@ -2,11 +2,10 @@
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required, minLength } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import { ATTRIBUTE_TYPES } from './constants';
import customAttributeMixin from '../../../../mixins/customAttributeMixin';
export default {
components: {},
mixins: [customAttributeMixin],
props: {
selectedAttribute: {
type: Object,
@@ -116,7 +115,7 @@ export default {
},
setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern
? this.getRegexp(this.selectedAttribute.regex_pattern).source
? getRegexp(this.selectedAttribute.regex_pattern).source
: null;
this.displayName = this.selectedAttribute.attribute_display_name;
this.description = this.selectedAttribute.attribute_description;
@@ -1,238 +1,222 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useAlert } from 'dashboard/composables';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
export default {
components: {
AddCanned,
EditCanned,
},
data() {
return {
loading: {},
showAddPopup: false,
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedResponse: {},
cannedResponseAPI: {
message: '',
},
sortOrder: 'asc',
};
},
computed: {
...mapGetters({
records: 'getCannedResponses',
uiFlags: 'getUIFlags',
}),
// Delete Modal
deleteConfirmText() {
return `${this.$t('CANNED_MGMT.DELETE.CONFIRM.YES')} ${
this.selectedResponse.short_code
}`;
},
deleteRejectText() {
return `${this.$t('CANNED_MGMT.DELETE.CONFIRM.NO')} ${
this.selectedResponse.short_code
}`;
},
deleteMessage() {
return ` ${this.selectedResponse.short_code}?`;
},
},
mounted() {
// Fetch API Call
this.$store.dispatch('getCannedResponse').then(() => {
this.toggleSort();
});
},
methods: {
toggleSort() {
this.records.sort((a, b) => {
if (this.sortOrder === 'asc') {
return a.short_code.localeCompare(b.short_code);
}
return b.short_code.localeCompare(a.short_code);
});
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
},
showAlertMessage(message) {
// Reset loading, current selected agent
this.loading[this.selectedResponse.id] = false;
this.selectedResponse = {};
// Show message
this.cannedResponseAPI.message = message;
useAlert(message);
},
// Edit Function
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
// Edit Modal Functions
openEditPopup(response) {
this.showEditPopup = true;
this.selectedResponse = response;
},
hideEditPopup() {
this.showEditPopup = false;
},
const showAddPopup = ref(false);
const loading = ref({});
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const activeResponse = ref({});
const cannedResponseAPI = ref({ message: '' });
// Delete Modal Functions
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
// Set loading and call Delete API
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteCannedResponse(this.selectedResponse.id);
},
deleteCannedResponse(id) {
this.$store
.dispatch('deleteCannedResponse', id)
.then(() => {
this.showAlertMessage(
this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE')
);
})
.catch(error => {
const errorMessage =
error?.message || this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
this.showAlertMessage(errorMessage);
});
},
},
const sortOrder = ref('asc');
const records = computed(() =>
getters.getSortedCannedResponses.value(sortOrder.value)
);
const uiFlags = computed(() => getters.getUIFlags.value);
const deleteConfirmText = computed(
() =>
`${t('CANNED_MGMT.DELETE.CONFIRM.YES')} ${activeResponse.value.short_code}`
);
const deleteRejectText = computed(
() =>
`${t('CANNED_MGMT.DELETE.CONFIRM.NO')} ${activeResponse.value.short_code}`
);
const deleteMessage = computed(() => {
return ` ${activeResponse.value.short_code} ? `;
});
const toggleSort = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
};
const fetchCannedResponses = async () => {
try {
await store.dispatch('getCannedResponse');
} catch (error) {
// Ignore Error
}
};
onMounted(() => {
fetchCannedResponses();
});
const showAlertMessage = message => {
loading[activeResponse.value.id] = false;
activeResponse.value = {};
cannedResponseAPI.value.message = message;
useAlert(message);
};
const openAddPopup = () => {
showAddPopup.value = true;
};
const hideAddPopup = () => {
showAddPopup.value = false;
};
const openEditPopup = response => {
showEditPopup.value = true;
activeResponse.value = response;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const openDeletePopup = response => {
showDeleteConfirmationPopup.value = true;
activeResponse.value = response;
};
const closeDeletePopup = () => {
showDeleteConfirmationPopup.value = false;
};
const deleteCannedResponse = async id => {
try {
await store.dispatch('deleteCannedResponse', id);
showAlertMessage(t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
showAlertMessage(errorMessage);
}
};
const confirmDeletion = () => {
loading[activeResponse.value.id] = true;
closeDeletePopup();
deleteCannedResponse(activeResponse.value.id);
};
</script>
<template>
<div class="flex-1 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup()"
<BaseSettingsHeader
:title="$t('CANNED_MGMT.HEADER')"
:description="$t('CANNED_MGMT.DESCRIPTION')"
:link-text="$t('CANNED_MGMT.LEARN_MORE')"
feature-name="canned_responses"
>
{{ $t('CANNED_MGMT.HEADER_BTN_TXT') }}
</woot-button>
<!-- List Canned Response -->
<div class="flex flex-row gap-4 p-8">
<div class="w-full xl:w-3/5">
<p
v-if="!uiFlags.fetchingList && !records.length"
class="flex flex-col items-center justify-center h-full"
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
/>
{{ $t('CANNED_MGMT.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
<table
v-if="!uiFlags.fetchingList && records.length"
class="woot-table"
>
<thead>
<!-- Header -->
<th
v-for="thHeader in $t('CANNED_MGMT.LIST.TABLE_HEADER')"
:key="thHeader"
class="last:text-right first:m-0 first:p-0"
<div class="mt-6 flex-1">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
/>
<p
v-else-if="!records.length"
class="flex flex-col items-center justify-center h-full text-base text-slate-600 dark:text-slate-300 py-8"
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<table
v-else
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<thead>
<th
v-for="thHeader in $t('CANNED_MGMT.LIST.TABLE_HEADER')"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-slate-700 dark:text-slate-300"
>
<span v-if="thHeader !== $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')">
{{ thHeader }}
</span>
<button
v-if="thHeader === $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')"
class="flex items-center p-0 cursor-pointer"
@click="toggleSort"
>
<p v-if="thHeader !== $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')">
<span class="mb-0">
{{ thHeader }}
</p>
<button
v-if="thHeader === $t('CANNED_MGMT.LIST.TABLE_HEADER[0]')"
class="flex items-center p-0 cursor-pointer"
@click="toggleSort"
>
<p class="uppercase">
{{ thHeader }}
</p>
<fluent-icon
class="mb-2 ml-2"
:icon="sortOrder === 'asc' ? 'chevron-up' : 'chevron-down'"
/>
</button>
</th>
</thead>
<tbody>
<tr
v-for="(cannedItem, index) in records"
:key="cannedItem.short_code"
</span>
<fluent-icon
class="ml-2"
:icon="sortOrder === 'desc' ? 'chevron-up' : 'chevron-down'"
/>
</button>
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
>
<tr
v-for="(cannedItem, index) in records"
:key="cannedItem.short_code"
>
<td
class="py-4 pr-4 truncate max-w-xs font-medium"
:title="cannedItem.short_code"
>
<!-- Short Code -->
<td
class="w-[8.75rem] truncate max-w-[8.75rem]"
:title="cannedItem.short_code"
>
{{ cannedItem.short_code }}
</td>
<!-- Content -->
<td class="break-all whitespace-normal">
{{ cannedItem.content }}
</td>
<!-- Action Buttons -->
<td class="flex justify-end gap-1 min-w-[12.5rem]">
<woot-button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="openEditPopup(cannedItem)"
/>
<woot-button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div class="hidden w-1/3 xl:block">
<span v-dompurify-html="$t('CANNED_MGMT.SIDEBAR_TXT')" />
</div>
{{ cannedItem.short_code }}
</td>
<td class="py-4 pr-4 md:break-all whitespace-normal">
{{ cannedItem.content }}
</td>
<td class="py-4 flex justify-end gap-1">
<woot-button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="openEditPopup(cannedItem)"
/>
<woot-button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Agent -->
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddCanned :on-close="hideAddPopup" />
</woot-modal>
<!-- Edit Canned Response -->
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditCanned
v-if="showEditPopup"
:id="selectedResponse.id"
:edshort-code="selectedResponse.short_code"
:edcontent="selectedResponse.content"
:id="activeResponse.id"
:edshort-code="activeResponse.short_code"
:edcontent="activeResponse.content"
:on-close="hideEditPopup"
/>
</woot-modal>
<!-- Delete Canned Response -->
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
@@ -1,18 +1,13 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const CannedHome = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/canned-response'),
component: SettingsContent,
props: {
headerTitle: 'CANNED_MGMT.HEADER',
icon: 'chat-multiple',
showNewButton: false,
},
component: SettingsWrapper,
children: [
{
path: '',
@@ -1,126 +1,123 @@
<script>
<script setup>
import { ref, computed, watch, provide } from 'vue';
import { useRoute, useRouter } from 'dashboard/composables/route';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import MacroForm from './MacroForm.vue';
import { MACRO_ACTION_TYPES } from './constants';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
import macrosMixin from 'dashboard/mixins/macrosMixin';
import { useMacros } from 'dashboard/composables/useMacros';
export default {
components: {
MacroForm,
},
mixins: [macrosMixin],
provide() {
return {
macroActionTypes: this.macroActionTypes,
};
},
data() {
return {
macro: null,
mode: 'CREATE',
macroActionTypes: MACRO_ACTION_TYPES,
};
},
computed: {
...mapGetters({
uiFlags: 'macros/getUIFlags',
}),
macroId() {
return this.$route.params.macroId;
},
},
watch: {
$route: {
handler() {
this.fetchDropdownData();
if (this.$route.params.macroId) {
this.fetchMacro();
} else {
this.initNewMacro();
}
},
immediate: true,
},
},
methods: {
fetchDropdownData() {
this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
},
fetchMacro() {
this.mode = 'EDIT';
this.manifestMacro();
},
async manifestMacro() {
await this.$store.dispatch('macros/getSingleMacro', this.macroId);
const singleMacro = this.$store.getters['macros/getMacro'](this.macroId);
this.macro = this.formatMacro(singleMacro);
},
formatMacro(macro) {
const formattedActions = macro.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = this.macroActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
actionParams = [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item => [...action.action_params].includes(item.id));
} else if (inputType === 'team_message') {
actionParams = {
team_ids: [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
} else actionParams = [...action.action_params];
}
return {
...action,
action_params: actionParams,
const store = useStore();
const getters = useStoreGetters();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { getMacroDropdownValues } = useMacros();
const macro = ref(null);
const mode = ref('CREATE');
const macroActionTypes = MACRO_ACTION_TYPES;
provide('macroActionTypes', macroActionTypes);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const macroId = computed(() => route.params.macroId);
const fetchDropdownData = () => {
store.dispatch('agents/get');
store.dispatch('teams/get');
store.dispatch('labels/get');
};
const formatMacro = macroData => {
const formattedActions = macroData.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = macroActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
actionParams = getMacroDropdownValues(action.action_name).filter(item =>
[...action.action_params].includes(item.id)
);
} else if (inputType === 'team_message') {
actionParams = {
team_ids: getMacroDropdownValues(action.action_name).filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
});
return {
...macro,
actions: formattedActions,
};
},
initNewMacro() {
this.mode = 'CREATE';
this.macro = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
},
async saveMacro(macro) {
try {
const action = this.mode === 'EDIT' ? 'macros/update' : 'macros/create';
let successMessage =
this.mode === 'EDIT'
? this.$t('MACROS.EDIT.API.SUCCESS_MESSAGE')
: this.$t('MACROS.ADD.API.SUCCESS_MESSAGE');
let serializedMacro = JSON.parse(JSON.stringify(macro));
serializedMacro.actions = actionQueryGenerator(serializedMacro.actions);
await this.$store.dispatch(action, serializedMacro);
useAlert(successMessage);
this.$router.push({ name: 'macros_wrapper' });
} catch (error) {
useAlert(this.$t('MACROS.ERROR'));
}
},
} else actionParams = [...action.action_params];
}
return {
...action,
action_params: actionParams,
};
});
return {
...macroData,
actions: formattedActions,
};
};
const manifestMacro = async () => {
await store.dispatch('macros/getSingleMacro', macroId.value);
const singleMacro = store.getters['macros/getMacro'](macroId.value);
macro.value = formatMacro(singleMacro);
};
const fetchMacro = () => {
mode.value = 'EDIT';
manifestMacro();
};
const initNewMacro = () => {
mode.value = 'CREATE';
macro.value = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
};
watch(
() => route,
() => {
fetchDropdownData();
if (route.params.macroId) {
fetchMacro();
} else {
initNewMacro();
}
},
{ immediate: true, deep: true }
);
const saveMacro = async macroData => {
try {
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
const successMessage =
mode.value === 'EDIT'
? t('MACROS.EDIT.API.SUCCESS_MESSAGE')
: t('MACROS.ADD.API.SUCCESS_MESSAGE');
let serializedMacro = JSON.parse(JSON.stringify(macroData));
serializedMacro.actions = actionQueryGenerator(serializedMacro.actions);
await store.dispatch(action, serializedMacro);
useAlert(successMessage);
router.push({ name: 'macros_wrapper' });
} catch (error) {
useAlert(t('MACROS.ERROR'));
}
};
</script>
@@ -128,11 +125,12 @@ export default {
<div class="flex flex-col flex-1 h-full overflow-auto">
<woot-loading-state
v-if="uiFlags.isFetchingItem"
:message="$t('MACROS.EDITOR.LOADING')"
:message="t('MACROS.EDITOR.LOADING')"
/>
<MacroForm
v-if="macro && !uiFlags.isFetchingItem"
:macro-data.sync="macro"
:macro-data="macro"
@update:macro-data="macro = $event"
@submit="saveMacro"
/>
</div>
@@ -1,84 +1,80 @@
<script>
import { inject } from 'vue';
<script setup>
import { computed, inject } from 'vue';
import { useMacros } from 'dashboard/composables/useMacros';
import { useI18n } from 'dashboard/composables/useI18n';
import ActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import macrosMixin from 'dashboard/mixins/macrosMixin';
export default {
components: {
ActionInput,
const props = defineProps({
singleNode: {
type: Boolean,
default: false,
},
mixins: [macrosMixin],
props: {
singleNode: {
type: Boolean,
default: false,
},
value: {
type: Object,
default: () => ({}),
},
errorKey: {
type: String,
default: '',
},
fileName: {
type: String,
default: '',
},
value: {
type: Object,
default: () => ({}),
},
setup() {
const macroActionTypes = inject('macroActionTypes');
return { macroActionTypes };
errorKey: {
type: String,
default: '',
},
computed: {
actionData: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
errorMessage() {
if (!this.errorKey) return '';
fileName: {
type: String,
default: '',
},
});
return this.$t(`MACROS.ERRORS.${this.errorKey}`);
},
showActionInput() {
if (
this.actionData.action_name === 'send_email_to_team' ||
this.actionData.action_name === 'send_message'
)
return false;
const type = this.macroActionTypes.find(
action => action.key === this.actionData.action_name
).inputType;
return !!type;
},
},
methods: {
dropdownValues() {
return this.getDropdownValues(this.value.action_name, this.$store);
},
},
const emit = defineEmits(['input', 'resetAction', 'deleteNode']);
const { t } = useI18n();
const macroActionTypes = inject('macroActionTypes');
const { getMacroDropdownValues } = useMacros();
const actionData = computed({
get: () => props.value,
set: value => emit('input', value),
});
const errorMessage = computed(() => {
if (!props.errorKey) return '';
return t(`MACROS.ERRORS.${props.errorKey}`);
});
const showActionInput = computed(() => {
if (
actionData.value.action_name === 'send_email_to_team' ||
actionData.value.action_name === 'send_message'
)
return false;
const type = macroActionTypes.find(
action => action.key === actionData.value.action_name
).inputType;
return !!type;
});
const dropdownValues = () => {
return getMacroDropdownValues(props.value.action_name);
};
</script>
<template>
<div class="macro__node-action-container">
<div class="relative flex items-center w-full min-w-0 basis-full">
<woot-button
v-if="!singleNode"
size="small"
variant="clear"
color-scheme="secondary"
icon="navigation"
class="macros__node-drag-handle"
class="absolute cursor-move -left-8 macros__node-drag-handle"
/>
<div
class="macro__node-action-item"
:class="{
'has-error': errorKey,
}"
class="flex-grow p-2 mr-2 rounded-md shadow-sm"
:class="
errorKey
? 'bg-red-50 animate-shake dark:bg-red-800'
: 'bg-white dark:bg-slate-700'
"
>
<ActionInput
v-model="actionData"
@@ -103,39 +99,3 @@ export default {
/>
</div>
</template>
<style scoped lang="scss">
.macros__node-drag-handle {
@apply cursor-move -left-8 absolute;
}
.macro__node-action-container {
@apply w-full min-w-0 basis-full items-center flex relative;
.macro__node-action-item {
@apply flex-grow bg-white dark:bg-slate-700 p-2 mr-2 rounded-md shadow-sm;
&.has-error {
animation: shake 0.3s ease-in-out 0s 2;
@apply bg-red-50 dark:bg-red-800;
}
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(0.234375rem);
}
50% {
transform: translateX(-0.234375rem);
}
75% {
transform: translateX(0.234375rem);
}
100% {
transform: translateX(0);
}
}
</style>
@@ -229,8 +229,11 @@ export default {
</button>
</div>
</FormSection>
<FormSection :title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')">
<ChangePassword v-if="!globalConfig.disableUserProfileUpdate" />
<FormSection
v-if="!globalConfig.disableUserProfileUpdate"
:title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')"
>
<ChangePassword />
</FormSection>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
@@ -18,6 +18,15 @@ const getters = {
getCannedResponses(_state) {
return _state.records;
},
getSortedCannedResponses(_state) {
return sortOrder =>
[..._state.records].sort((a, b) => {
if (sortOrder === 'asc') {
return a.short_code.localeCompare(b.short_code);
}
return b.short_code.localeCompare(a.short_code);
});
},
getUIFlags(_state) {
return _state.uiFlags;
},
@@ -0,0 +1,43 @@
import CannedResponses from '../../cannedResponse';
const CANNED_RESPONSES = [
{ short_code: 'hello', content: 'Hi ' },
{ short_code: 'ask', content: 'Ask questions' },
{ short_code: 'greet', content: 'Good morning' },
];
const getters = CannedResponses.getters;
describe('#getCannedResponses', () => {
it('returns canned responses', () => {
const state = { records: CANNED_RESPONSES };
expect(getters.getCannedResponses(state)).toEqual(CANNED_RESPONSES);
});
});
describe('#getSortedCannedResponses', () => {
it('returns sort canned responses in ascending order', () => {
const state = { records: CANNED_RESPONSES };
expect(getters.getSortedCannedResponses(state)('asc')).toEqual([
CANNED_RESPONSES[1],
CANNED_RESPONSES[2],
CANNED_RESPONSES[0],
]);
});
it('returns sort canned responses in descending order', () => {
const state = { records: CANNED_RESPONSES };
expect(getters.getSortedCannedResponses(state)('desc')).toEqual([
CANNED_RESPONSES[0],
CANNED_RESPONSES[2],
CANNED_RESPONSES[1],
]);
});
});
describe('#getUIFlags', () => {
it('returns uiFlags', () => {
const state = { uiFlags: { isFetching: true } };
expect(getters.getUIFlags(state)).toEqual({ isFetching: true });
});
});
@@ -1,59 +1,63 @@
<script>
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
<script setup>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
mixins: [keyboardEventListenerMixins],
props: {
placement: {
type: String,
default: 'top',
},
defineProps({
placement: {
type: String,
default: 'top',
},
methods: {
dropdownMenuButtons() {
return this.$refs.dropdownMenu.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
},
getActiveButtonIndex(menuButtons) {
const focusedButton = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
},
getKeyboardEvents() {
const menuButtons = this.dropdownMenuButtons();
return {
ArrowUp: () => this.focusPreviousButton(menuButtons),
ArrowDown: () => this.focusNextButton(menuButtons),
};
},
focusPreviousButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
this.focusButton(menuButtons, newIndex);
},
focusNextButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
this.focusButton(menuButtons, newIndex);
},
focusButton(menuButtons, newIndex) {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
},
});
const dropdownMenuRef = ref(null);
const dropdownMenuButtons = () => {
return dropdownMenuRef.value.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
};
const getActiveButtonIndex = menuButtons => {
const focusedButton = dropdownMenuRef.value.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
};
const focusButton = (menuButtons, newIndex) => {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
};
const focusPreviousButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
focusButton(menuButtons, newIndex);
};
const focusNextButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
focusButton(menuButtons, newIndex);
};
const keyboardEvents = {
ArrowUp: {
action: () => focusPreviousButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
ArrowDown: {
action: () => focusNextButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, dropdownMenuRef);
</script>
<template>
<ul
ref="dropdownMenu"
ref="dropdownMenuRef"
class="dropdown menu vertical"
:class="[placement && `dropdown--${placement}`]"
>
@@ -1,22 +1,58 @@
/**
* Checks if a string is a valid E.164 phone number format.
* @param {string} value - The phone number to validate.
* @returns {boolean} True if the number is in E.164 format, false otherwise.
*/
export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/);
/**
* Validates a phone number after removing the dial code.
* @param {string} value - The full phone number including dial code.
* @param {string} dialCode - The dial code to remove before validation.
* @returns {boolean} True if the number (without dial code) is valid, false otherwise.
*/
export const isPhoneNumberValid = (value, dialCode) => {
const number = value.replace(dialCode, '');
return !!number.match(/^[0-9]{1,14}$/);
};
/**
* Checks if a string is either a valid E.164 phone number or empty.
* @param {string} value - The phone number to validate.
* @returns {boolean} True if the number is in E.164 format or empty, false otherwise.
*/
export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === '';
/**
* Validates a phone number with dial code, requiring at least 5 digits.
* @param {string} value - The full phone number including dial code.
* @returns {boolean} True if the number is valid, false otherwise.
*/
export const isPhoneNumberValidWithDialCode = value => {
const number = value.replace(/^\+/, ''); // Remove the '+' sign
return !!number.match(/^[1-9]\d{4,}$/); // Validate the phone number with minimum 5 digits
};
/**
* Checks if a string starts with a plus sign.
* @param {string} value - The string to check.
* @returns {boolean} True if the string starts with '+', false otherwise.
*/
export const startsWithPlus = value => value.startsWith('+');
/**
* Checks if a string is a valid URL (starts with 'http') or is empty.
* @param {string} [value=''] - The string to check.
* @returns {boolean} True if the string is a valid URL or empty, false otherwise.
*/
export const shouldBeUrl = (value = '') =>
value ? value.startsWith('http') : true;
/**
* Validates a password for complexity requirements.
* @param {string} value - The password to validate.
* @returns {boolean} True if the password meets all requirements, false otherwise.
*/
export const isValidPassword = value => {
const containsUppercase = /[A-Z]/.test(value);
const containsLowercase = /[a-z]/.test(value);
@@ -32,8 +68,18 @@ export const isValidPassword = value => {
);
};
/**
* Checks if a string consists only of digits.
* @param {string} value - The string to check.
* @returns {boolean} True if the string contains only digits, false otherwise.
*/
export const isNumber = value => /^\d+$/.test(value);
/**
* Validates a domain name.
* @param {string} value - The domain name to validate.
* @returns {boolean} True if the domain is valid or empty, false otherwise.
*/
export const isDomain = value => {
if (value !== '') {
const domainRegex = /^([\p{L}0-9]+(-[\p{L}0-9]+)*\.)+[a-z]{2,}$/gmu;
@@ -41,3 +87,16 @@ export const isDomain = value => {
}
return true;
};
/**
* Creates a RegExp object from a string representation of a regular expression.
* @param {string} regexPatternValue - The string representation of the regex (e.g., '/pattern/flags').
* @returns {RegExp} A RegExp object created from the input string.
*/
export const getRegexp = regexPatternValue => {
let lastSlash = regexPatternValue.lastIndexOf('/');
return new RegExp(
regexPatternValue.slice(1, lastSlash),
regexPatternValue.slice(lastSlash + 1)
);
};
@@ -8,6 +8,7 @@ import {
isPhoneNumberValid,
isNumber,
isDomain,
getRegexp,
} from '../Validators';
describe('#shouldBeUrl', () => {
@@ -115,3 +116,40 @@ describe('#startsWithPlus', () => {
expect(startsWithPlus('123456789')).toEqual(false);
});
});
describe('#getRegexp', () => {
it('should create a correct RegExp object', () => {
const regexPattern = '/^[a-z]+$/i';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('abc')).toBe(true);
expect(regex.test('ABC')).toBe(true);
expect(regex.test('123')).toBe(false);
});
it('should handle regex with flags', () => {
const regexPattern = '/hello/gi';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('hello')).toBe(true);
expect(regex.test('HELLO')).toBe(false);
expect(regex.test('Hello World')).toBe(true);
});
it('should handle regex with special characters', () => {
const regexPattern = '/\\d{3}-\\d{2}-\\d{4}/';
const regex = getRegexp(regexPattern);
expect(regex).toBeInstanceOf(RegExp);
expect(regex.toString()).toBe(regexPattern);
expect(regex.test('123-45-6789')).toBe(true);
expect(regex.test('12-34-5678')).toBe(false);
});
});
@@ -1,6 +1,5 @@
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import PlaygroundHeader from '../../components/playground/Header.vue';
import UserMessage from '../../components/playground/UserMessage.vue';
import BotMessage from '../../components/playground/BotMessage.vue';
@@ -13,7 +12,7 @@ export default {
BotMessage,
TypingIndicator,
},
mixins: [messageFormatterMixin, keyboardEventListenerMixins],
mixins: [messageFormatterMixin],
props: {
componentData: {
type: Object,
@@ -35,16 +34,6 @@ export default {
this.focusInput();
},
methods: {
getKeyboardEvents() {
return {
'$mod+Enter': {
action: () => {
this.onMessageSend();
},
allowOnFocusedInput: true,
},
};
},
focusInput() {
this.$refs.messageInput.focus();
},
@@ -133,6 +122,8 @@ export default {
placeholder="Type a message... [CMD/CTRL + Enter to send]"
autofocus
autocomplete="off"
@keydown.meta.enter="onMessageSend"
@keydown.ctrl.enter="onMessageSend"
/>
</div>
</section>
@@ -3,25 +3,19 @@ import CustomButton from 'shared/components/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { isEmptyObject } from 'widget/helpers/utils';
import { getRegexp } from 'shared/helpers/Validators';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import routerMixin from 'widget/mixins/routerMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
import customAttributeMixin from '../../../dashboard/mixins/customAttributeMixin';
export default {
components: {
CustomButton,
Spinner,
},
mixins: [
routerMixin,
darkModeMixin,
messageFormatterMixin,
configMixin,
customAttributeMixin,
],
mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin],
props: {
options: {
type: Object,
@@ -184,7 +178,7 @@ export default {
return this.formValues[name] || null;
},
getValidation({ type, name, field_type, regex_pattern }) {
let regex = regex_pattern ? this.getRegexp(regex_pattern) : null;
let regex = regex_pattern ? getRegexp(regex_pattern) : null;
const validations = {
emailAddress: 'email',
phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],