feat: run macros from the reply editor with # (#15416)
This commit is contained in:
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import { DROPDOWN_SEARCH_THRESHOLD } from '../helper/filterHelper';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import EmojiIcon from 'next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
@@ -124,6 +125,12 @@ const toggleOption = option => {
|
||||
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
|
||||
>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
|
||||
<EmojiIcon
|
||||
v-if="item.emoji"
|
||||
:value="item.emoji"
|
||||
:color="item.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -165,6 +172,13 @@ const toggleOption = option => {
|
||||
preserve-open
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<template v-if="option.emoji" #icon>
|
||||
<EmojiIcon
|
||||
:value="option.emoji"
|
||||
:color="option.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
</template>
|
||||
<template #label>
|
||||
{{ option.name }}
|
||||
<Icon
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import EmojiIcon from 'next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
@@ -130,7 +131,15 @@ const toggleSelected = option => {
|
||||
:icon="selectedItem.icon"
|
||||
:label="selectedItem.name"
|
||||
@click="toggle"
|
||||
/>
|
||||
>
|
||||
<template v-if="selectedItem.emoji" #icon>
|
||||
<EmojiIcon
|
||||
:value="selectedItem.emoji"
|
||||
:color="selectedItem.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
sm
|
||||
@@ -172,6 +181,13 @@ const toggleSelected = option => {
|
||||
:icon="option.icon"
|
||||
@click="toggleSelected(option)"
|
||||
>
|
||||
<template v-if="option.emoji" #icon>
|
||||
<EmojiIcon
|
||||
:value="option.emoji"
|
||||
:color="option.iconColor"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
</template>
|
||||
<template #label>
|
||||
{{ option.name }}
|
||||
<Icon
|
||||
|
||||
@@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue';
|
||||
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import MacroList from '../conversation/MacroList.vue';
|
||||
import TagTools from '../conversation/TagTools.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
|
||||
@@ -86,6 +87,7 @@ const props = defineProps({
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
enableMacros: { type: Boolean, default: false },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
signature: { type: String, default: '' },
|
||||
// allowSignature is a kill switch, ensuring no signature methods
|
||||
@@ -104,6 +106,8 @@ const emit = defineEmits([
|
||||
'toggleCannedMenu',
|
||||
'toggleVariablesMenu',
|
||||
'toggleToolsMenu',
|
||||
'toggleMacrosMenu',
|
||||
'executeMacro',
|
||||
'clearSelection',
|
||||
'blur',
|
||||
'focus',
|
||||
@@ -194,11 +198,13 @@ const showCannedMenu = ref(false);
|
||||
const showVariables = ref(false);
|
||||
const showEmojiMenu = ref(false);
|
||||
const showToolsMenu = ref(false);
|
||||
const showMacroMenu = ref(false);
|
||||
const toolSearchKey = ref('');
|
||||
const mentionSearchKey = ref('');
|
||||
const cannedSearchKey = ref('');
|
||||
const variableSearchKey = ref('');
|
||||
const emojiSearchKey = ref('');
|
||||
const macroSearchKey = ref('');
|
||||
const range = ref(null);
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
const showSelectionMenu = ref(false);
|
||||
@@ -253,6 +259,10 @@ const shouldShowCannedResponses = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const shouldShowMacros = computed(() => {
|
||||
return props.enableMacros && showMacroMenu.value;
|
||||
});
|
||||
|
||||
const shouldShowUserMentions = computed(() => {
|
||||
return showUserMentions.value && props.isPrivate;
|
||||
});
|
||||
@@ -269,6 +279,7 @@ const dismissUserMentions = () => dismissPicker(showUserMentions);
|
||||
const dismissCannedResponses = () => dismissPicker(showCannedMenu);
|
||||
const dismissVariables = () => dismissPicker(showVariables);
|
||||
const dismissEmojiMenu = () => dismissPicker(showEmojiMenu);
|
||||
const dismissMacros = () => dismissPicker(showMacroMenu);
|
||||
|
||||
// Deleting the trigger drops the suggestion, so the plugin closes the picker through
|
||||
// `onExit` on its own.
|
||||
@@ -280,6 +291,11 @@ const removeSuggestionTrigger = () => {
|
||||
editorView.focus();
|
||||
};
|
||||
|
||||
const onSelectMacro = macro => {
|
||||
removeSuggestionTrigger();
|
||||
emit('executeMacro', macro);
|
||||
};
|
||||
|
||||
function createSuggestionPlugin({
|
||||
trigger,
|
||||
minChars = 0,
|
||||
@@ -351,6 +367,12 @@ const plugins = computed(() => {
|
||||
isPrivate: () => props.isPrivate,
|
||||
getVariables: () => props.variables,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '#',
|
||||
showMenu: showMacroMenu,
|
||||
searchTerm: macroSearchKey,
|
||||
isAllowed: () => props.enableMacros,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: ':',
|
||||
minChars: 2,
|
||||
@@ -384,6 +406,9 @@ watch(shouldShowCannedResponses, updatedValue => {
|
||||
watch(shouldShowVariables, updatedValue => {
|
||||
emit('toggleVariablesMenu', updatedValue);
|
||||
});
|
||||
watch(shouldShowMacros, updatedValue => {
|
||||
emit('toggleMacrosMenu', updatedValue);
|
||||
});
|
||||
watch(showToolsMenu, updatedValue => {
|
||||
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
|
||||
});
|
||||
@@ -484,6 +509,7 @@ function reloadState(content = props.modelValue) {
|
||||
showVariables.value = false;
|
||||
showEmojiMenu.value = false;
|
||||
showToolsMenu.value = false;
|
||||
showMacroMenu.value = false;
|
||||
|
||||
const unrefContent = unref(content);
|
||||
state = createState(
|
||||
@@ -948,6 +974,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@remove-trigger="removeSuggestionTrigger"
|
||||
@select-variable="content => insertSpecialContent('variable', content)"
|
||||
/>
|
||||
<MacroList
|
||||
v-if="shouldShowMacros"
|
||||
:caret-position="caretPosition"
|
||||
:search-key="macroSearchKey"
|
||||
@close="dismissMacros"
|
||||
@remove-trigger="removeSuggestionTrigger"
|
||||
@select-macro="onSelectMacro"
|
||||
/>
|
||||
<KeyboardEmojiSelector
|
||||
v-if="showEmojiMenu"
|
||||
:caret-position="caretPosition"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useOrderedMacros } from 'dashboard/composables/useOrderedMacros';
|
||||
import { useMacros } from 'dashboard/composables/useMacros';
|
||||
import CaretAnchoredPicker from 'dashboard/components-next/preview-picker/CaretAnchoredPicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
caretPosition: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectMacro', 'close', 'removeTrigger']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
const { resolveMacroActions } = useMacros();
|
||||
|
||||
const uiFlags = useMapGetter('macros/getUIFlags');
|
||||
|
||||
// The trigger can already be followed by text, from a draft or a caret moved back onto it
|
||||
const searchQuery = ref(props.searchKey);
|
||||
|
||||
const searchTerm = computed(() => searchQuery.value.trim().toLowerCase());
|
||||
|
||||
const items = computed(() =>
|
||||
orderedMacros.value
|
||||
.filter(macro => macro.name?.toLowerCase().includes(searchTerm.value))
|
||||
.map(macro => ({
|
||||
id: macro.id,
|
||||
macro,
|
||||
label: macro.name,
|
||||
title: macro.name,
|
||||
subtitle: t('CONVERSATION.PICKER.MACRO.ACTION_COUNT', {
|
||||
count: macro.actions.length,
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
const onSelect = item => emit('selectMacro', item.macro);
|
||||
|
||||
onMounted(() => {
|
||||
if (!orderedMacros.value.length) store.dispatch('macros/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CaretAnchoredPicker
|
||||
v-model:search="searchQuery"
|
||||
:caret-position="caretPosition"
|
||||
:items="items"
|
||||
:search-placeholder="t('CONVERSATION.PICKER.MACRO.SEARCH_PLACEHOLDER')"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:empty-label="t('COMBOBOX.EMPTY_STATE')"
|
||||
@select="onSelect"
|
||||
@close="emit('close')"
|
||||
@remove-trigger="emit('removeTrigger')"
|
||||
>
|
||||
<template #preview="{ item }">
|
||||
<div v-if="item" class="px-4 py-3">
|
||||
<div
|
||||
v-for="(action, index) in resolveMacroActions(item.macro)"
|
||||
:key="index"
|
||||
class="relative flex flex-col pb-2 group ps-4 last:pb-0 gap-0.5"
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 -bottom-1 w-px start-[3.5px] bg-n-slate-6 group-last:hidden"
|
||||
/>
|
||||
<span
|
||||
class="absolute border-2 rounded-full top-1 start-0 size-2 bg-n-solid-1 border-n-weak dark:border-n-slate-6"
|
||||
/>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ t(`MACROS.ACTIONS.${action.actionName}`) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="action.actionValue"
|
||||
class="text-sm break-words text-n-slate-12"
|
||||
>
|
||||
{{ action.actionValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CaretAnchoredPicker>
|
||||
</template>
|
||||
@@ -52,10 +52,13 @@ import {
|
||||
getContactVariables,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useMacroExecution } from 'dashboard/composables/useMacroExecution';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
const EmojiIconPicker = defineAsyncComponent(
|
||||
@@ -81,6 +84,7 @@ export default {
|
||||
QuotedEmailPreview,
|
||||
CopilotEditorSection,
|
||||
CopilotReplyBottomPanel,
|
||||
ConversationResolveAttributesModal,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
emits: ['toggleEditorSize'],
|
||||
@@ -96,6 +100,7 @@ export default {
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const messageEditor = useTemplateRef('messageEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const macroExecution = useMacroExecution();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
return {
|
||||
@@ -108,6 +113,7 @@ export default {
|
||||
messageEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
macroExecution,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -135,6 +141,7 @@ export default {
|
||||
showUserMentions: false,
|
||||
showCannedMenu: false,
|
||||
showVariablesMenu: false,
|
||||
showMacrosMenu: false,
|
||||
newConversationModalActive: false,
|
||||
showArticleSearchPopover: false,
|
||||
hasRecordedAudio: false,
|
||||
@@ -149,7 +156,15 @@ export default {
|
||||
lastEmail: 'getLastEmailInSelectedChat',
|
||||
globalConfig: 'globalConfig/get',
|
||||
isMetaMessageSendingDisabled: 'globalConfig/isMetaMessageSendingDisabled',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
isMacrosEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.MACROS
|
||||
);
|
||||
},
|
||||
currentContact() {
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
if (!senderId) return {};
|
||||
@@ -774,6 +789,7 @@ export default {
|
||||
!this.showMentions &&
|
||||
!this.showCannedMenu &&
|
||||
!this.showVariablesMenu &&
|
||||
!this.showMacrosMenu &&
|
||||
this.isFocused &&
|
||||
this.isEditorHotKeyEnabled(selectedKey)
|
||||
);
|
||||
@@ -822,6 +838,18 @@ export default {
|
||||
toggleVariablesMenu(value) {
|
||||
this.showVariablesMenu = value;
|
||||
},
|
||||
toggleMacrosMenu(value) {
|
||||
this.showMacrosMenu = value;
|
||||
},
|
||||
onExecuteMacro(macro) {
|
||||
const pending = this.macroExecution.execute(macro, this.currentChat.id);
|
||||
if (pending) {
|
||||
this.$refs.resolveAttributesModal?.open(
|
||||
pending.missing,
|
||||
pending.customAttributes
|
||||
);
|
||||
}
|
||||
},
|
||||
openWhatsappTemplateModal() {
|
||||
this.showWhatsAppTemplatesModal = true;
|
||||
},
|
||||
@@ -1401,6 +1429,7 @@ export default {
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
:disabled="isEditorDisabled"
|
||||
:enable-macros="isMacrosEnabled"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="messageSignature"
|
||||
@@ -1414,6 +1443,8 @@ export default {
|
||||
@toggle-user-mention="toggleUserMention"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@toggle-variables-menu="toggleVariablesMenu"
|
||||
@toggle-macros-menu="toggleMacrosMenu"
|
||||
@execute-macro="onExecuteMacro"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
@@ -1518,6 +1549,12 @@ export default {
|
||||
@cancel="hideContentTemplatesModal"
|
||||
/>
|
||||
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModal"
|
||||
@submit="macroExecution.submitPendingAttributes"
|
||||
@close="macroExecution.dismissPendingAttributes"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import { useMacroExecution } from '../useMacroExecution';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables');
|
||||
vi.mock('dashboard/composables/useConversationRequiredAttributes');
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
const CONVERSATION_ID = 42;
|
||||
|
||||
const addLabel = { action_name: 'add_label', action_params: ['spam'] };
|
||||
const resolveConversation = {
|
||||
action_name: 'resolve_conversation',
|
||||
action_params: [],
|
||||
};
|
||||
|
||||
const macroWith = actions => ({ id: 7, name: 'Test macro', actions });
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const checkMissingAttributes = vi.fn();
|
||||
|
||||
const mockConversation = (customAttributes = {}) => {
|
||||
useMapGetter.mockImplementation(getter =>
|
||||
getter === 'getConversationById'
|
||||
? { value: () => ({ custom_attributes: customAttributes }) }
|
||||
: { value: null }
|
||||
);
|
||||
};
|
||||
|
||||
describe('useMacroExecution', () => {
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset().mockResolvedValue(undefined);
|
||||
checkMissingAttributes.mockReset().mockReturnValue({
|
||||
hasMissing: false,
|
||||
missing: [],
|
||||
});
|
||||
useAlert.mockClear();
|
||||
useTrack.mockClear();
|
||||
|
||||
useStore.mockReturnValue({ dispatch });
|
||||
useConversationRequiredAttributes.mockReturnValue({
|
||||
checkMissingAttributes,
|
||||
});
|
||||
mockConversation();
|
||||
});
|
||||
|
||||
it('runs a macro that cannot resolve the conversation straight away', async () => {
|
||||
const { execute } = useMacroExecution();
|
||||
|
||||
const pending = execute(macroWith([addLabel]), CONVERSATION_ID);
|
||||
await flushPromises();
|
||||
|
||||
expect(pending).toBeNull();
|
||||
expect(checkMissingAttributes).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith('macros/execute', {
|
||||
macroId: 7,
|
||||
conversationIds: [CONVERSATION_ID],
|
||||
});
|
||||
expect(useAlert).toHaveBeenCalledWith(
|
||||
'MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'
|
||||
);
|
||||
});
|
||||
|
||||
it('runs a resolving macro when no required attributes are missing', async () => {
|
||||
const { execute } = useMacroExecution();
|
||||
|
||||
const pending = execute(macroWith([resolveConversation]), CONVERSATION_ID);
|
||||
await flushPromises();
|
||||
|
||||
expect(pending).toBeNull();
|
||||
expect(checkMissingAttributes).toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith('macros/execute', expect.anything());
|
||||
});
|
||||
|
||||
it('holds back a resolving macro and reports the missing attributes', async () => {
|
||||
checkMissingAttributes.mockReturnValue({
|
||||
hasMissing: true,
|
||||
missing: ['priority'],
|
||||
});
|
||||
mockConversation({ category: 'sales' });
|
||||
|
||||
const { execute } = useMacroExecution();
|
||||
|
||||
const pending = execute(macroWith([resolveConversation]), CONVERSATION_ID);
|
||||
await flushPromises();
|
||||
|
||||
expect(pending).toEqual({
|
||||
missing: ['priority'],
|
||||
customAttributes: { category: 'sales' },
|
||||
});
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([['resolved'], [1]])(
|
||||
'treats change_status %s as resolving the conversation',
|
||||
async status => {
|
||||
checkMissingAttributes.mockReturnValue({
|
||||
hasMissing: true,
|
||||
missing: ['priority'],
|
||||
});
|
||||
|
||||
const { execute } = useMacroExecution();
|
||||
const macro = macroWith([
|
||||
{ action_name: 'change_status', action_params: [status] },
|
||||
]);
|
||||
|
||||
expect(execute(macro, CONVERSATION_ID)).not.toBeNull();
|
||||
await flushPromises();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not treat change_status open as resolving the conversation', async () => {
|
||||
checkMissingAttributes.mockReturnValue({
|
||||
hasMissing: true,
|
||||
missing: ['priority'],
|
||||
});
|
||||
|
||||
const { execute } = useMacroExecution();
|
||||
const macro = macroWith([
|
||||
{ action_name: 'change_status', action_params: ['open'] },
|
||||
]);
|
||||
|
||||
expect(execute(macro, CONVERSATION_ID)).toBeNull();
|
||||
await flushPromises();
|
||||
expect(dispatch).toHaveBeenCalledWith('macros/execute', expect.anything());
|
||||
});
|
||||
|
||||
it('saves the submitted attributes before running the pending macro', async () => {
|
||||
checkMissingAttributes.mockReturnValue({
|
||||
hasMissing: true,
|
||||
missing: ['priority'],
|
||||
});
|
||||
mockConversation({ category: 'sales' });
|
||||
|
||||
const { execute, submitPendingAttributes } = useMacroExecution();
|
||||
execute(macroWith([resolveConversation]), CONVERSATION_ID);
|
||||
await submitPendingAttributes({ attributes: { priority: 'high' } });
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, 'updateCustomAttributes', {
|
||||
conversationId: CONVERSATION_ID,
|
||||
customAttributes: { category: 'sales', priority: 'high' },
|
||||
});
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, 'macros/execute', {
|
||||
macroId: 7,
|
||||
conversationIds: [CONVERSATION_ID],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not run the macro when saving the attributes fails', async () => {
|
||||
checkMissingAttributes.mockReturnValue({
|
||||
hasMissing: true,
|
||||
missing: ['priority'],
|
||||
});
|
||||
dispatch.mockRejectedValueOnce(new Error('nope'));
|
||||
|
||||
const { execute, submitPendingAttributes } = useMacroExecution();
|
||||
execute(macroWith([resolveConversation]), CONVERSATION_ID);
|
||||
await submitPendingAttributes({ attributes: { priority: 'high' } });
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(useAlert).toHaveBeenCalledWith(
|
||||
'CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR'
|
||||
);
|
||||
});
|
||||
|
||||
it('still runs the macro when the prompt is dismissed', async () => {
|
||||
checkMissingAttributes.mockReturnValue({
|
||||
hasMissing: true,
|
||||
missing: ['priority'],
|
||||
});
|
||||
|
||||
const { execute, dismissPendingAttributes } = useMacroExecution();
|
||||
execute(macroWith([resolveConversation]), CONVERSATION_ID);
|
||||
dismissPendingAttributes();
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('macros/execute', expect.anything());
|
||||
expect(useAlert).toHaveBeenCalledWith(
|
||||
'MACROS.EXECUTE.EXECUTED_WITHOUT_RESOLVING'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a dismissal when nothing is pending', async () => {
|
||||
const { dismissPendingAttributes } = useMacroExecution();
|
||||
|
||||
dismissPendingAttributes();
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alerts when the macro fails to run', async () => {
|
||||
dispatch.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const { execute, executingMacroId } = useMacroExecution();
|
||||
execute(macroWith([addLabel]), CONVERSATION_ID);
|
||||
await flushPromises();
|
||||
|
||||
expect(useAlert).toHaveBeenCalledWith('MACROS.ERROR');
|
||||
expect(executingMacroId.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/automationHelper.js');
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
@@ -58,6 +57,8 @@ describe('useMacros', () => {
|
||||
{
|
||||
id: 1,
|
||||
name: '⚙️ sales team',
|
||||
icon: 'chat-1-line',
|
||||
icon_color: '#64748B',
|
||||
description: 'This is our internal sales team',
|
||||
allow_auto_assign: true,
|
||||
account_id: 1,
|
||||
@@ -80,6 +81,12 @@ describe('useMacros', () => {
|
||||
is_member: true,
|
||||
},
|
||||
];
|
||||
const mockTeamOptions = mockTeams.map(team => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
emoji: team.icon,
|
||||
iconColor: team.icon_color,
|
||||
}));
|
||||
const mockAgents = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -126,15 +133,14 @@ describe('useMacros', () => {
|
||||
); // +2 for "None" and "Self"
|
||||
});
|
||||
|
||||
it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
|
||||
it('returns teams with "None" option for assign_team', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const assignTeamResult = getMacroDropdownValues('assign_team');
|
||||
expect(assignTeamResult[0]).toEqual({
|
||||
id: 'nil',
|
||||
name: 'AUTOMATION.NONE_OPTION',
|
||||
});
|
||||
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
|
||||
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
|
||||
expect(assignTeamResult.slice(1)).toEqual(mockTeamOptions);
|
||||
});
|
||||
|
||||
it('returns agents with "None" and "Self" options for assign_agent type', () => {
|
||||
@@ -169,6 +175,49 @@ describe('useMacros', () => {
|
||||
expect(getMacroDropdownValues('unknown_type')).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves macro actions into name and value pairs', () => {
|
||||
const { resolveMacroActions } = useMacros();
|
||||
const macro = {
|
||||
actions: [
|
||||
{ action_name: 'assign_team', action_params: [1, 'nil'] },
|
||||
{ action_name: 'assign_agent', action_params: [9] },
|
||||
{ action_name: 'add_label', action_params: ['sales', 'billing'] },
|
||||
{ action_name: 'change_priority', action_params: ['high'] },
|
||||
{ action_name: 'send_attachment', action_params: ['blob-1'] },
|
||||
{ action_name: 'send_message', action_params: ['Hello there'] },
|
||||
{ action_name: 'resolve_conversation', action_params: [] },
|
||||
],
|
||||
files: [{ blob_id: 'blob-1', filename: 'invoice.pdf' }],
|
||||
};
|
||||
|
||||
expect(resolveMacroActions(macro)).toEqual([
|
||||
{
|
||||
actionName: 'ASSIGN_TEAM',
|
||||
actionValue: '⚙️ sales team, AUTOMATION.NONE_OPTION',
|
||||
},
|
||||
{ actionName: 'ASSIGN_AGENT', actionValue: 'Clark Kent' },
|
||||
{ actionName: 'ADD_LABEL', actionValue: 'sales, billing' },
|
||||
{
|
||||
actionName: 'CHANGE_PRIORITY',
|
||||
actionValue: 'MACROS.PRIORITY_TYPES.HIGH',
|
||||
},
|
||||
{ actionName: 'SEND_ATTACHMENT', actionValue: 'invoice.pdf' },
|
||||
{ actionName: 'SEND_MESSAGE', actionValue: 'Hello there' },
|
||||
{ actionName: 'RESOLVE_CONVERSATION', actionValue: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves actions the macro builder does not offer', () => {
|
||||
const { resolveMacroActions } = useMacros();
|
||||
const macro = {
|
||||
actions: [{ action_name: 'change_status', action_params: ['resolved'] }],
|
||||
};
|
||||
|
||||
expect(resolveMacroActions(macro)).toEqual([
|
||||
{ actionName: 'CHANGE_STATUS', actionValue: 'resolved' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty data correctly', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useOrderedMacros } from '../useOrderedMacros';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useUISettings');
|
||||
|
||||
const macros = [
|
||||
{ id: 1, name: 'Resolve' },
|
||||
{ id: 2, name: 'Mark as spam' },
|
||||
{ id: 3, name: 'Remove team' },
|
||||
];
|
||||
|
||||
const updateUISettings = vi.fn();
|
||||
|
||||
const mockSettings = (savedOrder, currentMacros = macros) => {
|
||||
useMapGetter.mockImplementation(getter =>
|
||||
getter === 'macros/getMacros' ? { value: currentMacros } : { value: null }
|
||||
);
|
||||
useUISettings.mockReturnValue({
|
||||
uiSettings: {
|
||||
value: savedOrder ? { macros_display_order: savedOrder } : {},
|
||||
},
|
||||
updateUISettings,
|
||||
});
|
||||
};
|
||||
|
||||
describe('useOrderedMacros', () => {
|
||||
beforeEach(() => {
|
||||
updateUISettings.mockClear();
|
||||
});
|
||||
|
||||
it('keeps the store order when nothing has been arranged', () => {
|
||||
mockSettings(null);
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
|
||||
expect(orderedMacros.value.map(({ id }) => id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('keeps the store order when the saved order is empty', () => {
|
||||
mockSettings([]);
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
|
||||
expect(orderedMacros.value.map(({ id }) => id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('sorts macros by the saved order', () => {
|
||||
mockSettings([3, 1, 2]);
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
|
||||
expect(orderedMacros.value.map(({ id }) => id)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it('pushes macros missing from the saved order to the end', () => {
|
||||
mockSettings([3]);
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
|
||||
expect(orderedMacros.value.map(({ id }) => id)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it('ignores saved ids that no longer exist', () => {
|
||||
mockSettings([99, 2, 1], [macros[0], macros[1]]);
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
|
||||
expect(orderedMacros.value.map(({ id }) => id)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('returns an empty list when there are no macros', () => {
|
||||
mockSettings([3, 1, 2], []);
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
|
||||
expect(orderedMacros.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('saves the new order as ids when written to', () => {
|
||||
mockSettings([1, 2, 3]);
|
||||
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
orderedMacros.value = [macros[2], macros[0], macros[1]];
|
||||
|
||||
expect(updateUISettings).toHaveBeenCalledWith({
|
||||
macros_display_order: [3, 1, 2],
|
||||
});
|
||||
});
|
||||
});
|
||||
117
app/javascript/dashboard/composables/useMacroExecution.js
Normal file
117
app/javascript/dashboard/composables/useMacroExecution.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
// change_status is not offered by the macro builder, but the API accepts it and
|
||||
// it resolves the conversation just like resolve_conversation does. Its param is
|
||||
// stored as raw JSON, so the status can be the enum name or its integer value.
|
||||
const RESOLVED_STATUSES = ['resolved', 1];
|
||||
|
||||
const resolvesConversation = macro =>
|
||||
macro.actions.some(
|
||||
({ action_name: name, action_params: params }) =>
|
||||
name === 'resolve_conversation' ||
|
||||
(name === 'change_status' && RESOLVED_STATUSES.includes(params?.[0]))
|
||||
);
|
||||
|
||||
/**
|
||||
* Runs a macro against a conversation, holding back the ones that resolve it until
|
||||
* the required custom attributes are filled in.
|
||||
*
|
||||
* `execute` returns the attributes to prompt for when the caller has to open the
|
||||
* modal first, and null once the macro has been handed off.
|
||||
*/
|
||||
export function useMacroExecution() {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const conversationById = useMapGetter('getConversationById');
|
||||
|
||||
const executingMacroId = ref(null);
|
||||
const pendingExecution = ref(null);
|
||||
|
||||
const customAttributesFor = conversationId =>
|
||||
conversationById.value(conversationId)?.custom_attributes || {};
|
||||
|
||||
const runMacro = async (
|
||||
{ macro, conversationId },
|
||||
skippedResolve = false
|
||||
) => {
|
||||
try {
|
||||
executingMacroId.value = macro.id;
|
||||
await store.dispatch('macros/execute', {
|
||||
macroId: macro.id,
|
||||
conversationIds: [conversationId],
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.EXECUTED_A_MACRO);
|
||||
useAlert(
|
||||
skippedResolve
|
||||
? t('MACROS.EXECUTE.EXECUTED_WITHOUT_RESOLVING')
|
||||
: t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('MACROS.ERROR'));
|
||||
} finally {
|
||||
executingMacroId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const execute = (macro, conversationId) => {
|
||||
const execution = { macro, conversationId };
|
||||
|
||||
if (!resolvesConversation(macro)) {
|
||||
runMacro(execution);
|
||||
return null;
|
||||
}
|
||||
|
||||
const customAttributes = customAttributesFor(conversationId);
|
||||
const { hasMissing, missing } = checkMissingAttributes(customAttributes);
|
||||
if (!hasMissing) {
|
||||
runMacro(execution);
|
||||
return null;
|
||||
}
|
||||
|
||||
pendingExecution.value = execution;
|
||||
return { missing, customAttributes };
|
||||
};
|
||||
|
||||
const submitPendingAttributes = async ({ attributes }) => {
|
||||
const execution = pendingExecution.value;
|
||||
pendingExecution.value = null;
|
||||
|
||||
try {
|
||||
await store.dispatch('updateCustomAttributes', {
|
||||
conversationId: execution.conversationId,
|
||||
customAttributes: {
|
||||
...customAttributesFor(execution.conversationId),
|
||||
...attributes,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR'));
|
||||
return;
|
||||
}
|
||||
|
||||
runMacro(execution);
|
||||
};
|
||||
|
||||
// Dismissing the modal still runs the macro, the backend leaves the
|
||||
// conversation unresolved while the required attributes are empty.
|
||||
const dismissPendingAttributes = () => {
|
||||
if (!pendingExecution.value) return;
|
||||
|
||||
runMacro(pendingExecution.value, true);
|
||||
pendingExecution.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
executingMacroId,
|
||||
execute,
|
||||
submitPendingAttributes,
|
||||
dismissPendingAttributes,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,15 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
import { generateTeamOptions } from 'dashboard/helper/automationHelper';
|
||||
import {
|
||||
resolveActionName,
|
||||
getFileName,
|
||||
} from 'dashboard/routes/dashboard/settings/macros/macroHelper';
|
||||
|
||||
/**
|
||||
* Composable for handling macro-related functionality
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
* @returns {Object} An object containing the getMacroDropdownValues and resolveMacroActions functions
|
||||
*/
|
||||
export const useMacros = () => {
|
||||
const { t } = useI18n();
|
||||
@@ -28,9 +33,7 @@ export const useMacros = () => {
|
||||
const getMacroDropdownValues = type => {
|
||||
switch (type) {
|
||||
case 'assign_team':
|
||||
return withNoneOption(teams.value);
|
||||
case 'send_email_to_team':
|
||||
return teams.value;
|
||||
return withNoneOption(generateTeamOptions(teams.value));
|
||||
case 'assign_agent':
|
||||
return [
|
||||
...withNoneOption(),
|
||||
@@ -53,7 +56,40 @@ export const useMacros = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveActionValue = (
|
||||
{ action_name: name, action_params: params },
|
||||
files
|
||||
) => {
|
||||
if (!params?.length) return '';
|
||||
|
||||
const options = getMacroDropdownValues(name);
|
||||
if (options.length) {
|
||||
return params
|
||||
.map(id => options.find(option => option.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
if (name === 'send_attachment') return getFileName(params[0], name, files);
|
||||
|
||||
return params[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a macro into the action name and value pairs shown in its preview.
|
||||
* Actions that store option ids are looked up in the same dropdown values the
|
||||
* macro builder offers, so the preview never drifts from what can be built.
|
||||
* @param {Object} macro - The macro to resolve
|
||||
* @returns {Array} An array of { actionName, actionValue } pairs
|
||||
*/
|
||||
const resolveMacroActions = macro =>
|
||||
macro.actions.map(action => ({
|
||||
actionName: resolveActionName(action.action_name),
|
||||
actionValue: resolveActionValue(action, macro.files),
|
||||
}));
|
||||
|
||||
return {
|
||||
getMacroDropdownValues,
|
||||
resolveMacroActions,
|
||||
};
|
||||
};
|
||||
|
||||
40
app/javascript/dashboard/composables/useOrderedMacros.js
Normal file
40
app/javascript/dashboard/composables/useOrderedMacros.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const MACROS_ORDER_KEY = 'macros_display_order';
|
||||
|
||||
/**
|
||||
* Macros in the order the agent arranged them in the sidebar, so every surface
|
||||
* that lists them stays in sync. Writing back saves a new order.
|
||||
*/
|
||||
export function useOrderedMacros() {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const macros = useMapGetter('macros/getMacros');
|
||||
|
||||
const orderedMacros = computed({
|
||||
get: () => {
|
||||
const savedOrder = uiSettings.value?.[MACROS_ORDER_KEY] ?? [];
|
||||
const currentMacros = macros.value ?? [];
|
||||
|
||||
if (!savedOrder.length || !currentMacros.length) {
|
||||
return currentMacros;
|
||||
}
|
||||
|
||||
const orderMap = new Map(savedOrder.map((id, index) => [id, index]));
|
||||
|
||||
// Anything the agent never arranged keeps its store order, at the end
|
||||
return [...currentMacros].sort(
|
||||
(a, b) =>
|
||||
(orderMap.get(a.id) ?? Infinity) - (orderMap.get(b.id) ?? Infinity)
|
||||
);
|
||||
},
|
||||
set: newOrder => {
|
||||
updateUISettings({
|
||||
[MACROS_ORDER_KEY]: newOrder.map(({ id }) => id),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { orderedMacros };
|
||||
}
|
||||
@@ -94,6 +94,16 @@ export const generateConditionOptions = (options, key = 'id') => {
|
||||
});
|
||||
};
|
||||
|
||||
// Teams carry an emoji icon picker value in `icon`, which is not a CSS class and
|
||||
// cannot be handed to the generic Icon component the dropdowns render.
|
||||
export const generateTeamOptions = teams =>
|
||||
(teams || []).map(team => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
emoji: team.icon,
|
||||
iconColor: team.icon_color,
|
||||
}));
|
||||
|
||||
export const getActionOptions = ({
|
||||
agents,
|
||||
teams,
|
||||
@@ -105,8 +115,10 @@ export const getActionOptions = ({
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
|
||||
assign_team: addNoneToListFn ? addNoneToListFn(teams) : teams,
|
||||
send_email_to_team: teams,
|
||||
assign_team: addNoneToListFn
|
||||
? addNoneToListFn(generateTeamOptions(teams))
|
||||
: generateTeamOptions(teams),
|
||||
send_email_to_team: generateTeamOptions(teams),
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
change_priority: priorityOptions,
|
||||
@@ -144,7 +156,7 @@ export const getConditionOptions = ({
|
||||
assignee_id: agents,
|
||||
contact: contacts,
|
||||
inbox_id: inboxes,
|
||||
team_id: teams,
|
||||
team_id: generateTeamOptions(teams),
|
||||
campaigns: generateConditionOptions(campaigns),
|
||||
browser_language: languages,
|
||||
conversation_language: languages,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
emptyMacro,
|
||||
resolveActionName,
|
||||
resolveLabels,
|
||||
resolveTeamIds,
|
||||
getFileName,
|
||||
resolveAgents,
|
||||
} from '../../routes/dashboard/settings/macros/macroHelper';
|
||||
import { MACRO_ACTION_TYPES } from '../../routes/dashboard/settings/macros/constants';
|
||||
import { teams, labels, files, agents } from './macrosFixtures';
|
||||
import { files } from './macrosFixtures';
|
||||
|
||||
describe('#emptyMacro', () => {
|
||||
const defaultMacro = {
|
||||
@@ -40,35 +37,6 @@ describe('#resolveActionName', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveTeamIds', () => {
|
||||
it('resolves team names from ids, and returns a joined string', () => {
|
||||
const resolvedTeams = '⚙️ sales team, 🤷♂️ fayaz';
|
||||
expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams);
|
||||
});
|
||||
|
||||
it('resolves nil as None', () => {
|
||||
expect(resolveTeamIds(teams, ['nil'])).toEqual('None');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveLabels', () => {
|
||||
it('resolves labels names from ids and returns a joined string', () => {
|
||||
const resolvedLabels = 'sales, billing';
|
||||
expect(resolveLabels(labels, ['sales', 'billing'])).toEqual(resolvedLabels);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveAgents', () => {
|
||||
it('resolves agents names from ids and returns a joined string', () => {
|
||||
const resolvedAgents = 'John Doe';
|
||||
expect(resolveAgents(agents, [1])).toEqual(resolvedAgents);
|
||||
});
|
||||
|
||||
it('resolves nil and self values', () => {
|
||||
expect(resolveAgents(agents, ['nil', 'self'])).toEqual('None, Self');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getFileName', () => {
|
||||
it('returns the correct file name from the list of files', () => {
|
||||
expect(getFileName(files[0].blob_id, 'send_attachment', files)).toEqual(
|
||||
|
||||
@@ -170,6 +170,10 @@
|
||||
"SEARCH_PLACEHOLDER": "Search emojis",
|
||||
"EMPTY_STATE": "Type to search emojis"
|
||||
},
|
||||
"MACRO": {
|
||||
"SEARCH_PLACEHOLDER": "Search macros",
|
||||
"ACTION_COUNT": "1 action | {count} actions"
|
||||
},
|
||||
"MENTION": {
|
||||
"SEARCH_PLACEHOLDER": "Search agents and teams",
|
||||
"FILTER": {
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"MUTE_CONVERSATION": "Mute Conversation",
|
||||
"SNOOZE_CONVERSATION": "Snooze Conversation",
|
||||
"RESOLVE_CONVERSATION": "Resolve Conversation",
|
||||
"CHANGE_STATUS": "Change Status",
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useMacroExecution } from 'dashboard/composables/useMacroExecution';
|
||||
import { useOrderedMacros } from 'dashboard/composables/useOrderedMacros';
|
||||
|
||||
import Draggable from 'vuedraggable';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
@@ -22,136 +19,33 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { accountScopedUrl } = useAccount();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
const { orderedMacros } = useOrderedMacros();
|
||||
const {
|
||||
executingMacroId,
|
||||
execute,
|
||||
submitPendingAttributes,
|
||||
dismissPendingAttributes,
|
||||
} = useMacroExecution();
|
||||
|
||||
const dragging = ref(false);
|
||||
const executingMacroId = ref(null);
|
||||
const pendingExecution = ref(null);
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
|
||||
const macros = useMapGetter('macros/getMacros');
|
||||
const uiFlags = useMapGetter('macros/getUIFlags');
|
||||
const conversationById = useMapGetter('getConversationById');
|
||||
|
||||
const MACROS_ORDER_KEY = 'macros_display_order';
|
||||
|
||||
const orderedMacros = computed({
|
||||
get: () => {
|
||||
// Get saved order array and current macros
|
||||
const savedOrder = uiSettings.value?.[MACROS_ORDER_KEY] ?? [];
|
||||
const currentMacros = macros.value ?? [];
|
||||
|
||||
// Return unmodified macros if not present or macro is not available
|
||||
if (!savedOrder.length || !currentMacros.length) {
|
||||
return currentMacros;
|
||||
}
|
||||
|
||||
// Create a Map of id -> position for faster lookups
|
||||
const orderMap = new Map(savedOrder.map((id, index) => [id, index]));
|
||||
|
||||
return [...currentMacros].sort((a, b) => {
|
||||
// Use Infinity for items not in saved order (pushes them to end)
|
||||
const aPos = orderMap.get(a.id) ?? Infinity;
|
||||
const bPos = orderMap.get(b.id) ?? Infinity;
|
||||
return aPos - bPos;
|
||||
});
|
||||
},
|
||||
set: newOrder => {
|
||||
// Update settings with array of ids from new order
|
||||
updateUISettings({
|
||||
[MACROS_ORDER_KEY]: newOrder.map(({ id }) => id),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragging.value = false;
|
||||
};
|
||||
|
||||
const customAttributesFor = conversationId =>
|
||||
conversationById.value(conversationId)?.custom_attributes || {};
|
||||
|
||||
// change_status is not offered by the macro builder, but the API accepts it and
|
||||
// it resolves the conversation just like resolve_conversation does. Its param is
|
||||
// stored as raw JSON, so the status can be the enum name or its integer value.
|
||||
const RESOLVED_STATUSES = ['resolved', 1];
|
||||
|
||||
const resolvesConversation = macro =>
|
||||
macro.actions.some(
|
||||
({ action_name: name, action_params: params }) =>
|
||||
name === 'resolve_conversation' ||
|
||||
(name === 'change_status' && RESOLVED_STATUSES.includes(params?.[0]))
|
||||
);
|
||||
|
||||
const runMacro = async ({ macro, conversationId }, skippedResolve = false) => {
|
||||
try {
|
||||
executingMacroId.value = macro.id;
|
||||
await store.dispatch('macros/execute', {
|
||||
macroId: macro.id,
|
||||
conversationIds: [conversationId],
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.EXECUTED_A_MACRO);
|
||||
useAlert(
|
||||
skippedResolve
|
||||
? t('MACROS.EXECUTE.EXECUTED_WITHOUT_RESOLVING')
|
||||
: t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(t('MACROS.ERROR'));
|
||||
} finally {
|
||||
executingMacroId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onExecuteMacro = macro => {
|
||||
const execution = { macro, conversationId: props.conversationId };
|
||||
|
||||
if (!resolvesConversation(macro)) {
|
||||
runMacro(execution);
|
||||
return;
|
||||
const pending = execute(macro, props.conversationId);
|
||||
if (pending) {
|
||||
resolveAttributesModalRef.value?.open(
|
||||
pending.missing,
|
||||
pending.customAttributes
|
||||
);
|
||||
}
|
||||
|
||||
const customAttributes = customAttributesFor(execution.conversationId);
|
||||
const { hasMissing, missing } = checkMissingAttributes(customAttributes);
|
||||
if (!hasMissing) {
|
||||
runMacro(execution);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingExecution.value = execution;
|
||||
resolveAttributesModalRef.value?.open(missing, customAttributes);
|
||||
};
|
||||
|
||||
const onAttributesSubmit = async ({ attributes }) => {
|
||||
const execution = pendingExecution.value;
|
||||
pendingExecution.value = null;
|
||||
|
||||
try {
|
||||
await store.dispatch('updateCustomAttributes', {
|
||||
conversationId: execution.conversationId,
|
||||
customAttributes: {
|
||||
...customAttributesFor(execution.conversationId),
|
||||
...attributes,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR'));
|
||||
return;
|
||||
}
|
||||
|
||||
runMacro(execution);
|
||||
};
|
||||
|
||||
// Dismissing the modal still runs the macro, the backend leaves the
|
||||
// conversation unresolved while the required attributes are empty.
|
||||
const onAttributesClose = () => {
|
||||
if (!pendingExecution.value) return;
|
||||
|
||||
runMacro(pendingExecution.value, true);
|
||||
pendingExecution.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -204,8 +98,8 @@ onMounted(() => {
|
||||
</Draggable>
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="onAttributesSubmit"
|
||||
@close="onAttributesClose"
|
||||
@submit="submitPendingAttributes"
|
||||
@close="dismissPendingAttributes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,7 @@ const closeMacroPreview = () => {
|
||||
faded
|
||||
xs
|
||||
:is-loading="isExecuting"
|
||||
:disabled="isExecuting"
|
||||
@click="$emit('execute')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import {
|
||||
resolveActionName,
|
||||
resolveTeamIds,
|
||||
resolveLabels,
|
||||
resolveAgents,
|
||||
} from 'dashboard/routes/dashboard/settings/macros/macroHelper';
|
||||
import { useMacros } from 'dashboard/composables/useMacros';
|
||||
|
||||
const props = defineProps({
|
||||
macro: {
|
||||
@@ -15,40 +9,14 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const { resolveMacroActions } = useMacros();
|
||||
|
||||
const getActionValue = (key, params) => {
|
||||
const actionsMap = {
|
||||
assign_team: resolveTeamIds(teams.value, params),
|
||||
add_label: resolveLabels(labels.value, params),
|
||||
remove_label: resolveLabels(labels.value, params),
|
||||
assign_agent: resolveAgents(agents.value, params),
|
||||
remove_assigned_agent: null,
|
||||
mute_conversation: null,
|
||||
snooze_conversation: null,
|
||||
resolve_conversation: null,
|
||||
remove_assigned_team: null,
|
||||
send_webhook_event: params[0],
|
||||
send_message: params[0],
|
||||
send_email_transcript: params[0],
|
||||
add_private_note: params[0],
|
||||
};
|
||||
return actionsMap[key] || '';
|
||||
};
|
||||
|
||||
const resolvedMacro = computed(() => {
|
||||
return props.macro.actions.map(action => ({
|
||||
actionName: resolveActionName(action.action_name),
|
||||
actionValue: getActionValue(action.action_name, action.action_params),
|
||||
}));
|
||||
});
|
||||
const resolvedMacro = computed(() => resolveMacroActions(props.macro));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="macro-preview absolute border border-n-weak max-h-[22.5rem] z-50 w-64 rounded-md bg-n-alpha-3 backdrop-blur-[100px] shadow-lg bottom-8 right-8 overflow-y-auto p-4 text-left rtl:text-right"
|
||||
class="macro-preview absolute border border-n-weak max-h-[22.5rem] z-50 w-64 rounded-md bg-n-alpha-3 backdrop-blur-[100px] shadow-lg bottom-8 end-8 overflow-y-auto p-4 text-start"
|
||||
>
|
||||
<h6 class="mb-4 text-sm text-n-slate-12">
|
||||
{{ macro.name }}
|
||||
@@ -56,14 +24,14 @@ const resolvedMacro = computed(() => {
|
||||
<div
|
||||
v-for="(action, i) in resolvedMacro"
|
||||
:key="i"
|
||||
class="relative pl-4 macro-block"
|
||||
class="relative ps-4 macro-block"
|
||||
>
|
||||
<div
|
||||
v-if="i !== macro.actions.length - 1"
|
||||
class="top-[0.390625rem] absolute -bottom-1 left-0 w-px bg-n-slate-6"
|
||||
class="top-[0.390625rem] absolute -bottom-1 start-0 w-px bg-n-slate-6"
|
||||
/>
|
||||
<div
|
||||
class="absolute -left-[0.21875rem] top-[0.2734375rem] w-2 h-2 rounded-full bg-n-solid-1 border-2 border-solid border-n-weak dark:border-n-slate-6"
|
||||
class="absolute -start-[0.21875rem] top-[0.2734375rem] w-2 h-2 rounded-full bg-n-solid-1 border-2 border-solid border-n-weak dark:border-n-slate-6"
|
||||
/>
|
||||
<p class="mb-1 text-xs text-n-slate-11">
|
||||
{{ $t(`MACROS.ACTIONS.${action.actionName}`) }}
|
||||
|
||||
@@ -39,11 +39,12 @@ const isPublicMacroReadOnly = computed(
|
||||
() => macro.value?.visibility === 'global' && !isAdmin.value
|
||||
);
|
||||
|
||||
const fetchDropdownData = () => {
|
||||
store.dispatch('agents/get');
|
||||
store.dispatch('teams/get');
|
||||
store.dispatch('labels/get');
|
||||
};
|
||||
const fetchDropdownData = () =>
|
||||
Promise.all([
|
||||
store.dispatch('agents/get'),
|
||||
store.dispatch('teams/get'),
|
||||
store.dispatch('labels/get'),
|
||||
]);
|
||||
|
||||
const formatMacro = macroData => {
|
||||
const formattedActions = macroData.actions.map(action => {
|
||||
@@ -56,13 +57,6 @@ const formatMacro = macroData => {
|
||||
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,
|
||||
};
|
||||
} else actionParams = [...action.action_params];
|
||||
}
|
||||
return {
|
||||
@@ -77,7 +71,10 @@ const formatMacro = macroData => {
|
||||
};
|
||||
|
||||
const manifestMacro = async () => {
|
||||
await store.dispatch('macros/getSingleMacro', macroId.value);
|
||||
await Promise.all([
|
||||
fetchDropdownData(),
|
||||
store.dispatch('macros/getSingleMacro', macroId.value),
|
||||
]);
|
||||
const singleMacro = store.getters['macros/getMacro'](macroId.value);
|
||||
macro.value = formatMacro(singleMacro);
|
||||
};
|
||||
@@ -104,10 +101,10 @@ const initNewMacro = () => {
|
||||
watch(
|
||||
() => route,
|
||||
() => {
|
||||
fetchDropdownData();
|
||||
if (route.params.macroId) {
|
||||
fetchMacro();
|
||||
} else {
|
||||
fetchDropdownData();
|
||||
initNewMacro();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,11 +37,7 @@ const errorMessage = computed(() => {
|
||||
});
|
||||
|
||||
const showActionInput = computed(() => {
|
||||
if (
|
||||
actionData.value.action_name === 'send_email_to_team' ||
|
||||
actionData.value.action_name === 'send_message'
|
||||
)
|
||||
return false;
|
||||
if (actionData.value.action_name === 'send_message') return false;
|
||||
const type = macroActionTypes.value.find(
|
||||
action => action.key === actionData.value.action_name
|
||||
).inputType;
|
||||
|
||||
@@ -10,39 +10,8 @@ export const emptyMacro = {
|
||||
visibility: 'global',
|
||||
};
|
||||
|
||||
export const resolveActionName = key => {
|
||||
return macroActionTypes.find(i => i.key === key).label;
|
||||
};
|
||||
|
||||
export const resolveTeamIds = (teams, ids) => {
|
||||
return ids
|
||||
.map(id => {
|
||||
if (id === 'nil') return 'None';
|
||||
const team = teams.find(i => i.id === id);
|
||||
return team ? team.name : '';
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
export const resolveLabels = (labels, ids) => {
|
||||
return ids
|
||||
.map(id => {
|
||||
const label = labels.find(i => i.title === id);
|
||||
return label ? label.title : '';
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
export const resolveAgents = (agents, ids) => {
|
||||
return ids
|
||||
.map(id => {
|
||||
if (id === 'nil') return 'None';
|
||||
if (id === 'self') return 'Self';
|
||||
const agent = agents.find(i => i.id === id);
|
||||
return agent ? agent.name : '';
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
export const resolveActionName = key =>
|
||||
macroActionTypes.find(i => i.key === key)?.label ?? key.toUpperCase();
|
||||
|
||||
export const getFileName = (id, actionType, files) => {
|
||||
if (!id || !files) return '';
|
||||
|
||||
Reference in New Issue
Block a user