@dev.smartpricing/message-composer-layer 4.5.1-next.3 → 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.
Files changed (73) hide show
  1. package/README.md +133 -4
  2. package/app/api/automations.ts +74 -0
  3. package/app/api/index.ts +1 -0
  4. package/app/api/messageGroups.ts +5 -1
  5. package/app/app.config.ts +10 -0
  6. package/app/components/Automations/ActionCard.vue +174 -0
  7. package/app/components/Automations/ActionEditor.vue +398 -0
  8. package/app/components/Automations/Actions.vue +148 -0
  9. package/app/components/Automations/CloneModal.vue +78 -0
  10. package/app/components/Automations/Conditions.vue +155 -0
  11. package/app/components/Automations/Create.vue +114 -0
  12. package/app/components/Automations/Data.vue +46 -0
  13. package/app/components/Automations/DeleteModal.vue +80 -0
  14. package/app/components/Automations/Edit.vue +145 -0
  15. package/app/components/Automations/Form.vue +282 -0
  16. package/app/components/Automations/List.vue +370 -0
  17. package/app/components/Automations/PageGate.vue +50 -0
  18. package/app/components/Automations/SaveProblems.vue +54 -0
  19. package/app/components/Automations/Unavailable.vue +71 -0
  20. package/app/components/Automations/UnavailableBadge.vue +35 -0
  21. package/app/components/Common/MessageGroupSelect.vue +30 -1
  22. package/app/components/Composer/EmailComposer.vue +5 -1
  23. package/app/components/Composer/WabaSelectModal.vue +3 -1
  24. package/app/components/Composer/WhatsappComposer.vue +5 -1
  25. package/app/components/Filters/DateInput.vue +57 -0
  26. package/app/components/Filters/Editor.vue +163 -0
  27. package/app/components/Filters/Row.vue +119 -0
  28. package/app/components/Filters/Summary.vue +215 -0
  29. package/app/components/Filters/UnavailableRow.vue +74 -0
  30. package/app/components/Filters/ValueInput.vue +215 -0
  31. package/app/components/Layout/BasePage.vue +2 -0
  32. package/app/components/TemplateList/BaseTable.vue +1 -22
  33. package/app/composables/apiErrorMessages.ts +1 -0
  34. package/app/composables/automationsContext.ts +42 -0
  35. package/app/composables/composerContext.ts +3 -3
  36. package/app/composables/useAutomationClone.ts +33 -0
  37. package/app/composables/useAutomationEditorTrip.ts +77 -0
  38. package/app/composables/useAutomationSaveProblems.ts +82 -0
  39. package/app/composables/useAutomationUnavailable.ts +98 -0
  40. package/app/composables/useAutomationsGate.ts +68 -0
  41. package/app/composables/useBuiltInOptionLists.ts +44 -0
  42. package/app/composables/useEditorTripBack.ts +15 -0
  43. package/app/composables/useMessageGroupWaba.ts +7 -10
  44. package/app/composables/useTemplateOperations.ts +8 -3
  45. package/app/middleware/automations-enabled.ts +12 -0
  46. package/app/pages/automations/[id].vue +87 -0
  47. package/app/pages/automations/index.vue +28 -0
  48. package/app/pages/automations/new.vue +53 -0
  49. package/app/pages/template/email/[id].vue +5 -0
  50. package/app/pages/template/email/create.vue +5 -0
  51. package/app/pages/template/whatsapp/[id].vue +5 -0
  52. package/app/pages/template/whatsapp/create.vue +5 -0
  53. package/app/queries/automations.ts +206 -0
  54. package/app/queries/index.ts +1 -0
  55. package/app/queries/messageGroups.ts +1 -3
  56. package/app/stores/appContext.ts +1 -1
  57. package/app/stores/editorTrip.ts +67 -0
  58. package/app/types/app-config.d.ts +3 -0
  59. package/app/types/tracking.ts +33 -0
  60. package/app/utils/automations/form.ts +389 -0
  61. package/app/utils/automations/metadata.ts +71 -0
  62. package/app/utils/automations/unavailable.ts +90 -0
  63. package/app/utils/filters/data/countries.json +998 -0
  64. package/app/utils/filters/data/languages.json +754 -0
  65. package/app/utils/filters/rows.ts +367 -0
  66. package/app/utils/testIds.const.ts +107 -1
  67. package/i18n/locales/de.ts +269 -1
  68. package/i18n/locales/en.ts +255 -1
  69. package/i18n/locales/es.ts +258 -1
  70. package/i18n/locales/fr.ts +264 -1
  71. package/i18n/locales/it.ts +260 -1
  72. package/package.json +4 -4
  73. package/app.config.ts +0 -5
@@ -0,0 +1,282 @@
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
+ :data-testid="automationsTestIds.form"
213
+ >
214
+ <AutomationsUnavailable :id="UNAVAILABLE_ELEMENT_ID" :items="unavailableItems" @show="show" />
215
+
216
+ <h3 class="heading-sm">{{ t('automations.form.title') }}</h3>
217
+
218
+ <UFormField
219
+ :label="t('automations.form.name')"
220
+ :error="submitted && !form.name.trim() ? t('automations.form.required') : undefined"
221
+ >
222
+ <UInput
223
+ v-model="form.name"
224
+ :placeholder="t('automations.form.namePlaceholder')"
225
+ :disabled="system"
226
+ class="w-full"
227
+ :data-testid="automationsTestIds.nameInput"
228
+ />
229
+ </UFormField>
230
+
231
+ <template v-if="dataForm">
232
+ <USeparator />
233
+
234
+ <AutomationsData v-model="form.data" :form="dataForm" />
235
+ </template>
236
+
237
+ <USeparator />
238
+
239
+ <UFormField :id="AUTOMATION_TRIGGER_ELEMENT_ID" :label="t('automations.form.trigger')">
240
+ <template v-if="!trigger" #hint>
241
+ <AutomationsUnavailableBadge :item="unavailable.triggerItem()" />
242
+ </template>
243
+ <USelect
244
+ :model-value="form.triggerType"
245
+ :items="triggerOptions"
246
+ :disabled="system"
247
+ :data-testid="automationsTestIds.triggerSelect"
248
+ @update:model-value="changeTrigger"
249
+ />
250
+ </UFormField>
251
+
252
+ <template v-if="trigger">
253
+ <USeparator />
254
+
255
+ <AutomationsConditions
256
+ :id="AUTOMATION_CONDITIONS_ELEMENT_ID"
257
+ v-model="form.conditions"
258
+ v-model:open="filtersOpen"
259
+ :trigger="trigger"
260
+ :option-lists="context.optionLists.value"
261
+ :currency="context.currency?.value"
262
+ :read-only="system"
263
+ @report="filtersReport = $event"
264
+ />
265
+
266
+ <USeparator />
267
+
268
+ <AutomationsActions
269
+ v-model="form.actions"
270
+ :trigger="trigger"
271
+ :channels="channels"
272
+ :unusable-channels="unusableChannels"
273
+ :unavailable="unavailableByAction"
274
+ :invalid-keys="invalidActionKeys"
275
+ :submitted="submitted"
276
+ :open-action="openAction"
277
+ :read-only="system"
278
+ @edit-message="emit('editMessage', $event)"
279
+ />
280
+ </template>
281
+ </UCard>
282
+ </template>
@@ -0,0 +1,370 @@
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
+ },
162
+ {
163
+ id: 'audience',
164
+ accessorFn: (automation) => automation.settings.conditions.length,
165
+ header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.audience')),
166
+ cell: ({ row }) =>
167
+ row.original.settings.conditions.length > 0
168
+ ? t('automations.audienceSpecific')
169
+ : t('automations.audienceAll'),
170
+ },
171
+ {
172
+ id: 'createdAt',
173
+ accessorKey: 'created_at',
174
+ header: (ctx) => getSortableHeader(ctx, t('automations.list.headers.createdAt')),
175
+ cell: ({ row }) => formatDate(row.original.created_at),
176
+ },
177
+ {
178
+ id: 'actions',
179
+ enableSorting: false,
180
+ header: '',
181
+ meta: {
182
+ class: {
183
+ th: 'w-[66px]',
184
+ td: 'w-[66px]',
185
+ },
186
+ },
187
+ },
188
+ ]
189
+
190
+ const columnPinning = ref({
191
+ right: ['actions'],
192
+ })
193
+
194
+ const toast = useToast()
195
+ const deleteModal = useOverlay().create(AutomationsDeleteModal, { destroyOnClose: true })
196
+ const { mutateAsync: deleteAutomation } = useDeleteAutomationMutation()
197
+
198
+ const listNames = (messageGroups: Array<{ name: string }>) =>
199
+ messageGroups.map(({ name }) => name).join(', ')
200
+
201
+ function askDelete(automation: Automation) {
202
+ deleteModal.open({
203
+ automation,
204
+ action: async (messageGroupIds) => {
205
+ const result = await deleteAutomation({ id: automation.id, messageGroupIds })
206
+ trackEvent('automation_list.delete', {
207
+ automation_name: automation.name,
208
+ automation_id: automation.id,
209
+ })
210
+
211
+ // Nothing is read off the answer without a check: with no selection
212
+ // it is empty, and in demo mode it is not a delete result at all.
213
+ const deleted = result?.deleted ?? []
214
+ const kept = result?.kept ?? []
215
+ const description = [
216
+ deleted.length > 0 &&
217
+ t('automations.toast.deleted_messages', { names: listNames(deleted) }),
218
+ kept.length > 0 && t('automations.toast.kept_messages', { names: listNames(kept) }),
219
+ ]
220
+ .filter(Boolean)
221
+ .join('\n')
222
+ toast.add({
223
+ title: t('automations.toast.deleted'),
224
+ ...(description && { description }),
225
+ color: kept.length > 0 ? 'warning' : 'success',
226
+ ui: { description: 'whitespace-pre-line' },
227
+ })
228
+ },
229
+ })
230
+ }
231
+
232
+ const { askClone } = useAutomationClone()
233
+
234
+ /** A System Automation cannot be deleted (ADR 0016): its row does not offer it. */
235
+ function getRowActions(automation: Automation) {
236
+ return [
237
+ {
238
+ label: t('common.actions.edit'),
239
+ icon: 'ph:pencil-simple',
240
+ 'data-testid': automationsTestIds.editAction,
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
+ 'data-testid': automationsTestIds.duplicateAction,
253
+ onSelect: () => {
254
+ trackEvent('automation_list.duplicate', {
255
+ automation_name: automation.name,
256
+ automation_id: automation.id,
257
+ })
258
+ askClone(automation)
259
+ },
260
+ },
261
+ ...(isSystemAutomation(automation)
262
+ ? []
263
+ : [
264
+ {
265
+ label: t('common.actions.delete'),
266
+ icon: 'ph:trash',
267
+ color: 'error' as const,
268
+ 'data-testid': automationsTestIds.deleteAction,
269
+ onSelect: () => askDelete(automation),
270
+ },
271
+ ]),
272
+ ]
273
+ }
274
+ </script>
275
+
276
+ <template>
277
+ <div class="flex-1 space-y-2.5 relative min-h-0 flex flex-col">
278
+ <div class="grid grid-cols-4 items-center gap-3 shrink-0">
279
+ <UInput
280
+ v-model="search"
281
+ type="search"
282
+ leading-icon="ph:magnifying-glass-bold"
283
+ :placeholder="t('automations.list.search')"
284
+ :data-testid="automationsTestIds.searchInput"
285
+ />
286
+ <USelect
287
+ v-model="filterTrigger"
288
+ :items="triggerOptions"
289
+ :data-testid="automationsTestIds.filterTrigger"
290
+ />
291
+ <USelect
292
+ v-model="filterStatus"
293
+ :items="statusOptions"
294
+ :data-testid="automationsTestIds.filterStatus"
295
+ />
296
+ <USelect
297
+ v-model="filterChannel"
298
+ :items="channelOptions"
299
+ :data-testid="automationsTestIds.filterChannel"
300
+ />
301
+ </div>
302
+
303
+ <UCard
304
+ :ui="{
305
+ root: 'flex-1 min-h-0 flex flex-col',
306
+ body: 'overflow-auto relative flex-1 p-0 sm:p-0 relative',
307
+ }"
308
+ >
309
+ <UTable
310
+ v-model:column-pinning="columnPinning"
311
+ v-model:sorting="sorting"
312
+ :row-pinning="rowPinning"
313
+ :get-row-id="(automation: Automation) => automation.id"
314
+ :data="filteredAutomations"
315
+ :columns="columns"
316
+ :loading="isPending"
317
+ :empty="t('automations.list.empty')"
318
+ sticky
319
+ class="flex-1 bg-white"
320
+ :data-testid="automationsTestIds.table"
321
+ :ui="{
322
+ tr: 'hover:bg-muted',
323
+ root: 'overflow-visible static',
324
+ }"
325
+ >
326
+ <template #name-cell="{ row }">
327
+ <div class="flex items-center gap-2">
328
+ <span>{{ row.original.name }}</span>
329
+ <UTooltip
330
+ v-if="isSystemAutomation(row.original)"
331
+ :text="t('automations.system.tooltip')"
332
+ >
333
+ <UBadge
334
+ :label="t('automations.system.badge')"
335
+ color="info"
336
+ variant="subtle"
337
+ size="sm"
338
+ :data-testid="automationsTestIds.lockedBadge"
339
+ />
340
+ </UTooltip>
341
+ </div>
342
+ </template>
343
+
344
+ <template #enabled-cell="{ row }">
345
+ <UBadge
346
+ :label="
347
+ row.original.active ? t('automations.list.active') : t('automations.list.inactive')
348
+ "
349
+ :color="row.original.active ? 'success' : 'neutral'"
350
+ variant="soft"
351
+ :data-testid="automationsTestIds.statusCell"
352
+ />
353
+ </template>
354
+
355
+ <template #channel-cell="{ row }">
356
+ <span :data-testid="automationsTestIds.channelsCell">{{
357
+ getChannels(row.original) || '-'
358
+ }}</span>
359
+ </template>
360
+
361
+ <template #actions-cell="{ row }">
362
+ <SMoreActions
363
+ :actions="getRowActions(row.original)"
364
+ :button-props="{ 'data-testid': automationsTestIds.rowActionsButton }"
365
+ />
366
+ </template>
367
+ </UTable>
368
+ </UCard>
369
+ </div>
370
+ </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>