feat: run macros from the reply editor with # (#15416)

This commit is contained in:
Sivin Varghese
2026-08-13 12:23:35 +05:30
committed by GitHub
parent 1d905fb019
commit 13dfe1a6c6
21 changed files with 808 additions and 260 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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')"

View File

@@ -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();
});
});

View File

@@ -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: [] },

View File

@@ -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],
});
});
});

View 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,
};
}

View File

@@ -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,
};
};

View 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 };
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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>

View File

@@ -54,6 +54,7 @@ const closeMacroPreview = () => {
faded
xs
:is-loading="isExecuting"
:disabled="isExecuting"
@click="$emit('execute')"
/>
</div>

View File

@@ -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}`) }}

View File

@@ -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();
}
},

View File

@@ -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;

View File

@@ -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 '';