feat: Macros listing and Editor (#5606)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Fayaz Ahmed 2022-10-20 05:43:13 +05:30 committed by GitHub
parent 1fb1be3ddc
commit 22d5703b92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1287 additions and 31 deletions

View file

@ -54,3 +54,5 @@ exclude_patterns:
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'

View file

@ -1,8 +1,5 @@
<template>
<div
class="filter"
:class="{ error: v.action_params.$dirty && v.action_params.$error }"
>
<div class="filter" :class="actionInputStyles">
<div class="filter-inputs">
<select
v-model="action_name"
@ -60,6 +57,7 @@
</div>
</div>
<woot-button
v-if="!isMacro"
icon="dismiss"
variant="clear"
color-scheme="secondary"
@ -120,6 +118,10 @@ export default {
type: String,
default: '',
},
isMacro: {
type: Boolean,
default: false,
},
},
computed: {
action_name: {
@ -146,6 +148,12 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionInputStyles() {
return {
error: this.v.action_params.$dirty && this.v.action_params.$error,
'is-a-macro': this.isMacro,
};
},
},
methods: {
removeAction() {
@ -165,6 +173,18 @@ export default {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
margin-bottom: var(--space-small);
&.is-a-macro {
margin-bottom: 0;
background: var(--white);
padding: var(--space-zero);
border: unset;
border-radius: unset;
}
}
.no-margin-bottom {
margin-bottom: 0;
}
.filter.error {

View file

@ -0,0 +1,71 @@
export const teams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
export const labels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];

View file

@ -0,0 +1,17 @@
import { emptyMacro } from '../../routes/dashboard/settings/macros/macroHelper';
describe('#emptyMacro', () => {
const defaultMacro = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
it('returns the default macro', () => {
expect(emptyMacro).toEqual(defaultMacro);
});
});

View file

@ -1,5 +1,73 @@
{
"MACROS": {
"HEADER": "Macros"
"HEADER": "Macros",
"HEADER_BTN_TXT": "Add a new macro",
"HEADER_BTN_TXT_SAVE": "Save macro",
"LOADING": "Fetching macros",
"SIDEBAR_TXT": "<p><b>Macros</b><p>A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click. When the agents run the macro, the actions would be performed sequentially in the order they are defined. Macros improve productivity and increase consistency in actions. </p><p>A macro can be helpful in 2 ways. </p><p><b>As an agent assist:</b> If an agent performs a set of actions multiple times, they can save it as a macro and execute all the actions together using a single click.</p><p><b>As an option to onboard a team member:</b> Every agent has to perform many different checks/actions during each conversation. Onboarding a new support team member will be easy if pre-defined macros are available on the account. Instead of describing each step in detail, the manager/team lead can point to the macros used in different scenarios.</p>",
"ERROR": "Something went wrong. Please try again",
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
"ADD": {
"FORM": {
"NAME": {
"LABEL": "Macro name",
"PLACEHOLDER": "Enter a name for your macro",
"ERROR": "Name is required for creating a macro"
},
"ACTIONS": {
"LABEL": "Actions"
}
},
"API": {
"SUCCESS_MESSAGE": "Macro added successfully",
"ERROR_MESSAGE": "Unable to create macro, Please try again later"
}
},
"LIST": {
"TABLE_HEADER": [
"Name",
"Created by",
"Last updated by",
"Visibility"
],
"404": "No macros found"
},
"DELETE": {
"TOOLTIP": "Delete macro",
"CONFIRM": {
"MESSAGE": "Are you sure to delete ",
"YES": "Yes, Delete",
"NO": "No"
},
"API": {
"SUCCESS_MESSAGE": "Macro deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
"SUCCESS_MESSAGE": "Macro updated successfully",
"ERROR_MESSAGE": "Could not update Macro, Please try again later"
}
},
"EDITOR": {
"START_FLOW": "Start Flow",
"END_FLOW": "End Flow",
"LOADING": "Fetching macro",
"ADD_BTN_TOOLTIP": "Add new action",
"DELETE_BTN_TOOLTIP": "Delete Action",
"VISIBILITY": {
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
"DESCRIPTION": "This macro is available publicly for all agents in this account."
},
"PERSONAL": {
"LABEL": "Private",
"DESCRIPTION": "This macro will be private to you and not be available to others."
}
}
}
}
}

View file

@ -0,0 +1,20 @@
export default {
methods: {
getDropdownValues(type) {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return this.teams;
case 'add_label':
return this.labels.map(i => {
return {
id: i.title,
name: i.title,
};
});
default:
return [];
}
},
},
};

View file

@ -0,0 +1,41 @@
import { createWrapper } from '@vue/test-utils';
import macrosMixin from '../macrosMixin';
import Vue from 'vue';
import { teams, labels } from '../../helper/specs/macrosFixtures';
describe('webhookMixin', () => {
describe('#getEventLabel', () => {
it('returns correct i18n translation:', () => {
const Component = {
render() {},
title: 'MyComponent',
mixins: [macrosMixin],
data: () => {
return {
teams,
labels,
};
},
methods: {
$t(text) {
return text;
},
},
};
const resolvedLabels = labels.map(i => {
return {
id: i.title,
name: i.title,
};
});
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.getDropdownValues('assign_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('send_email_to_team')).toEqual(teams);
expect(wrapper.vm.getDropdownValues('add_label')).toEqual(resolvedLabels);
expect(wrapper.vm.getDropdownValues()).toEqual([]);
});
});
});

View file

@ -225,7 +225,7 @@ export default {
mode === 'EDIT'
? this.$t('AUTOMATION.EDIT.API.SUCCESS_MESSAGE')
: this.$t('AUTOMATION.ADD.API.SUCCESS_MESSAGE');
await await this.$store.dispatch(action, payload);
await this.$store.dispatch(action, payload);
this.showAlert(this.$t(successMessage));
this.hideAddPopup();
this.hideEditPopup();

View file

@ -0,0 +1,58 @@
<template>
<div>
<button
v-tooltip="tooltip"
class="macros__action-button"
:class="type"
@click="$emit('click')"
>
<fluent-icon :icon="icon" aria-hidden="true" />
</button>
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
default: 'add',
},
tooltip: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
},
};
</script>
<style scoped lang="scss">
.macros__action-button {
height: var(--space-three);
width: var(--space-three);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: var(--font-size-default);
border-radius: var(--border-radius-rounded);
position: relative;
margin-left: var(--space-one);
&.add {
background-color: var(--g-100);
color: var(--g-600);
}
&.delete {
position: absolute;
top: calc(var(--space-three) / -2);
right: calc(var(--space-three) / -2);
background-color: var(--r-100);
color: var(--r-600);
}
}
</style>

View file

@ -1,11 +1,121 @@
<template>
<div>
Macros
<div class="column content-box">
<router-link
:to="addAccountScoping('settings/macros/new')"
class="button success button--fixed-right-top"
>
<fluent-icon icon="add-circle" />
<span class="button__content">
{{ $t('MACROS.HEADER_BTN_TXT') }}
</span>
</router-link>
<div class="row">
<div class="small-8 columns with-right-space">
<div
v-if="!uiFlags.isFetching && !records.length"
class="macros__empty-state"
>
<p class="no-items-error-message">
{{ $t('MACROS.LIST.404') }}
</p>
</div>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('MACROS.LOADING')"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th
v-for="thHeader in $t('MACROS.LIST.TABLE_HEADER')"
:key="thHeader"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<macros-table-row
v-for="(macro, index) in records"
:key="index"
:macro="macro"
@delete="openDeletePopup(macro, index)"
/>
</tbody>
</table>
</div>
<div class="small-4 columns">
<span v-dompurify-html="$t('MACROS.SIDEBAR_TXT')" />
</div>
</div>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('LABEL_MGMT.DELETE.CONFIRM.TITLE')"
:message="$t('MACROS.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="$t('MACROS.DELETE.CONFIRM.YES')"
:reject-text="$t('MACROS.DELETE.CONFIRM.NO')"
/>
</div>
</template>
<script>
export default {};
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import accountMixin from 'dashboard/mixins/account.js';
import MacrosTableRow from './MacrosTableRow';
export default {
components: {
MacrosTableRow,
},
mixins: [alertMixin, accountMixin],
data() {
return {
showDeleteConfirmationPopup: false,
selectedResponse: {},
loading: {},
};
},
computed: {
...mapGetters({
records: ['macros/getMacros'],
uiFlags: 'macros/getUIFlags',
}),
deleteMessage() {
return ` ${this.selectedResponse.name}?`;
},
},
mounted() {
this.$store.dispatch('macros/get');
},
methods: {
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteMacro(this.selectedResponse.id);
},
async deleteMacro(id) {
try {
await this.$store.dispatch('macros/delete', id);
this.showAlert(this.$t('MACROS.DELETE.API.SUCCESS_MESSAGE'));
this.loading[this.selectedResponse.id] = false;
} catch (error) {
this.showAlert(this.$t('MACROS.DELETE.API.ERROR_MESSAGE'));
}
},
},
};
</script>
<style></style>
<style scoped>
.macros__empty-state {
padding: var(--space-slab);
}
</style>

View file

@ -1,9 +1,137 @@
<template>
<div>MacrosEditor</div>
<div class="column content-box">
<woot-loading-state
v-if="uiFlags.isFetchingItem"
:message="$t('MACROS.EDITOR.LOADING')"
/>
<macro-form
v-if="macro && !uiFlags.isFetchingItem"
:macro-data.sync="macro"
@submit="saveMacro"
/>
</div>
</template>
<script>
export default {};
import MacroForm from './MacroForm';
import { MACRO_ACTION_TYPES } from './constants';
import { mapGetters } from 'vuex';
import { emptyMacro } from './macroHelper';
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
import alertMixin from 'shared/mixins/alertMixin';
import macrosMixin from 'dashboard/mixins/macrosMixin';
export default {
components: {
MacroForm,
},
mixins: [alertMixin, macrosMixin],
provide() {
return {
macroActionTypes: this.macroActionTypes,
};
},
data() {
return {
macro: null,
mode: 'CREATE',
macroActionTypes: MACRO_ACTION_TYPES,
};
},
computed: {
...mapGetters({
uiFlags: 'macros/getUIFlags',
labels: 'labels/getLabels',
teams: 'teams/getTeams',
}),
macroId() {
return this.$route.params.macroId;
},
},
watch: {
$route: {
handler() {
if (this.$route.params.macroId) {
this.fetchMacro();
} else {
this.initNewMacro();
}
},
immediate: true,
},
},
methods: {
fetchMacro() {
this.mode = 'EDIT';
this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
this.manifestMacro();
},
async manifestMacro() {
await this.$store.dispatch('macros/getSingleMacro', this.macroId);
const singleMacro = this.$store.getters['macros/getMacro'](this.macroId);
this.macro = this.formatMacro(singleMacro);
},
formatMacro(macro) {
const formattedActions = macro.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = this.macroActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select') {
actionParams = [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item => [...action.action_params].includes(item.id));
} else if (inputType === 'team_message') {
actionParams = {
team_ids: [
...this.getDropdownValues(action.action_name, this.$store),
].filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
} else actionParams = [...action.action_params];
}
return {
...action,
action_params: actionParams,
};
});
return {
...macro,
actions: formattedActions,
};
},
initNewMacro() {
this.mode = 'CREATE';
this.macro = emptyMacro;
},
async saveMacro(macro) {
try {
const action = this.mode === 'EDIT' ? 'macros/update' : 'macros/create';
let successMessage =
this.mode === 'EDIT'
? this.$t('MACROS.EDIT.API.SUCCESS_MESSAGE')
: this.$t('MACROS.ADD.API.SUCCESS_MESSAGE');
let serializedMacro = JSON.parse(JSON.stringify(macro));
serializedMacro.actions = actionQueryGenerator(serializedMacro.actions);
await this.$store.dispatch(action, serializedMacro);
this.showAlert(successMessage);
this.$router.push({ name: 'macros_wrapper' });
} catch (error) {
this.showAlert(this.$t('MACROS.ERROR'));
}
},
},
};
</script>
<style></style>
<style scoped>
.content-box {
padding: 0;
height: 100vh;
}
</style>

View file

@ -0,0 +1,126 @@
<template>
<div class="row">
<div class="small-8 columns with-right-space macros-canvas">
<macro-nodes
v-model="macro.actions"
@addNewNode="appendNode"
@deleteNode="deleteNode"
@resetAction="resetNode"
/>
</div>
<div class="small-4 columns">
<macro-properties
:macro-name="macro.name"
:macro-visibility="macro.visibility"
@update:name="updateName"
@update:visibility="updateVisibility"
@submit="submit"
/>
</div>
</div>
</template>
<script>
import MacroNodes from './MacroNodes';
import MacroProperties from './MacroProperties';
import { required, requiredIf } from 'vuelidate/lib/validators';
export default {
components: {
MacroNodes,
MacroProperties,
},
provide() {
return {
$v: this.$v,
};
},
props: {
macroData: {
type: Object,
default: () => ({}),
},
},
data() {
return {
macro: this.macroData,
};
},
watch: {
macroData: {
handler() {
this.macro = this.macroData;
},
immediate: true,
},
},
validations: {
macro: {
name: {
required,
},
visibility: {
required,
},
actions: {
required,
$each: {
action_params: {
required: requiredIf(prop => {
if (prop.action_name === 'send_email_to_team') return true;
return !(
prop.action_name === 'mute_conversation' ||
prop.action_name === 'snooze_conversation' ||
prop.action_name === 'resolve_conversation'
);
}),
},
},
},
},
},
mounted() {
this.$v.$reset();
},
methods: {
updateName(value) {
this.macro.name = value;
},
updateVisibility(value) {
this.macro.visibility = value;
},
appendNode() {
this.macro.actions.push({
action_name: 'assign_team',
action_params: [],
});
},
deleteNode(index) {
this.macro.actions.splice(index, 1);
},
submit() {
this.$v.$touch();
if (this.$v.$invalid) return;
this.$emit('submit', this.macro);
},
resetNode(index) {
this.macro.actions[index].action_params = [];
},
},
};
</script>
<style scoped lang="scss">
.row {
height: 100%;
}
.macros-canvas {
background-image: radial-gradient(var(--s-100) 1.2px, transparent 0);
background-size: var(--space-normal) var(--space-normal);
height: 100%;
max-height: 100%;
padding: var(--space-normal) var(--space-three);
max-height: 100vh;
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,149 @@
<template>
<div class="macro__node-action-container">
<fluent-icon
v-if="!singleNode"
size="20"
icon="navigation"
class="macros__node-drag-handle"
/>
<div
class="macro__node-action-item"
:class="{
'has-error': hasError($v.macro.actions.$each[index]),
}"
>
<action-input
v-model="actionData"
:action-types="macroActionTypes"
:dropdown-values="dropdownValues()"
:show-action-input="showActionInput"
:show-remove-button="false"
:is-macro="true"
:v="$v.macro.actions.$each[index]"
@resetAction="$emit('resetAction')"
/>
<macro-action-button
v-if="!singleNode"
icon="dismiss-circle"
class="macro__node macro__node-action-button-delete"
type="delete"
:tooltip="$t('MACROS.EDITOR.DELETE_BTN_TOOLTIP')"
@click="$emit('deleteNode')"
/>
</div>
</div>
</template>
<script>
import ActionInput from 'dashboard/components/widgets/AutomationActionInput';
import MacroActionButton from './ActionButton.vue';
import macrosMixin from 'dashboard/mixins/macrosMixin';
import { mapGetters } from 'vuex';
export default {
components: {
ActionInput,
MacroActionButton,
},
mixins: [macrosMixin],
inject: ['macroActionTypes', '$v'],
props: {
singleNode: {
type: Boolean,
default: false,
},
value: {
type: Object,
default: () => ({}),
},
index: {
type: Number,
default: 0,
},
},
computed: {
...mapGetters({
labels: 'labels/getLabels',
teams: 'teams/getTeams',
}),
actionData: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
showActionInput() {
if (
this.actionData.action_name === 'send_email_to_team' ||
this.actionData.action_name === 'send_message'
)
return false;
const type = this.macroActionTypes.find(
action => action.key === this.actionData.action_name
).inputType;
return !!type;
},
},
methods: {
dropdownValues() {
return this.getDropdownValues(this.value.action_name, this.$store);
},
hasError(v) {
return !!(v.action_params.$dirty && v.action_params.$error);
},
},
};
</script>
<style scoped lang="scss">
.macro__node-action-container {
position: relative;
.macros__node-drag-handle {
position: absolute;
left: var(--space-minus-medium);
top: var(--space-smaller);
cursor: move;
color: var(--s-400);
}
.macro__node-action-item {
background-color: var(--white);
padding: var(--space-slab);
border-radius: var(--border-radius-medium);
box-shadow: rgb(0 0 0 / 3%) 0px 6px 24px 0px,
rgb(0 0 0 / 6%) 0px 0px 0px 1px;
.macro__node-action-button-delete {
display: none;
}
&:hover {
.macro__node-action-button-delete {
display: flex;
}
}
&.has-error {
animation: shake 0.3s ease-in-out 0s 2;
background-color: var(--r-50);
}
}
}
@keyframes shake {
0% {
transform: translateX(0);
}
25% {
transform: translateX(0.375rem);
}
50% {
transform: translateX(-0.375rem);
}
75% {
transform: translateX(0.375rem);
}
100% {
transform: translateX(0);
}
}
</style>

View file

@ -0,0 +1,109 @@
<template>
<div class="macros__nodes">
<macros-pill :label="$t('MACROS.EDITOR.START_FLOW')" class="macro__node" />
<draggable
:list="actionData"
animation="200"
ghost-class="ghost"
tag="div"
class="macros__nodes-draggable"
handle=".macros__node-drag-handle"
@start="dragging = true"
@end="dragging = false"
>
<div v-for="(action, i) in actionData" :key="i" class="macro__node">
<macro-node
v-model="actionData[i]"
class="macros__node-action"
type="add"
:index="i"
:single-node="actionData.length === 1"
@resetAction="$emit('resetAction', i)"
@deleteNode="$emit('deleteNode', i)"
/>
</div>
</draggable>
<macro-action-button
icon="add-circle"
class="macro__node"
:tooltip="$t('MACROS.EDITOR.ADD_BTN_TOOLTIP')"
type="add"
@click="$emit('addNewNode')"
/>
<macros-pill :label="$t('MACROS.EDITOR.END_FLOW')" class="macro__node" />
</div>
</template>
<script>
import MacrosPill from './Pill.vue';
import Draggable from 'vuedraggable';
import MacroNode from './MacroNode.vue';
import MacroActionButton from './ActionButton.vue';
export default {
components: {
Draggable,
MacrosPill,
MacroNode,
MacroActionButton,
},
props: {
value: {
type: Array,
default: () => [],
},
},
data() {
return {
dragging: false,
};
},
computed: {
actionData: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
},
};
</script>
<style scoped lang="scss">
.macros__nodes {
max-width: 800px;
}
.macro__node:not(:last-child) {
position: relative;
padding-bottom: var(--space-three);
}
.macro__node:not(:last-child):not(.sortable-chosen):after,
.macros__nodes-draggable:after {
content: '';
position: absolute;
height: var(--space-three);
width: var(--space-smaller);
margin-left: var(--space-medium);
background-image: url("data:image/svg+xml,%3Csvg width='4' height='30' viewBox='0 0 4 30' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cline x1='1.50098' y1='0.579529' x2='1.50098' y2='30.5795' stroke='%2393afc8' stroke-width='2' stroke-dasharray='5 5'/%3E%3C/svg%3E%0A");
}
.macros__nodes-draggable {
position: relative;
padding-bottom: var(--space-three);
}
.macros__node-action-container {
position: relative;
.drag-handle {
position: absolute;
left: var(--space-minus-medium);
top: var(--space-smaller);
cursor: move;
color: var(--s-400);
}
}
</style>

View file

@ -0,0 +1,174 @@
<template>
<div class="macros__properties-panel">
<div>
<woot-input
:value="macroName"
:label="$t('MACROS.ADD.FORM.NAME.LABEL')"
:placeholder="$t('MACROS.ADD.FORM.NAME.PLACEHOLDER')"
:error="$v.macro.name.$error ? $t('MACROS.ADD.FORM.NAME.ERROR') : null"
:class="{ error: $v.macro.name.$error }"
@input="onUpdateName($event)"
/>
</div>
<div>
<p class="title">{{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }}</p>
<div class="macros__form-visibility">
<button
class="card"
:class="isActive('global')"
@click="onUpdateVisibility('global')"
>
<fluent-icon
v-if="macroVisibility === 'global'"
icon="checkmark-circle"
type="solid"
class="visibility-check"
/>
<p class="title">
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL') }}
</p>
<p class="subtitle">
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
</p>
</button>
<button
class="card"
:class="isActive('personal')"
@click="onUpdateVisibility('personal')"
>
<fluent-icon
v-if="macroVisibility === 'personal'"
icon="checkmark-circle"
type="solid"
class="visibility-check"
/>
<p class="title">
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL') }}
</p>
<p class="subtitle">
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.DESCRIPTION') }}
</p>
</button>
</div>
<div class="macros__info-panel">
<fluent-icon icon="info" size="20" />
<p>
{{ $t('MACROS.ORDER_INFO') }}
</p>
</div>
</div>
<div class="macros__submit-button">
<woot-button
size="expanded"
color-scheme="success"
@click="$emit('submit')"
>
{{ $t('MACROS.HEADER_BTN_TXT_SAVE') }}
</woot-button>
</div>
</div>
</template>
<script>
export default {
inject: ['$v'],
props: {
macroName: {
type: String,
default: '',
},
macroVisibility: {
type: String,
default: 'global',
},
},
methods: {
isActive(key) {
return { active: this.macroVisibility === key };
},
onUpdateName(value) {
this.$emit('update:name', value);
},
onUpdateVisibility(value) {
this.$emit('update:visibility', value);
},
},
};
</script>
<style scoped lang="scss">
.macros__properties-panel {
padding: var(--space-slab);
background-color: var(--white);
// full screen height subtracted by the height of the header
height: calc(100vh - 5.6rem);
display: flex;
flex-direction: column;
border-left: 1px solid var(--s-50);
}
.macros__submit-button {
margin-top: auto;
}
.macros__form-visibility {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-slab);
.card {
padding: var(--space-small);
border-radius: var(--border-radius-normal);
border: 1px solid var(--s-200);
text-align: left;
cursor: pointer;
position: relative;
&.active {
background-color: var(--w-25);
border: 1px solid var(--w-300);
}
.subtitle {
font-size: var(--font-size-mini);
color: var(--s-500);
}
.title {
display: block;
margin: 0;
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: 1.8;
color: var(--color-body);
}
.visibility-check {
position: absolute;
color: var(--w-500);
top: var(--space-small);
right: var(--space-small);
}
}
}
.macros__info-panel {
margin-top: var(--space-small);
display: flex;
background-color: var(--s-50);
padding: var(--space-small);
border-radius: var(--border-radius-normal);
align-items: flex-start;
svg {
flex-shrink: 0;
}
p {
margin-left: var(--space-small);
color: var(--s-600);
}
}
::v-deep input[type='text'] {
margin-bottom: var(--space-small);
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<tr>
<td>{{ macro.name }}</td>
<td>
<div class="avatar-container">
<thumbnail :username="macro.created_by.name" size="24px" />
<span class="ml-2">{{ macro.created_by.name }}</span>
</div>
</td>
<td>
<div class="avatar-container">
<thumbnail :username="macro.updated_by.name" size="24px" />
<span class="ml-2">{{ macro.updated_by.name }}</span>
</div>
</td>
<td>{{ visibilityLabel }}</td>
<td class="button-wrapper">
<router-link :to="addAccountScoping(`settings/macros/${macro.id}/edit`)">
<woot-button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
variant="smooth"
size="tiny"
color-scheme="secondary"
class-names="grey-btn"
icon="edit"
/>
</router-link>
<woot-button
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
@click="$emit('delete')"
/>
</td>
</tr>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import accountMixin from 'dashboard/mixins/account.js';
export default {
components: {
Thumbnail,
},
mixins: [accountMixin],
props: {
macro: {
type: Object,
required: true,
},
},
computed: {
visibilityLabel() {
return this.macro.visibility === 'global'
? this.$t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL')
: this.$t('MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL');
},
},
};
</script>
<style scoped lang="scss">
.avatar-container {
display: flex;
align-items: center;
span {
margin-left: var(--space-one);
}
}
</style>

View file

@ -0,0 +1,30 @@
<template>
<div>
<div class="macros-item macros-pill">
<span>{{ label }}</span>
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
required: true,
},
},
};
</script>
<style scoped>
.macros-pill {
padding: var(--space-slab);
background-color: var(--w-500);
max-width: max-content;
color: var(--white);
font-size: var(--font-size-small);
border-radius: var(--border-radius-full);
position: relative;
}
</style>

View file

@ -0,0 +1,42 @@
export const MACRO_ACTION_TYPES = [
{
key: 'assign_team',
label: 'Assign a team',
inputType: 'multi_select',
},
{
key: 'add_label',
label: 'Add a label',
inputType: 'multi_select',
},
{
key: 'send_email_transcript',
label: 'Send an email transcript',
inputType: 'email',
},
{
key: 'mute_conversation',
label: 'Mute conversation',
inputType: null,
},
{
key: 'snooze_conversation',
label: 'Snooze conversation',
inputType: null,
},
{
key: 'resolve_conversation',
label: 'Resolve conversation',
inputType: null,
},
{
key: 'send_attachment',
label: 'Send Attachment',
inputType: 'attachment',
},
{
key: 'send_message',
label: 'Send a message',
inputType: 'textarea',
},
];

View file

@ -0,0 +1,10 @@
export const emptyMacro = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};

View file

@ -8,10 +8,14 @@ export default {
{
path: frontendURL('accounts/:accountId/settings/macros'),
component: SettingsContent,
props: {
headerTitle: 'MACROS.HEADER',
icon: 'flash-settings',
showNewButton: false,
props: params => {
const showBackButton = params.name !== 'macros_wrapper';
return {
headerTitle: 'MACROS.HEADER',
headerButtonText: 'MACROS.HEADER_BTN_TXT',
icon: 'flash-settings',
showBackButton,
};
},
children: [
{

View file

@ -2,14 +2,16 @@ import Vue from 'vue';
import Vuex from 'vuex';
import accounts from './modules/accounts';
import agents from './modules/agents';
import agentBots from './modules/agentBots';
import agents from './modules/agents';
import articles from './modules/helpCenterArticles';
import attributes from './modules/attributes';
import auth from './modules/auth';
import automations from './modules/automations';
import bulkActions from './modules/bulkActions';
import campaigns from './modules/campaigns';
import cannedResponse from './modules/cannedResponse';
import categories from './modules/helpCenterCategories';
import contactConversations from './modules/contactConversations';
import contactLabels from './modules/contactLabels';
import contactNotes from './modules/contactNotes';
@ -30,28 +32,29 @@ import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
import integrations from './modules/integrations';
import labels from './modules/labels';
import macros from './modules/macros';
import notifications from './modules/notifications';
import portals from './modules/helpCenterPortals';
import reports from './modules/reports';
import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import articles from './modules/helpCenterArticles';
import portals from './modules/helpCenterPortals';
import categories from './modules/helpCenterCategories';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
accounts,
agents,
agentBots,
agents,
articles,
attributes,
auth,
automations,
bulkActions,
campaigns,
cannedResponse,
categories,
contactConversations,
contactLabels,
contactNotes,
@ -72,14 +75,13 @@ export default new Vuex.Store({
inboxMembers,
integrations,
labels,
macros,
notifications,
portals,
reports,
teamMembers,
teams,
userNotificationSettings,
webhooks,
articles,
portals,
categories,
},
});

View file

@ -1,8 +1,8 @@
:root {
// border-radius
--border-radius-small: 0.3rem;
--border-radius-normal: 0.5rem;
--border-radius-medium: 0.7rem;
--border-radius-large: 0.9rem;
--border-radius-rounded: 50%;
--border-radius-small: 0.3rem;
--border-radius-normal: 0.5rem;
--border-radius-medium: 0.7rem;
--border-radius-large: 0.9rem;
--border-radius-full: 10rem;
--border-radius-rounded: 50%;
}

View file

@ -107,6 +107,7 @@
"microphone-stop-outline": "M18,18H6V6H18V18Z",
"microphone-pause-outline": "M14,19H18V5H14M6,19H10V5H6V19Z",
"microphone-play-outline": "M8,5.14V19.14L19,12.14L8,5.14Z",
"navigation-outline": "M3 17h18a1 1 0 0 1 .117 1.993L21 19H3a1 1 0 0 1-.117-1.993L3 17h18H3Zm0-6l18-.002a1 1 0 0 1 .117 1.993l-.117.007L3 13a1 1 0 0 1-.117-1.993L3 11l18-.002L3 11Zm0-6h18a1 1 0 0 1 .117 1.993L21 7H3a1 1 0 0 1-.117-1.993L3 5h18H3Z",
"number-symbol-outline": "M10.987 2.89a.75.75 0 1 0-1.474-.28L8.494 7.999 3.75 8a.75.75 0 1 0 0 1.5l4.46-.002-.946 5-4.514.002a.75.75 0 0 0 0 1.5l4.23-.002-.967 5.116a.75.75 0 1 0 1.474.278l1.02-5.395 5.474-.002-.968 5.119a.75.75 0 1 0 1.474.278l1.021-5.398 4.742-.002a.75.75 0 1 0 0-1.5l-4.458.002.946-5 4.512-.002a.75.75 0 1 0 0-1.5l-4.229.002.966-5.104a.75.75 0 0 0-1.474-.28l-1.018 5.385-5.474.002.966-5.107Zm-1.25 6.608 5.474-.003-.946 5-5.474.002.946-5Z",
"open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z",
"panel-sidebar-outline": "M4.75 4A2.75 2.75 0 0 0 2 6.75v10.5A2.75 2.75 0 0 0 4.75 20h14.5A2.75 2.75 0 0 0 22 17.25V6.75A2.75 2.75 0 0 0 19.25 4H4.75ZM9 18.5v-13h10.25c.69 0 1.25.56 1.25 1.25v10.5c0 .69-.56 1.25-1.25 1.25H9ZM5.5 3.5h4M5.5 5.5h4M5.5 7.5h4M5.5 9.5h4",