feat: Add the ability for the agents to execute a macro (#5698)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
parent
d54392cb53
commit
c3ec1d4f8a
14 changed files with 431 additions and 14 deletions
|
@ -5,9 +5,12 @@ import Button from './ui/WootButton';
|
|||
import Code from './Code';
|
||||
import ColorPicker from './widgets/ColorPicker';
|
||||
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
|
||||
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
|
||||
import ContextMenu from './ui/ContextMenu.vue';
|
||||
import DeleteModal from './widgets/modal/DeleteModal.vue';
|
||||
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
|
||||
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import FeatureToggle from './widgets/FeatureToggle';
|
||||
import HorizontalBar from './widgets/chart/HorizontalBarChart';
|
||||
import Input from './widgets/forms/Input.vue';
|
||||
import Label from './ui/Label';
|
||||
|
@ -21,8 +24,6 @@ import SubmitButton from './buttons/FormSubmitButton';
|
|||
import Tabs from './ui/Tabs/Tabs';
|
||||
import TabsItem from './ui/Tabs/TabsItem';
|
||||
import Thumbnail from './widgets/Thumbnail.vue';
|
||||
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
|
||||
import ContextMenu from './ui/ContextMenu.vue';
|
||||
|
||||
const WootUIKit = {
|
||||
AvatarUploader,
|
||||
|
@ -31,9 +32,12 @@ const WootUIKit = {
|
|||
Code,
|
||||
ColorPicker,
|
||||
ConfirmDeleteModal,
|
||||
ConfirmModal,
|
||||
ContextMenu,
|
||||
DeleteModal,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
FeatureToggle,
|
||||
HorizontalBar,
|
||||
Input,
|
||||
Label,
|
||||
|
@ -47,8 +51,6 @@ const WootUIKit = {
|
|||
Tabs,
|
||||
TabsItem,
|
||||
Thumbnail,
|
||||
ConfirmModal,
|
||||
ContextMenu,
|
||||
install(Vue) {
|
||||
const keys = Object.keys(this);
|
||||
keys.pop(); // remove 'install' from keys
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div v-if="isFeatureEnabled">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
props: {
|
||||
featureKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
isFeatureEnabled() {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, this.featureKey);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,4 +1,11 @@
|
|||
import { emptyMacro } from '../../routes/dashboard/settings/macros/macroHelper';
|
||||
import {
|
||||
emptyMacro,
|
||||
resolveActionName,
|
||||
resolveLabels,
|
||||
resolveTeamIds,
|
||||
} from '../../routes/dashboard/settings/macros/macroHelper';
|
||||
import { MACRO_ACTION_TYPES } from '../../routes/dashboard/settings/macros/constants';
|
||||
import { teams, labels } from './macrosFixtures';
|
||||
|
||||
describe('#emptyMacro', () => {
|
||||
const defaultMacro = {
|
||||
|
@ -15,3 +22,31 @@ describe('#emptyMacro', () => {
|
|||
expect(emptyMacro).toEqual(defaultMacro);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveActionName', () => {
|
||||
it('resolve action name from key and return the correct label', () => {
|
||||
expect(resolveActionName(MACRO_ACTION_TYPES[0].key)).toEqual(
|
||||
MACRO_ACTION_TYPES[0].label
|
||||
);
|
||||
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).toEqual(
|
||||
MACRO_ACTION_TYPES[1].label
|
||||
);
|
||||
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).not.toEqual(
|
||||
MACRO_ACTION_TYPES[0].label
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveLabels', () => {
|
||||
it('resolves labels names from ids and returns a joined string', () => {
|
||||
const resolvedLabels = 'sales, billing';
|
||||
expect(resolveLabels(labels, ['sales', 'billing'])).toEqual(resolvedLabels);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -208,7 +208,8 @@
|
|||
"CONVERSATION_LABELS": "Conversation Labels",
|
||||
"CONVERSATION_INFO": "Conversation Information",
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations"
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"MACROS": "Macros"
|
||||
}
|
||||
},
|
||||
"CONVERSATION_CUSTOM_ATTRIBUTES": {
|
||||
|
|
|
@ -24,12 +24,7 @@
|
|||
}
|
||||
},
|
||||
"LIST": {
|
||||
"TABLE_HEADER": [
|
||||
"Name",
|
||||
"Created by",
|
||||
"Last updated by",
|
||||
"Visibility"
|
||||
],
|
||||
"TABLE_HEADER": ["Name", "Created by", "Last updated by", "Visibility"],
|
||||
"404": "No macros found"
|
||||
},
|
||||
"DELETE": {
|
||||
|
@ -68,6 +63,11 @@
|
|||
"DESCRIPTION": "This macro will be private to you and not be available to others."
|
||||
}
|
||||
}
|
||||
},
|
||||
"EXECUTE": {
|
||||
"BUTTON_TOOLTIP": "Execute",
|
||||
"PREVIEW": "Preview Macro",
|
||||
"EXECUTED_SUCCESSFULLY": "Macro executed successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -119,6 +119,7 @@ describe('uiSettingsMixin', () => {
|
|||
{ name: 'conversation_info' },
|
||||
{ name: 'contact_attributes' },
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'macros' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { mapGetters } from 'vuex';
|
||||
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = [
|
||||
{ name: 'conversation_actions' },
|
||||
{ name: 'macros' },
|
||||
{ name: 'conversation_info' },
|
||||
{ name: 'contact_attributes' },
|
||||
{ name: 'previous_conversation' },
|
||||
|
@ -30,7 +31,17 @@ export default {
|
|||
...mapGetters({ uiSettings: 'getUISettings' }),
|
||||
conversationSidebarItemsOrder() {
|
||||
const { conversation_sidebar_items_order: itemsOrder } = this.uiSettings;
|
||||
return itemsOrder || DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;
|
||||
// If the sidebar order is not set, use the default order.
|
||||
if (!itemsOrder) {
|
||||
return DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER;
|
||||
}
|
||||
// If the sidebar order doesn't have the new elements, then add them to the list.
|
||||
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER.forEach(item => {
|
||||
if (!itemsOrder.find(i => i.name === item.name)) {
|
||||
itemsOrder.push(item);
|
||||
}
|
||||
});
|
||||
return itemsOrder;
|
||||
},
|
||||
contactSidebarItemsOrder() {
|
||||
const { contact_sidebar_items_order: itemsOrder } = this.uiSettings;
|
||||
|
|
|
@ -93,6 +93,19 @@
|
|||
/>
|
||||
</accordion-item>
|
||||
</div>
|
||||
<woot-feature-toggle
|
||||
v-else-if="element.name === 'macros'"
|
||||
feature-key="macros"
|
||||
>
|
||||
<accordion-item
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.MACROS')"
|
||||
:is-open="isContactSidebarItemOpen('is_macro_open')"
|
||||
compact
|
||||
@click="value => toggleSidebarUIState('is_macro_open', value)"
|
||||
>
|
||||
<macros-list :conversation-id="conversationId" />
|
||||
</accordion-item>
|
||||
</woot-feature-toggle>
|
||||
</div>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
|
@ -112,6 +125,7 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
|||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import MacrosList from './Macros/List';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -123,6 +137,7 @@ export default {
|
|||
CustomAttributeSelector,
|
||||
ConversationAction,
|
||||
draggable,
|
||||
MacrosList,
|
||||
},
|
||||
mixins: [alertMixin, uiSettingsMixin],
|
||||
props: {
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!uiFlags.isFetching && !macros.length"
|
||||
class="macros_list--empty-state"
|
||||
>
|
||||
<p class="no-items-error-message">
|
||||
{{ $t('MACROS.LIST.404') }}
|
||||
</p>
|
||||
<router-link :to="addAccountScoping('settings/macros')">
|
||||
<woot-button
|
||||
variant="smooth"
|
||||
icon="add"
|
||||
size="tiny"
|
||||
class="macros_add-button"
|
||||
>
|
||||
{{ $t('MACROS.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
</router-link>
|
||||
</div>
|
||||
<woot-loading-state
|
||||
v-if="uiFlags.isFetching"
|
||||
:message="$t('MACROS.LOADING')"
|
||||
/>
|
||||
<div v-if="!uiFlags.isFetching && macros.length" class="macros-list">
|
||||
<macro-item
|
||||
v-for="macro in macros"
|
||||
:key="macro.id"
|
||||
:macro="macro"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import MacroItem from './MacroItem';
|
||||
import accountMixin from 'dashboard/mixins/account.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MacroItem,
|
||||
},
|
||||
mixins: [accountMixin],
|
||||
props: {
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
macros: ['macros/getMacros'],
|
||||
uiFlags: 'macros/getUIFlags',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('macros/get');
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.macros-list {
|
||||
padding: var(--space-smaller);
|
||||
}
|
||||
.macros_list--empty-state {
|
||||
padding: var(--space-slab);
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.macros_add-button {
|
||||
margin: var(--space-small) auto 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<div class="macro">
|
||||
<span class="text-truncate">{{ macro.name }}</span>
|
||||
<div class="macros-actions">
|
||||
<woot-button
|
||||
v-tooltip.left-start="$t('MACROS.EXECUTE.PREVIEW')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="info"
|
||||
class="margin-right-smaller"
|
||||
@click="toggleMacroPreview(macro)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.left-start="$t('MACROS.EXECUTE.BUTTON_TOOLTIP')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="play-circle"
|
||||
:is-loading="isExecuting"
|
||||
@click="executeMacro(macro)"
|
||||
/>
|
||||
</div>
|
||||
<transition name="menu-slide">
|
||||
<macro-preview
|
||||
v-if="showPreview"
|
||||
v-on-clickaway="closeMacroPreview"
|
||||
:macro="macro"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import MacroPreview from './MacroPreview';
|
||||
export default {
|
||||
components: {
|
||||
MacroPreview,
|
||||
},
|
||||
mixins: [alertMixin, clickaway],
|
||||
props: {
|
||||
macro: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isExecuting: false,
|
||||
showPreview: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async executeMacro(macro) {
|
||||
try {
|
||||
this.isExecuting = true;
|
||||
await this.$store.dispatch('macros/execute', {
|
||||
macroId: macro.id,
|
||||
conversationIds: [this.conversationId],
|
||||
});
|
||||
this.showAlert(this.$t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'));
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('MACROS.ERROR'));
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
},
|
||||
toggleMacroPreview() {
|
||||
this.showPreview = !this.showPreview;
|
||||
},
|
||||
closeMacroPreview() {
|
||||
this.showPreview = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.macro {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: var(--space-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--border-radius-normal);
|
||||
color: var(--s-700);
|
||||
|
||||
&:hover {
|
||||
background: var(--s-25);
|
||||
color: var(--s-600);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--w-300);
|
||||
}
|
||||
|
||||
.macros-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div class="macro-preview">
|
||||
<p class="macro-title">{{ macro.name }}</p>
|
||||
<div v-for="(action, i) in resolvedMacro" :key="i" class="macro-block">
|
||||
<div v-if="i !== macro.actions.length - 1" class="macro-block-border" />
|
||||
<div class="macro-block-dot" />
|
||||
<p class="macro-action-name">{{ action.actionName }}</p>
|
||||
<p class="macro-action-params">{{ action.actionValue }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
resolveActionName,
|
||||
resolveTeamIds,
|
||||
resolveLabels,
|
||||
} from 'dashboard/routes/dashboard/settings/macros/macroHelper';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
macro: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
resolvedMacro() {
|
||||
return this.macro.actions.map(action => {
|
||||
return {
|
||||
actionName: resolveActionName(action.action_name),
|
||||
actionValue: this.getActionValue(
|
||||
action.action_name,
|
||||
action.action_params
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
...mapGetters({
|
||||
labels: 'labels/getLabels',
|
||||
teams: 'teams/getTeams',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
getActionValue(key, params) {
|
||||
const actionsMap = {
|
||||
assign_team: resolveTeamIds(this.teams, params),
|
||||
add_label: resolveLabels(this.labels, params),
|
||||
mute_conversation: null,
|
||||
snooze_conversation: null,
|
||||
resolve_conversation: null,
|
||||
send_webhook_event: params[0],
|
||||
send_message: params[0],
|
||||
send_email_transcript: params[0],
|
||||
};
|
||||
return actionsMap[key] || '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.macro-preview {
|
||||
position: absolute;
|
||||
max-height: calc(var(--space-giga) * 1.5);
|
||||
min-height: var(--space-jumbo);
|
||||
width: calc(var(--space-giga) + var(--space-large));
|
||||
border-radius: var(--border-radius-normal);
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-dropdown-pane);
|
||||
bottom: calc(var(--space-three) + var(--space-half));
|
||||
right: calc(var(--space-three) + var(--space-half));
|
||||
overflow-y: auto;
|
||||
padding: var(--space-slab);
|
||||
|
||||
.macro-title {
|
||||
margin-bottom: var(--space-slab);
|
||||
color: var(--s-900);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.macro-block {
|
||||
position: relative;
|
||||
padding-left: var(--space-slab);
|
||||
&:not(:last-child) {
|
||||
padding-bottom: var(--space-slab);
|
||||
}
|
||||
|
||||
.macro-block-border {
|
||||
top: 0.625rem;
|
||||
position: absolute;
|
||||
bottom: var(--space-minus-half);
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background-color: var(--s-100);
|
||||
}
|
||||
|
||||
.macro-block-dot {
|
||||
position: absolute;
|
||||
left: -0.35rem;
|
||||
height: var(--space-small);
|
||||
width: var(--space-small);
|
||||
border: 2px solid var(--s-100);
|
||||
background-color: var(--white);
|
||||
border-radius: var(--border-radius-full);
|
||||
top: 0.4375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.macro-action-name {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--s-500);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,3 +1,4 @@
|
|||
import { MACRO_ACTION_TYPES as macroActionTypes } from 'dashboard/routes/dashboard/settings/macros/constants.js';
|
||||
export const emptyMacro = {
|
||||
name: '',
|
||||
actions: [
|
||||
|
@ -8,3 +9,25 @@ 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 => {
|
||||
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(', ');
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue