@dev.smartpricing/message-composer-layer 4.5.1-next.2 → 4.5.1-next.4
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/README.md +132 -3
- package/app/api/automations.ts +74 -0
- package/app/api/index.ts +1 -0
- package/app/api/messageGroups.ts +5 -1
- package/app/app.config.ts +10 -0
- package/app/components/Automations/ActionCard.vue +174 -0
- package/app/components/Automations/ActionEditor.vue +398 -0
- package/app/components/Automations/Actions.vue +148 -0
- package/app/components/Automations/CloneModal.vue +78 -0
- package/app/components/Automations/Conditions.vue +155 -0
- package/app/components/Automations/Create.vue +114 -0
- package/app/components/Automations/Data.vue +46 -0
- package/app/components/Automations/DeleteModal.vue +80 -0
- package/app/components/Automations/Edit.vue +145 -0
- package/app/components/Automations/Form.vue +282 -0
- package/app/components/Automations/List.vue +370 -0
- package/app/components/Automations/PageGate.vue +50 -0
- package/app/components/Automations/SaveProblems.vue +54 -0
- package/app/components/Automations/Unavailable.vue +71 -0
- package/app/components/Automations/UnavailableBadge.vue +35 -0
- package/app/components/Common/MessageGroupSelect.vue +30 -1
- package/app/components/Composer/EmailComposer.vue +5 -1
- package/app/components/Composer/WhatsappComposer.vue +5 -1
- package/app/components/Filters/DateInput.vue +57 -0
- package/app/components/Filters/Editor.vue +163 -0
- package/app/components/Filters/Row.vue +119 -0
- package/app/components/Filters/Summary.vue +215 -0
- package/app/components/Filters/UnavailableRow.vue +74 -0
- package/app/components/Filters/ValueInput.vue +215 -0
- package/app/components/Layout/BasePage.vue +2 -0
- package/app/components/TemplateList/BaseTable.vue +0 -11
- package/app/composables/apiErrorMessages.ts +1 -0
- package/app/composables/automationsContext.ts +42 -0
- package/app/composables/composerContext.ts +0 -6
- package/app/composables/useAutomationClone.ts +33 -0
- package/app/composables/useAutomationEditorTrip.ts +77 -0
- package/app/composables/useAutomationSaveProblems.ts +82 -0
- package/app/composables/useAutomationUnavailable.ts +98 -0
- package/app/composables/useAutomationsGate.ts +68 -0
- package/app/composables/useBuiltInOptionLists.ts +44 -0
- package/app/composables/useEditorTripBack.ts +15 -0
- package/app/composables/useMessageGroupWaba.ts +4 -7
- package/app/composables/useTemplateOperations.ts +8 -3
- package/app/middleware/automations-enabled.ts +12 -0
- package/app/pages/automations/[id].vue +87 -0
- package/app/pages/automations/index.vue +28 -0
- package/app/pages/automations/new.vue +53 -0
- package/app/pages/template/email/[id].vue +5 -0
- package/app/pages/template/email/create.vue +5 -0
- package/app/pages/template/whatsapp/[id].vue +5 -0
- package/app/pages/template/whatsapp/create.vue +5 -0
- package/app/queries/automations.ts +206 -0
- package/app/queries/index.ts +1 -0
- package/app/queries/messageGroups.ts +1 -3
- package/app/stores/appContext.ts +0 -3
- package/app/stores/editorTrip.ts +67 -0
- package/app/types/app-config.d.ts +3 -0
- package/app/types/tracking.ts +33 -0
- package/app/utils/automations/form.ts +389 -0
- package/app/utils/automations/metadata.ts +71 -0
- package/app/utils/automations/unavailable.ts +90 -0
- package/app/utils/filters/data/countries.json +998 -0
- package/app/utils/filters/data/languages.json +754 -0
- package/app/utils/filters/rows.ts +367 -0
- package/app/utils/testIds.const.ts +107 -1
- package/i18n/locales/de.ts +269 -1
- package/i18n/locales/en.ts +255 -1
- package/i18n/locales/es.ts +258 -1
- package/i18n/locales/fr.ts +264 -1
- package/i18n/locales/it.ts +260 -1
- package/package.json +4 -4
- package/app.config.ts +0 -5
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
AutomationChannel,
|
|
4
|
+
AutomationTrigger,
|
|
5
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
6
|
+
import { useMessageGroupApprovalsQuery } from '~/queries'
|
|
7
|
+
import {
|
|
8
|
+
AUTOMATION_RECURRENCES,
|
|
9
|
+
cloneFormData,
|
|
10
|
+
dateReferenceValue,
|
|
11
|
+
localSendingTime,
|
|
12
|
+
parseDateReferenceValue,
|
|
13
|
+
type AutomationFormAction,
|
|
14
|
+
type AutomationRecurrence,
|
|
15
|
+
} from '../../utils/automations/form'
|
|
16
|
+
import type { AutomationUnavailableKind } from '../../utils/automations/unavailable'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The slideover that adds or edits one Action: its Channel, its Message Group
|
|
20
|
+
* and its Schedule. It works on a copy, handed back on Save only.
|
|
21
|
+
*/
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
/** The Action to start from; the slideover is open while there is one. */
|
|
24
|
+
action: AutomationFormAction | undefined
|
|
25
|
+
/** Whether `action` is one the form does not hold yet. */
|
|
26
|
+
isNew: boolean
|
|
27
|
+
trigger: AutomationTrigger
|
|
28
|
+
/** The Channels an Action can be on here. */
|
|
29
|
+
channels: AutomationChannel[]
|
|
30
|
+
/** The Channels the Organization cannot use. */
|
|
31
|
+
unusableChannels: AutomationChannel[]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
save: [action: AutomationFormAction]
|
|
36
|
+
close: []
|
|
37
|
+
/** The user leaves for the Message editor, with the Action as edited so far. */
|
|
38
|
+
editMessage: [request: { action: AutomationFormAction; target: 'create' | 'edit' }]
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const { t } = useI18n()
|
|
42
|
+
|
|
43
|
+
const edited = ref<AutomationFormAction>()
|
|
44
|
+
|
|
45
|
+
watch(
|
|
46
|
+
() => props.action,
|
|
47
|
+
(action) => {
|
|
48
|
+
edited.value = action ? cloneFormData(action) : undefined
|
|
49
|
+
},
|
|
50
|
+
{ immediate: true },
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const open = computed({
|
|
54
|
+
get: () => Boolean(props.action),
|
|
55
|
+
set: (isOpen) => {
|
|
56
|
+
if (!isOpen) emit('close')
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const { data: approvals } = useMessageGroupApprovalsQuery(() =>
|
|
61
|
+
edited.value ? [edited.value.messageGroupId] : [],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const unavailable = useAutomationUnavailable()
|
|
65
|
+
|
|
66
|
+
/** What the Action being edited holds that is no longer available. */
|
|
67
|
+
const unavailableItems = computed(() =>
|
|
68
|
+
edited.value
|
|
69
|
+
? unavailable.actionItems(edited.value, {
|
|
70
|
+
trigger: props.trigger,
|
|
71
|
+
unusableChannels: props.unusableChannels,
|
|
72
|
+
approvals: approvals.value,
|
|
73
|
+
})
|
|
74
|
+
: [],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
function unavailableOf(...kinds: AutomationUnavailableKind[]) {
|
|
78
|
+
return unavailableItems.value.find((item) => kinds.includes(item.kind))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const unusableChannel = computed(() => unavailableOf('channel'))
|
|
82
|
+
const unlistedDateReference = computed(() => unavailableOf('date_reference'))
|
|
83
|
+
const unapproved = computed(() => unavailableOf('message_group_inactive', 'message_group_active'))
|
|
84
|
+
|
|
85
|
+
/** The Action's own Channel stays in the list when it cannot be picked here, as a disabled option. */
|
|
86
|
+
const channelOptions = computed(() => {
|
|
87
|
+
const options: { value: AutomationChannel; label: string; disabled?: boolean }[] =
|
|
88
|
+
props.channels.map((channel) => ({
|
|
89
|
+
value: channel,
|
|
90
|
+
label: t(`automations.channels.${channel}`),
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
const own = edited.value?.channel
|
|
94
|
+
if (own && !props.channels.includes(own)) {
|
|
95
|
+
const channel = t(`automations.channels.${own}`)
|
|
96
|
+
options.unshift({
|
|
97
|
+
value: own,
|
|
98
|
+
label: props.unusableChannels.includes(own)
|
|
99
|
+
? t('automations.unavailable.channel_option', { channel })
|
|
100
|
+
: channel,
|
|
101
|
+
disabled: true,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return options
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function setChannel(channel: AutomationChannel) {
|
|
108
|
+
if (!edited.value || channel === edited.value.channel) return
|
|
109
|
+
edited.value.channel = channel
|
|
110
|
+
// A Message Group is of one Channel.
|
|
111
|
+
edited.value.messageGroupId = ''
|
|
112
|
+
edited.value.messageGroupName = ''
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A stored Date Reference the Trigger no longer lists shows as it is, and cannot be picked. */
|
|
116
|
+
const dateReferenceOptions = computed(() => [
|
|
117
|
+
...(edited.value && unlistedDateReference.value
|
|
118
|
+
? [
|
|
119
|
+
{
|
|
120
|
+
value: dateReferenceValue(edited.value),
|
|
121
|
+
label: edited.value.dateReference,
|
|
122
|
+
disabled: true,
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
: []),
|
|
126
|
+
...props.trigger.date_references.flatMap((reference) =>
|
|
127
|
+
reference.directions.map(({ direction, label }) => ({
|
|
128
|
+
value: dateReferenceValue({ direction, dateReference: reference.key }),
|
|
129
|
+
label,
|
|
130
|
+
})),
|
|
131
|
+
),
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
function setDateReference(value: string) {
|
|
135
|
+
if (edited.value) Object.assign(edited.value, parseDateReferenceValue(value))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Every hour of the day, by its UTC value and with the user's clock as label.
|
|
139
|
+
const sendingTimeOptions = Array.from({ length: 24 }, (_, hour) => {
|
|
140
|
+
const value = `${String(hour).padStart(2, '0')}:00:00Z`
|
|
141
|
+
return { value, label: localSendingTime(value) }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const NO_RECURRENCE = 'none'
|
|
145
|
+
|
|
146
|
+
const recurrenceOptions = computed(() => [
|
|
147
|
+
{ value: NO_RECURRENCE, label: t('automations.recurrence.none') },
|
|
148
|
+
...AUTOMATION_RECURRENCES.map((recurrence) => ({
|
|
149
|
+
value: recurrence,
|
|
150
|
+
label: t(`automations.recurrence.${recurrence}`),
|
|
151
|
+
})),
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
// A recurrence and a strict delay exclude each other.
|
|
155
|
+
function setRecurrence(value: string) {
|
|
156
|
+
if (!edited.value) return
|
|
157
|
+
edited.value.recurrence = value === NO_RECURRENCE ? undefined : (value as AutomationRecurrence)
|
|
158
|
+
if (edited.value.recurrence) edited.value.strictDelay = false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function setStrictDelay(strict: boolean) {
|
|
162
|
+
if (!edited.value) return
|
|
163
|
+
edited.value.strictDelay = strict
|
|
164
|
+
if (strict) edited.value.recurrence = undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const advancedOpen = ref(false)
|
|
168
|
+
|
|
169
|
+
// Immediate: an Action reopened after an editor trip is there from the start.
|
|
170
|
+
watch(
|
|
171
|
+
open,
|
|
172
|
+
(isOpen) => {
|
|
173
|
+
if (!isOpen || !edited.value) return
|
|
174
|
+
const { recurrence, strictDelay, preventDuplicates } = edited.value
|
|
175
|
+
advancedOpen.value = Boolean(recurrence) || strictDelay || !preventDuplicates
|
|
176
|
+
},
|
|
177
|
+
{ immediate: true },
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
function editMessage(target: 'create' | 'edit') {
|
|
181
|
+
if (edited.value) emit('editMessage', { action: edited.value, target })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function save() {
|
|
185
|
+
if (!edited.value?.messageGroupId) return
|
|
186
|
+
// An emptied number input holds no number.
|
|
187
|
+
const days = Math.max(0, Math.round(Number(edited.value.days) || 0))
|
|
188
|
+
emit('save', { ...edited.value, days })
|
|
189
|
+
}
|
|
190
|
+
</script>
|
|
191
|
+
|
|
192
|
+
<template>
|
|
193
|
+
<USlideover
|
|
194
|
+
v-model:open="open"
|
|
195
|
+
close-icon="ph:x"
|
|
196
|
+
:close="{ 'data-testid': automationsTestIds.channelSlideoverCloseButton }"
|
|
197
|
+
:title="isNew ? t('automations.channel.addTitle') : t('automations.channel.editTitle')"
|
|
198
|
+
:ui="{ content: 'max-w-4xl', body: 'p-0 sm:p-0', footer: 'justify-end' }"
|
|
199
|
+
:content="dialogTestId(automationsTestIds.channelSlideover)"
|
|
200
|
+
>
|
|
201
|
+
<template #body>
|
|
202
|
+
<div v-if="edited" class="grid grid-cols-2 h-full divide-muted divide-x">
|
|
203
|
+
<div class="space-y-5 py-6">
|
|
204
|
+
<div v-if="unavailableItems.length > 0" class="px-6">
|
|
205
|
+
<AutomationsUnavailable :items="unavailableItems" in-place />
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<UFormField class="px-6" :label="t('automations.channel.channelType')">
|
|
209
|
+
<template v-if="unusableChannel" #hint>
|
|
210
|
+
<AutomationsUnavailableBadge :item="unusableChannel" />
|
|
211
|
+
</template>
|
|
212
|
+
<USelect
|
|
213
|
+
:model-value="edited.channel"
|
|
214
|
+
:items="channelOptions"
|
|
215
|
+
class="w-full"
|
|
216
|
+
:data-testid="automationsTestIds.channelTypeSelect"
|
|
217
|
+
@update:model-value="setChannel"
|
|
218
|
+
/>
|
|
219
|
+
</UFormField>
|
|
220
|
+
|
|
221
|
+
<USeparator />
|
|
222
|
+
|
|
223
|
+
<UFormField
|
|
224
|
+
class="px-6"
|
|
225
|
+
:label="t('automations.channel.selectMessage')"
|
|
226
|
+
:description="t('automations.channel.selectMessageHint')"
|
|
227
|
+
>
|
|
228
|
+
<template v-if="unapproved" #hint>
|
|
229
|
+
<AutomationsUnavailableBadge :item="unapproved" />
|
|
230
|
+
</template>
|
|
231
|
+
<!-- Approved only: an Action is Active from the start. -->
|
|
232
|
+
<CommonMessageGroupSelect
|
|
233
|
+
:key="edited.channel"
|
|
234
|
+
v-model="edited.messageGroupId"
|
|
235
|
+
:render-type="edited.channel === 'whatsapp' ? 'whatsapp_v1' : 'email_v1'"
|
|
236
|
+
:placeholder="t('automations.channel.selectMessagePlaceholder')"
|
|
237
|
+
:search-placeholder="t('automations.channel.searchMessagePlaceholder')"
|
|
238
|
+
@select="edited.messageGroupName = $event.name"
|
|
239
|
+
/>
|
|
240
|
+
|
|
241
|
+
<!-- Not on a Channel that cannot be used here: its editor would turn the user away. -->
|
|
242
|
+
<p
|
|
243
|
+
v-if="channels.includes(edited.channel)"
|
|
244
|
+
class="paragraph-sm text-muted mt-2 flex items-center gap-1"
|
|
245
|
+
>
|
|
246
|
+
{{ t('automations.channel.cannotFindMessage') }}
|
|
247
|
+
<UButton
|
|
248
|
+
variant="link"
|
|
249
|
+
size="sm"
|
|
250
|
+
color="secondary"
|
|
251
|
+
:label="t('automations.channel.createNew')"
|
|
252
|
+
:data-testid="automationsTestIds.channelCreateNewButton"
|
|
253
|
+
@click="editMessage('create')"
|
|
254
|
+
/>
|
|
255
|
+
<UButton
|
|
256
|
+
v-if="edited.messageGroupId"
|
|
257
|
+
variant="link"
|
|
258
|
+
size="sm"
|
|
259
|
+
color="secondary"
|
|
260
|
+
:label="t('common.actions.edit')"
|
|
261
|
+
:data-testid="automationsTestIds.channelEditTemplateButton"
|
|
262
|
+
@click="editMessage('edit')"
|
|
263
|
+
/>
|
|
264
|
+
</p>
|
|
265
|
+
</UFormField>
|
|
266
|
+
|
|
267
|
+
<USeparator />
|
|
268
|
+
|
|
269
|
+
<UFormField
|
|
270
|
+
class="px-6"
|
|
271
|
+
:label="t('automations.channel.when')"
|
|
272
|
+
:description="t('automations.channel.whenHint')"
|
|
273
|
+
>
|
|
274
|
+
<template v-if="unlistedDateReference" #hint>
|
|
275
|
+
<AutomationsUnavailableBadge :item="unlistedDateReference" />
|
|
276
|
+
</template>
|
|
277
|
+
<div class="flex items-center gap-2">
|
|
278
|
+
<UFieldGroup>
|
|
279
|
+
<UInputNumber
|
|
280
|
+
v-model="edited.days"
|
|
281
|
+
:min="0"
|
|
282
|
+
:decrement="false"
|
|
283
|
+
:increment="false"
|
|
284
|
+
:data-testid="automationsTestIds.channelDaysInput"
|
|
285
|
+
/>
|
|
286
|
+
<UBadge color="neutral" variant="outline" size="sm">
|
|
287
|
+
{{ t('automations.channel.chooseDays') }}
|
|
288
|
+
</UBadge>
|
|
289
|
+
</UFieldGroup>
|
|
290
|
+
|
|
291
|
+
<USelect
|
|
292
|
+
:model-value="dateReferenceValue(edited)"
|
|
293
|
+
:items="dateReferenceOptions"
|
|
294
|
+
class="w-full"
|
|
295
|
+
:data-testid="automationsTestIds.channelDateRefSelect"
|
|
296
|
+
@update:model-value="setDateReference"
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
</UFormField>
|
|
300
|
+
|
|
301
|
+
<UFormField class="w-1/2 px-6" :label="t('automations.channel.sendingTime')">
|
|
302
|
+
<USelect
|
|
303
|
+
v-model="edited.sendingTimeUTC"
|
|
304
|
+
:items="sendingTimeOptions"
|
|
305
|
+
class="w-full"
|
|
306
|
+
:data-testid="automationsTestIds.channelSendingTimeSelect"
|
|
307
|
+
/>
|
|
308
|
+
</UFormField>
|
|
309
|
+
|
|
310
|
+
<USeparator />
|
|
311
|
+
|
|
312
|
+
<UCollapsible
|
|
313
|
+
v-model:open="advancedOpen"
|
|
314
|
+
class="px-6"
|
|
315
|
+
:data-testid="automationsTestIds.channelAdvancedCollapse"
|
|
316
|
+
>
|
|
317
|
+
<UButton
|
|
318
|
+
:label="t('automations.advanced')"
|
|
319
|
+
color="neutral"
|
|
320
|
+
variant="ghost"
|
|
321
|
+
size="sm"
|
|
322
|
+
:trailing-icon="advancedOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'"
|
|
323
|
+
block
|
|
324
|
+
:data-testid="automationsTestIds.channelAdvancedCollapseButton"
|
|
325
|
+
/>
|
|
326
|
+
|
|
327
|
+
<template #content>
|
|
328
|
+
<div class="space-y-4 pt-4">
|
|
329
|
+
<UFormField :label="t('automations.recurrence.label')">
|
|
330
|
+
<USelect
|
|
331
|
+
:model-value="edited.recurrence ?? NO_RECURRENCE"
|
|
332
|
+
:items="recurrenceOptions"
|
|
333
|
+
class="w-full"
|
|
334
|
+
:disabled="edited.strictDelay"
|
|
335
|
+
:data-testid="automationsTestIds.channelRecurrenceSelect"
|
|
336
|
+
@update:model-value="setRecurrence"
|
|
337
|
+
/>
|
|
338
|
+
</UFormField>
|
|
339
|
+
|
|
340
|
+
<USwitch
|
|
341
|
+
:model-value="edited.strictDelay"
|
|
342
|
+
:label="t('automations.strictDelay.label')"
|
|
343
|
+
:description="t('automations.strictDelay.hint')"
|
|
344
|
+
:data-testid="automationsTestIds.channelStrictDelaySwitch"
|
|
345
|
+
@update:model-value="setStrictDelay"
|
|
346
|
+
/>
|
|
347
|
+
|
|
348
|
+
<USwitch
|
|
349
|
+
v-model="edited.preventDuplicates"
|
|
350
|
+
:label="t('automations.preventDuplicates.label')"
|
|
351
|
+
:description="t('automations.preventDuplicates.hint')"
|
|
352
|
+
:data-testid="automationsTestIds.channelPreventDuplicatesSwitch"
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
355
|
+
</template>
|
|
356
|
+
</UCollapsible>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<div class="flex flex-col h-full p-6">
|
|
360
|
+
<p class="label-lg mb-3">{{ t('automations.channel.preview') }}</p>
|
|
361
|
+
|
|
362
|
+
<div class="flex-1" :data-testid="automationsTestIds.channelPreview">
|
|
363
|
+
<CommonPreviewById
|
|
364
|
+
v-if="edited.messageGroupId"
|
|
365
|
+
:id="edited.messageGroupId"
|
|
366
|
+
class="w-full h-full min-h-[400px]"
|
|
367
|
+
show-subject
|
|
368
|
+
/>
|
|
369
|
+
<UEmpty
|
|
370
|
+
v-else
|
|
371
|
+
variant="naked"
|
|
372
|
+
icon="ph:image-square"
|
|
373
|
+
:title="t('automations.channel.no_message.title')"
|
|
374
|
+
:description="t('automations.channel.no_message.description')"
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</template>
|
|
380
|
+
|
|
381
|
+
<template #footer>
|
|
382
|
+
<UButton
|
|
383
|
+
color="neutral"
|
|
384
|
+
variant="outline"
|
|
385
|
+
:label="t('common.actions.cancel')"
|
|
386
|
+
:data-testid="automationsTestIds.channelSlideoverCancel"
|
|
387
|
+
@click="open = false"
|
|
388
|
+
/>
|
|
389
|
+
<UButton
|
|
390
|
+
color="primary"
|
|
391
|
+
:label="t('common.actions.save')"
|
|
392
|
+
:disabled="!edited?.messageGroupId"
|
|
393
|
+
:data-testid="automationsTestIds.channelSlideoverSave"
|
|
394
|
+
@click="save"
|
|
395
|
+
/>
|
|
396
|
+
</template>
|
|
397
|
+
</USlideover>
|
|
398
|
+
</template>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
AutomationChannel,
|
|
4
|
+
AutomationTrigger,
|
|
5
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
6
|
+
import type {
|
|
7
|
+
AutomationMessageRequest,
|
|
8
|
+
OpenAutomationAction,
|
|
9
|
+
} from '~/composables/useAutomationEditorTrip'
|
|
10
|
+
import { automationActionElementId } from '~/composables/useAutomationUnavailable'
|
|
11
|
+
import { newAutomationFormAction, type AutomationFormAction } from '../../utils/automations/form'
|
|
12
|
+
import type { AutomationUnavailableItem } from '../../utils/automations/unavailable'
|
|
13
|
+
|
|
14
|
+
/** The Actions of an Automation: their cards, "add", and the editor of one. */
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
trigger: AutomationTrigger
|
|
17
|
+
/** The Channels an Action can be on here. */
|
|
18
|
+
channels: AutomationChannel[]
|
|
19
|
+
/** The Channels the Organization cannot use: an Action on one is kept, and not sent. */
|
|
20
|
+
unusableChannels: AutomationChannel[]
|
|
21
|
+
/** What each Action holds that is no longer available, by the Action's key. */
|
|
22
|
+
unavailable: Map<string, AutomationUnavailableItem[]>
|
|
23
|
+
/** The keys of the Actions a refused save pointed at. */
|
|
24
|
+
invalidKeys?: string[]
|
|
25
|
+
/** The form was submitted: having no Action shows as an error. */
|
|
26
|
+
submitted?: boolean
|
|
27
|
+
/** The Action the editor starts open on: the one an editor trip left from. */
|
|
28
|
+
openAction?: OpenAutomationAction
|
|
29
|
+
/** No Action is added, edited or removed: only the Active switch works. */
|
|
30
|
+
readOnly?: boolean
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
/** The user leaves for the Message editor from the Action in the editor. */
|
|
35
|
+
editMessage: [request: AutomationMessageRequest]
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
const actions = defineModel<AutomationFormAction[]>({ required: true })
|
|
39
|
+
|
|
40
|
+
const { t } = useI18n()
|
|
41
|
+
const { trackEvent } = useTracking()
|
|
42
|
+
const { confirm } = useConfirm()
|
|
43
|
+
|
|
44
|
+
/** The Action in the editor: a copy of one of the form's, or one not added yet. */
|
|
45
|
+
const editing = ref<AutomationFormAction | undefined>(props.openAction?.action)
|
|
46
|
+
const editingIsNew = ref(props.openAction?.isNew ?? false)
|
|
47
|
+
|
|
48
|
+
function add() {
|
|
49
|
+
const [channel] = props.channels
|
|
50
|
+
if (!channel) return
|
|
51
|
+
editingIsNew.value = true
|
|
52
|
+
editing.value = newAutomationFormAction(props.trigger, channel)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveEdited(edited: AutomationFormAction) {
|
|
56
|
+
const index = actions.value.findIndex((action) => action.key === edited.key)
|
|
57
|
+
if (index === -1) {
|
|
58
|
+
actions.value = [...actions.value, edited]
|
|
59
|
+
trackEvent('automation.action_added', { channel: edited.channel })
|
|
60
|
+
} else {
|
|
61
|
+
actions.value = actions.value.with(index, edited)
|
|
62
|
+
}
|
|
63
|
+
editing.value = undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toggle(action: AutomationFormAction, active: boolean) {
|
|
67
|
+
const status = active ? 'active' : 'inactive'
|
|
68
|
+
trackEvent('automation.action_toggled', { channel: action.channel, new_status: status })
|
|
69
|
+
actions.value = actions.value.map((current) =>
|
|
70
|
+
current.key === action.key ? { ...current, status } : current,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function edit(action: AutomationFormAction) {
|
|
75
|
+
editingIsNew.value = false
|
|
76
|
+
editing.value = action
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function remove(action: AutomationFormAction) {
|
|
80
|
+
const confirmed = await confirm({
|
|
81
|
+
title: t('automations.form.actions.deleteConfirm'),
|
|
82
|
+
message: t('automations.form.actions.deleteConfirmDescription'),
|
|
83
|
+
confirmProps: { label: t('common.actions.delete') },
|
|
84
|
+
cancelProps: { label: t('common.actions.cancel') },
|
|
85
|
+
destructive: true,
|
|
86
|
+
})
|
|
87
|
+
if (!confirmed) return
|
|
88
|
+
|
|
89
|
+
trackEvent('automation.action_deleted', { channel: action.channel })
|
|
90
|
+
actions.value = actions.value.filter((current) => current.key !== action.key)
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<UFormField
|
|
96
|
+
:label="t('automations.form.messages')"
|
|
97
|
+
:error="submitted && actions.length === 0 ? t('automations.form.required') : undefined"
|
|
98
|
+
>
|
|
99
|
+
<div class="space-y-4">
|
|
100
|
+
<p
|
|
101
|
+
v-if="actions.length === 0"
|
|
102
|
+
class="paragraph-sm text-muted"
|
|
103
|
+
:data-testid="automationsTestIds.actionsEmpty"
|
|
104
|
+
>
|
|
105
|
+
{{ t('automations.form.actions.empty') }}
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
<div v-else class="space-y-3">
|
|
109
|
+
<AutomationsActionCard
|
|
110
|
+
v-for="action in actions"
|
|
111
|
+
:id="automationActionElementId(action.key)"
|
|
112
|
+
:key="action.key"
|
|
113
|
+
:action="action"
|
|
114
|
+
:trigger="trigger"
|
|
115
|
+
:unavailable="unavailable.get(action.key) ?? []"
|
|
116
|
+
:invalid="invalidKeys?.includes(action.key)"
|
|
117
|
+
:read-only="readOnly"
|
|
118
|
+
:data-testid="`${automationsTestIds.actionCard}-${action.key}`"
|
|
119
|
+
:data-testvalue="action.key"
|
|
120
|
+
@edit="edit(action)"
|
|
121
|
+
@remove="remove(action)"
|
|
122
|
+
@toggle="toggle(action, $event)"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<UButton
|
|
127
|
+
v-if="!readOnly"
|
|
128
|
+
color="primary"
|
|
129
|
+
variant="outline"
|
|
130
|
+
:label="t('automations.actions.addChannel')"
|
|
131
|
+
:disabled="channels.length === 0"
|
|
132
|
+
:data-testid="automationsTestIds.addChannelButton"
|
|
133
|
+
@click="add"
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<AutomationsActionEditor
|
|
138
|
+
:action="editing"
|
|
139
|
+
:is-new="editingIsNew"
|
|
140
|
+
:trigger="trigger"
|
|
141
|
+
:channels="channels"
|
|
142
|
+
:unusable-channels="unusableChannels"
|
|
143
|
+
@save="saveEdited"
|
|
144
|
+
@close="editing = undefined"
|
|
145
|
+
@edit-message="emit('editMessage', { ...$event, isNew: editingIsNew })"
|
|
146
|
+
/>
|
|
147
|
+
</UFormField>
|
|
148
|
+
</template>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Asks the name of the copy before an Automation is duplicated. Opened
|
|
4
|
+
* through `useOverlay`; `action` makes the copy while the modal shows it
|
|
5
|
+
* running, and the modal closes once it is made.
|
|
6
|
+
*/
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
defaultName: string
|
|
9
|
+
action: (name: string) => Promise<void>
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{ close: [] }>()
|
|
13
|
+
|
|
14
|
+
const { t } = useI18n()
|
|
15
|
+
|
|
16
|
+
const name = ref(props.defaultName)
|
|
17
|
+
const trimmedName = computed(() => name.value.trim())
|
|
18
|
+
const cloning = ref(false)
|
|
19
|
+
|
|
20
|
+
async function confirmClone() {
|
|
21
|
+
if (!trimmedName.value || cloning.value) return
|
|
22
|
+
|
|
23
|
+
cloning.value = true
|
|
24
|
+
try {
|
|
25
|
+
await props.action(trimmedName.value)
|
|
26
|
+
emit('close')
|
|
27
|
+
} catch {
|
|
28
|
+
// Already said by the request's own error toast; the modal stays open
|
|
29
|
+
// with the name, for another try.
|
|
30
|
+
} finally {
|
|
31
|
+
cloning.value = false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<UModal
|
|
38
|
+
:title="t('automations.clone.title')"
|
|
39
|
+
close-icon="ph:x"
|
|
40
|
+
:close="{ disabled: cloning, onClick: () => emit('close') }"
|
|
41
|
+
:dismissible="false"
|
|
42
|
+
:content="dialogTestId(automationsTestIds.cloneModal)"
|
|
43
|
+
>
|
|
44
|
+
<template #body>
|
|
45
|
+
<UFormField :label="t('automations.form.name')">
|
|
46
|
+
<UInput
|
|
47
|
+
v-model="name"
|
|
48
|
+
:placeholder="t('automations.clone.namePlaceholder')"
|
|
49
|
+
:disabled="cloning"
|
|
50
|
+
autofocus
|
|
51
|
+
class="w-full"
|
|
52
|
+
:data-testid="automationsTestIds.cloneModalNameInput"
|
|
53
|
+
@keyup.enter="confirmClone"
|
|
54
|
+
/>
|
|
55
|
+
</UFormField>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<template #footer>
|
|
59
|
+
<div class="flex gap-2 justify-end w-full">
|
|
60
|
+
<UButton
|
|
61
|
+
variant="outline"
|
|
62
|
+
color="neutral"
|
|
63
|
+
:label="t('common.actions.cancel')"
|
|
64
|
+
:disabled="cloning"
|
|
65
|
+
:data-testid="automationsTestIds.cloneModalCancel"
|
|
66
|
+
@click="emit('close')"
|
|
67
|
+
/>
|
|
68
|
+
<UButton
|
|
69
|
+
:label="t('common.actions.duplicate')"
|
|
70
|
+
:loading="cloning"
|
|
71
|
+
:disabled="!trimmedName"
|
|
72
|
+
:data-testid="automationsTestIds.cloneModalConfirm"
|
|
73
|
+
@click="confirmClone"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
</UModal>
|
|
78
|
+
</template>
|