@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.
Files changed (71) hide show
  1. package/README.md +132 -3
  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 +163 -0
  7. package/app/components/Automations/ActionEditor.vue +376 -0
  8. package/app/components/Automations/Actions.vue +141 -0
  9. package/app/components/Automations/CloneModal.vue +74 -0
  10. package/app/components/Automations/Conditions.vue +146 -0
  11. package/app/components/Automations/Create.vue +106 -0
  12. package/app/components/Automations/Data.vue +46 -0
  13. package/app/components/Automations/DeleteModal.vue +68 -0
  14. package/app/components/Automations/Edit.vue +135 -0
  15. package/app/components/Automations/Form.vue +279 -0
  16. package/app/components/Automations/List.vue +343 -0
  17. package/app/components/Automations/PageGate.vue +50 -0
  18. package/app/components/Automations/SaveProblems.vue +52 -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/WhatsappComposer.vue +5 -1
  24. package/app/components/Filters/DateInput.vue +57 -0
  25. package/app/components/Filters/Editor.vue +163 -0
  26. package/app/components/Filters/Row.vue +119 -0
  27. package/app/components/Filters/Summary.vue +215 -0
  28. package/app/components/Filters/UnavailableRow.vue +74 -0
  29. package/app/components/Filters/ValueInput.vue +215 -0
  30. package/app/components/Layout/BasePage.vue +2 -0
  31. package/app/components/TemplateList/BaseTable.vue +0 -11
  32. package/app/composables/automationsContext.ts +42 -0
  33. package/app/composables/composerContext.ts +0 -6
  34. package/app/composables/useAutomationClone.ts +33 -0
  35. package/app/composables/useAutomationEditorTrip.ts +77 -0
  36. package/app/composables/useAutomationSaveProblems.ts +82 -0
  37. package/app/composables/useAutomationUnavailable.ts +98 -0
  38. package/app/composables/useAutomationsGate.ts +68 -0
  39. package/app/composables/useBuiltInOptionLists.ts +44 -0
  40. package/app/composables/useEditorTripBack.ts +15 -0
  41. package/app/composables/useMessageGroupWaba.ts +4 -7
  42. package/app/composables/useTemplateOperations.ts +8 -3
  43. package/app/middleware/automations-enabled.ts +12 -0
  44. package/app/pages/automations/[id].vue +85 -0
  45. package/app/pages/automations/index.vue +27 -0
  46. package/app/pages/automations/new.vue +51 -0
  47. package/app/pages/template/email/[id].vue +5 -0
  48. package/app/pages/template/email/create.vue +5 -0
  49. package/app/pages/template/whatsapp/[id].vue +5 -0
  50. package/app/pages/template/whatsapp/create.vue +5 -0
  51. package/app/queries/automations.ts +206 -0
  52. package/app/queries/index.ts +1 -0
  53. package/app/queries/messageGroups.ts +1 -3
  54. package/app/stores/appContext.ts +0 -3
  55. package/app/stores/editorTrip.ts +67 -0
  56. package/app/types/app-config.d.ts +3 -0
  57. package/app/types/tracking.ts +33 -0
  58. package/app/utils/automations/form.ts +368 -0
  59. package/app/utils/automations/metadata.ts +71 -0
  60. package/app/utils/automations/unavailable.ts +90 -0
  61. package/app/utils/filters/data/countries.json +998 -0
  62. package/app/utils/filters/data/languages.json +754 -0
  63. package/app/utils/filters/rows.ts +367 -0
  64. package/app/utils/testIds.const.ts +0 -1
  65. package/i18n/locales/de.ts +268 -1
  66. package/i18n/locales/en.ts +254 -1
  67. package/i18n/locales/es.ts +257 -1
  68. package/i18n/locales/fr.ts +263 -1
  69. package/i18n/locales/it.ts +259 -1
  70. package/package.json +4 -4
  71. 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
  },
@@ -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>