@dev.smartpricing/message-composer-layer 4.5.1-next.2 → 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 +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 +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/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/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 +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 +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 +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,146 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
AutomationTrigger,
|
|
4
|
+
Condition,
|
|
5
|
+
FilterField,
|
|
6
|
+
FiltersReport,
|
|
7
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
8
|
+
import type { AutomationOption } from '~/composables/automationsContext'
|
|
9
|
+
import { cloneFormData } from '../../utils/automations/form'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Conditions of an Automation: shown with `FiltersSummary`, edited with
|
|
13
|
+
* `FiltersEditor` in a slideover that changes nothing until its Save
|
|
14
|
+
* (ADR 0018). The fields are the Trigger's Condition Fields, each with the
|
|
15
|
+
* option list the Host supplies under the name the field declares (ADR 0017).
|
|
16
|
+
*/
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
trigger: AutomationTrigger
|
|
19
|
+
/** The Host's option lists, by name. One that is not there shows as empty. */
|
|
20
|
+
optionLists: Record<string, AutomationOption[]>
|
|
21
|
+
/** ISO code of the currency `price` fields show; EUR when absent. */
|
|
22
|
+
currency?: string
|
|
23
|
+
/** The Conditions are only shown: there is no way into the filter editor. */
|
|
24
|
+
readOnly?: boolean
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const conditions = defineModel<Condition[]>({ required: true })
|
|
28
|
+
/** Whether the filter editor is open. */
|
|
29
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{
|
|
32
|
+
/** What is wrong with the Conditions as they stand; `undefined` when there is none. */
|
|
33
|
+
report: [report: FiltersReport | undefined]
|
|
34
|
+
}>()
|
|
35
|
+
|
|
36
|
+
const { t } = useI18n()
|
|
37
|
+
const unavailable = useAutomationUnavailable()
|
|
38
|
+
|
|
39
|
+
const fields = computed<FilterField[]>(() =>
|
|
40
|
+
props.trigger.condition_fields.map(({ key, label, type, option_list }) => ({
|
|
41
|
+
key,
|
|
42
|
+
label,
|
|
43
|
+
type,
|
|
44
|
+
options: option_list ? (props.optionLists[option_list] ?? []) : undefined,
|
|
45
|
+
})),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// On screen a Condition is a "filter", and a "between" is one of them.
|
|
49
|
+
const filterCount = computed(() => new Set(conditions.value.map(({ field }) => field)).size)
|
|
50
|
+
|
|
51
|
+
// `FiltersSummary` is not there without Conditions, and then reports nothing.
|
|
52
|
+
watch(
|
|
53
|
+
() => conditions.value.length,
|
|
54
|
+
(count) => {
|
|
55
|
+
if (count === 0) emit('report', undefined)
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
/** What the slideover edits: the Conditions change only on its Save. */
|
|
60
|
+
const edited = ref<Condition[]>([])
|
|
61
|
+
const report = ref<FiltersReport>()
|
|
62
|
+
|
|
63
|
+
/** The alert of the slideover: what is unavailable in the Conditions being edited. */
|
|
64
|
+
const editedUnavailable = computed(() => unavailable.conditionItems(report.value, fields.value))
|
|
65
|
+
|
|
66
|
+
watch(open, (isOpen) => {
|
|
67
|
+
if (isOpen) edited.value = cloneFormData(conditions.value)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
function save() {
|
|
71
|
+
conditions.value = edited.value
|
|
72
|
+
open.value = false
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<UFormField
|
|
78
|
+
:label="t('automations.form.sendTotitle')"
|
|
79
|
+
:description="t('automations.form.sendToHint')"
|
|
80
|
+
>
|
|
81
|
+
<div class="flex items-center gap-1">
|
|
82
|
+
<p class="paragraph-sm text-primary-800">
|
|
83
|
+
{{
|
|
84
|
+
filterCount > 0
|
|
85
|
+
? t('filters.applied', { count: filterCount })
|
|
86
|
+
: t('automations.form.sendTo_all')
|
|
87
|
+
}}
|
|
88
|
+
</p>
|
|
89
|
+
|
|
90
|
+
<USlideover
|
|
91
|
+
v-if="!readOnly"
|
|
92
|
+
v-model:open="open"
|
|
93
|
+
close-icon="ph:x"
|
|
94
|
+
:title="t('automations.form.create_specific_audience')"
|
|
95
|
+
:description="t('automations.form.create_specific_audience_hint')"
|
|
96
|
+
:ui="{ content: 'max-w-4xl', footer: 'justify-end' }"
|
|
97
|
+
>
|
|
98
|
+
<UButton
|
|
99
|
+
color="secondary"
|
|
100
|
+
variant="ghost"
|
|
101
|
+
size="sm"
|
|
102
|
+
:icon="filterCount > 0 ? 'ph:pencil-simple-line-bold' : 'ph:plus-bold'"
|
|
103
|
+
:label="filterCount > 0 ? t('common.actions.edit') : t('automations.form.addFilters')"
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
<template #body>
|
|
107
|
+
<AutomationsUnavailable :items="editedUnavailable" in-place class="mb-4" />
|
|
108
|
+
<FiltersEditor
|
|
109
|
+
v-model="edited"
|
|
110
|
+
:fields="fields"
|
|
111
|
+
:currency="currency"
|
|
112
|
+
:title="t('automations.form.includeIf')"
|
|
113
|
+
test-id-prefix="automation"
|
|
114
|
+
@report="report = $event"
|
|
115
|
+
/>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<template #footer>
|
|
119
|
+
<UButton
|
|
120
|
+
color="primary"
|
|
121
|
+
variant="outline"
|
|
122
|
+
:label="t('common.actions.cancel')"
|
|
123
|
+
@click="open = false"
|
|
124
|
+
/>
|
|
125
|
+
<!-- A half-filled row is in no Condition: saving would drop it without a word. -->
|
|
126
|
+
<UButton
|
|
127
|
+
color="primary"
|
|
128
|
+
:label="t('common.actions.save')"
|
|
129
|
+
:disabled="Boolean(report?.halfFilled)"
|
|
130
|
+
@click="save"
|
|
131
|
+
/>
|
|
132
|
+
</template>
|
|
133
|
+
</USlideover>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<FiltersSummary
|
|
137
|
+
v-if="conditions.length > 0"
|
|
138
|
+
:model-value="conditions"
|
|
139
|
+
:fields="fields"
|
|
140
|
+
:currency="currency"
|
|
141
|
+
test-id-prefix="automation"
|
|
142
|
+
class="mt-3"
|
|
143
|
+
@report="emit('report', $event)"
|
|
144
|
+
/>
|
|
145
|
+
</UFormField>
|
|
146
|
+
</template>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
AutomationCatalog,
|
|
4
|
+
AutomationTrigger,
|
|
5
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
6
|
+
import { resolveErrorToast } from '~/composables/apiErrorMessages'
|
|
7
|
+
import type { AutomationsContext } from '~/composables/automationsContext'
|
|
8
|
+
import { useCreateAutomationMutation } from '~/queries'
|
|
9
|
+
import {
|
|
10
|
+
automationSaveProblems,
|
|
11
|
+
automationSetupProperties,
|
|
12
|
+
cloneFormData,
|
|
13
|
+
newAutomationForm,
|
|
14
|
+
toCreateAutomationBody,
|
|
15
|
+
type AutomationFormState,
|
|
16
|
+
} from '../../utils/automations/form'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A new Automation: the form, its save, and what a refused save says. The
|
|
20
|
+
* page around it holds the header and calls `submit`.
|
|
21
|
+
*/
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
catalog: AutomationCatalog
|
|
24
|
+
/** The Trigger a new Automation starts on. */
|
|
25
|
+
initialTrigger: AutomationTrigger
|
|
26
|
+
context: AutomationsContext
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const { t } = useI18n()
|
|
30
|
+
const toast = useToast()
|
|
31
|
+
const { trackEvent } = useTracking()
|
|
32
|
+
|
|
33
|
+
const trip = useAutomationEditorTrip('automation-create')
|
|
34
|
+
|
|
35
|
+
const form = ref<AutomationFormState>(
|
|
36
|
+
trip.restored?.form ?? newAutomationForm(props.initialTrigger),
|
|
37
|
+
)
|
|
38
|
+
const submitted = ref(false)
|
|
39
|
+
const formElement = useTemplateRef('formElement')
|
|
40
|
+
|
|
41
|
+
const incomplete = computed(() => !form.value.name.trim() || form.value.actions.length === 0)
|
|
42
|
+
|
|
43
|
+
const problems = useAutomationSaveProblems(() => props.catalog)
|
|
44
|
+
|
|
45
|
+
const { mutateAsync: createAutomation, isLoading: saving } = useCreateAutomationMutation()
|
|
46
|
+
|
|
47
|
+
async function submit() {
|
|
48
|
+
submitted.value = true
|
|
49
|
+
problems.clear()
|
|
50
|
+
// Something unavailable stops the save: nothing is sent, the alert says what to fix.
|
|
51
|
+
if (formElement.value?.blocked) {
|
|
52
|
+
formElement.value.showUnavailable()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (incomplete.value || saving.value) return
|
|
56
|
+
|
|
57
|
+
const sent = cloneFormData(form.value)
|
|
58
|
+
let created
|
|
59
|
+
try {
|
|
60
|
+
created = await createAutomation(toCreateAutomationBody(sent))
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const refused = automationSaveProblems(error)
|
|
63
|
+
if (refused) {
|
|
64
|
+
problems.show(refused, sent)
|
|
65
|
+
} else {
|
|
66
|
+
// The request carries no error toast of its own, so a 422 gets none.
|
|
67
|
+
const { data, status = 0 } = error as { data?: { code?: string }; status?: number }
|
|
68
|
+
toast.add({ ...resolveErrorToast({ status, code: data?.code }, t), color: 'error' })
|
|
69
|
+
}
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
trackEvent(
|
|
74
|
+
'Automation setup',
|
|
75
|
+
automationSetupProperties(
|
|
76
|
+
sent,
|
|
77
|
+
props.catalog.triggers.find(({ type }) => type === sent.triggerType),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
toast.add({ title: t('automations.toast.created'), color: 'success' })
|
|
81
|
+
// A body that is not an Automation (ADR 0016) has no Automation to open.
|
|
82
|
+
await navigateTo(
|
|
83
|
+
created?.id
|
|
84
|
+
? { name: 'automation-edit', params: { id: created.id } }
|
|
85
|
+
: { name: 'automations-list' },
|
|
86
|
+
{ replace: true },
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
defineExpose({ submit, saving })
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<template>
|
|
94
|
+
<AutomationsSaveProblems :lines="problems.lines.value" :incomplete="submitted && incomplete" />
|
|
95
|
+
|
|
96
|
+
<AutomationsForm
|
|
97
|
+
ref="formElement"
|
|
98
|
+
v-model="form"
|
|
99
|
+
:catalog="catalog"
|
|
100
|
+
:context="context"
|
|
101
|
+
:submitted="submitted"
|
|
102
|
+
:invalid-action-keys="problems.invalidActionKeys.value"
|
|
103
|
+
:open-action="trip.restored?.openAction"
|
|
104
|
+
@edit-message="trip.leave({ form }, $event)"
|
|
105
|
+
/>
|
|
106
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AutomationMetadataForm } from '../../utils/automations/metadata'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The values an Automation asks its user for: one text field per field of
|
|
6
|
+
* its stored `metadata.form`, whatever the Automation. Description and labels
|
|
7
|
+
* are its Host's words, not the layer's: one that is a key of the Host's own
|
|
8
|
+
* translations is shown translated, any other as stored.
|
|
9
|
+
*/
|
|
10
|
+
defineProps<{ form: AutomationMetadataForm }>()
|
|
11
|
+
|
|
12
|
+
/** The values, by the key of their field. */
|
|
13
|
+
const data = defineModel<Record<string, string>>({ required: true })
|
|
14
|
+
|
|
15
|
+
const { t, te } = useI18n()
|
|
16
|
+
|
|
17
|
+
/** Smartconnect seeds its labels as keys of its own locale files. */
|
|
18
|
+
function hostText(stored: string): string {
|
|
19
|
+
return te(stored) ? t(stored) : stored
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="space-y-3">
|
|
25
|
+
<div class="flex items-center gap-2">
|
|
26
|
+
<h3 class="heading-sm">{{ t('automations.form.data.title') }}</h3>
|
|
27
|
+
<UBadge
|
|
28
|
+
v-if="form.fields.some((field) => field.required)"
|
|
29
|
+
:label="t('automations.form.data.requiredBadge')"
|
|
30
|
+
color="info"
|
|
31
|
+
variant="subtle"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<p v-if="form.description" class="paragraph-sm text-muted">{{ hostText(form.description) }}</p>
|
|
36
|
+
|
|
37
|
+
<UFormField
|
|
38
|
+
v-for="field in form.fields"
|
|
39
|
+
:key="field.key"
|
|
40
|
+
:label="hostText(field.label)"
|
|
41
|
+
:required="field.required"
|
|
42
|
+
>
|
|
43
|
+
<UInput v-model="data[field.key]" class="w-full" />
|
|
44
|
+
</UFormField>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Automation } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import { useAutomationMessageGroupsQuery } from '~/queries'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Asks before an Automation is deleted, and which of its Message Groups go
|
|
7
|
+
* with it: none unless the user checks it (ADR 0016). Opened through
|
|
8
|
+
* `useOverlay`; `action` does the delete while the modal shows it running.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
automation: Automation
|
|
12
|
+
action: (messageGroupIds: string[]) => Promise<void>
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
|
|
17
|
+
const { data: messageGroups, isPending } = useAutomationMessageGroupsQuery(() => props.automation)
|
|
18
|
+
|
|
19
|
+
const items = computed(() =>
|
|
20
|
+
(messageGroups.value ?? []).map(({ id, name }) => ({ value: id, label: name })),
|
|
21
|
+
)
|
|
22
|
+
const selected = ref<string[]>([])
|
|
23
|
+
const deleting = ref(false)
|
|
24
|
+
|
|
25
|
+
async function confirmDelete() {
|
|
26
|
+
deleting.value = true
|
|
27
|
+
try {
|
|
28
|
+
await props.action(selected.value)
|
|
29
|
+
} catch {
|
|
30
|
+
// Already said by the request's own error toast; the modal closes.
|
|
31
|
+
} finally {
|
|
32
|
+
deleting.value = false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<SConfirmModal
|
|
39
|
+
:modal-props="{ title: t('automations.delete.title') }"
|
|
40
|
+
:confirm-props="{ label: t('common.actions.delete'), disabled: isPending }"
|
|
41
|
+
:cancel-props="{ label: t('common.actions.cancel') }"
|
|
42
|
+
:loading="deleting"
|
|
43
|
+
:action="confirmDelete"
|
|
44
|
+
destructive
|
|
45
|
+
>
|
|
46
|
+
<template #body>
|
|
47
|
+
<div class="space-y-4">
|
|
48
|
+
<p class="my-0 text-muted">{{ t('automations.delete.confirm') }}</p>
|
|
49
|
+
|
|
50
|
+
<!-- No delete before the choice is on screen: it would skip it. -->
|
|
51
|
+
<USkeleton v-if="isPending" class="h-12 w-full" />
|
|
52
|
+
|
|
53
|
+
<template v-else-if="items.length > 0">
|
|
54
|
+
<UCheckboxGroup
|
|
55
|
+
v-model="selected"
|
|
56
|
+
:items="items"
|
|
57
|
+
:legend="t('automations.delete.messages')"
|
|
58
|
+
:disabled="deleting"
|
|
59
|
+
/>
|
|
60
|
+
<p class="my-0 flex items-start gap-1.5 paragraph-sm text-warning">
|
|
61
|
+
<UIcon name="ph:warning" class="mt-0.5 shrink-0" />
|
|
62
|
+
{{ t('automations.delete.warning') }}
|
|
63
|
+
</p>
|
|
64
|
+
</template>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
</SConfirmModal>
|
|
68
|
+
</template>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Automation, AutomationCatalog } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import { resolveErrorToast } from '~/composables/apiErrorMessages'
|
|
4
|
+
import type { AutomationsContext } from '~/composables/automationsContext'
|
|
5
|
+
import { useUpdateAutomationMutation } from '~/queries'
|
|
6
|
+
import {
|
|
7
|
+
automationPartlySaved,
|
|
8
|
+
automationSaveProblems,
|
|
9
|
+
automationSetupProperties,
|
|
10
|
+
cloneFormData,
|
|
11
|
+
toAutomationForm,
|
|
12
|
+
toUpdateAutomationBody,
|
|
13
|
+
withPartlySavedActions,
|
|
14
|
+
type AutomationFormState,
|
|
15
|
+
} from '../../utils/automations/form'
|
|
16
|
+
import { automationMetadataForm, isSystemAutomation } from '../../utils/automations/metadata'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An existing Automation: the form, its save, and what a refused or a
|
|
20
|
+
* half-saved save says. The page around it holds the header and calls
|
|
21
|
+
* `submit`.
|
|
22
|
+
*/
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
catalog: AutomationCatalog
|
|
25
|
+
/** The Automation as it was read. The form starts from it and does not follow it. */
|
|
26
|
+
automation: Automation
|
|
27
|
+
context: AutomationsContext
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const { t } = useI18n()
|
|
31
|
+
const toast = useToast()
|
|
32
|
+
const { trackEvent } = useTracking()
|
|
33
|
+
|
|
34
|
+
const trip = useAutomationEditorTrip('automation-edit', props.automation.id)
|
|
35
|
+
|
|
36
|
+
/** The Automation as the Automation Engine holds it: what a save is an update of. */
|
|
37
|
+
const stored = shallowRef(trip.restored?.stored ?? props.automation)
|
|
38
|
+
const form = ref<AutomationFormState>(trip.restored?.form ?? toAutomationForm(props.automation))
|
|
39
|
+
const submitted = ref(false)
|
|
40
|
+
const formElement = useTemplateRef('formElement')
|
|
41
|
+
|
|
42
|
+
/** A System Automation: the form lets through only what its user may change. */
|
|
43
|
+
const system = computed(() => isSystemAutomation(stored.value))
|
|
44
|
+
const dataForm = computed(() => automationMetadataForm(stored.value.metadata))
|
|
45
|
+
const { askClone } = useAutomationClone()
|
|
46
|
+
|
|
47
|
+
const incomplete = computed(() => !form.value.name.trim() || form.value.actions.length === 0)
|
|
48
|
+
|
|
49
|
+
const problems = useAutomationSaveProblems(() => props.catalog)
|
|
50
|
+
|
|
51
|
+
const { mutateAsync: updateAutomation, isLoading: saving } = useUpdateAutomationMutation()
|
|
52
|
+
|
|
53
|
+
async function submit() {
|
|
54
|
+
submitted.value = true
|
|
55
|
+
problems.clear()
|
|
56
|
+
// Something unavailable stops the save: nothing is sent, the alert says what to fix.
|
|
57
|
+
if (formElement.value?.blocked) {
|
|
58
|
+
formElement.value.showUnavailable()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (incomplete.value || saving.value) return
|
|
62
|
+
|
|
63
|
+
const sent = cloneFormData(form.value)
|
|
64
|
+
try {
|
|
65
|
+
await updateAutomation({ id: stored.value.id, ...toUpdateAutomationBody(sent, stored.value) })
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const refused = automationSaveProblems(error)
|
|
68
|
+
const partlySaved = automationPartlySaved(error)
|
|
69
|
+
if (refused) {
|
|
70
|
+
problems.show(refused, sent)
|
|
71
|
+
} else if (partlySaved) {
|
|
72
|
+
// Nothing was rolled back (ADR 0016): the next save is an update of
|
|
73
|
+
// what the Automation Engine now holds; the form stays as the user left it.
|
|
74
|
+
stored.value = partlySaved.automation
|
|
75
|
+
form.value.actions = withPartlySavedActions(form.value.actions, sent.actions, partlySaved)
|
|
76
|
+
toast.add({
|
|
77
|
+
title: t('automations.toast.partly_saved'),
|
|
78
|
+
description: t('automations.toast.partly_saved_description'),
|
|
79
|
+
color: 'error',
|
|
80
|
+
})
|
|
81
|
+
} else {
|
|
82
|
+
// The request carries no error toast of its own, so a 422 gets none.
|
|
83
|
+
const { data, status = 0 } = error as { data?: { code?: string }; status?: number }
|
|
84
|
+
toast.add({ ...resolveErrorToast({ status, code: data?.code }, t), color: 'error' })
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
trackEvent(
|
|
90
|
+
'Automation setup',
|
|
91
|
+
automationSetupProperties(
|
|
92
|
+
sent,
|
|
93
|
+
props.catalog.triggers.find(({ type }) => type === sent.triggerType),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
toast.add({ title: t('automations.toast.updated'), color: 'success' })
|
|
97
|
+
await navigateTo({ name: 'automations-list' })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
defineExpose({ submit, saving })
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<template>
|
|
104
|
+
<UAlert
|
|
105
|
+
v-if="system"
|
|
106
|
+
color="info"
|
|
107
|
+
variant="soft"
|
|
108
|
+
icon="ph:lock-simple-bold"
|
|
109
|
+
:title="t('automations.system.alert')"
|
|
110
|
+
:actions="[
|
|
111
|
+
{
|
|
112
|
+
label: t('common.actions.duplicate'),
|
|
113
|
+
color: 'info',
|
|
114
|
+
variant: 'outline',
|
|
115
|
+
size: 'sm',
|
|
116
|
+
onClick: () => askClone(stored),
|
|
117
|
+
},
|
|
118
|
+
]"
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
<AutomationsSaveProblems :lines="problems.lines.value" :incomplete="submitted && incomplete" />
|
|
122
|
+
|
|
123
|
+
<AutomationsForm
|
|
124
|
+
ref="formElement"
|
|
125
|
+
v-model="form"
|
|
126
|
+
:catalog="catalog"
|
|
127
|
+
:context="context"
|
|
128
|
+
:submitted="submitted"
|
|
129
|
+
:invalid-action-keys="problems.invalidActionKeys.value"
|
|
130
|
+
:open-action="trip.restored?.openAction"
|
|
131
|
+
:data-form="dataForm"
|
|
132
|
+
:system="system"
|
|
133
|
+
@edit-message="trip.leave({ form, stored }, $event)"
|
|
134
|
+
/>
|
|
135
|
+
</template>
|