Merge branch 'develop' into chore-replace-availability-mixin-with-composable-CW-3472
This commit is contained in:
@@ -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>
|
||||
|
||||
+188
-183
@@ -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"
|
||||
|
||||
+40
-44
@@ -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
-12
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user