@escalated-dev/escalated 0.11.5 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/pages.json +1 -1
- package/src/components/WorkflowActionConfig.vue +73 -174
- package/src/components/WorkflowActionList.vue +65 -106
- package/src/components/WorkflowConditionBuilder.vue +56 -48
- package/src/components/WorkflowTriggerSelector.vue +34 -38
- package/src/pages/Admin/Workflows/Builder.vue +24 -10
- package/src/pages/Admin/Workflows/Form.vue +21 -9
- package/src/pages/Admin/Workflows/Index.vue +28 -22
- package/src/utils/workflowContract.js +143 -0
package/package.json
CHANGED
package/pages.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "Generated by scripts/generate-page-manifest.js from src/pages. Every name here resolves to a component; a name a backend renders that is not here is a blank screen.",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
4
4
|
"pages": [
|
|
5
5
|
"Escalated/Admin/ApiTokens/Index",
|
|
6
6
|
"Escalated/Admin/AuditLog/Index",
|
|
@@ -4,16 +4,19 @@ import VariableTokenInput from './VariableTokenInput.vue';
|
|
|
4
4
|
|
|
5
5
|
const props = defineProps({
|
|
6
6
|
actionType: { type: String, required: true },
|
|
7
|
-
|
|
7
|
+
// The action's `value`, which the contract keeps scalar. A structured value
|
|
8
|
+
// a backend stored itself (Laravel's `{agent_id, strategy}`, say) is left
|
|
9
|
+
// untouched until someone edits the field.
|
|
10
|
+
modelValue: { type: [String, Number, Object, Array], default: '' },
|
|
8
11
|
});
|
|
9
12
|
|
|
10
13
|
const emit = defineEmits(['update:modelValue']);
|
|
11
14
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
15
|
+
const isStructured = computed(() => props.modelValue !== null && typeof props.modelValue === 'object');
|
|
16
|
+
const value = computed(() => (isStructured.value ? '' : (props.modelValue ?? '')));
|
|
14
17
|
|
|
15
|
-
function update(
|
|
16
|
-
emit('update:modelValue',
|
|
18
|
+
function update(next) {
|
|
19
|
+
emit('update:modelValue', next);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
const statusOptions = [
|
|
@@ -26,17 +29,6 @@ const statusOptions = [
|
|
|
26
29
|
'closed',
|
|
27
30
|
];
|
|
28
31
|
const priorityOptions = ['low', 'medium', 'high', 'urgent', 'critical'];
|
|
29
|
-
const assignModes = [
|
|
30
|
-
{ value: 'specific', label: 'Specific agent' },
|
|
31
|
-
{ value: 'least_busy', label: 'Least busy agent' },
|
|
32
|
-
{ value: 'round_robin', label: 'Round robin' },
|
|
33
|
-
];
|
|
34
|
-
const delayUnits = [
|
|
35
|
-
{ value: 'minutes', label: 'Minutes' },
|
|
36
|
-
{ value: 'hours', label: 'Hours' },
|
|
37
|
-
{ value: 'days', label: 'Days' },
|
|
38
|
-
];
|
|
39
|
-
const webhookMethods = ['POST', 'PUT', 'PATCH'];
|
|
40
32
|
|
|
41
33
|
const inputClass =
|
|
42
34
|
'w-full rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-3 py-2 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none focus:ring-1 focus:ring-[var(--esc-panel-border-input)]';
|
|
@@ -47,38 +39,15 @@ const labelClass = 'block text-xs font-medium text-[var(--esc-panel-text-muted)]
|
|
|
47
39
|
|
|
48
40
|
<template>
|
|
49
41
|
<div class="space-y-3 pt-2">
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<label :class="labelClass">Assignment mode</label>
|
|
54
|
-
<select
|
|
55
|
-
:class="selectClass"
|
|
56
|
-
:value="config.mode || 'specific'"
|
|
57
|
-
@change="update('mode', $event.target.value)"
|
|
58
|
-
>
|
|
59
|
-
<option v-for="m in assignModes" :key="m.value" :value="m.value">{{ m.label }}</option>
|
|
60
|
-
</select>
|
|
61
|
-
</div>
|
|
62
|
-
<div v-if="(config.mode || 'specific') === 'specific'">
|
|
63
|
-
<label :class="labelClass">Agent</label>
|
|
64
|
-
<input
|
|
65
|
-
:class="inputClass"
|
|
66
|
-
:value="config.agent || ''"
|
|
67
|
-
placeholder="Agent ID or email"
|
|
68
|
-
@input="update('agent', $event.target.value)"
|
|
69
|
-
/>
|
|
70
|
-
</div>
|
|
71
|
-
</template>
|
|
42
|
+
<p v-if="isStructured" class="text-xs text-[var(--esc-panel-text-muted)]">
|
|
43
|
+
This action was configured outside the builder. Editing it here replaces that configuration.
|
|
44
|
+
</p>
|
|
72
45
|
|
|
73
46
|
<!-- Change Status -->
|
|
74
|
-
<template v-
|
|
47
|
+
<template v-if="actionType === 'change_status'">
|
|
75
48
|
<div>
|
|
76
49
|
<label :class="labelClass">New status</label>
|
|
77
|
-
<select
|
|
78
|
-
:class="selectClass"
|
|
79
|
-
:value="config.status || ''"
|
|
80
|
-
@change="update('status', $event.target.value)"
|
|
81
|
-
>
|
|
50
|
+
<select :class="selectClass" :value="value" @change="update($event.target.value)">
|
|
82
51
|
<option value="">Select status...</option>
|
|
83
52
|
<option v-for="s in statusOptions" :key="s" :value="s">{{ s }}</option>
|
|
84
53
|
</select>
|
|
@@ -89,11 +58,7 @@ const labelClass = 'block text-xs font-medium text-[var(--esc-panel-text-muted)]
|
|
|
89
58
|
<template v-else-if="actionType === 'change_priority'">
|
|
90
59
|
<div>
|
|
91
60
|
<label :class="labelClass">New priority</label>
|
|
92
|
-
<select
|
|
93
|
-
:class="selectClass"
|
|
94
|
-
:value="config.priority || ''"
|
|
95
|
-
@change="update('priority', $event.target.value)"
|
|
96
|
-
>
|
|
61
|
+
<select :class="selectClass" :value="value" @change="update($event.target.value)">
|
|
97
62
|
<option value="">Select priority...</option>
|
|
98
63
|
<option v-for="p in priorityOptions" :key="p" :value="p">{{ p }}</option>
|
|
99
64
|
</select>
|
|
@@ -106,182 +71,116 @@ const labelClass = 'block text-xs font-medium text-[var(--esc-panel-text-muted)]
|
|
|
106
71
|
<label :class="labelClass">Tag name</label>
|
|
107
72
|
<input
|
|
108
73
|
:class="inputClass"
|
|
109
|
-
:value="
|
|
74
|
+
:value="value"
|
|
110
75
|
placeholder="Enter tag name"
|
|
111
|
-
@input="update(
|
|
76
|
+
@input="update($event.target.value)"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<!-- Set Department -->
|
|
82
|
+
<template v-else-if="actionType === 'set_department' || actionType === 'move_department'">
|
|
83
|
+
<div>
|
|
84
|
+
<label :class="labelClass">Department ID</label>
|
|
85
|
+
<input
|
|
86
|
+
:class="inputClass"
|
|
87
|
+
:value="value"
|
|
88
|
+
placeholder="Department ID"
|
|
89
|
+
@input="update($event.target.value)"
|
|
112
90
|
/>
|
|
113
91
|
</div>
|
|
114
92
|
</template>
|
|
115
93
|
|
|
116
|
-
<!--
|
|
117
|
-
<template v-else-if="actionType === '
|
|
94
|
+
<!-- Assign Agent -->
|
|
95
|
+
<template v-else-if="actionType === 'assign_agent'">
|
|
118
96
|
<div>
|
|
119
|
-
<label :class="labelClass">
|
|
97
|
+
<label :class="labelClass">Agent</label>
|
|
120
98
|
<input
|
|
121
99
|
:class="inputClass"
|
|
122
|
-
:value="
|
|
123
|
-
placeholder="
|
|
124
|
-
@input="update(
|
|
100
|
+
:value="value"
|
|
101
|
+
placeholder="Agent user ID"
|
|
102
|
+
@input="update($event.target.value)"
|
|
125
103
|
/>
|
|
126
104
|
</div>
|
|
127
105
|
</template>
|
|
128
106
|
|
|
129
107
|
<!-- Add Internal Note -->
|
|
130
|
-
<template v-else-if="actionType === 'add_note'">
|
|
108
|
+
<template v-else-if="actionType === 'add_note' || actionType === 'add_internal_note'">
|
|
131
109
|
<div>
|
|
132
110
|
<label :class="labelClass">Note content</label>
|
|
133
111
|
<VariableTokenInput
|
|
134
|
-
:model-value="
|
|
112
|
+
:model-value="value"
|
|
135
113
|
:multiline="true"
|
|
136
114
|
placeholder="Write the note..."
|
|
137
|
-
@update:model-value="update(
|
|
115
|
+
@update:model-value="update($event)"
|
|
138
116
|
/>
|
|
139
117
|
</div>
|
|
140
118
|
</template>
|
|
141
119
|
|
|
142
|
-
<!--
|
|
143
|
-
<template v-else-if="actionType === '
|
|
144
|
-
<div>
|
|
145
|
-
<label :class="labelClass">To</label>
|
|
146
|
-
<VariableTokenInput
|
|
147
|
-
:model-value="config.to || ''"
|
|
148
|
-
placeholder="Recipient email or variable"
|
|
149
|
-
@update:model-value="update('to', $event)"
|
|
150
|
-
/>
|
|
151
|
-
</div>
|
|
152
|
-
<div>
|
|
153
|
-
<label :class="labelClass">Subject</label>
|
|
154
|
-
<VariableTokenInput
|
|
155
|
-
:model-value="config.subject || ''"
|
|
156
|
-
placeholder="Email subject"
|
|
157
|
-
@update:model-value="update('subject', $event)"
|
|
158
|
-
/>
|
|
159
|
-
</div>
|
|
120
|
+
<!-- Insert Canned Reply -->
|
|
121
|
+
<template v-else-if="actionType === 'insert_canned_reply'">
|
|
160
122
|
<div>
|
|
161
|
-
<label :class="labelClass">
|
|
123
|
+
<label :class="labelClass">Reply</label>
|
|
162
124
|
<VariableTokenInput
|
|
163
|
-
:model-value="
|
|
125
|
+
:model-value="value"
|
|
164
126
|
:multiline="true"
|
|
165
|
-
placeholder="
|
|
166
|
-
@update:model-value="update(
|
|
127
|
+
placeholder="The reply to post on the ticket..."
|
|
128
|
+
@update:model-value="update($event)"
|
|
167
129
|
/>
|
|
168
130
|
</div>
|
|
169
131
|
</template>
|
|
170
132
|
|
|
171
|
-
<!--
|
|
172
|
-
<template v-else-if="actionType === '
|
|
173
|
-
<div>
|
|
174
|
-
<label :class="labelClass">URL</label>
|
|
175
|
-
<input
|
|
176
|
-
:class="inputClass"
|
|
177
|
-
:value="config.url || ''"
|
|
178
|
-
placeholder="https://..."
|
|
179
|
-
@input="update('url', $event.target.value)"
|
|
180
|
-
/>
|
|
181
|
-
</div>
|
|
182
|
-
<div>
|
|
183
|
-
<label :class="labelClass">Method</label>
|
|
184
|
-
<select
|
|
185
|
-
:class="selectClass"
|
|
186
|
-
:value="config.method || 'POST'"
|
|
187
|
-
@change="update('method', $event.target.value)"
|
|
188
|
-
>
|
|
189
|
-
<option v-for="m in webhookMethods" :key="m" :value="m">{{ m }}</option>
|
|
190
|
-
</select>
|
|
191
|
-
</div>
|
|
192
|
-
<div>
|
|
193
|
-
<label :class="labelClass">Headers (JSON)</label>
|
|
194
|
-
<input
|
|
195
|
-
:class="inputClass"
|
|
196
|
-
:value="config.headers || ''"
|
|
197
|
-
:placeholder="headersPlaceholder"
|
|
198
|
-
@input="update('headers', $event.target.value)"
|
|
199
|
-
/>
|
|
200
|
-
</div>
|
|
133
|
+
<!-- Add Follower -->
|
|
134
|
+
<template v-else-if="actionType === 'add_follower'">
|
|
201
135
|
<div>
|
|
202
|
-
<label :class="labelClass">
|
|
203
|
-
<
|
|
204
|
-
:model-value="config.body || ''"
|
|
205
|
-
:multiline="true"
|
|
206
|
-
placeholder="JSON body with variables..."
|
|
207
|
-
@update:model-value="update('body', $event)"
|
|
208
|
-
/>
|
|
136
|
+
<label :class="labelClass">Follower</label>
|
|
137
|
+
<input :class="inputClass" :value="value" placeholder="User ID" @input="update($event.target.value)" />
|
|
209
138
|
</div>
|
|
210
139
|
</template>
|
|
211
140
|
|
|
212
141
|
<!-- Delay -->
|
|
213
142
|
<template v-else-if="actionType === 'delay'">
|
|
214
|
-
<div
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
/>
|
|
224
|
-
</div>
|
|
225
|
-
<div class="flex-1">
|
|
226
|
-
<label :class="labelClass">Unit</label>
|
|
227
|
-
<select
|
|
228
|
-
:class="selectClass + ' w-full'"
|
|
229
|
-
:value="config.unit || 'hours'"
|
|
230
|
-
@change="update('unit', $event.target.value)"
|
|
231
|
-
>
|
|
232
|
-
<option v-for="u in delayUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
|
|
233
|
-
</select>
|
|
234
|
-
</div>
|
|
143
|
+
<div>
|
|
144
|
+
<label :class="labelClass">Wait (minutes)</label>
|
|
145
|
+
<input
|
|
146
|
+
type="number"
|
|
147
|
+
min="1"
|
|
148
|
+
:class="inputClass"
|
|
149
|
+
:value="value || 1"
|
|
150
|
+
@input="update(parseInt($event.target.value) || 1)"
|
|
151
|
+
/>
|
|
235
152
|
</div>
|
|
236
153
|
<div class="rounded-lg bg-orange-500/10 px-3 py-2 text-xs text-orange-400">
|
|
237
|
-
Wait {{
|
|
154
|
+
Wait {{ value || 1 }} minutes, then continue...
|
|
238
155
|
</div>
|
|
239
156
|
</template>
|
|
240
157
|
|
|
241
|
-
<!--
|
|
242
|
-
<template v-else-if="actionType === '
|
|
158
|
+
<!-- Send Webhook -->
|
|
159
|
+
<template v-else-if="actionType === 'send_webhook'">
|
|
243
160
|
<div>
|
|
244
|
-
<label :class="labelClass">
|
|
161
|
+
<label :class="labelClass">URL</label>
|
|
245
162
|
<input
|
|
246
163
|
:class="inputClass"
|
|
247
|
-
:value="
|
|
248
|
-
placeholder="
|
|
249
|
-
@input="update(
|
|
164
|
+
:value="value"
|
|
165
|
+
placeholder="https://..."
|
|
166
|
+
@input="update($event.target.value)"
|
|
250
167
|
/>
|
|
251
168
|
</div>
|
|
252
169
|
</template>
|
|
253
170
|
|
|
254
|
-
<!--
|
|
255
|
-
<template v-else-if="actionType === 'snooze'">
|
|
256
|
-
<div class="flex items-center gap-3">
|
|
257
|
-
<div class="flex-1">
|
|
258
|
-
<label :class="labelClass">Duration</label>
|
|
259
|
-
<input
|
|
260
|
-
type="number"
|
|
261
|
-
min="1"
|
|
262
|
-
:class="inputClass"
|
|
263
|
-
:value="config.duration || 1"
|
|
264
|
-
@input="update('duration', parseInt($event.target.value) || 1)"
|
|
265
|
-
/>
|
|
266
|
-
</div>
|
|
267
|
-
<div class="flex-1">
|
|
268
|
-
<label :class="labelClass">Unit</label>
|
|
269
|
-
<select
|
|
270
|
-
:class="selectClass + ' w-full'"
|
|
271
|
-
:value="config.unit || 'hours'"
|
|
272
|
-
@change="update('unit', $event.target.value)"
|
|
273
|
-
>
|
|
274
|
-
<option v-for="u in delayUnits" :key="u.value" :value="u.value">{{ u.label }}</option>
|
|
275
|
-
</select>
|
|
276
|
-
</div>
|
|
277
|
-
</div>
|
|
278
|
-
</template>
|
|
279
|
-
|
|
280
|
-
<!-- Close Ticket (no config needed) -->
|
|
171
|
+
<!-- Close Ticket (no value needed) -->
|
|
281
172
|
<template v-else-if="actionType === 'close_ticket'">
|
|
282
173
|
<p class="text-xs text-[var(--esc-panel-text-muted)]">
|
|
283
174
|
This action closes the ticket immediately. No configuration needed.
|
|
284
175
|
</p>
|
|
285
176
|
</template>
|
|
177
|
+
|
|
178
|
+
<!-- Anything else the backend lists -->
|
|
179
|
+
<template v-else>
|
|
180
|
+
<div>
|
|
181
|
+
<label :class="labelClass">Value</label>
|
|
182
|
+
<input :class="inputClass" :value="value" @input="update($event.target.value)" />
|
|
183
|
+
</div>
|
|
184
|
+
</template>
|
|
286
185
|
</div>
|
|
287
186
|
</template>
|
|
@@ -1,93 +1,72 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { ref, computed } from 'vue';
|
|
3
3
|
import WorkflowActionConfig from './WorkflowActionConfig.vue';
|
|
4
|
+
import { CORE_ACTION_TYPES, normalizeOptions } from '../utils/workflowContract.js';
|
|
4
5
|
|
|
5
6
|
const props = defineProps({
|
|
7
|
+
// `[{type, value}]` (workflow-admin-contract.md).
|
|
6
8
|
modelValue: { type: Array, default: () => [] },
|
|
9
|
+
// What the backend executes. Absent means the core catalog every backend
|
|
10
|
+
// handles; anything else the backend lists is offered as well.
|
|
11
|
+
actionTypes: { type: [Array, Object], default: null },
|
|
7
12
|
});
|
|
8
13
|
|
|
9
14
|
const emit = defineEmits(['update:modelValue']);
|
|
10
15
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
const PERSON_ICON =
|
|
17
|
+
'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z';
|
|
18
|
+
const TAG_ICON =
|
|
19
|
+
'M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z';
|
|
20
|
+
const DEPARTMENT_ICON =
|
|
21
|
+
'M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75z';
|
|
22
|
+
const NOTE_ICON =
|
|
23
|
+
'M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z';
|
|
24
|
+
const MAIL_ICON =
|
|
25
|
+
'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75';
|
|
26
|
+
const BOLT_ICON = 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z';
|
|
27
|
+
|
|
28
|
+
const ACTION_STYLES = {
|
|
29
|
+
assign_agent: { icon: PERSON_ICON, color: 'text-blue-400 bg-blue-500/10' },
|
|
30
|
+
change_status: {
|
|
21
31
|
icon: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
22
32
|
color: 'text-emerald-400 bg-emerald-500/10',
|
|
23
33
|
},
|
|
24
|
-
{
|
|
25
|
-
value: 'change_priority',
|
|
26
|
-
label: 'Change Priority',
|
|
34
|
+
change_priority: {
|
|
27
35
|
icon: 'M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12',
|
|
28
36
|
color: 'text-red-400 bg-red-500/10',
|
|
29
37
|
},
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
color: 'text-pink-400 bg-pink-500/10',
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
value: 'move_department',
|
|
44
|
-
label: 'Move Department',
|
|
45
|
-
icon: 'M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75z',
|
|
46
|
-
color: 'text-cyan-400 bg-cyan-500/10',
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
value: 'add_note',
|
|
50
|
-
label: 'Add Internal Note',
|
|
51
|
-
icon: 'M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z',
|
|
52
|
-
color: 'text-amber-400 bg-amber-500/10',
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
value: 'send_email',
|
|
56
|
-
label: 'Send Email Notification',
|
|
57
|
-
icon: 'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
|
|
58
|
-
color: 'text-sky-400 bg-sky-500/10',
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
value: 'send_webhook',
|
|
62
|
-
label: 'Send Webhook',
|
|
38
|
+
add_tag: { icon: TAG_ICON, color: 'text-violet-400 bg-violet-500/10' },
|
|
39
|
+
remove_tag: { icon: TAG_ICON, color: 'text-pink-400 bg-pink-500/10' },
|
|
40
|
+
set_department: { icon: DEPARTMENT_ICON, color: 'text-cyan-400 bg-cyan-500/10' },
|
|
41
|
+
move_department: { icon: DEPARTMENT_ICON, color: 'text-cyan-400 bg-cyan-500/10' },
|
|
42
|
+
add_note: { icon: NOTE_ICON, color: 'text-amber-400 bg-amber-500/10' },
|
|
43
|
+
add_internal_note: { icon: NOTE_ICON, color: 'text-amber-400 bg-amber-500/10' },
|
|
44
|
+
insert_canned_reply: { icon: MAIL_ICON, color: 'text-sky-400 bg-sky-500/10' },
|
|
45
|
+
send_notification: { icon: MAIL_ICON, color: 'text-sky-400 bg-sky-500/10' },
|
|
46
|
+
add_follower: { icon: PERSON_ICON, color: 'text-indigo-400 bg-indigo-500/10' },
|
|
47
|
+
send_webhook: {
|
|
63
48
|
icon: 'M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244',
|
|
64
49
|
color: 'text-indigo-400 bg-indigo-500/10',
|
|
65
50
|
},
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
70
|
-
color: 'text-orange-400 bg-orange-500/10',
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
value: 'apply_macro',
|
|
74
|
-
label: 'Apply Macro',
|
|
75
|
-
icon: 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z',
|
|
76
|
-
color: 'text-yellow-400 bg-yellow-500/10',
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
value: 'close_ticket',
|
|
80
|
-
label: 'Close Ticket',
|
|
51
|
+
delay: { icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z', color: 'text-orange-400 bg-orange-500/10' },
|
|
52
|
+
apply_macro: { icon: BOLT_ICON, color: 'text-yellow-400 bg-yellow-500/10' },
|
|
53
|
+
close_ticket: {
|
|
81
54
|
icon: 'M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
82
55
|
color: 'text-gray-400 bg-gray-500/10',
|
|
83
56
|
},
|
|
84
|
-
{
|
|
85
|
-
value: 'snooze',
|
|
86
|
-
label: 'Snooze',
|
|
57
|
+
snooze_ticket: {
|
|
87
58
|
icon: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0',
|
|
88
59
|
color: 'text-teal-400 bg-teal-500/10',
|
|
89
60
|
},
|
|
90
|
-
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const DEFAULT_STYLE = { icon: BOLT_ICON, color: 'text-gray-400 bg-gray-500/10' };
|
|
64
|
+
|
|
65
|
+
function withStyle(option) {
|
|
66
|
+
return { ...option, ...(ACTION_STYLES[option.value] ?? DEFAULT_STYLE) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const availableTypes = computed(() => normalizeOptions(props.actionTypes, CORE_ACTION_TYPES).map(withStyle));
|
|
91
70
|
|
|
92
71
|
const showTypeSelector = ref(false);
|
|
93
72
|
const expandedIndex = ref(null);
|
|
@@ -95,12 +74,16 @@ const dragIndex = ref(null);
|
|
|
95
74
|
|
|
96
75
|
const actions = computed(() => props.modelValue);
|
|
97
76
|
|
|
77
|
+
// A stored action may have a type the backend no longer lists; it still gets a
|
|
78
|
+
// label, derived from its name.
|
|
98
79
|
function getActionType(value) {
|
|
99
|
-
|
|
80
|
+
const listed = availableTypes.value.find((t) => t.value === value);
|
|
81
|
+
|
|
82
|
+
return listed ?? withStyle(normalizeOptions([value], CORE_ACTION_TYPES)[0]);
|
|
100
83
|
}
|
|
101
84
|
|
|
102
85
|
function addAction(typeValue) {
|
|
103
|
-
const newActions = [...actions.value, { type: typeValue,
|
|
86
|
+
const newActions = [...actions.value, { type: typeValue, value: '' }];
|
|
104
87
|
emit('update:modelValue', newActions);
|
|
105
88
|
showTypeSelector.value = false;
|
|
106
89
|
expandedIndex.value = newActions.length - 1;
|
|
@@ -112,8 +95,8 @@ function removeAction(index) {
|
|
|
112
95
|
if (expandedIndex.value === index) expandedIndex.value = null;
|
|
113
96
|
}
|
|
114
97
|
|
|
115
|
-
function
|
|
116
|
-
const newActions = actions.value.map((a, i) => (i === index ? { ...a,
|
|
98
|
+
function updateValue(index, value) {
|
|
99
|
+
const newActions = actions.value.map((a, i) => (i === index ? { ...a, value } : a));
|
|
117
100
|
emit('update:modelValue', newActions);
|
|
118
101
|
}
|
|
119
102
|
|
|
@@ -122,40 +105,16 @@ function toggleExpand(index) {
|
|
|
122
105
|
}
|
|
123
106
|
|
|
124
107
|
function getActionSummary(action) {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
case 'change_priority':
|
|
136
|
-
return c.priority || 'Not configured';
|
|
137
|
-
case 'add_tag':
|
|
138
|
-
case 'remove_tag':
|
|
139
|
-
return c.tag || 'Not configured';
|
|
140
|
-
case 'move_department':
|
|
141
|
-
return c.department || 'Not configured';
|
|
142
|
-
case 'add_note':
|
|
143
|
-
return c.body ? c.body.substring(0, 40) + (c.body.length > 40 ? '...' : '') : 'Not configured';
|
|
144
|
-
case 'send_email':
|
|
145
|
-
return c.to || 'Not configured';
|
|
146
|
-
case 'send_webhook':
|
|
147
|
-
return c.url || 'Not configured';
|
|
148
|
-
case 'delay':
|
|
149
|
-
return `Wait ${c.duration || 1} ${c.unit || 'hours'}`;
|
|
150
|
-
case 'apply_macro':
|
|
151
|
-
return c.macro || 'Not configured';
|
|
152
|
-
case 'close_ticket':
|
|
153
|
-
return 'Close immediately';
|
|
154
|
-
case 'snooze':
|
|
155
|
-
return `${c.duration || 1} ${c.unit || 'hours'}`;
|
|
156
|
-
default:
|
|
157
|
-
return 'Not configured';
|
|
158
|
-
}
|
|
108
|
+
const value = action.value;
|
|
109
|
+
|
|
110
|
+
if (action.type === 'delay') return `Wait ${value || 1} minutes`;
|
|
111
|
+
if (action.type === 'close_ticket') return 'Close immediately';
|
|
112
|
+
if (value === null || value === undefined || value === '') return 'Not configured';
|
|
113
|
+
if (typeof value === 'object') return 'Configured outside the builder';
|
|
114
|
+
|
|
115
|
+
const text = String(value);
|
|
116
|
+
|
|
117
|
+
return text.length > 40 ? `${text.substring(0, 40)}...` : text;
|
|
159
118
|
}
|
|
160
119
|
|
|
161
120
|
// Drag-and-drop reordering
|
|
@@ -294,8 +253,8 @@ function onDragEnd() {
|
|
|
294
253
|
<div v-if="expandedIndex === i" class="border-t border-[var(--esc-panel-border)] px-4 pb-4">
|
|
295
254
|
<WorkflowActionConfig
|
|
296
255
|
:action-type="action.type"
|
|
297
|
-
:model-value="action.
|
|
298
|
-
@update:model-value="
|
|
256
|
+
:model-value="action.value"
|
|
257
|
+
@update:model-value="updateValue(i, $event)"
|
|
299
258
|
/>
|
|
300
259
|
</div>
|
|
301
260
|
</div>
|
|
@@ -325,7 +284,7 @@ function onDragEnd() {
|
|
|
325
284
|
class="absolute left-0 z-20 mt-2 w-full rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] py-2 shadow-2xl"
|
|
326
285
|
>
|
|
327
286
|
<button
|
|
328
|
-
v-for="at in
|
|
287
|
+
v-for="at in availableTypes"
|
|
329
288
|
:key="at.value"
|
|
330
289
|
type="button"
|
|
331
290
|
class="flex w-full items-center gap-3 px-4 py-2 text-left transition-colors hover:bg-[var(--esc-panel-hover)]"
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed } from 'vue';
|
|
3
|
+
import { DEFAULT_OPERATORS, humanize, normalizeOptions } from '../utils/workflowContract.js';
|
|
3
4
|
|
|
4
5
|
const props = defineProps({
|
|
6
|
+
// The editor's `{match, conditions}`; the builder sends it as `{all}` or
|
|
7
|
+
// `{any}` (workflow-admin-contract.md).
|
|
5
8
|
modelValue: {
|
|
6
9
|
type: Object,
|
|
7
10
|
default: () => ({ match: 'all', conditions: [] }),
|
|
8
11
|
},
|
|
12
|
+
// The operators the backend evaluates. Absent means the contract's twelve.
|
|
13
|
+
operators: { type: [Array, Object], default: null },
|
|
9
14
|
});
|
|
10
15
|
|
|
11
16
|
const emit = defineEmits(['update:modelValue']);
|
|
12
17
|
|
|
18
|
+
// The field names every backend resolves (workflow-admin-contract.md#conditions).
|
|
13
19
|
const fields = [
|
|
14
20
|
{
|
|
15
21
|
value: 'status',
|
|
@@ -28,43 +34,48 @@ const fields = [
|
|
|
28
34
|
},
|
|
29
35
|
{ value: 'priority', label: 'Priority', type: 'enum', options: ['low', 'medium', 'high', 'urgent', 'critical'] },
|
|
30
36
|
{
|
|
31
|
-
value: '
|
|
37
|
+
value: 'ticket_type',
|
|
32
38
|
label: 'Type',
|
|
33
39
|
type: 'enum',
|
|
34
40
|
options: ['question', 'problem', 'incident', 'task', 'feature_request'],
|
|
35
41
|
},
|
|
36
42
|
{ value: 'channel', label: 'Channel', type: 'enum', options: ['web', 'email', 'chat', 'api', 'phone'] },
|
|
37
|
-
{ value: 'tags', label: 'Tags', type: 'text' },
|
|
38
|
-
{ value: 'department', label: 'Department', type: 'text' },
|
|
39
|
-
{ value: 'assigned_agent', label: 'Assigned Agent', type: 'text' },
|
|
40
43
|
{ value: 'subject', label: 'Subject', type: 'text' },
|
|
44
|
+
{ value: 'description', label: 'Description', type: 'text' },
|
|
45
|
+
{ value: 'tags', label: 'Tags', type: 'text' },
|
|
46
|
+
{ value: 'department_id', label: 'Department', type: 'text' },
|
|
47
|
+
{ value: 'assigned_to', label: 'Assigned Agent', type: 'text' },
|
|
41
48
|
{ value: 'hours_since_created', label: 'Hours Since Created', type: 'number' },
|
|
42
49
|
{ value: 'hours_since_updated', label: 'Hours Since Updated', type: 'number' },
|
|
43
|
-
{ value: 'reply_count', label: 'Reply Count', type: 'number' },
|
|
44
|
-
{ value: 'custom_field', label: 'Custom Field', type: 'text' },
|
|
45
50
|
];
|
|
46
51
|
|
|
52
|
+
const OPERATOR_LABELS = {
|
|
53
|
+
equals: 'equals',
|
|
54
|
+
not_equals: 'not equals',
|
|
55
|
+
contains: 'contains',
|
|
56
|
+
not_contains: 'does not contain',
|
|
57
|
+
starts_with: 'starts with',
|
|
58
|
+
ends_with: 'ends with',
|
|
59
|
+
greater_than: 'greater than',
|
|
60
|
+
less_than: 'less than',
|
|
61
|
+
greater_or_equal: 'at least',
|
|
62
|
+
less_or_equal: 'at most',
|
|
63
|
+
is_empty: 'is empty',
|
|
64
|
+
is_not_empty: 'is not empty',
|
|
65
|
+
};
|
|
66
|
+
|
|
47
67
|
const operatorsByType = {
|
|
48
|
-
enum: [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{ value: 'in', label: 'in' },
|
|
52
|
-
],
|
|
53
|
-
text: [
|
|
54
|
-
{ value: 'equals', label: 'equals' },
|
|
55
|
-
{ value: 'not_equals', label: 'not equals' },
|
|
56
|
-
{ value: 'contains', label: 'contains' },
|
|
57
|
-
{ value: 'is_empty', label: 'is empty' },
|
|
58
|
-
{ value: 'matches', label: 'matches' },
|
|
59
|
-
],
|
|
60
|
-
number: [
|
|
61
|
-
{ value: 'equals', label: 'equals' },
|
|
62
|
-
{ value: 'not_equals', label: 'not equals' },
|
|
63
|
-
{ value: 'greater_than', label: 'greater than' },
|
|
64
|
-
{ value: 'less_than', label: 'less than' },
|
|
65
|
-
],
|
|
68
|
+
enum: ['equals', 'not_equals', 'is_empty', 'is_not_empty'],
|
|
69
|
+
text: ['equals', 'not_equals', 'contains', 'not_contains', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty'],
|
|
70
|
+
number: ['equals', 'not_equals', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal'],
|
|
66
71
|
};
|
|
67
72
|
|
|
73
|
+
const VALUELESS_OPERATORS = ['is_empty', 'is_not_empty'];
|
|
74
|
+
|
|
75
|
+
const backendOperators = computed(() =>
|
|
76
|
+
props.operators ? normalizeOptions(props.operators, []).map((o) => o.value) : null,
|
|
77
|
+
);
|
|
78
|
+
|
|
68
79
|
const data = computed(() => props.modelValue);
|
|
69
80
|
|
|
70
81
|
function update(patch) {
|
|
@@ -94,14 +105,28 @@ function getFieldConfig(fieldValue) {
|
|
|
94
105
|
return fields.find((f) => f.value === fieldValue) || fields[0];
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
function operatorLabel(operator) {
|
|
109
|
+
return OPERATOR_LABELS[operator] ?? humanize(operator).toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The operators that make sense for the field. When the backend lists its own,
|
|
113
|
+
// only those are offered: the canonical ones that fit the field, plus any
|
|
114
|
+
// extras it evaluates.
|
|
97
115
|
function getOperators(fieldValue) {
|
|
98
|
-
const
|
|
99
|
-
|
|
116
|
+
const forType = operatorsByType[getFieldConfig(fieldValue).type] || operatorsByType.text;
|
|
117
|
+
const listed = backendOperators.value;
|
|
118
|
+
const values = listed ? listed.filter((op) => forType.includes(op) || !DEFAULT_OPERATORS.includes(op)) : forType;
|
|
119
|
+
|
|
120
|
+
return values.map((value) => ({ value, label: operatorLabel(value) }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function valueless(operator) {
|
|
124
|
+
return VALUELESS_OPERATORS.includes(operator);
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
function onFieldChange(index, newField) {
|
|
103
128
|
const operators = getOperators(newField);
|
|
104
|
-
updateCondition(index, { field: newField, operator: operators[0]
|
|
129
|
+
updateCondition(index, { field: newField, operator: operators[0]?.value ?? 'equals', value: '' });
|
|
105
130
|
}
|
|
106
131
|
|
|
107
132
|
const previewText = computed(() => {
|
|
@@ -110,11 +135,9 @@ const previewText = computed(() => {
|
|
|
110
135
|
return data.value.conditions
|
|
111
136
|
.map((c) => {
|
|
112
137
|
const field = getFieldConfig(c.field);
|
|
113
|
-
|
|
114
|
-
(operatorsByType[field.type] || []).find((o) => o.value === c.operator)?.label || c.operator;
|
|
115
|
-
if (c.operator === 'is_empty') return `${field.label} is empty`;
|
|
138
|
+
if (valueless(c.operator)) return `${field.label} ${operatorLabel(c.operator)}`;
|
|
116
139
|
const val = Array.isArray(c.value) ? `[${c.value.join(', ')}]` : c.value;
|
|
117
|
-
return `${field.label} ${
|
|
140
|
+
return `${field.label} ${operatorLabel(c.operator)} ${val}`;
|
|
118
141
|
})
|
|
119
142
|
.join(joiner);
|
|
120
143
|
});
|
|
@@ -179,10 +202,10 @@ const previewText = computed(() => {
|
|
|
179
202
|
</select>
|
|
180
203
|
|
|
181
204
|
<!-- Value (contextual) -->
|
|
182
|
-
<template v-if="condition.operator
|
|
205
|
+
<template v-if="!valueless(condition.operator)">
|
|
183
206
|
<!-- Enum select for enum fields -->
|
|
184
207
|
<select
|
|
185
|
-
v-if="getFieldConfig(condition.field).type === 'enum'
|
|
208
|
+
v-if="getFieldConfig(condition.field).type === 'enum'"
|
|
186
209
|
:value="condition.value"
|
|
187
210
|
class="rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-2 py-1.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none"
|
|
188
211
|
@change="updateCondition(i, { value: $event.target.value })"
|
|
@@ -193,21 +216,6 @@ const previewText = computed(() => {
|
|
|
193
216
|
</option>
|
|
194
217
|
</select>
|
|
195
218
|
|
|
196
|
-
<!-- Multi-select for "in" operator on enum fields -->
|
|
197
|
-
<select
|
|
198
|
-
v-else-if="getFieldConfig(condition.field).type === 'enum' && condition.operator === 'in'"
|
|
199
|
-
:value="condition.value"
|
|
200
|
-
multiple
|
|
201
|
-
class="min-h-[60px] rounded-lg border border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] px-2 py-1.5 text-sm text-[var(--esc-panel-text-secondary)] focus:border-[var(--esc-panel-border-input)] focus:outline-none"
|
|
202
|
-
@change="
|
|
203
|
-
updateCondition(i, { value: Array.from($event.target.selectedOptions, (o) => o.value) })
|
|
204
|
-
"
|
|
205
|
-
>
|
|
206
|
-
<option v-for="opt in getFieldConfig(condition.field).options" :key="opt" :value="opt">
|
|
207
|
-
{{ opt }}
|
|
208
|
-
</option>
|
|
209
|
-
</select>
|
|
210
|
-
|
|
211
219
|
<!-- Number input -->
|
|
212
220
|
<input
|
|
213
221
|
v-else-if="getFieldConfig(condition.field).type === 'number'"
|
|
@@ -1,74 +1,70 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed } from 'vue';
|
|
3
|
+
import { DEFAULT_TRIGGER_EVENTS, normalizeOptions } from '../utils/workflowContract.js';
|
|
3
4
|
|
|
4
5
|
const props = defineProps({
|
|
5
6
|
modelValue: { type: String, default: '' },
|
|
7
|
+
// The events the backend fires, in any shape normalizeOptions accepts.
|
|
8
|
+
// Absent means the contract's five canonical triggers.
|
|
9
|
+
options: { type: [Array, Object], default: null },
|
|
6
10
|
});
|
|
7
11
|
|
|
8
12
|
const emit = defineEmits(['update:modelValue']);
|
|
9
13
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
const REPLY_ICON =
|
|
15
|
+
'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z';
|
|
16
|
+
const DEFAULT_ICON = 'M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z';
|
|
17
|
+
|
|
18
|
+
// Descriptions and icons for the events backends are known to fire. An event
|
|
19
|
+
// not listed here still renders, with its derived label and a generic icon.
|
|
20
|
+
const DETAILS = {
|
|
21
|
+
'ticket.created': {
|
|
14
22
|
description: 'When a new ticket is submitted',
|
|
15
23
|
icon: 'M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
16
24
|
},
|
|
17
|
-
{
|
|
18
|
-
value: 'ticket_updated',
|
|
19
|
-
label: 'Ticket Updated',
|
|
25
|
+
'ticket.updated': {
|
|
20
26
|
description: 'When any ticket field is modified',
|
|
21
27
|
icon: 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182',
|
|
22
28
|
},
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
description: 'When a reply is posted on a ticket',
|
|
27
|
-
icon: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z',
|
|
29
|
+
'ticket.assigned': {
|
|
30
|
+
description: 'When a ticket is assigned to an agent',
|
|
31
|
+
icon: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z',
|
|
28
32
|
},
|
|
29
|
-
{
|
|
30
|
-
value: 'status_changed',
|
|
31
|
-
label: 'Status Changed',
|
|
33
|
+
'ticket.status_changed': {
|
|
32
34
|
description: 'When the ticket status is updated',
|
|
33
35
|
icon: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
34
36
|
},
|
|
35
|
-
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
description: 'When a ticket is assigned to an agent',
|
|
39
|
-
icon: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z',
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
value: 'ticket_escalated',
|
|
43
|
-
label: 'Ticket Escalated',
|
|
37
|
+
'reply.created': { description: 'When a reply is posted on a ticket', icon: REPLY_ICON },
|
|
38
|
+
'ticket.replied': { description: 'When a reply is posted on a ticket', icon: REPLY_ICON },
|
|
39
|
+
'ticket.escalated': {
|
|
44
40
|
description: 'When a ticket is escalated',
|
|
45
41
|
icon: 'M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12',
|
|
46
42
|
},
|
|
47
|
-
{
|
|
48
|
-
value: 'sla_breached',
|
|
49
|
-
label: 'SLA Breached',
|
|
43
|
+
'sla.breached': {
|
|
50
44
|
description: 'When an SLA target is breached',
|
|
51
45
|
icon: 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z',
|
|
52
46
|
},
|
|
53
|
-
{
|
|
54
|
-
value: 'sla_warning',
|
|
55
|
-
label: 'SLA Warning',
|
|
47
|
+
'sla.warning': {
|
|
56
48
|
description: 'When an SLA target is approaching',
|
|
57
49
|
icon: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
58
50
|
},
|
|
59
|
-
{
|
|
60
|
-
value: 'chat_started',
|
|
61
|
-
label: 'Chat Started',
|
|
51
|
+
'chat.started': {
|
|
62
52
|
description: 'When a live chat session begins',
|
|
63
53
|
icon: 'M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155',
|
|
64
54
|
},
|
|
65
|
-
{
|
|
66
|
-
value: 'chat_ended',
|
|
67
|
-
label: 'Chat Ended',
|
|
55
|
+
'chat.ended': {
|
|
68
56
|
description: 'When a live chat session ends',
|
|
69
57
|
icon: 'M15 11.25l-3-3m0 0l-3 3m3-3v7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
70
58
|
},
|
|
71
|
-
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const triggers = computed(() =>
|
|
62
|
+
normalizeOptions(props.options, DEFAULT_TRIGGER_EVENTS).map((option) => ({
|
|
63
|
+
...option,
|
|
64
|
+
description: DETAILS[option.value]?.description ?? '',
|
|
65
|
+
icon: DETAILS[option.value]?.icon ?? DEFAULT_ICON,
|
|
66
|
+
})),
|
|
67
|
+
);
|
|
72
68
|
|
|
73
69
|
const selected = computed(() => props.modelValue);
|
|
74
70
|
|
|
@@ -109,7 +105,7 @@ function select(value) {
|
|
|
109
105
|
>
|
|
110
106
|
{{ trigger.label }}
|
|
111
107
|
</span>
|
|
112
|
-
<span class="mt-1 text-[10px] leading-tight text-[var(--esc-panel-text-muted)]">
|
|
108
|
+
<span v-if="trigger.description" class="mt-1 text-[10px] leading-tight text-[var(--esc-panel-text-muted)]">
|
|
113
109
|
{{ trigger.description }}
|
|
114
110
|
</span>
|
|
115
111
|
<div
|
|
@@ -5,18 +5,26 @@ import WorkflowConditionBuilder from '../../../components/WorkflowConditionBuild
|
|
|
5
5
|
import WorkflowActionList from '../../../components/WorkflowActionList.vue';
|
|
6
6
|
import { useForm } from '@inertiajs/vue3';
|
|
7
7
|
import { ref, computed } from 'vue';
|
|
8
|
+
import { toActionState, toConditionState, toWirePayload } from '../../../utils/workflowContract.js';
|
|
8
9
|
|
|
9
10
|
const props = defineProps({
|
|
10
11
|
workflow: { type: Object, default: null },
|
|
12
|
+
// What the backend fires, executes and evaluates. Any shape
|
|
13
|
+
// normalizeOptions accepts; absent means the contract's defaults.
|
|
14
|
+
triggerEvents: { type: [Array, Object], default: null },
|
|
15
|
+
actionTypes: { type: [Array, Object], default: null },
|
|
16
|
+
operators: { type: [Array, Object], default: null },
|
|
11
17
|
});
|
|
12
18
|
|
|
19
|
+
// The form holds the editor's state; submit() sends it in the shape every
|
|
20
|
+
// backend reads (escalated-developer-context domain-model/workflow-admin-contract.md).
|
|
13
21
|
const form = useForm({
|
|
14
22
|
name: props.workflow?.name || '',
|
|
15
23
|
description: props.workflow?.description || '',
|
|
16
|
-
|
|
17
|
-
conditions: props.workflow?.conditions
|
|
18
|
-
actions: props.workflow?.actions
|
|
19
|
-
|
|
24
|
+
trigger_event: props.workflow?.trigger_event || props.workflow?.trigger || '',
|
|
25
|
+
conditions: toConditionState(props.workflow?.conditions),
|
|
26
|
+
actions: toActionState(props.workflow?.actions),
|
|
27
|
+
is_active: props.workflow?.is_active ?? props.workflow?.active ?? true,
|
|
20
28
|
});
|
|
21
29
|
|
|
22
30
|
const expandedStep = ref(props.workflow ? null : 1);
|
|
@@ -26,6 +34,8 @@ function toggleStep(step) {
|
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
function submit() {
|
|
37
|
+
form.transform(toWirePayload);
|
|
38
|
+
|
|
29
39
|
if (props.workflow) {
|
|
30
40
|
form.put(route('escalated.admin.workflows.update', props.workflow.id));
|
|
31
41
|
} else {
|
|
@@ -34,7 +44,7 @@ function submit() {
|
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
const isValid = computed(() => {
|
|
37
|
-
return form.name && form.
|
|
47
|
+
return form.name && form.trigger_event && form.actions.length > 0;
|
|
38
48
|
});
|
|
39
49
|
|
|
40
50
|
const stepBorderColors = {
|
|
@@ -64,7 +74,7 @@ const stepDescriptions = {
|
|
|
64
74
|
function stepHasData(step) {
|
|
65
75
|
switch (step) {
|
|
66
76
|
case 1:
|
|
67
|
-
return !!form.
|
|
77
|
+
return !!form.trigger_event;
|
|
68
78
|
case 2:
|
|
69
79
|
return form.conditions?.conditions?.length > 0;
|
|
70
80
|
case 3:
|
|
@@ -108,7 +118,7 @@ function stepHasData(step) {
|
|
|
108
118
|
<div>
|
|
109
119
|
<label class="flex items-center gap-2 text-sm text-[var(--esc-panel-text-secondary)]">
|
|
110
120
|
<input
|
|
111
|
-
v-model="form.
|
|
121
|
+
v-model="form.is_active"
|
|
112
122
|
type="checkbox"
|
|
113
123
|
class="rounded border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)] text-cyan-500 focus:ring-cyan-500/20"
|
|
114
124
|
/>
|
|
@@ -175,13 +185,17 @@ function stepHasData(step) {
|
|
|
175
185
|
<!-- Step content -->
|
|
176
186
|
<div v-if="expandedStep === step" class="border-t border-[var(--esc-panel-border)] px-6 py-4">
|
|
177
187
|
<!-- Trigger selector -->
|
|
178
|
-
<WorkflowTriggerSelector
|
|
188
|
+
<WorkflowTriggerSelector
|
|
189
|
+
v-if="step === 1"
|
|
190
|
+
v-model="form.trigger_event"
|
|
191
|
+
:options="triggerEvents"
|
|
192
|
+
/>
|
|
179
193
|
|
|
180
194
|
<!-- Condition builder -->
|
|
181
|
-
<WorkflowConditionBuilder v-if="step === 2" v-model="form.conditions" />
|
|
195
|
+
<WorkflowConditionBuilder v-if="step === 2" v-model="form.conditions" :operators="operators" />
|
|
182
196
|
|
|
183
197
|
<!-- Action list -->
|
|
184
|
-
<WorkflowActionList v-if="step === 3" v-model="form.actions" />
|
|
198
|
+
<WorkflowActionList v-if="step === 3" v-model="form.actions" :action-types="actionTypes" />
|
|
185
199
|
</div>
|
|
186
200
|
</div>
|
|
187
201
|
</div>
|
|
@@ -4,21 +4,33 @@ import Builder from './Builder.vue';
|
|
|
4
4
|
/**
|
|
5
5
|
* The workflow editor, under the name the backends render.
|
|
6
6
|
*
|
|
7
|
-
* Builder.vue is the editor itself
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Builder.vue is the editor itself. Several backends render
|
|
8
|
+
* `Escalated/Admin/Workflows/Form` for both create and edit, and a page name
|
|
9
|
+
* with no component behind it is not an error in Inertia -- the panel simply
|
|
10
|
+
* comes up blank. This is that name.
|
|
11
11
|
*/
|
|
12
12
|
defineProps({
|
|
13
13
|
workflow: { type: Object, default: null },
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
15
|
+
// What the backend fires, evaluates and executes, passed alongside the
|
|
16
|
+
// workflow (escalated-developer-context domain-model/workflow-admin-contract.md).
|
|
17
|
+
// The builder offers exactly these, so they are handed on.
|
|
18
|
+
//
|
|
19
|
+
// Declared in snake_case because that is what arrives. Vue folds
|
|
20
|
+
// kebab-case into camelCase and nothing else, so a `triggerEvents`
|
|
21
|
+
// declaration never matched the `trigger_events` the backends send -- it
|
|
22
|
+
// landed on Builder's root element as an attribute.
|
|
23
|
+
trigger_events: { type: [Array, Object], default: null },
|
|
24
|
+
operators: { type: [Array, Object], default: null },
|
|
25
|
+
action_types: { type: [Array, Object], default: null },
|
|
19
26
|
});
|
|
20
27
|
</script>
|
|
21
28
|
|
|
22
29
|
<template>
|
|
23
|
-
<Builder
|
|
30
|
+
<Builder
|
|
31
|
+
:workflow="workflow"
|
|
32
|
+
:trigger-events="trigger_events"
|
|
33
|
+
:action-types="action_types"
|
|
34
|
+
:operators="operators"
|
|
35
|
+
/>
|
|
24
36
|
</template>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
3
3
|
import { Link, router } from '@inertiajs/vue3';
|
|
4
4
|
import { ref } from 'vue';
|
|
5
|
+
import { humanize } from '../../../utils/workflowContract.js';
|
|
5
6
|
|
|
6
7
|
defineProps({
|
|
7
8
|
workflows: { type: Array, default: () => [] },
|
|
@@ -9,6 +10,16 @@ defineProps({
|
|
|
9
10
|
|
|
10
11
|
const dragIndex = ref(null);
|
|
11
12
|
|
|
13
|
+
// Backends send `trigger_event` and `is_active` (workflow-admin-contract.md).
|
|
14
|
+
// `trigger` and `active` are read too, for anything still sending the aliases.
|
|
15
|
+
function triggerOf(workflow) {
|
|
16
|
+
return workflow.trigger_event ?? workflow.trigger ?? '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isActive(workflow) {
|
|
20
|
+
return workflow.is_active ?? workflow.active ?? false;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
function destroy(id) {
|
|
13
24
|
if (confirm('Delete this workflow? This cannot be undone.')) {
|
|
14
25
|
router.delete(route('escalated.admin.workflows.destroy', id));
|
|
@@ -16,9 +27,7 @@ function destroy(id) {
|
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
function toggleActive(workflow) {
|
|
19
|
-
router.
|
|
20
|
-
active: !workflow.active,
|
|
21
|
-
});
|
|
30
|
+
router.post(route('escalated.admin.workflows.toggle', workflow.id));
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
function onDragStart(index) {
|
|
@@ -35,7 +44,7 @@ function onDrop(event, workflows, targetIndex) {
|
|
|
35
44
|
const ids = workflows.map((w) => w.id);
|
|
36
45
|
const [moved] = ids.splice(dragIndex.value, 1);
|
|
37
46
|
ids.splice(targetIndex, 0, moved);
|
|
38
|
-
router.post(route('escalated.admin.workflows.reorder'), { ids });
|
|
47
|
+
router.post(route('escalated.admin.workflows.reorder'), { workflow_ids: ids });
|
|
39
48
|
dragIndex.value = null;
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -45,23 +54,20 @@ function onDragEnd() {
|
|
|
45
54
|
|
|
46
55
|
function triggerBadgeClass(trigger) {
|
|
47
56
|
const classes = {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
'ticket.created': 'bg-blue-500/10 text-blue-400',
|
|
58
|
+
'ticket.updated': 'bg-cyan-500/10 text-cyan-400',
|
|
59
|
+
'reply.created': 'bg-emerald-500/10 text-emerald-400',
|
|
60
|
+
'ticket.replied': 'bg-emerald-500/10 text-emerald-400',
|
|
61
|
+
'ticket.status_changed': 'bg-violet-500/10 text-violet-400',
|
|
62
|
+
'ticket.assigned': 'bg-indigo-500/10 text-indigo-400',
|
|
63
|
+
'ticket.escalated': 'bg-red-500/10 text-red-400',
|
|
64
|
+
'sla.breached': 'bg-rose-500/10 text-rose-400',
|
|
65
|
+
'sla.warning': 'bg-amber-500/10 text-amber-400',
|
|
66
|
+
'chat.started': 'bg-teal-500/10 text-teal-400',
|
|
67
|
+
'chat.ended': 'bg-gray-500/10 text-gray-400',
|
|
58
68
|
};
|
|
59
69
|
return classes[trigger] || 'bg-gray-500/10 text-gray-400';
|
|
60
70
|
}
|
|
61
|
-
|
|
62
|
-
function triggerLabel(trigger) {
|
|
63
|
-
return (trigger || '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
64
|
-
}
|
|
65
71
|
</script>
|
|
66
72
|
|
|
67
73
|
<template>
|
|
@@ -192,21 +198,21 @@ function triggerLabel(trigger) {
|
|
|
192
198
|
<td class="px-4 py-3">
|
|
193
199
|
<span
|
|
194
200
|
class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
195
|
-
:class="triggerBadgeClass(workflow
|
|
201
|
+
:class="triggerBadgeClass(triggerOf(workflow))"
|
|
196
202
|
>
|
|
197
|
-
{{
|
|
203
|
+
{{ humanize(triggerOf(workflow)) }}
|
|
198
204
|
</span>
|
|
199
205
|
</td>
|
|
200
206
|
<td class="px-4 py-3">
|
|
201
207
|
<button
|
|
202
208
|
type="button"
|
|
203
209
|
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none"
|
|
204
|
-
:class="workflow
|
|
210
|
+
:class="isActive(workflow) ? 'bg-emerald-500' : 'bg-[var(--esc-panel-border)]'"
|
|
205
211
|
@click="toggleActive(workflow)"
|
|
206
212
|
>
|
|
207
213
|
<span
|
|
208
214
|
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
|
209
|
-
:class="workflow
|
|
215
|
+
:class="isActive(workflow) ? 'translate-x-4' : 'translate-x-0'"
|
|
210
216
|
/>
|
|
211
217
|
</button>
|
|
212
218
|
</td>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Workflows admin wire format, as fixed in escalated-developer-context
|
|
3
|
+
* `domain-model/workflow-admin-contract.md`.
|
|
4
|
+
*
|
|
5
|
+
* Every backend engine reads `trigger_event` with dotted names, conditions as
|
|
6
|
+
* `{all: [...]}` or `{any: [...]}`, and actions as `{type, value}`. The builder
|
|
7
|
+
* used to send `trigger: 'ticket_created'`, `{match, conditions}` and
|
|
8
|
+
* `{type, config}`, none of which any backend reads. The older shapes are still
|
|
9
|
+
* read here, so a workflow saved that way, or stored by Laravel as
|
|
10
|
+
* `{match, rules}`, opens in the builder and is saved back in the contract shape.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_TRIGGER_EVENTS = [
|
|
14
|
+
{ value: 'ticket.created', label: 'Ticket Created' },
|
|
15
|
+
{ value: 'ticket.updated', label: 'Ticket Updated' },
|
|
16
|
+
{ value: 'ticket.assigned', label: 'Ticket Assigned' },
|
|
17
|
+
{ value: 'ticket.status_changed', label: 'Status Changed' },
|
|
18
|
+
{ value: 'reply.created', label: 'Reply Added' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const CORE_ACTION_TYPES = [
|
|
22
|
+
{ value: 'change_status', label: 'Change Status' },
|
|
23
|
+
{ value: 'change_priority', label: 'Change Priority' },
|
|
24
|
+
{ value: 'add_tag', label: 'Add Tag' },
|
|
25
|
+
{ value: 'remove_tag', label: 'Remove Tag' },
|
|
26
|
+
{ value: 'set_department', label: 'Set Department' },
|
|
27
|
+
{ value: 'assign_agent', label: 'Assign Agent' },
|
|
28
|
+
{ value: 'add_note', label: 'Add Internal Note' },
|
|
29
|
+
{ value: 'insert_canned_reply', label: 'Insert Canned Reply' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_OPERATORS = [
|
|
33
|
+
'equals',
|
|
34
|
+
'not_equals',
|
|
35
|
+
'contains',
|
|
36
|
+
'not_contains',
|
|
37
|
+
'starts_with',
|
|
38
|
+
'ends_with',
|
|
39
|
+
'greater_than',
|
|
40
|
+
'less_than',
|
|
41
|
+
'greater_or_equal',
|
|
42
|
+
'less_or_equal',
|
|
43
|
+
'is_empty',
|
|
44
|
+
'is_not_empty',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const ACRONYMS = { sla: 'SLA' };
|
|
48
|
+
|
|
49
|
+
/** `ticket.status_changed` -> `Ticket Status Changed`. */
|
|
50
|
+
export function humanize(value) {
|
|
51
|
+
return String(value ?? '')
|
|
52
|
+
.split(/[._\s]+/)
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map((word) => ACRONYMS[word.toLowerCase()] ?? word.charAt(0).toUpperCase() + word.slice(1))
|
|
55
|
+
.join(' ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A backend option list in any of the shapes the contract allows, as
|
|
60
|
+
* `[{value, label}]`: a list of `{value, label}`, a list of plain values, or a
|
|
61
|
+
* value -> label map. An absent or empty list means `fallback`.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeOptions(input, fallback = []) {
|
|
64
|
+
const known = new Map(fallback.map((option) => [option.value, option.label]));
|
|
65
|
+
const labelFor = (value) => known.get(value) ?? humanize(value);
|
|
66
|
+
|
|
67
|
+
let options = [];
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(input)) {
|
|
70
|
+
options = input.map((item) =>
|
|
71
|
+
item !== null && typeof item === 'object'
|
|
72
|
+
? { value: item.value, label: item.label ?? labelFor(item.value) }
|
|
73
|
+
: { value: item, label: labelFor(item) },
|
|
74
|
+
);
|
|
75
|
+
} else if (input !== null && typeof input === 'object') {
|
|
76
|
+
options = Object.entries(input).map(([value, label]) => ({ value, label: label || labelFor(value) }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return options.length ? options : fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Stored conditions, in any shape, as the editor's `{match, conditions}`. */
|
|
83
|
+
export function toConditionState(conditions) {
|
|
84
|
+
if (Array.isArray(conditions)) {
|
|
85
|
+
return { match: 'all', conditions };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (conditions === null || typeof conditions !== 'object') {
|
|
89
|
+
return { match: 'all', conditions: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(conditions.all)) {
|
|
93
|
+
return { match: 'all', conditions: conditions.all };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (Array.isArray(conditions.any)) {
|
|
97
|
+
return { match: 'any', conditions: conditions.any };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const match = conditions.match === 'any' ? 'any' : 'all';
|
|
101
|
+
const list = conditions.rules ?? conditions.conditions;
|
|
102
|
+
|
|
103
|
+
return { match, conditions: Array.isArray(list) ? list : [] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The editor's `{match, conditions}` as the contract's `{all}` or `{any}`. */
|
|
107
|
+
export function toWireConditions(state) {
|
|
108
|
+
return { [state?.match === 'any' ? 'any' : 'all']: state?.conditions ?? [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The builder's old `{type, config}` kept the one value an action needs under
|
|
112
|
+
// one of these keys.
|
|
113
|
+
const LEGACY_CONFIG_KEYS = ['status', 'priority', 'tag', 'department', 'agent', 'body', 'url', 'duration', 'macro'];
|
|
114
|
+
|
|
115
|
+
/** Stored actions as `{type, value}`. */
|
|
116
|
+
export function toActionState(actions) {
|
|
117
|
+
if (!Array.isArray(actions)) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return actions.map((action) => {
|
|
122
|
+
if (Object.prototype.hasOwnProperty.call(action, 'value')) {
|
|
123
|
+
return { type: action.type, value: action.value };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const config = action.config ?? {};
|
|
127
|
+
const key = LEGACY_CONFIG_KEYS.find((candidate) => config[candidate] !== undefined && config[candidate] !== '');
|
|
128
|
+
|
|
129
|
+
return { type: action.type, value: key ? config[key] : '' };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The create/update request body. */
|
|
134
|
+
export function toWirePayload(form) {
|
|
135
|
+
return {
|
|
136
|
+
name: form.name,
|
|
137
|
+
description: form.description ? form.description : null,
|
|
138
|
+
trigger_event: form.trigger_event,
|
|
139
|
+
conditions: toWireConditions(form.conditions),
|
|
140
|
+
actions: (form.actions ?? []).map(({ type, value }) => ({ type, value: value ?? '' })),
|
|
141
|
+
is_active: Boolean(form.is_active),
|
|
142
|
+
};
|
|
143
|
+
}
|