@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,215 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
Condition,
|
|
4
|
+
FilterField,
|
|
5
|
+
FilterOptionValue,
|
|
6
|
+
FiltersReport,
|
|
7
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
8
|
+
import {
|
|
9
|
+
conditionsToFilterRows,
|
|
10
|
+
FILTER_OPERATOR_LABEL_KEYS,
|
|
11
|
+
filterCurrency,
|
|
12
|
+
isFilterRange,
|
|
13
|
+
missingFilterOptions,
|
|
14
|
+
rawConditionValue,
|
|
15
|
+
reportFilterRows,
|
|
16
|
+
type FilterEditableRow,
|
|
17
|
+
type FilterRow,
|
|
18
|
+
type FilterRowValue,
|
|
19
|
+
} from '../../utils/filters/rows'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Public (ADR 0018): a list of Conditions, read-only, one line each.
|
|
23
|
+
* Props-only, as `FiltersEditor`, and it leaves no Condition out: one the
|
|
24
|
+
* editor cannot edit shows as unavailable.
|
|
25
|
+
*/
|
|
26
|
+
const props = withDefaults(
|
|
27
|
+
defineProps<{
|
|
28
|
+
modelValue: Condition[]
|
|
29
|
+
/** The fields a Condition can be on. */
|
|
30
|
+
fields: FilterField[]
|
|
31
|
+
/** ISO code of the currency `price` fields show; EUR when absent. */
|
|
32
|
+
currency?: string
|
|
33
|
+
/** How many lines show before "View more". */
|
|
34
|
+
preview?: number
|
|
35
|
+
testIdPrefix?: string
|
|
36
|
+
}>(),
|
|
37
|
+
{ currency: undefined, preview: 3, testIdPrefix: undefined },
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const emit = defineEmits<{ report: [report: FiltersReport] }>()
|
|
41
|
+
|
|
42
|
+
const { t, locale } = useI18n()
|
|
43
|
+
|
|
44
|
+
interface Line {
|
|
45
|
+
id: number
|
|
46
|
+
/** "Total price is at least:". */
|
|
47
|
+
heading: string
|
|
48
|
+
value: string
|
|
49
|
+
/** Shown in place of `value`, which then goes in a tooltip. */
|
|
50
|
+
count?: string
|
|
51
|
+
/** Option values the field does not list, struck through. */
|
|
52
|
+
missing: string[]
|
|
53
|
+
/** Why the line is an unavailable one, as a sentence. */
|
|
54
|
+
unavailable?: string
|
|
55
|
+
/** A stored value, shown as it is. */
|
|
56
|
+
raw: boolean
|
|
57
|
+
/** A field the caller did not declare: the whole line shows as stored. */
|
|
58
|
+
unknown: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fieldsByKey = computed(() => new Map(props.fields.map((field) => [field.key, field])))
|
|
62
|
+
|
|
63
|
+
const rows = computed(() => {
|
|
64
|
+
let id = 0
|
|
65
|
+
return conditionsToFilterRows(props.modelValue, props.fields, () => ++id)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const report = computed(() => reportFilterRows(rows.value, props.fields))
|
|
69
|
+
watch(report, (value) => emit('report', value), { immediate: true })
|
|
70
|
+
|
|
71
|
+
function formatValue(row: FilterEditableRow, value: FilterRowValue): string {
|
|
72
|
+
if (value === undefined || Array.isArray(value)) return ''
|
|
73
|
+
switch (row.type) {
|
|
74
|
+
case 'price':
|
|
75
|
+
return new Intl.NumberFormat(locale.value, {
|
|
76
|
+
style: 'currency',
|
|
77
|
+
currency: filterCurrency(props.currency),
|
|
78
|
+
}).format(value as number)
|
|
79
|
+
case 'number':
|
|
80
|
+
return new Intl.NumberFormat(locale.value).format(value as number)
|
|
81
|
+
case 'date':
|
|
82
|
+
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeZone: 'UTC' }).format(
|
|
83
|
+
new Date(`${value}T00:00:00Z`),
|
|
84
|
+
)
|
|
85
|
+
default:
|
|
86
|
+
return String(value)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function editableLine(row: FilterEditableRow): Line {
|
|
91
|
+
const field = fieldsByKey.value.get(row.field)
|
|
92
|
+
const operator = t(`filters.operators.${FILTER_OPERATOR_LABEL_KEYS[row.operator]}`)
|
|
93
|
+
const line: Line = {
|
|
94
|
+
id: row.id,
|
|
95
|
+
heading: `${field?.label ?? row.field} ${operator}`,
|
|
96
|
+
value: '',
|
|
97
|
+
missing: [],
|
|
98
|
+
raw: false,
|
|
99
|
+
unknown: false,
|
|
100
|
+
}
|
|
101
|
+
if (row.operator === 'exists') return line
|
|
102
|
+
line.heading += ':'
|
|
103
|
+
|
|
104
|
+
if (row.type === 'select' || row.type === 'multiselect') {
|
|
105
|
+
const missing = missingFilterOptions(row, field)
|
|
106
|
+
const selected: FilterOptionValue[] = Array.isArray(row.value)
|
|
107
|
+
? row.value
|
|
108
|
+
: row.value === undefined
|
|
109
|
+
? []
|
|
110
|
+
: [row.value]
|
|
111
|
+
const labels = (field?.options ?? [])
|
|
112
|
+
.filter((option) => selected.includes(option.value))
|
|
113
|
+
.map((option) => option.label)
|
|
114
|
+
|
|
115
|
+
line.value = labels.join(', ')
|
|
116
|
+
line.missing = missing.map(String)
|
|
117
|
+
if (labels.length > 1) line.count = t('filters.selected', { count: labels.length })
|
|
118
|
+
return line
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
line.value = isFilterRange(row.operator)
|
|
122
|
+
? `${formatValue(row, row.value)} - ${formatValue(row, row.to)}`
|
|
123
|
+
: formatValue(row, row.value)
|
|
124
|
+
return line
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toLine(row: FilterRow): Line | undefined {
|
|
128
|
+
if (row.kind === 'blank') return undefined
|
|
129
|
+
if (row.kind === 'editable') return editableLine(row)
|
|
130
|
+
|
|
131
|
+
const { field: fieldKey, operator, value } = row.condition
|
|
132
|
+
const field = fieldsByKey.value.get(fieldKey)
|
|
133
|
+
const operatorKey = FILTER_OPERATOR_LABEL_KEYS[operator]
|
|
134
|
+
return {
|
|
135
|
+
id: row.id,
|
|
136
|
+
heading:
|
|
137
|
+
field && operatorKey
|
|
138
|
+
? `${field.label} ${t(`filters.operators.${operatorKey}`)}`
|
|
139
|
+
: `${fieldKey} ${operator}`,
|
|
140
|
+
value: rawConditionValue(value),
|
|
141
|
+
missing: [],
|
|
142
|
+
unavailable: t(`filters.unavailable.${row.reason}`),
|
|
143
|
+
raw: true,
|
|
144
|
+
unknown: !field,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const lines = computed(() => rows.value.flatMap((row) => toLine(row) ?? []))
|
|
149
|
+
|
|
150
|
+
const expanded = ref(false)
|
|
151
|
+
const shown = computed(() => (expanded.value ? lines.value : lines.value.slice(0, props.preview)))
|
|
152
|
+
|
|
153
|
+
const prefix = computed(() => (props.testIdPrefix ? `${props.testIdPrefix}-` : ''))
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<template>
|
|
157
|
+
<div v-if="lines.length" class="space-y-1" :data-testid="`${prefix}filters-summary`">
|
|
158
|
+
<div
|
|
159
|
+
v-for="line in shown"
|
|
160
|
+
:key="line.id"
|
|
161
|
+
class="flex flex-wrap items-baseline gap-1"
|
|
162
|
+
:data-testid="`${prefix}filters-summary-item`"
|
|
163
|
+
>
|
|
164
|
+
<span class="label-sm text-muted" :class="{ 'font-mono': line.unknown }">
|
|
165
|
+
{{ line.heading }}
|
|
166
|
+
</span>
|
|
167
|
+
<UTooltip v-if="line.count" :text="line.value">
|
|
168
|
+
<span class="paragraph-sm text-highlighted">{{ line.count }}</span>
|
|
169
|
+
</UTooltip>
|
|
170
|
+
<span
|
|
171
|
+
v-else-if="line.value"
|
|
172
|
+
class="paragraph-sm text-highlighted"
|
|
173
|
+
:class="{ 'font-mono': line.raw }"
|
|
174
|
+
>
|
|
175
|
+
{{ line.value }}
|
|
176
|
+
</span>
|
|
177
|
+
<UTooltip
|
|
178
|
+
v-for="value in line.missing"
|
|
179
|
+
:key="value"
|
|
180
|
+
:text="t('filters.unavailable.option_description')"
|
|
181
|
+
>
|
|
182
|
+
<span
|
|
183
|
+
class="paragraph-sm text-muted line-through"
|
|
184
|
+
:data-testid="`${prefix}filters-summary-unavailable-option`"
|
|
185
|
+
>
|
|
186
|
+
{{ value }}
|
|
187
|
+
</span>
|
|
188
|
+
</UTooltip>
|
|
189
|
+
<UTooltip v-if="line.unavailable" :text="line.unavailable">
|
|
190
|
+
<UBadge
|
|
191
|
+
color="warning"
|
|
192
|
+
variant="subtle"
|
|
193
|
+
size="sm"
|
|
194
|
+
:data-testid="`${prefix}filters-summary-unavailable-badge`"
|
|
195
|
+
>
|
|
196
|
+
{{ t('filters.unavailable.badge') }}
|
|
197
|
+
</UBadge>
|
|
198
|
+
</UTooltip>
|
|
199
|
+
</div>
|
|
200
|
+
<UButton
|
|
201
|
+
v-if="lines.length > preview"
|
|
202
|
+
variant="link"
|
|
203
|
+
color="neutral"
|
|
204
|
+
size="xs"
|
|
205
|
+
class="px-0"
|
|
206
|
+
:label="t(expanded ? 'common.view_less' : 'common.view_more')"
|
|
207
|
+
:trailing-icon="expanded ? 'ph:caret-up-bold' : 'ph:caret-down-bold'"
|
|
208
|
+
:data-testid="`${prefix}filters-summary-toggle`"
|
|
209
|
+
@click="expanded = !expanded"
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
<div v-else :data-testid="`${prefix}filters-summary`">
|
|
213
|
+
<span class="paragraph-sm text-muted">-</span>
|
|
214
|
+
</div>
|
|
215
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FilterField } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import {
|
|
4
|
+
FILTER_OPERATOR_LABEL_KEYS,
|
|
5
|
+
rawConditionValue,
|
|
6
|
+
type FilterUnavailableRow,
|
|
7
|
+
} from '../../utils/filters/rows'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Internal to the filter components: a Condition the editor cannot edit,
|
|
11
|
+
* locked and removable. On a field the caller did not declare it shows raw.
|
|
12
|
+
*/
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
row: FilterUnavailableRow
|
|
15
|
+
field: FilterField | undefined
|
|
16
|
+
testId: string
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{ remove: [] }>()
|
|
20
|
+
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
|
|
23
|
+
const isUnknownField = computed(() => props.row.reason === 'unknown_field')
|
|
24
|
+
|
|
25
|
+
const operatorLabel = computed(() => {
|
|
26
|
+
const { operator } = props.row.condition
|
|
27
|
+
const key = FILTER_OPERATOR_LABEL_KEYS[operator]
|
|
28
|
+
// A stored operator the engine's vocabulary does not have shows as it is.
|
|
29
|
+
return isUnknownField.value || !key ? operator : t(`filters.operators.${key}`)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const rawValue = computed(() => rawConditionValue(props.row.condition.value))
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div class="flex gap-2" :data-testid="testId">
|
|
37
|
+
<div
|
|
38
|
+
class="flex-1 grid grid-cols-5 gap-2 items-center rounded-md border border-dashed border-muted bg-elevated/50 px-2.5 py-1.5 min-h-8"
|
|
39
|
+
>
|
|
40
|
+
<p
|
|
41
|
+
class="col-span-2 paragraph-sm truncate"
|
|
42
|
+
:class="{ 'font-mono': isUnknownField }"
|
|
43
|
+
:data-testid="`${testId}-field`"
|
|
44
|
+
>
|
|
45
|
+
{{ field?.label ?? row.condition.field }}
|
|
46
|
+
</p>
|
|
47
|
+
<p class="paragraph-sm truncate" :class="{ 'font-mono': isUnknownField }">
|
|
48
|
+
{{ operatorLabel }}
|
|
49
|
+
</p>
|
|
50
|
+
<div class="col-span-2 flex items-center justify-between gap-2 min-w-0">
|
|
51
|
+
<p class="paragraph-sm font-mono truncate">{{ rawValue }}</p>
|
|
52
|
+
<UTooltip :text="t(`filters.unavailable.${row.reason}`)">
|
|
53
|
+
<UBadge
|
|
54
|
+
:color="isUnknownField ? 'error' : 'warning'"
|
|
55
|
+
variant="subtle"
|
|
56
|
+
class="shrink-0"
|
|
57
|
+
:data-testid="`${testId}-badge`"
|
|
58
|
+
>
|
|
59
|
+
{{ t('filters.unavailable.badge') }}
|
|
60
|
+
</UBadge>
|
|
61
|
+
</UTooltip>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<UButton
|
|
65
|
+
color="error"
|
|
66
|
+
variant="ghost"
|
|
67
|
+
icon="ph:trash"
|
|
68
|
+
class="shrink-0"
|
|
69
|
+
:aria-label="t('filters.actions.remove_filter')"
|
|
70
|
+
:data-testid="`${testId}-delete-button`"
|
|
71
|
+
@click="emit('remove')"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FilterField, FilterOptionValue } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import {
|
|
4
|
+
isFilterRange,
|
|
5
|
+
missingFilterOptions,
|
|
6
|
+
type FilterEditableRow,
|
|
7
|
+
type FilterRowValue,
|
|
8
|
+
} from '../../utils/filters/rows'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Internal to the filter components: the value of one row, by field type.
|
|
12
|
+
* An option value the field does not list is shown as unavailable and kept
|
|
13
|
+
* until the user removes it (ADR 0017).
|
|
14
|
+
*/
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
row: FilterEditableRow
|
|
17
|
+
field: FilterField
|
|
18
|
+
currency: string
|
|
19
|
+
testId: string
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
'update:value': [value: FilterRowValue]
|
|
24
|
+
'update:to': [value: FilterRowValue]
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const { t } = useI18n()
|
|
28
|
+
|
|
29
|
+
const isRange = computed(() => isFilterRange(props.row.operator))
|
|
30
|
+
const options = computed(() => props.field.options ?? [])
|
|
31
|
+
const missing = computed(() => missingFilterOptions(props.row, props.field))
|
|
32
|
+
|
|
33
|
+
// Select: the missing value stays the selected item, so it shows and is not lost.
|
|
34
|
+
const selectItems = computed(() => [
|
|
35
|
+
...options.value,
|
|
36
|
+
...missing.value.map((value) => ({
|
|
37
|
+
value,
|
|
38
|
+
label: t('filters.unavailable.option'),
|
|
39
|
+
disabled: true,
|
|
40
|
+
})),
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
const selected = computed<FilterOptionValue[]>(() =>
|
|
44
|
+
Array.isArray(props.row.value) ? props.row.value : [],
|
|
45
|
+
)
|
|
46
|
+
const selectedKnown = computed(() =>
|
|
47
|
+
selected.value.filter((value) => !missing.value.includes(value)),
|
|
48
|
+
)
|
|
49
|
+
const selectedLabels = computed(() =>
|
|
50
|
+
options.value
|
|
51
|
+
.filter((option) => selectedKnown.value.includes(option.value))
|
|
52
|
+
.map((option) => option.label),
|
|
53
|
+
)
|
|
54
|
+
const selectionLabel = computed(() =>
|
|
55
|
+
selectedLabels.value.length === 1
|
|
56
|
+
? selectedLabels.value[0]
|
|
57
|
+
: t('filters.selected', { count: selectedLabels.value.length }),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
function onMultiselect(values: FilterOptionValue[]) {
|
|
61
|
+
emit('update:value', [...values, ...missing.value])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function removeMissing(value: FilterOptionValue) {
|
|
65
|
+
emit(
|
|
66
|
+
'update:value',
|
|
67
|
+
selected.value.filter((item) => item !== value),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const numberFormat = computed<Intl.NumberFormatOptions | undefined>(() =>
|
|
72
|
+
props.field.type === 'price'
|
|
73
|
+
? { style: 'currency', currency: props.currency, currencyDisplay: 'narrowSymbol' }
|
|
74
|
+
: undefined,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
function asNumber(value: FilterRowValue): number | undefined {
|
|
78
|
+
return typeof value === 'number' ? value : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asString(value: FilterRowValue): string | undefined {
|
|
82
|
+
return typeof value === 'string' ? value : undefined
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<div v-if="row.operator === 'exists'" />
|
|
88
|
+
|
|
89
|
+
<USelectMenu
|
|
90
|
+
v-else-if="field.type === 'select'"
|
|
91
|
+
:model-value="row.value as FilterOptionValue | undefined"
|
|
92
|
+
:items="selectItems"
|
|
93
|
+
value-key="value"
|
|
94
|
+
placeholder=" "
|
|
95
|
+
class="w-full"
|
|
96
|
+
:ui="{ content: 'min-w-fit' }"
|
|
97
|
+
:search-input="{ placeholder: t('common.search') }"
|
|
98
|
+
:data-testid="`${testId}-select`"
|
|
99
|
+
@update:model-value="emit('update:value', $event)"
|
|
100
|
+
>
|
|
101
|
+
<UBadge
|
|
102
|
+
v-if="missing.length"
|
|
103
|
+
color="warning"
|
|
104
|
+
variant="outline"
|
|
105
|
+
class="border border-dashed ring-0 truncate"
|
|
106
|
+
:data-testid="`${testId}-unavailable-option`"
|
|
107
|
+
>
|
|
108
|
+
{{ t('filters.unavailable.option') }}
|
|
109
|
+
</UBadge>
|
|
110
|
+
</USelectMenu>
|
|
111
|
+
|
|
112
|
+
<div v-else-if="field.type === 'multiselect'" class="flex flex-col gap-1">
|
|
113
|
+
<USelectMenu
|
|
114
|
+
:model-value="selectedKnown"
|
|
115
|
+
multiple
|
|
116
|
+
:items="options"
|
|
117
|
+
value-key="value"
|
|
118
|
+
placeholder=" "
|
|
119
|
+
class="w-full"
|
|
120
|
+
virtualize
|
|
121
|
+
:reset-search-term-on-select="false"
|
|
122
|
+
:ui="{ content: 'min-w-fit' }"
|
|
123
|
+
:search-input="{
|
|
124
|
+
placeholder: t('common.search'),
|
|
125
|
+
'data-testid': `${testId}-multiselect-search`,
|
|
126
|
+
}"
|
|
127
|
+
:data-testid="`${testId}-multiselect`"
|
|
128
|
+
@update:model-value="onMultiselect"
|
|
129
|
+
>
|
|
130
|
+
<UTooltip v-if="selectedLabels.length" :text="selectedLabels.join(', ')">
|
|
131
|
+
<UBadge color="secondary" class="truncate">
|
|
132
|
+
{{ selectionLabel }}
|
|
133
|
+
</UBadge>
|
|
134
|
+
</UTooltip>
|
|
135
|
+
</USelectMenu>
|
|
136
|
+
|
|
137
|
+
<div v-if="missing.length" class="flex flex-wrap gap-1">
|
|
138
|
+
<UTooltip
|
|
139
|
+
v-for="value in missing"
|
|
140
|
+
:key="value"
|
|
141
|
+
:text="t('filters.unavailable.option_description')"
|
|
142
|
+
>
|
|
143
|
+
<UBadge
|
|
144
|
+
color="warning"
|
|
145
|
+
variant="outline"
|
|
146
|
+
class="border border-dashed ring-0 gap-1"
|
|
147
|
+
:data-testid="`${testId}-unavailable-option`"
|
|
148
|
+
>
|
|
149
|
+
{{ t('filters.unavailable.option') }}
|
|
150
|
+
<UButton
|
|
151
|
+
color="warning"
|
|
152
|
+
variant="link"
|
|
153
|
+
size="xs"
|
|
154
|
+
icon="ph:x-bold"
|
|
155
|
+
class="p-0"
|
|
156
|
+
:aria-label="t('filters.actions.remove_option')"
|
|
157
|
+
:data-testid="`${testId}-unavailable-option-remove`"
|
|
158
|
+
@click="removeMissing(value)"
|
|
159
|
+
/>
|
|
160
|
+
</UBadge>
|
|
161
|
+
</UTooltip>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div
|
|
166
|
+
v-else-if="field.type === 'number' || field.type === 'price'"
|
|
167
|
+
class="flex items-center gap-2"
|
|
168
|
+
>
|
|
169
|
+
<UInputNumber
|
|
170
|
+
:model-value="asNumber(row.value)"
|
|
171
|
+
class="w-full"
|
|
172
|
+
:format-options="numberFormat"
|
|
173
|
+
:increment="!isRange"
|
|
174
|
+
:decrement="!isRange"
|
|
175
|
+
:data-testid="isRange ? `${testId}-from-input` : `${testId}-input`"
|
|
176
|
+
@update:model-value="emit('update:value', $event ?? undefined)"
|
|
177
|
+
/>
|
|
178
|
+
<template v-if="isRange">
|
|
179
|
+
<p class="label-xs">{{ t('filters.and') }}</p>
|
|
180
|
+
<UInputNumber
|
|
181
|
+
:model-value="asNumber(row.to)"
|
|
182
|
+
class="w-full"
|
|
183
|
+
:format-options="numberFormat"
|
|
184
|
+
:increment="false"
|
|
185
|
+
:decrement="false"
|
|
186
|
+
:data-testid="`${testId}-to-input`"
|
|
187
|
+
@update:model-value="emit('update:to', $event ?? undefined)"
|
|
188
|
+
/>
|
|
189
|
+
</template>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div v-else-if="field.type === 'date'" class="flex items-center gap-2">
|
|
193
|
+
<FiltersDateInput
|
|
194
|
+
:model-value="asString(row.value)"
|
|
195
|
+
:test-id="isRange ? `${testId}-from` : testId"
|
|
196
|
+
@update:model-value="emit('update:value', $event)"
|
|
197
|
+
/>
|
|
198
|
+
<template v-if="isRange">
|
|
199
|
+
<p class="label-xs">{{ t('filters.and') }}</p>
|
|
200
|
+
<FiltersDateInput
|
|
201
|
+
:model-value="asString(row.to)"
|
|
202
|
+
:test-id="`${testId}-to`"
|
|
203
|
+
@update:model-value="emit('update:to', $event)"
|
|
204
|
+
/>
|
|
205
|
+
</template>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<UInput
|
|
209
|
+
v-else
|
|
210
|
+
:model-value="asString(row.value)"
|
|
211
|
+
class="w-full"
|
|
212
|
+
:data-testid="`${testId}-text`"
|
|
213
|
+
@update:model-value="emit('update:value', $event === '' ? undefined : String($event))"
|
|
214
|
+
/>
|
|
215
|
+
</template>
|
|
@@ -11,6 +11,7 @@ const props = defineProps<{
|
|
|
11
11
|
|
|
12
12
|
const emit = defineEmits<{
|
|
13
13
|
tabChange: [value: string | number]
|
|
14
|
+
back: []
|
|
14
15
|
}>()
|
|
15
16
|
|
|
16
17
|
watchEffect(() => {
|
|
@@ -28,6 +29,7 @@ watchEffect(() => {
|
|
|
28
29
|
:data-testid="testId"
|
|
29
30
|
class="bg-white sticky top-0 z-10"
|
|
30
31
|
@tab-change="emit('tabChange', $event)"
|
|
32
|
+
@back="emit('back')"
|
|
31
33
|
>
|
|
32
34
|
<template #actions>
|
|
33
35
|
<div class="flex items-center gap-1.5">
|
|
@@ -322,17 +322,6 @@ const paginationSummary = computed(() => {
|
|
|
322
322
|
v-model:sorting="sorting"
|
|
323
323
|
:data-testid="templateListTableTestIds.table"
|
|
324
324
|
>
|
|
325
|
-
<!-- Shown instead of the "no data" row while a list with no rows is loading. -->
|
|
326
|
-
<template #loading>
|
|
327
|
-
<div
|
|
328
|
-
class="flex items-center justify-center gap-2 text-muted"
|
|
329
|
-
:data-testid="templateListTableTestIds.loading"
|
|
330
|
-
>
|
|
331
|
-
<UIcon name="ph:circle-notch-bold" class="size-4 animate-spin" />
|
|
332
|
-
<span>{{ t('pages.message_library.loading') }}</span>
|
|
333
|
-
</div>
|
|
334
|
-
</template>
|
|
335
|
-
|
|
336
325
|
<template #name-cell="{ row }">
|
|
337
326
|
<div
|
|
338
327
|
class="flex items-center gap-2"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const CODE_TO_KEY: Record<string, string> = {
|
|
2
2
|
'session.missing': 'common.errors.session_expired',
|
|
3
3
|
'session.expired': 'common.errors.session_expired',
|
|
4
|
+
AUTOMATION_ENGINE_REJECTED: 'automations.toast.engine_rejected',
|
|
4
5
|
}
|
|
5
6
|
|
|
6
7
|
function descriptionKeyByStatus(status: number): string {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/** A value is what a Condition stores: a number where the Product's data holds one. */
|
|
4
|
+
export interface AutomationOption {
|
|
5
|
+
value: string | number
|
|
6
|
+
label: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a Host supplies to the Automation pages (ADR 0017), apart from
|
|
11
|
+
* `ComposerContext`. The Host fetches the lists and keeps them current.
|
|
12
|
+
*/
|
|
13
|
+
export interface AutomationsContext {
|
|
14
|
+
/**
|
|
15
|
+
* The option lists, by the name the Automation Catalog gives them
|
|
16
|
+
* (`option_list` of a Condition Field). A list that is not there shows as
|
|
17
|
+
* empty.
|
|
18
|
+
*/
|
|
19
|
+
optionLists: Ref<Record<string, AutomationOption[]>>
|
|
20
|
+
/** ISO code of the currency `price` fields show; EUR when absent. */
|
|
21
|
+
currency?: Ref<string | undefined>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const AUTOMATIONS_CONTEXT_KEY: InjectionKey<AutomationsContext> = Symbol.for(
|
|
25
|
+
'composer-automations-context',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/** Must be called in parent's app.vue or plugin before an Automation page renders. */
|
|
29
|
+
export function defineAutomationsContext(context: AutomationsContext): void {
|
|
30
|
+
// App-level provide, as `defineComposerContext`.
|
|
31
|
+
useNuxtApp().vueApp.provide(AUTOMATIONS_CONTEXT_KEY, context)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `undefined` when the Host provided none: an Automation page shows that as
|
|
36
|
+
* an error of its own (ADR 0019), so this does not throw.
|
|
37
|
+
*/
|
|
38
|
+
export function useAutomationsContext(): AutomationsContext | undefined {
|
|
39
|
+
if (import.meta.server) return undefined
|
|
40
|
+
|
|
41
|
+
return useNuxtApp().vueApp.runWithContext(() => inject(AUTOMATIONS_CONTEXT_KEY, undefined))
|
|
42
|
+
}
|
|
@@ -22,12 +22,6 @@ export interface ComposerContext {
|
|
|
22
22
|
* the Organization's (ADR 0015).
|
|
23
23
|
*/
|
|
24
24
|
metaWabaId?: Ref<string>
|
|
25
|
-
/**
|
|
26
|
-
* The Host works on `metaWabaId` only (Smartconnect): a new Message Group
|
|
27
|
-
* is submitted to it and the user is not asked. Applies while `metaWabaId`
|
|
28
|
-
* is one of the Organization's WABAs; otherwise the choice stays the user's.
|
|
29
|
-
*/
|
|
30
|
-
metaWabaLocked?: Ref<boolean>
|
|
31
25
|
isAdmin?: Ref<boolean>
|
|
32
26
|
organizationData?: Ref<OrganizationData>
|
|
33
27
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Automation } from '@dev.smartpricing/message-composer-utils/types'
|
|
2
|
+
import AutomationsCloneModal from '~/components/Automations/CloneModal.vue'
|
|
3
|
+
import { useCloneAutomationMutation } from '~/queries'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Duplicating an Automation, from wherever it is offered: the modal that asks
|
|
7
|
+
* the name of the copy, the clone, and the copy opened on its edit page.
|
|
8
|
+
* Called once, in the setup of the component that offers it.
|
|
9
|
+
*/
|
|
10
|
+
export function useAutomationClone() {
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
const toast = useToast()
|
|
13
|
+
const { trackEvent } = useTracking()
|
|
14
|
+
const cloneModal = useOverlay().create(AutomationsCloneModal, { destroyOnClose: true })
|
|
15
|
+
const { mutateAsync: cloneAutomation } = useCloneAutomationMutation()
|
|
16
|
+
|
|
17
|
+
/** Asks the name of the copy, then makes it from the Automation as stored. */
|
|
18
|
+
function askClone(automation: Pick<Automation, 'id' | 'name'>) {
|
|
19
|
+
cloneModal.open({
|
|
20
|
+
defaultName: t('common.copy_of', { name: automation.name }),
|
|
21
|
+
action: async (name) => {
|
|
22
|
+
const copy = await cloneAutomation({ id: automation.id, name })
|
|
23
|
+
trackEvent('automation.clone', { automation_name: name, source_id: automation.id })
|
|
24
|
+
toast.add({ title: t('automations.toast.cloned'), color: 'success' })
|
|
25
|
+
|
|
26
|
+
// In demo mode the answer is not an Automation: there is no copy to open.
|
|
27
|
+
if (copy?.id) await navigateTo({ name: 'automation-edit', params: { id: copy.id } })
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { askClone }
|
|
33
|
+
}
|