@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,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AutomationProblemLine } from '~/composables/useAutomationSaveProblems'
|
|
3
|
+
|
|
4
|
+
/** The alert at the top of the Automation form: why the last save went nowhere. */
|
|
5
|
+
defineProps<{
|
|
6
|
+
/** What a refused save said, each line with the place it is about. */
|
|
7
|
+
lines: AutomationProblemLine[]
|
|
8
|
+
/** The form was submitted with something required missing. */
|
|
9
|
+
incomplete?: boolean
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
function scrollTo(line: AutomationProblemLine) {
|
|
15
|
+
if (!line.target) return
|
|
16
|
+
document.getElementById(line.target)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<UAlert
|
|
22
|
+
v-if="lines.length > 0"
|
|
23
|
+
color="error"
|
|
24
|
+
variant="soft"
|
|
25
|
+
icon="ph:warning-circle-bold"
|
|
26
|
+
:title="t('automations.problems.title')"
|
|
27
|
+
>
|
|
28
|
+
<template #description>
|
|
29
|
+
<ul class="space-y-1">
|
|
30
|
+
<li v-for="(line, index) in lines" :key="index" class="flex items-baseline gap-2">
|
|
31
|
+
<span>{{ line.text }}</span>
|
|
32
|
+
<UButton
|
|
33
|
+
v-if="line.target"
|
|
34
|
+
variant="link"
|
|
35
|
+
color="error"
|
|
36
|
+
size="sm"
|
|
37
|
+
class="p-0 underline"
|
|
38
|
+
:label="t('automations.problems.show')"
|
|
39
|
+
@click="scrollTo(line)"
|
|
40
|
+
/>
|
|
41
|
+
</li>
|
|
42
|
+
</ul>
|
|
43
|
+
</template>
|
|
44
|
+
</UAlert>
|
|
45
|
+
<UAlert
|
|
46
|
+
v-else-if="incomplete"
|
|
47
|
+
color="error"
|
|
48
|
+
variant="soft"
|
|
49
|
+
icon="ph:warning-circle-bold"
|
|
50
|
+
:title="t('automations.form.error_banner')"
|
|
51
|
+
/>
|
|
52
|
+
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AutomationUnavailableItem } from '../../utils/automations/unavailable'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The summary of what an Automation holds that is no longer available: what
|
|
6
|
+
* must be fixed before saving, and what is only kept. An error while
|
|
7
|
+
* something blocks the save, a warning otherwise, nothing when the list is
|
|
8
|
+
* empty. The Automation form shows it at its top, the filter editor and the
|
|
9
|
+
* Action editor at theirs.
|
|
10
|
+
*/
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
items: AutomationUnavailableItem[]
|
|
13
|
+
/** Shown where the things are: no "Show" link. */
|
|
14
|
+
inPlace?: boolean
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{ show: [item: AutomationUnavailableItem] }>()
|
|
18
|
+
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
|
|
21
|
+
const groups = computed(() =>
|
|
22
|
+
[
|
|
23
|
+
{
|
|
24
|
+
title: t('automations.unavailable.to_fix'),
|
|
25
|
+
items: props.items.filter((item) => item.blocks),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
title: t('automations.unavailable.kept'),
|
|
29
|
+
items: props.items.filter((item) => !item.blocks),
|
|
30
|
+
},
|
|
31
|
+
].filter((group) => group.items.length > 0),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const blocked = computed(() => props.items.some((item) => item.blocks))
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<UAlert
|
|
39
|
+
v-if="items.length > 0"
|
|
40
|
+
:color="blocked ? 'error' : 'warning'"
|
|
41
|
+
variant="soft"
|
|
42
|
+
:icon="blocked ? 'ph:warning-circle-bold' : 'ph:warning-bold'"
|
|
43
|
+
:title="
|
|
44
|
+
blocked ? t('automations.unavailable.title_blocked') : t('automations.unavailable.title_kept')
|
|
45
|
+
"
|
|
46
|
+
data-testid="automation-unavailable"
|
|
47
|
+
>
|
|
48
|
+
<template #description>
|
|
49
|
+
<div class="space-y-3">
|
|
50
|
+
<div v-for="group in groups" :key="group.title">
|
|
51
|
+
<p class="label-sm">{{ group.title }}</p>
|
|
52
|
+
<ul class="mt-1 space-y-1">
|
|
53
|
+
<li v-for="item in group.items" :key="item.key">
|
|
54
|
+
<span class="font-semibold">{{ item.where }}:</span>
|
|
55
|
+
{{ item.text }}
|
|
56
|
+
<UButton
|
|
57
|
+
v-if="!inPlace && item.target"
|
|
58
|
+
variant="link"
|
|
59
|
+
:color="blocked ? 'error' : 'warning'"
|
|
60
|
+
size="sm"
|
|
61
|
+
class="p-0 underline"
|
|
62
|
+
:label="t('automations.problems.show')"
|
|
63
|
+
@click="emit('show', item)"
|
|
64
|
+
/>
|
|
65
|
+
</li>
|
|
66
|
+
</ul>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
</UAlert>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AutomationUnavailableItem } from '../../utils/automations/unavailable'
|
|
3
|
+
|
|
4
|
+
/** An unavailable thing marked where it is: a small badge, its sentence in a tooltip. */
|
|
5
|
+
defineProps<{
|
|
6
|
+
item: AutomationUnavailableItem
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { t } = useI18n()
|
|
10
|
+
|
|
11
|
+
const LABEL_KEYS: Record<AutomationUnavailableItem['kind'], string> = {
|
|
12
|
+
trigger: 'unavailable',
|
|
13
|
+
date_reference: 'unavailable',
|
|
14
|
+
condition_unknown_field: 'unavailable',
|
|
15
|
+
condition_not_editable: 'unavailable',
|
|
16
|
+
option: 'unavailable',
|
|
17
|
+
channel: 'not_set_up',
|
|
18
|
+
message_group_inactive: 'not_approved',
|
|
19
|
+
message_group_active: 'not_approved',
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<UTooltip :text="item.text">
|
|
25
|
+
<UBadge
|
|
26
|
+
:color="item.blocks ? 'error' : 'warning'"
|
|
27
|
+
variant="subtle"
|
|
28
|
+
size="sm"
|
|
29
|
+
class="shrink-0"
|
|
30
|
+
:data-testid="`automation-unavailable-badge-${item.kind}`"
|
|
31
|
+
>
|
|
32
|
+
{{ t(`automations.unavailable.badges.${LABEL_KEYS[item.kind]}`) }}
|
|
33
|
+
</UBadge>
|
|
34
|
+
</UTooltip>
|
|
35
|
+
</template>
|
|
@@ -35,6 +35,8 @@ const ELIGIBILITY_RULES: Record<
|
|
|
35
35
|
* menu's search input; pages of 100 load on demand through "Load more".
|
|
36
36
|
* The selected group stays in the menu even when the loaded pages no longer
|
|
37
37
|
* contain it (search cleared, paging reset, or preselected beyond page one).
|
|
38
|
+
* A selected group that cannot be picked is still shown, with its status and
|
|
39
|
+
* whatever the hide filter says: it was selected elsewhere, not offered here.
|
|
38
40
|
*/
|
|
39
41
|
const props = withDefaults(
|
|
40
42
|
defineProps<{
|
|
@@ -186,7 +188,7 @@ const items = computed(() => {
|
|
|
186
188
|
const externalReason = props.disabledGroupReasons?.[group.id]
|
|
187
189
|
const eligible = rule.value.isEligible(group.messages)
|
|
188
190
|
const disabled = !eligible || externalReason != null
|
|
189
|
-
if (hideIneligible.value && disabled) return acc
|
|
191
|
+
if (hideIneligible.value && disabled && group.id !== modelValue.value) return acc
|
|
190
192
|
// External reason wins over the mode's own tooltip when both apply.
|
|
191
193
|
let tooltip: string | undefined
|
|
192
194
|
if (externalReason != null) tooltip = externalReason
|
|
@@ -202,6 +204,21 @@ const items = computed(() => {
|
|
|
202
204
|
}, [])
|
|
203
205
|
})
|
|
204
206
|
|
|
207
|
+
// What keeps a group from being eligible, worst first.
|
|
208
|
+
const BLOCKING_STATUSES = ['rejected', 'pending', 'draft'] as const
|
|
209
|
+
const STATUS_COLORS = { rejected: 'error', pending: 'warning', draft: 'neutral' } as const
|
|
210
|
+
|
|
211
|
+
/** The selected group when it fails the mode's rule, with the status that says why. */
|
|
212
|
+
const selectedIneligible = computed(() => {
|
|
213
|
+
const group = messageGroups.value.find((g) => g.id === modelValue.value)
|
|
214
|
+
if (!group || rule.value.isEligible(group.messages)) return undefined
|
|
215
|
+
// A group with no Message has nothing past a draft.
|
|
216
|
+
const status =
|
|
217
|
+
BLOCKING_STATUSES.find((candidate) => group.messages.some((m) => m.status === candidate)) ??
|
|
218
|
+
'draft'
|
|
219
|
+
return { name: group.name, status, color: STATUS_COLORS[status] }
|
|
220
|
+
})
|
|
221
|
+
|
|
205
222
|
function handleUpdate(value: string) {
|
|
206
223
|
modelValue.value = value
|
|
207
224
|
const group = messageGroups.value.find((g) => g.id === value)
|
|
@@ -224,6 +241,18 @@ function handleUpdate(value: string) {
|
|
|
224
241
|
:data-testid="messageGroupSelectTestIds.root"
|
|
225
242
|
@update:model-value="handleUpdate"
|
|
226
243
|
>
|
|
244
|
+
<template v-if="selectedIneligible" #default>
|
|
245
|
+
<span class="truncate">{{ selectedIneligible.name }}</span>
|
|
246
|
+
<UBadge
|
|
247
|
+
leading-icon="ph:circle-fill"
|
|
248
|
+
:label="t(`status.${selectedIneligible.status}`)"
|
|
249
|
+
size="sm"
|
|
250
|
+
:color="selectedIneligible.color"
|
|
251
|
+
variant="outline"
|
|
252
|
+
class="shrink-0"
|
|
253
|
+
/>
|
|
254
|
+
</template>
|
|
255
|
+
|
|
227
256
|
<template #item-label="{ item }">
|
|
228
257
|
<UTooltip v-if="item.disabled" :text="item.tooltip">
|
|
229
258
|
<span class="text-muted">{{ item.label }}</span>
|
|
@@ -165,8 +165,12 @@ const { formState, draftSchema, validationErrors, validateDraft, validateFull, c
|
|
|
165
165
|
// ── Template operations ───────────────────────────────────────────
|
|
166
166
|
const successFlag = ref(false)
|
|
167
167
|
|
|
168
|
+
const editorTrip = useEditorTripStore()
|
|
169
|
+
|
|
168
170
|
const { saving, createTemplate, updateTemplate } = useTemplateOperations({
|
|
169
|
-
onSuccess: () => {
|
|
171
|
+
onSuccess: (saved) => {
|
|
172
|
+
// Recorded before the editor navigates: whoever opened it reads it on landing.
|
|
173
|
+
if (saved) editorTrip.recordSaved(saved)
|
|
170
174
|
successFlag.value = true
|
|
171
175
|
goBackOrFallback()
|
|
172
176
|
},
|
|
@@ -8,11 +8,13 @@ import type { OrganizationWaba } from '@dev.smartpricing/message-composer-utils/
|
|
|
8
8
|
*/
|
|
9
9
|
const props = defineProps<{
|
|
10
10
|
wabas: OrganizationWaba[]
|
|
11
|
+
/** The Host's WABA, already checked to be one of `wabas`. */
|
|
12
|
+
preselected?: string
|
|
11
13
|
}>()
|
|
12
14
|
|
|
13
15
|
const emit = defineEmits<{ close: [wabaId?: string] }>()
|
|
14
16
|
|
|
15
|
-
const selected = ref<string>()
|
|
17
|
+
const selected = ref<string | undefined>(props.preselected)
|
|
16
18
|
|
|
17
19
|
const items = computed(() =>
|
|
18
20
|
props.wabas.map((waba) => ({
|
|
@@ -134,8 +134,12 @@ const { formState, draftSchema, validationErrors, validateDraft, validateFull, c
|
|
|
134
134
|
// ── Template operations ───────────────────────────────────────────
|
|
135
135
|
const successFlag = ref(false)
|
|
136
136
|
|
|
137
|
+
const editorTrip = useEditorTripStore()
|
|
138
|
+
|
|
137
139
|
const { saving, createTemplate, updateTemplate } = useTemplateOperations({
|
|
138
|
-
onSuccess: () => {
|
|
140
|
+
onSuccess: (saved) => {
|
|
141
|
+
// Recorded before the editor navigates: whoever opened it reads it on landing.
|
|
142
|
+
if (saved) editorTrip.recordSaved(saved)
|
|
139
143
|
successFlag.value = true
|
|
140
144
|
changeTracking.clearAllChanges()
|
|
141
145
|
if (props.mode === 'create') {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { parseDate } from '@internationalized/date'
|
|
3
|
+
|
|
4
|
+
/** Internal to the filter components: one date, as `YYYY-MM-DD`. */
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
modelValue: string | undefined
|
|
7
|
+
testId: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{ 'update:modelValue': [value: string | undefined] }>()
|
|
11
|
+
|
|
12
|
+
const date = computed(() => (props.modelValue ? parseDate(props.modelValue) : undefined))
|
|
13
|
+
|
|
14
|
+
// The inputs also speak ranges and lists; here they only ever hold one date.
|
|
15
|
+
function onUpdate(value: unknown) {
|
|
16
|
+
const isDate = typeof value === 'object' && value !== null && 'year' in value && 'day' in value
|
|
17
|
+
// A date with a time or a zone would still print its date first.
|
|
18
|
+
emit('update:modelValue', isDate ? String(value).slice(0, 10) : undefined)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
|
|
23
|
+
const inputDate = useTemplateRef('inputDate')
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<UInputDate
|
|
28
|
+
ref="inputDate"
|
|
29
|
+
:model-value="date"
|
|
30
|
+
class="w-full"
|
|
31
|
+
:data-testid="`${testId}-date`"
|
|
32
|
+
@update:model-value="onUpdate"
|
|
33
|
+
>
|
|
34
|
+
<template #trailing>
|
|
35
|
+
<UPopover :reference="inputDate?.inputsRef[0]?.$el">
|
|
36
|
+
<UButton
|
|
37
|
+
color="neutral"
|
|
38
|
+
variant="link"
|
|
39
|
+
size="sm"
|
|
40
|
+
icon="ph:calendar-bold"
|
|
41
|
+
:aria-label="t('filters.actions.open_calendar')"
|
|
42
|
+
class="px-0"
|
|
43
|
+
:data-testid="`${testId}-calendar-button`"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<template #content>
|
|
47
|
+
<UCalendar
|
|
48
|
+
:model-value="date"
|
|
49
|
+
class="p-2"
|
|
50
|
+
:data-testid="`${testId}-calendar`"
|
|
51
|
+
@update:model-value="onUpdate"
|
|
52
|
+
/>
|
|
53
|
+
</template>
|
|
54
|
+
</UPopover>
|
|
55
|
+
</template>
|
|
56
|
+
</UInputDate>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
Condition,
|
|
4
|
+
FilterField,
|
|
5
|
+
FiltersReport,
|
|
6
|
+
} from '@dev.smartpricing/message-composer-utils/types'
|
|
7
|
+
import {
|
|
8
|
+
conditionsToFilterRows,
|
|
9
|
+
filterCurrency,
|
|
10
|
+
filterRowsToConditions,
|
|
11
|
+
newFilterRow,
|
|
12
|
+
reportFilterRows,
|
|
13
|
+
type FilterEditableRow,
|
|
14
|
+
type FilterRow,
|
|
15
|
+
} from '../../utils/filters/rows'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Public (ADR 0018): the rows of a list of Conditions, and "add". Props-only:
|
|
19
|
+
* it reads neither the Automation Catalog nor the Host's context. A Condition
|
|
20
|
+
* it cannot edit is an unavailable row, kept as it is until the user removes
|
|
21
|
+
* it; a half-filled row is reported and never emitted.
|
|
22
|
+
*/
|
|
23
|
+
const props = withDefaults(
|
|
24
|
+
defineProps<{
|
|
25
|
+
/** The fields a Condition can be on, in display order. */
|
|
26
|
+
fields: FilterField[]
|
|
27
|
+
/** ISO code of the currency `price` fields show; EUR when absent. */
|
|
28
|
+
currency?: string
|
|
29
|
+
title?: string
|
|
30
|
+
subtitle?: string
|
|
31
|
+
testIdPrefix?: string
|
|
32
|
+
}>(),
|
|
33
|
+
{ currency: undefined, title: undefined, subtitle: undefined, testIdPrefix: undefined },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const model = defineModel<Condition[]>({ required: true })
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{ report: [report: FiltersReport] }>()
|
|
39
|
+
|
|
40
|
+
const { t } = useI18n()
|
|
41
|
+
|
|
42
|
+
let lastId = 0
|
|
43
|
+
const nextId = () => ++lastId
|
|
44
|
+
|
|
45
|
+
const rows = ref<FilterRow[]>([])
|
|
46
|
+
/** What the rows were last worth, to tell the caller's changes from our own. */
|
|
47
|
+
let emitted: string | undefined
|
|
48
|
+
|
|
49
|
+
function readModel() {
|
|
50
|
+
rows.value = conditionsToFilterRows(model.value, props.fields, nextId)
|
|
51
|
+
emitted = JSON.stringify(model.value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Rows the user has not completed live only here: the rows are read again
|
|
55
|
+
// only when the Conditions change from outside, or the fields do.
|
|
56
|
+
watch(
|
|
57
|
+
model,
|
|
58
|
+
(conditions) => {
|
|
59
|
+
if (JSON.stringify(conditions) !== emitted) readModel()
|
|
60
|
+
},
|
|
61
|
+
{ deep: true, immediate: true },
|
|
62
|
+
)
|
|
63
|
+
watch(() => props.fields.map((field) => `${field.key}:${field.type}`).join('|'), readModel)
|
|
64
|
+
|
|
65
|
+
function commit(next: FilterRow[]) {
|
|
66
|
+
const before = JSON.stringify(filterRowsToConditions(rows.value))
|
|
67
|
+
rows.value = next
|
|
68
|
+
const conditions = filterRowsToConditions(next)
|
|
69
|
+
const serialized = JSON.stringify(conditions)
|
|
70
|
+
// A row in progress changes no Condition: nothing to tell the caller.
|
|
71
|
+
if (serialized === before) return
|
|
72
|
+
emitted = serialized
|
|
73
|
+
model.value = conditions
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const report = computed(() => reportFilterRows(rows.value, props.fields))
|
|
77
|
+
watch(report, (value) => emit('report', value), { immediate: true })
|
|
78
|
+
|
|
79
|
+
const shownCurrency = computed(() => filterCurrency(props.currency))
|
|
80
|
+
|
|
81
|
+
const fieldsByKey = computed(() => new Map(props.fields.map((field) => [field.key, field])))
|
|
82
|
+
|
|
83
|
+
function fieldOf(row: FilterRow): FilterField | undefined {
|
|
84
|
+
if (row.kind === 'blank') return undefined
|
|
85
|
+
return fieldsByKey.value.get(row.kind === 'editable' ? row.field : row.condition.field)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A field can be picked once: a field any row is on is taken. */
|
|
89
|
+
const freeFields = computed(() => {
|
|
90
|
+
const taken = new Set(rows.value.map((row) => fieldOf(row)?.key))
|
|
91
|
+
return props.fields.filter((field) => !taken.has(field.key))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
function pickableFields(row: FilterRow): FilterField[] {
|
|
95
|
+
const own = fieldOf(row)
|
|
96
|
+
return props.fields.filter((field) => field === own || freeFields.value.includes(field))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const hasBlankRow = computed(() => rows.value.some((row) => row.kind === 'blank'))
|
|
100
|
+
|
|
101
|
+
function addRow() {
|
|
102
|
+
commit([...rows.value, { kind: 'blank', id: nextId() }])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function replaceRow(id: number, row: FilterEditableRow) {
|
|
106
|
+
commit(rows.value.map((current) => (current.id === id ? row : current)))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pickField(id: number, fieldKey: string) {
|
|
110
|
+
const field = fieldsByKey.value.get(fieldKey)
|
|
111
|
+
if (field) replaceRow(id, newFilterRow(id, field))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function removeRow(id: number) {
|
|
115
|
+
commit(rows.value.filter((row) => row.id !== id))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const prefix = computed(() => (props.testIdPrefix ? `${props.testIdPrefix}-` : ''))
|
|
119
|
+
|
|
120
|
+
function rowTestId(row: FilterRow, index: number): string {
|
|
121
|
+
if (row.kind === 'editable') return `${prefix.value}filter-${row.field}`
|
|
122
|
+
return `${prefix.value}filter-${row.kind === 'blank' ? 'new' : `unavailable-${index}`}`
|
|
123
|
+
}
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<div class="flex flex-col gap-3" :data-testid="`${prefix}filters`">
|
|
128
|
+
<p v-if="title" class="label-md">{{ title }}</p>
|
|
129
|
+
<p v-if="subtitle" class="label-sm">{{ subtitle }}</p>
|
|
130
|
+
|
|
131
|
+
<template v-for="(row, index) in rows" :key="row.id">
|
|
132
|
+
<FiltersUnavailableRow
|
|
133
|
+
v-if="row.kind === 'unavailable'"
|
|
134
|
+
:row="row"
|
|
135
|
+
:field="fieldOf(row)"
|
|
136
|
+
:test-id="rowTestId(row, index)"
|
|
137
|
+
@remove="removeRow(row.id)"
|
|
138
|
+
/>
|
|
139
|
+
<FiltersRow
|
|
140
|
+
v-else
|
|
141
|
+
:row="row"
|
|
142
|
+
:field="fieldOf(row)"
|
|
143
|
+
:pickable-fields="pickableFields(row)"
|
|
144
|
+
:currency="shownCurrency"
|
|
145
|
+
:test-id="rowTestId(row, index)"
|
|
146
|
+
@pick="pickField(row.id, $event)"
|
|
147
|
+
@update="replaceRow(row.id, $event)"
|
|
148
|
+
@remove="removeRow(row.id)"
|
|
149
|
+
/>
|
|
150
|
+
</template>
|
|
151
|
+
|
|
152
|
+
<div>
|
|
153
|
+
<UButton
|
|
154
|
+
:disabled="freeFields.length === 0 || hasBlankRow"
|
|
155
|
+
color="secondary"
|
|
156
|
+
variant="outline"
|
|
157
|
+
:label="t('filters.actions.add_filter')"
|
|
158
|
+
:data-testid="`${prefix}filters-add-button`"
|
|
159
|
+
@click="addRow"
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</template>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FilterField } from '@dev.smartpricing/message-composer-utils/types'
|
|
3
|
+
import {
|
|
4
|
+
FILTER_OPERATOR_LABEL_KEYS,
|
|
5
|
+
filterRowOperators,
|
|
6
|
+
missingFilterOptions,
|
|
7
|
+
withFilterOperator,
|
|
8
|
+
type FilterBlankRow,
|
|
9
|
+
type FilterEditableRow,
|
|
10
|
+
type FilterRowOperator,
|
|
11
|
+
} from '../../utils/filters/rows'
|
|
12
|
+
|
|
13
|
+
/** Internal to the filter components: one row the user can edit. */
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
row: FilterBlankRow | FilterEditableRow
|
|
16
|
+
/** The row's own field, when it has one. */
|
|
17
|
+
field: FilterField | undefined
|
|
18
|
+
/** The fields the row can be on: its own and the ones no other row is on. */
|
|
19
|
+
pickableFields: FilterField[]
|
|
20
|
+
currency: string
|
|
21
|
+
testId: string
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits<{
|
|
25
|
+
/** The user picked a field: the row starts over on it. */
|
|
26
|
+
pick: [fieldKey: string]
|
|
27
|
+
update: [row: FilterEditableRow]
|
|
28
|
+
remove: []
|
|
29
|
+
}>()
|
|
30
|
+
|
|
31
|
+
const { t } = useI18n()
|
|
32
|
+
|
|
33
|
+
const fieldItems = computed(() =>
|
|
34
|
+
props.pickableFields.map((field) => ({ label: field.label, value: field.key })),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const operatorItems = computed(() =>
|
|
38
|
+
props.row.kind === 'editable'
|
|
39
|
+
? filterRowOperators(props.row.type).map((operator) => ({
|
|
40
|
+
label: t(`filters.operators.${FILTER_OPERATOR_LABEL_KEYS[operator]}`),
|
|
41
|
+
value: operator,
|
|
42
|
+
}))
|
|
43
|
+
: [],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const hasMissingOption = computed(
|
|
47
|
+
() => props.row.kind === 'editable' && missingFilterOptions(props.row, props.field).length > 0,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
function onField(fieldKey: string) {
|
|
51
|
+
if (props.row.kind === 'editable' && props.row.field === fieldKey) return
|
|
52
|
+
emit('pick', fieldKey)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onOperator(operator: FilterRowOperator) {
|
|
56
|
+
if (props.row.kind !== 'editable') return
|
|
57
|
+
emit('update', withFilterOperator(props.row, operator))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onValue(patch: Partial<Pick<FilterEditableRow, 'value' | 'to'>>) {
|
|
61
|
+
if (props.row.kind !== 'editable') return
|
|
62
|
+
emit('update', { ...props.row, ...patch, original: undefined })
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div class="flex gap-2" :data-testid="testId">
|
|
68
|
+
<div class="flex-1 grid grid-cols-5 items-start gap-2">
|
|
69
|
+
<USelectMenu
|
|
70
|
+
class="w-full col-span-2"
|
|
71
|
+
:items="fieldItems"
|
|
72
|
+
value-key="value"
|
|
73
|
+
:model-value="row.kind === 'editable' ? row.field : undefined"
|
|
74
|
+
:placeholder="t('filters.field_placeholder')"
|
|
75
|
+
:search-input="{ placeholder: t('common.search') }"
|
|
76
|
+
:data-testid="`${testId}-field-select`"
|
|
77
|
+
@update:model-value="onField"
|
|
78
|
+
/>
|
|
79
|
+
<USelect
|
|
80
|
+
:model-value="row.kind === 'editable' ? row.operator : undefined"
|
|
81
|
+
:disabled="row.kind === 'blank'"
|
|
82
|
+
class="w-full"
|
|
83
|
+
:items="operatorItems"
|
|
84
|
+
:ui="{ content: 'min-w-fit' }"
|
|
85
|
+
:data-testid="`${testId}-operator-select`"
|
|
86
|
+
@update:model-value="onOperator($event as FilterRowOperator)"
|
|
87
|
+
/>
|
|
88
|
+
|
|
89
|
+
<div class="col-span-2 flex items-start gap-2">
|
|
90
|
+
<FiltersValueInput
|
|
91
|
+
v-if="row.kind === 'editable' && field"
|
|
92
|
+
class="flex-1 min-w-0"
|
|
93
|
+
:row="row"
|
|
94
|
+
:field="field"
|
|
95
|
+
:currency="currency"
|
|
96
|
+
:test-id="`${testId}-value`"
|
|
97
|
+
@update:value="onValue({ value: $event })"
|
|
98
|
+
@update:to="onValue({ to: $event })"
|
|
99
|
+
/>
|
|
100
|
+
<UTooltip v-if="hasMissingOption" :text="t('filters.unavailable.option_description')">
|
|
101
|
+
<UIcon
|
|
102
|
+
name="ph:warning-bold"
|
|
103
|
+
class="size-4 mt-2 shrink-0 text-warning"
|
|
104
|
+
:data-testid="`${testId}-unavailable-option-badge`"
|
|
105
|
+
/>
|
|
106
|
+
</UTooltip>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<UButton
|
|
110
|
+
color="error"
|
|
111
|
+
variant="ghost"
|
|
112
|
+
icon="ph:trash"
|
|
113
|
+
class="shrink-0 self-start"
|
|
114
|
+
:aria-label="t('filters.actions.remove_filter')"
|
|
115
|
+
:data-testid="`${testId}-delete-button`"
|
|
116
|
+
@click="emit('remove')"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|