@dev.smartpricing/message-composer-layer 4.5.1-next.3 → 5.0.0-automations.0
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 +133 -4
- 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 +163 -0
- package/app/components/Automations/ActionEditor.vue +376 -0
- package/app/components/Automations/Actions.vue +141 -0
- package/app/components/Automations/CloneModal.vue +74 -0
- package/app/components/Automations/Conditions.vue +146 -0
- package/app/components/Automations/Create.vue +106 -0
- package/app/components/Automations/Data.vue +46 -0
- package/app/components/Automations/DeleteModal.vue +68 -0
- package/app/components/Automations/Edit.vue +135 -0
- package/app/components/Automations/Form.vue +279 -0
- package/app/components/Automations/List.vue +343 -0
- package/app/components/Automations/PageGate.vue +50 -0
- package/app/components/Automations/SaveProblems.vue +52 -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/WabaSelectModal.vue +3 -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 +1 -22
- package/app/composables/automationsContext.ts +42 -0
- package/app/composables/composerContext.ts +3 -3
- 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 +7 -10
- package/app/composables/useTemplateOperations.ts +8 -3
- package/app/middleware/automations-enabled.ts +12 -0
- package/app/pages/automations/[id].vue +85 -0
- package/app/pages/automations/index.vue +27 -0
- package/app/pages/automations/new.vue +51 -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 +1 -1
- 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 +368 -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 +0 -1
- package/i18n/locales/de.ts +268 -1
- package/i18n/locales/en.ts +254 -1
- package/i18n/locales/es.ts +257 -1
- package/i18n/locales/fr.ts +263 -1
- package/i18n/locales/it.ts +259 -1
- package/package.json +4 -4
- package/app.config.ts +0 -5
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
AUTOMATION_CHANNELS,
|
|
4
|
+
type AutomationCatalog,
|
|
5
|
+
type AutomationChannel,
|
|
6
|
+
type FiltersReport,
|
|
7
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
8
|
+
import type { AutomationsContext } from '~/composables/automationsContext'
|
|
9
|
+
import type {
|
|
10
|
+
AutomationMessageRequest,
|
|
11
|
+
OpenAutomationAction,
|
|
12
|
+
} from '~/composables/useAutomationEditorTrip'
|
|
13
|
+
import {
|
|
14
|
+
AUTOMATION_CONDITIONS_ELEMENT_ID,
|
|
15
|
+
AUTOMATION_TRIGGER_ELEMENT_ID,
|
|
16
|
+
} from '~/composables/useAutomationUnavailable'
|
|
17
|
+
import { useMessageGroupApprovalsQuery } from '~/queries'
|
|
18
|
+
import { cloneFormData, type AutomationFormState } from '../../utils/automations/form'
|
|
19
|
+
import type { AutomationMetadataForm } from '../../utils/automations/metadata'
|
|
20
|
+
import type {
|
|
21
|
+
ActionAvailability,
|
|
22
|
+
AutomationUnavailableItem,
|
|
23
|
+
} from '../../utils/automations/unavailable'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The fields of an Automation: its name, its Trigger, its Conditions and its
|
|
27
|
+
* Actions. Everything a Trigger offers comes from the Automation Catalog, and
|
|
28
|
+
* every option list from the Host's context (ADR 0017).
|
|
29
|
+
*/
|
|
30
|
+
const props = defineProps<{
|
|
31
|
+
catalog: AutomationCatalog
|
|
32
|
+
context: AutomationsContext
|
|
33
|
+
/** The form was submitted: what is missing shows on its field. */
|
|
34
|
+
submitted?: boolean
|
|
35
|
+
/** The keys of the Actions a refused save pointed at. */
|
|
36
|
+
invalidActionKeys?: string[]
|
|
37
|
+
/** The Action whose editor starts open: the one an editor trip left from. */
|
|
38
|
+
openAction?: OpenAutomationAction
|
|
39
|
+
/** The values the Automation asks for, when it asks for any. */
|
|
40
|
+
dataForm?: AutomationMetadataForm
|
|
41
|
+
/**
|
|
42
|
+
* A System Automation: only the Active switch of its Actions and the values
|
|
43
|
+
* it asks for can be changed (ADR 0016).
|
|
44
|
+
*/
|
|
45
|
+
system?: boolean
|
|
46
|
+
}>()
|
|
47
|
+
|
|
48
|
+
const emit = defineEmits<{
|
|
49
|
+
/** The user leaves for the Message editor from an Action. */
|
|
50
|
+
editMessage: [request: AutomationMessageRequest]
|
|
51
|
+
}>()
|
|
52
|
+
|
|
53
|
+
const form = defineModel<AutomationFormState>({ required: true })
|
|
54
|
+
|
|
55
|
+
const { t } = useI18n()
|
|
56
|
+
const { trackEvent } = useTracking()
|
|
57
|
+
const { confirm } = useConfirm()
|
|
58
|
+
|
|
59
|
+
const trigger = computed(() =>
|
|
60
|
+
props.catalog.triggers.find(({ type }) => type === form.value.triggerType),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
/** A stored Trigger the Automation Catalog no longer lists shows as it is, and cannot be picked. */
|
|
64
|
+
const triggerOptions = computed(() => [
|
|
65
|
+
...(trigger.value
|
|
66
|
+
? []
|
|
67
|
+
: [{ value: form.value.triggerType, label: form.value.triggerType, disabled: true }]),
|
|
68
|
+
...props.catalog.triggers.map(({ type, label }) => ({ value: type, label })),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A Trigger change starts over from the new Trigger's default Conditions
|
|
73
|
+
* (ADR 0017), and without Actions: their Date References are the old
|
|
74
|
+
* Trigger's. The user is asked first when that loses something.
|
|
75
|
+
*/
|
|
76
|
+
async function changeTrigger(type: string) {
|
|
77
|
+
const next = props.catalog.triggers.find((candidate) => candidate.type === type)
|
|
78
|
+
const current = trigger.value
|
|
79
|
+
if (!next || type === form.value.triggerType) return
|
|
80
|
+
|
|
81
|
+
const untouched =
|
|
82
|
+
form.value.actions.length === 0 &&
|
|
83
|
+
JSON.stringify(form.value.conditions) === JSON.stringify(current?.default_conditions ?? [])
|
|
84
|
+
if (!untouched) {
|
|
85
|
+
const confirmed = await confirm({
|
|
86
|
+
title: t('automations.form.changeTrigger.title'),
|
|
87
|
+
message: t('automations.form.changeTrigger.message'),
|
|
88
|
+
confirmProps: { label: t('automations.form.changeTrigger.confirm') },
|
|
89
|
+
cancelProps: { label: t('common.actions.cancel') },
|
|
90
|
+
})
|
|
91
|
+
if (!confirmed) return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
trackEvent('automation.trigger_changed', { from: form.value.triggerType, to: type })
|
|
95
|
+
form.value = {
|
|
96
|
+
...form.value,
|
|
97
|
+
triggerType: type,
|
|
98
|
+
conditions: cloneFormData(next.default_conditions),
|
|
99
|
+
actions: [],
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { isChannelAllowed } = useChannels()
|
|
104
|
+
const { status: whatsappStatus } = useWhatsappConfiguration()
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The Trigger's Channels the Host offers; WhatsApp only once the
|
|
108
|
+
* Organization is known to have a WABA (ADR 0015).
|
|
109
|
+
*/
|
|
110
|
+
const channels = computed<AutomationChannel[]>(() =>
|
|
111
|
+
(trigger.value?.channels ?? [])
|
|
112
|
+
.map(({ channel }) => channel)
|
|
113
|
+
.filter(
|
|
114
|
+
(channel) =>
|
|
115
|
+
isChannelAllowed(channel) &&
|
|
116
|
+
(channel !== 'whatsapp' || whatsappStatus.value === 'configured'),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The Channels the Organization cannot use: WhatsApp without a WABA. A lookup
|
|
122
|
+
* still running or failed says nothing, and a Channel the Host does not offer
|
|
123
|
+
* is not the Organization's doing: neither is called unusable.
|
|
124
|
+
*/
|
|
125
|
+
const unusableChannels = computed<AutomationChannel[]>(() =>
|
|
126
|
+
AUTOMATION_CHANNELS.filter(
|
|
127
|
+
(channel) => channel === 'whatsapp' && whatsappStatus.value === 'not_configured',
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const { data: approvals } = useMessageGroupApprovalsQuery(() =>
|
|
132
|
+
form.value.actions.map((action) => action.messageGroupId),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const availability = computed<ActionAvailability | undefined>(
|
|
136
|
+
() =>
|
|
137
|
+
trigger.value && {
|
|
138
|
+
trigger: trigger.value,
|
|
139
|
+
unusableChannels: unusableChannels.value,
|
|
140
|
+
approvals: approvals.value,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const unavailable = useAutomationUnavailable()
|
|
145
|
+
|
|
146
|
+
/** What `FiltersSummary` reports on the form's Conditions. */
|
|
147
|
+
const filtersReport = ref<FiltersReport>()
|
|
148
|
+
const filtersOpen = ref(false)
|
|
149
|
+
|
|
150
|
+
const unavailableByAction = computed(() => {
|
|
151
|
+
const current = availability.value
|
|
152
|
+
return new Map(
|
|
153
|
+
form.value.actions.map((action) => [
|
|
154
|
+
action.key,
|
|
155
|
+
current ? unavailable.actionItems(action, current) : [],
|
|
156
|
+
]),
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Everything unavailable in the Automation, top to bottom. Under a Trigger
|
|
162
|
+
* the catalog no longer lists nothing else can be told, or shown.
|
|
163
|
+
*/
|
|
164
|
+
const unavailableItems = computed<AutomationUnavailableItem[]>(() => {
|
|
165
|
+
if (!trigger.value) return [unavailable.triggerItem()].map(inSystemAutomation)
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
...unavailable.conditionItems(filtersReport.value, trigger.value.condition_fields),
|
|
169
|
+
...unavailableByAction.value.values(),
|
|
170
|
+
]
|
|
171
|
+
.flat()
|
|
172
|
+
.map(inSystemAutomation)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What stops the save of a System Automation: only an Active Action on an
|
|
177
|
+
* unapproved Message Group, which its user can turn off. The rest is its
|
|
178
|
+
* Host's, kept as stored by the backend too (ADR 0016).
|
|
179
|
+
*/
|
|
180
|
+
function inSystemAutomation(item: AutomationUnavailableItem): AutomationUnavailableItem {
|
|
181
|
+
if (!props.system) return item
|
|
182
|
+
return { ...item, blocks: item.kind === 'message_group_active' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const blocked = computed(() => unavailableItems.value.some((item) => item.blocks))
|
|
186
|
+
|
|
187
|
+
const UNAVAILABLE_ELEMENT_ID = 'automation-unavailable'
|
|
188
|
+
|
|
189
|
+
function scrollTo(id: string) {
|
|
190
|
+
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function show(item: AutomationUnavailableItem) {
|
|
194
|
+
if (item.target) scrollTo(item.target)
|
|
195
|
+
if (item.inFilters) filtersOpen.value = true
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
defineExpose({
|
|
199
|
+
/** Something unavailable stops the save: the caller does not send it. */
|
|
200
|
+
blocked,
|
|
201
|
+
/** Brings the user back to the alert that says what to fix. */
|
|
202
|
+
showUnavailable: () => scrollTo(UNAVAILABLE_ELEMENT_ID),
|
|
203
|
+
})
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<template>
|
|
207
|
+
<UCard
|
|
208
|
+
:ui="{
|
|
209
|
+
root: 'flex flex-col flex-1 min-h-0',
|
|
210
|
+
body: 'flex-1 w-full max-w-3xl mx-auto px-3 sm:pt-10 overflow-auto space-y-6',
|
|
211
|
+
}"
|
|
212
|
+
>
|
|
213
|
+
<AutomationsUnavailable :id="UNAVAILABLE_ELEMENT_ID" :items="unavailableItems" @show="show" />
|
|
214
|
+
|
|
215
|
+
<h3 class="heading-sm">{{ t('automations.form.title') }}</h3>
|
|
216
|
+
|
|
217
|
+
<UFormField
|
|
218
|
+
:label="t('automations.form.name')"
|
|
219
|
+
:error="submitted && !form.name.trim() ? t('automations.form.required') : undefined"
|
|
220
|
+
>
|
|
221
|
+
<UInput
|
|
222
|
+
v-model="form.name"
|
|
223
|
+
:placeholder="t('automations.form.namePlaceholder')"
|
|
224
|
+
:disabled="system"
|
|
225
|
+
class="w-full"
|
|
226
|
+
/>
|
|
227
|
+
</UFormField>
|
|
228
|
+
|
|
229
|
+
<template v-if="dataForm">
|
|
230
|
+
<USeparator />
|
|
231
|
+
|
|
232
|
+
<AutomationsData v-model="form.data" :form="dataForm" />
|
|
233
|
+
</template>
|
|
234
|
+
|
|
235
|
+
<USeparator />
|
|
236
|
+
|
|
237
|
+
<UFormField :id="AUTOMATION_TRIGGER_ELEMENT_ID" :label="t('automations.form.trigger')">
|
|
238
|
+
<template v-if="!trigger" #hint>
|
|
239
|
+
<AutomationsUnavailableBadge :item="unavailable.triggerItem()" />
|
|
240
|
+
</template>
|
|
241
|
+
<USelect
|
|
242
|
+
:model-value="form.triggerType"
|
|
243
|
+
:items="triggerOptions"
|
|
244
|
+
:disabled="system"
|
|
245
|
+
@update:model-value="changeTrigger"
|
|
246
|
+
/>
|
|
247
|
+
</UFormField>
|
|
248
|
+
|
|
249
|
+
<template v-if="trigger">
|
|
250
|
+
<USeparator />
|
|
251
|
+
|
|
252
|
+
<AutomationsConditions
|
|
253
|
+
:id="AUTOMATION_CONDITIONS_ELEMENT_ID"
|
|
254
|
+
v-model="form.conditions"
|
|
255
|
+
v-model:open="filtersOpen"
|
|
256
|
+
:trigger="trigger"
|
|
257
|
+
:option-lists="context.optionLists.value"
|
|
258
|
+
:currency="context.currency?.value"
|
|
259
|
+
:read-only="system"
|
|
260
|
+
@report="filtersReport = $event"
|
|
261
|
+
/>
|
|
262
|
+
|
|
263
|
+
<USeparator />
|
|
264
|
+
|
|
265
|
+
<AutomationsActions
|
|
266
|
+
v-model="form.actions"
|
|
267
|
+
:trigger="trigger"
|
|
268
|
+
:channels="channels"
|
|
269
|
+
:unusable-channels="unusableChannels"
|
|
270
|
+
:unavailable="unavailableByAction"
|
|
271
|
+
:invalid-keys="invalidActionKeys"
|
|
272
|
+
:submitted="submitted"
|
|
273
|
+
:open-action="openAction"
|
|
274
|
+
:read-only="system"
|
|
275
|
+
@edit-message="emit('editMessage', $event)"
|
|
276
|
+
/>
|
|
277
|
+
</template>
|
|
278
|
+
</UCard>
|
|
279
|
+
</template>
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
Automation,
|
|
4
|
+
AutomationCatalog,
|
|
5
|
+
AutomationChannel,
|
|
6
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
7
|
+
import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
|
|
8
|
+
import type { TableColumn } from '#ui/types'
|
|
9
|
+
import AutomationsDeleteModal from '~/components/Automations/DeleteModal.vue'
|
|
10
|
+
import { useAutomationsQuery, useDeleteAutomationMutation } from '~/queries'
|
|
11
|
+
import { isSystemAutomation } from '../../utils/automations/metadata'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The Automations of the Effective Organization for the Host's Product, with
|
|
15
|
+
* client-side search, filter and sort: the Automation Engine pages nothing
|
|
16
|
+
* (ADR 0016). Trigger labels come from the Automation Catalog. System
|
|
17
|
+
* Automations stay on top, whatever the sort.
|
|
18
|
+
*/
|
|
19
|
+
const props = defineProps<{ catalog: AutomationCatalog }>()
|
|
20
|
+
|
|
21
|
+
const { t, te } = useI18n()
|
|
22
|
+
const { trackEvent } = useTracking()
|
|
23
|
+
|
|
24
|
+
const { data: automations, isPending } = useAutomationsQuery()
|
|
25
|
+
|
|
26
|
+
// A lookup still running or failed is not "no WABA": WhatsApp stays listed.
|
|
27
|
+
const { status: whatsappStatus } = useWhatsappConfiguration()
|
|
28
|
+
const whatsappOff = computed(
|
|
29
|
+
() => whatsappStatus.value === 'unavailable' || whatsappStatus.value === 'not_configured',
|
|
30
|
+
)
|
|
31
|
+
const { hasEmail } = useChannels()
|
|
32
|
+
/** A Channel the Host does not offer, or the Organization cannot use, is left out. */
|
|
33
|
+
function isShown(channel: string): boolean {
|
|
34
|
+
if (channel === 'whatsapp') return !whatsappOff.value
|
|
35
|
+
if (channel === 'email') return hasEmail.value
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const search = ref('')
|
|
40
|
+
const filterTrigger = ref('all')
|
|
41
|
+
const filterStatus = ref('all')
|
|
42
|
+
const filterChannel = ref('all')
|
|
43
|
+
// Column ids are the ones Smartconnect's list reports in `sort_changed`.
|
|
44
|
+
const sorting = ref([{ id: 'createdAt', desc: false }])
|
|
45
|
+
|
|
46
|
+
const filterState = computed(() => ({
|
|
47
|
+
search_term: search.value,
|
|
48
|
+
trigger_type: filterTrigger.value,
|
|
49
|
+
status: filterStatus.value,
|
|
50
|
+
channel: filterChannel.value,
|
|
51
|
+
}))
|
|
52
|
+
const debouncedFilterState = refDebounced(filterState, 1000)
|
|
53
|
+
|
|
54
|
+
watch(debouncedFilterState, (state) => {
|
|
55
|
+
trackEvent('automation_list.filter_changed', state)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
watch(sorting, ([current]) => {
|
|
59
|
+
if (!current) return
|
|
60
|
+
trackEvent('automation_list.sort_changed', {
|
|
61
|
+
column: current.id,
|
|
62
|
+
direction: current.desc ? 'desc' : 'asc',
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const triggerOptions = computed(() => [
|
|
67
|
+
{ label: t('automations.list.allTriggers'), value: 'all' },
|
|
68
|
+
...props.catalog.triggers.map((trigger) => ({ label: trigger.label, value: trigger.type })),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
const statusOptions = computed(() => [
|
|
72
|
+
{ label: t('automations.list.allStatuses'), value: 'all' },
|
|
73
|
+
{ label: t('automations.list.active'), value: 'active' },
|
|
74
|
+
{ label: t('automations.list.inactive'), value: 'inactive' },
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
const catalogChannels = computed<AutomationChannel[]>(() => [
|
|
78
|
+
...new Set(props.catalog.triggers.flatMap((trigger) => trigger.channels.map((c) => c.channel))),
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
const channelOptions = computed(() => [
|
|
82
|
+
{ label: t('automations.list.allChannels'), value: 'all' },
|
|
83
|
+
...catalogChannels.value
|
|
84
|
+
.filter(isShown)
|
|
85
|
+
.map((channel) => ({ label: getChannelLabel(channel), value: channel })),
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
function getChannelLabel(channel: string): string {
|
|
89
|
+
const key = `automations.channels.${channel}`
|
|
90
|
+
return te(key) ? t(key) : channel
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getTriggerType(automation: Automation): string {
|
|
94
|
+
const { type } = automation.settings.trigger
|
|
95
|
+
return (Array.isArray(type) ? type[0] : type) ?? ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getTriggerLabel(automation: Automation): string {
|
|
99
|
+
const type = getTriggerType(automation)
|
|
100
|
+
// A Trigger the catalog no longer lists shows as its raw value.
|
|
101
|
+
return props.catalog.triggers.find((trigger) => trigger.type === type)?.label ?? type
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const filteredAutomations = computed(() => {
|
|
105
|
+
const query = search.value.trim().toLowerCase()
|
|
106
|
+
|
|
107
|
+
return (automations.value ?? []).filter((automation) => {
|
|
108
|
+
const channels = automation.actions.map((action) => action.config.channel)
|
|
109
|
+
// Nothing of it can be sent while WhatsApp is not set up.
|
|
110
|
+
if (channels.length > 0 && !channels.some(isShown)) return false
|
|
111
|
+
if (query && !automation.name.toLowerCase().includes(query)) return false
|
|
112
|
+
if (filterTrigger.value !== 'all' && getTriggerType(automation) !== filterTrigger.value) {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
if (filterStatus.value !== 'all' && automation.active !== (filterStatus.value === 'active')) {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
if (filterChannel.value !== 'all' && !channels.includes(filterChannel.value)) return false
|
|
119
|
+
return true
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/** System Automations are pinned on top, in the order they were read: the sort is of the rest. */
|
|
124
|
+
const rowPinning = computed(() => ({
|
|
125
|
+
top: filteredAutomations.value.filter(isSystemAutomation).map(({ id }) => id),
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
/** "Email (2), WhatsApp (1)": the Actions of an Automation, by Channel. */
|
|
129
|
+
function getChannels(automation: Automation): string {
|
|
130
|
+
const counts = new Map<string, number>()
|
|
131
|
+
for (const { config } of automation.actions) {
|
|
132
|
+
if (isShown(config.channel)) counts.set(config.channel, (counts.get(config.channel) ?? 0) + 1)
|
|
133
|
+
}
|
|
134
|
+
return [...counts]
|
|
135
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
136
|
+
.map(([channel, count]) => `${getChannelLabel(channel)} (${count})`)
|
|
137
|
+
.join(', ')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const columns: TableColumn<Automation>[] = [
|
|
141
|
+
{
|
|
142
|
+
accessorKey: 'name',
|
|
143
|
+
header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.name')),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'trigger',
|
|
147
|
+
accessorFn: getTriggerType,
|
|
148
|
+
header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.trigger')),
|
|
149
|
+
cell: ({ row }) => getTriggerLabel(row.original),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'enabled',
|
|
153
|
+
// Active first when ascending.
|
|
154
|
+
accessorFn: (automation) => (automation.active ? 0 : 1),
|
|
155
|
+
header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.status')),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'channel',
|
|
159
|
+
accessorFn: (automation) => automation.actions.length,
|
|
160
|
+
header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.channels')),
|
|
161
|
+
cell: ({ row }) => getChannels(row.original) || '-',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'audience',
|
|
165
|
+
accessorFn: (automation) => automation.settings.conditions.length,
|
|
166
|
+
header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.audience')),
|
|
167
|
+
cell: ({ row }) =>
|
|
168
|
+
row.original.settings.conditions.length > 0
|
|
169
|
+
? t('automations.audienceSpecific')
|
|
170
|
+
: t('automations.audienceAll'),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'createdAt',
|
|
174
|
+
accessorKey: 'created_at',
|
|
175
|
+
header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.createdAt')),
|
|
176
|
+
cell: ({ row }) => formatDate(row.original.created_at),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: 'actions',
|
|
180
|
+
enableSorting: false,
|
|
181
|
+
header: '',
|
|
182
|
+
meta: {
|
|
183
|
+
class: {
|
|
184
|
+
th: 'w-[66px]',
|
|
185
|
+
td: 'w-[66px]',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
const columnPinning = ref({
|
|
192
|
+
right: ['actions'],
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const toast = useToast()
|
|
196
|
+
const deleteModal = useOverlay().create(AutomationsDeleteModal, { destroyOnClose: true })
|
|
197
|
+
const { mutateAsync: deleteAutomation } = useDeleteAutomationMutation()
|
|
198
|
+
|
|
199
|
+
const listNames = (messageGroups: Array<{ name: string }>) =>
|
|
200
|
+
messageGroups.map(({ name }) => name).join(', ')
|
|
201
|
+
|
|
202
|
+
function askDelete(automation: Automation) {
|
|
203
|
+
deleteModal.open({
|
|
204
|
+
automation,
|
|
205
|
+
action: async (messageGroupIds) => {
|
|
206
|
+
const result = await deleteAutomation({ id: automation.id, messageGroupIds })
|
|
207
|
+
trackEvent('automation_list.delete', {
|
|
208
|
+
automation_name: automation.name,
|
|
209
|
+
automation_id: automation.id,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Nothing is read off the answer without a check: with no selection
|
|
213
|
+
// it is empty, and in demo mode it is not a delete result at all.
|
|
214
|
+
const deleted = result?.deleted ?? []
|
|
215
|
+
const kept = result?.kept ?? []
|
|
216
|
+
const description = [
|
|
217
|
+
deleted.length > 0 &&
|
|
218
|
+
t('automations.toast.deleted_messages', { names: listNames(deleted) }),
|
|
219
|
+
kept.length > 0 && t('automations.toast.kept_messages', { names: listNames(kept) }),
|
|
220
|
+
]
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join('\n')
|
|
223
|
+
toast.add({
|
|
224
|
+
title: t('automations.toast.deleted'),
|
|
225
|
+
...(description && { description }),
|
|
226
|
+
color: kept.length > 0 ? 'warning' : 'success',
|
|
227
|
+
ui: { description: 'whitespace-pre-line' },
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { askClone } = useAutomationClone()
|
|
234
|
+
|
|
235
|
+
/** A System Automation cannot be deleted (ADR 0016): its row does not offer it. */
|
|
236
|
+
function getRowActions(automation: Automation) {
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
label: t('common.actions.edit'),
|
|
240
|
+
icon: 'ph:pencil-simple',
|
|
241
|
+
onSelect: () => {
|
|
242
|
+
trackEvent('automation_list.edit', {
|
|
243
|
+
automation_name: automation.name,
|
|
244
|
+
automation_id: automation.id,
|
|
245
|
+
})
|
|
246
|
+
return navigateTo({ name: 'automation-edit', params: { id: automation.id } })
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
label: t('common.actions.duplicate'),
|
|
251
|
+
icon: 'ph:copy',
|
|
252
|
+
onSelect: () => {
|
|
253
|
+
trackEvent('automation_list.duplicate', {
|
|
254
|
+
automation_name: automation.name,
|
|
255
|
+
automation_id: automation.id,
|
|
256
|
+
})
|
|
257
|
+
askClone(automation)
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
...(isSystemAutomation(automation)
|
|
261
|
+
? []
|
|
262
|
+
: [
|
|
263
|
+
{
|
|
264
|
+
label: t('common.actions.delete'),
|
|
265
|
+
icon: 'ph:trash',
|
|
266
|
+
color: 'error' as const,
|
|
267
|
+
onSelect: () => askDelete(automation),
|
|
268
|
+
},
|
|
269
|
+
]),
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
</script>
|
|
273
|
+
|
|
274
|
+
<template>
|
|
275
|
+
<div class="flex-1 space-y-2.5 relative min-h-0 flex flex-col">
|
|
276
|
+
<div class="grid grid-cols-4 items-center gap-3 shrink-0">
|
|
277
|
+
<UInput
|
|
278
|
+
v-model="search"
|
|
279
|
+
type="search"
|
|
280
|
+
leading-icon="ph:magnifying-glass-bold"
|
|
281
|
+
:placeholder="t('automations.list.search')"
|
|
282
|
+
/>
|
|
283
|
+
<USelect v-model="filterTrigger" :items="triggerOptions" />
|
|
284
|
+
<USelect v-model="filterStatus" :items="statusOptions" />
|
|
285
|
+
<USelect v-model="filterChannel" :items="channelOptions" />
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<UCard
|
|
289
|
+
:ui="{
|
|
290
|
+
root: 'flex-1 min-h-0 flex flex-col',
|
|
291
|
+
body: 'overflow-auto relative flex-1 p-0 sm:p-0 relative',
|
|
292
|
+
}"
|
|
293
|
+
>
|
|
294
|
+
<UTable
|
|
295
|
+
v-model:column-pinning="columnPinning"
|
|
296
|
+
v-model:sorting="sorting"
|
|
297
|
+
:row-pinning="rowPinning"
|
|
298
|
+
:get-row-id="(automation: Automation) => automation.id"
|
|
299
|
+
:data="filteredAutomations"
|
|
300
|
+
:columns="columns"
|
|
301
|
+
:loading="isPending"
|
|
302
|
+
:empty="t('automations.list.empty')"
|
|
303
|
+
sticky
|
|
304
|
+
class="flex-1 bg-white"
|
|
305
|
+
:ui="{
|
|
306
|
+
tr: 'hover:bg-muted',
|
|
307
|
+
root: 'overflow-visible static',
|
|
308
|
+
}"
|
|
309
|
+
>
|
|
310
|
+
<template #name-cell="{ row }">
|
|
311
|
+
<div class="flex items-center gap-2">
|
|
312
|
+
<span>{{ row.original.name }}</span>
|
|
313
|
+
<UTooltip
|
|
314
|
+
v-if="isSystemAutomation(row.original)"
|
|
315
|
+
:text="t('automations.system.tooltip')"
|
|
316
|
+
>
|
|
317
|
+
<UBadge
|
|
318
|
+
:label="t('automations.system.badge')"
|
|
319
|
+
color="info"
|
|
320
|
+
variant="subtle"
|
|
321
|
+
size="sm"
|
|
322
|
+
/>
|
|
323
|
+
</UTooltip>
|
|
324
|
+
</div>
|
|
325
|
+
</template>
|
|
326
|
+
|
|
327
|
+
<template #enabled-cell="{ row }">
|
|
328
|
+
<UBadge
|
|
329
|
+
:label="
|
|
330
|
+
row.original.active ? t('automations.list.active') : t('automations.list.inactive')
|
|
331
|
+
"
|
|
332
|
+
:color="row.original.active ? 'success' : 'neutral'"
|
|
333
|
+
variant="soft"
|
|
334
|
+
/>
|
|
335
|
+
</template>
|
|
336
|
+
|
|
337
|
+
<template #actions-cell="{ row }">
|
|
338
|
+
<SMoreActions :actions="getRowActions(row.original)" />
|
|
339
|
+
</template>
|
|
340
|
+
</UTable>
|
|
341
|
+
</UCard>
|
|
342
|
+
</div>
|
|
343
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AutomationCatalog } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import type { AutomationsContext } from '~/composables/automationsContext'
|
|
4
|
+
import type { AutomationsGate } from '~/composables/useAutomationsGate'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shows an Automation page's content once its gate is open, and until then
|
|
8
|
+
* the error alert of the Organization gate and nothing else (ADR 0019).
|
|
9
|
+
*/
|
|
10
|
+
defineProps<{ gate: AutomationsGate }>()
|
|
11
|
+
|
|
12
|
+
defineSlots<{
|
|
13
|
+
default(props: { catalog: AutomationCatalog; context: AutomationsContext }): unknown
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<slot v-if="gate.state === 'ready'" :catalog="gate.catalog" :context="gate.context" />
|
|
21
|
+
|
|
22
|
+
<div
|
|
23
|
+
v-else-if="gate.state === 'loading'"
|
|
24
|
+
class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-muted"
|
|
25
|
+
role="status"
|
|
26
|
+
>
|
|
27
|
+
<UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8" />
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<UAlert
|
|
31
|
+
v-else
|
|
32
|
+
color="error"
|
|
33
|
+
variant="subtle"
|
|
34
|
+
icon="i-lucide-alert-triangle"
|
|
35
|
+
:title="gate.title"
|
|
36
|
+
:description="gate.description"
|
|
37
|
+
:actions="
|
|
38
|
+
gate.retry
|
|
39
|
+
? [
|
|
40
|
+
{
|
|
41
|
+
label: t('common.organization.retry'),
|
|
42
|
+
color: 'error',
|
|
43
|
+
variant: 'outline',
|
|
44
|
+
onClick: gate.retry,
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
: undefined
|
|
48
|
+
"
|
|
49
|
+
/>
|
|
50
|
+
</template>
|