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
23 changed files with 1287 additions and 31 deletions

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: [
{