@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 4.5.1-next.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 (61) hide show
  1. package/README.md +228 -46
  2. package/app/api/brands.ts +20 -13
  3. package/app/api/client.ts +15 -6
  4. package/app/api/compilation.ts +13 -3
  5. package/app/api/dynamicValues.ts +6 -2
  6. package/app/api/media.ts +6 -3
  7. package/app/api/messageGroups.ts +29 -14
  8. package/app/api/meta.ts +26 -4
  9. package/app/api/organization.ts +10 -1
  10. package/app/api/tracking.ts +2 -2
  11. package/app/components/Common/AppContextErrors.vue +30 -0
  12. package/app/components/Common/HtmlPreviewIframe.vue +17 -3
  13. package/app/components/Common/MessageGroupSelect.vue +164 -26
  14. package/app/components/Common/MessagePreviewSlideover.vue +3 -0
  15. package/app/components/Common/OrganizationGate.vue +51 -0
  16. package/app/components/Common/OrganizationSelect.vue +27 -0
  17. package/app/components/Common/WhatsappNotConfigured.vue +20 -0
  18. package/app/components/Composer/EmailComposer.vue +4 -0
  19. package/app/components/Composer/MessageLibrary.vue +17 -7
  20. package/app/components/Composer/WabaSelectModal.vue +75 -0
  21. package/app/components/Composer/WhatsappComposer.vue +78 -3
  22. package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
  23. package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
  24. package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -0
  25. package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
  26. package/app/components/TemplateList/BaseTable.vue +156 -108
  27. package/app/components/Whatsapp/Preview.vue +12 -4
  28. package/app/components/Whatsapp/PreviewPanel.vue +7 -1
  29. package/app/composables/composerContext.ts +9 -7
  30. package/app/composables/useEffectiveOrganization.ts +34 -0
  31. package/app/composables/useMessageGroupList.ts +186 -0
  32. package/app/composables/useMessageGroupWaba.ts +73 -0
  33. package/app/composables/useMessageSendFlow.ts +284 -0
  34. package/app/composables/usePagedMessageGroups.ts +78 -0
  35. package/app/composables/useTemplateOperations.ts +23 -4
  36. package/app/composables/useTracking.ts +14 -5
  37. package/app/composables/useWhatsappConfiguration.ts +32 -0
  38. package/app/layouts/default.vue +5 -1
  39. package/app/pages/brands.vue +1 -0
  40. package/app/pages/images.vue +4 -0
  41. package/app/pages/index.vue +1 -0
  42. package/app/pages/preview/[id].vue +2 -0
  43. package/app/pages/template/whatsapp/[id].vue +1 -0
  44. package/app/queries/brands.ts +27 -7
  45. package/app/queries/compilation.ts +16 -3
  46. package/app/queries/dynamicValues.ts +19 -2
  47. package/app/queries/media.ts +8 -3
  48. package/app/queries/messageGroups.ts +56 -17
  49. package/app/queries/meta.ts +13 -8
  50. package/app/stores/appContext.ts +18 -48
  51. package/app/stores/effectiveOrganization.ts +128 -0
  52. package/app/stores/emailEditor.ts +5 -1
  53. package/app/stores/metaSubmission.ts +7 -17
  54. package/app/types/tracking.ts +17 -0
  55. package/app/utils/testIds.const.ts +29 -0
  56. package/i18n/locales/de.ts +84 -2
  57. package/i18n/locales/en.ts +80 -2
  58. package/i18n/locales/es.ts +81 -2
  59. package/i18n/locales/fr.ts +81 -2
  60. package/i18n/locales/it.ts +82 -2
  61. package/package.json +2 -2
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import { useAppContextStore } from '~/stores/appContext'
3
+
4
+ /**
5
+ * Host-integration problems reported by the app-context store (missing
6
+ * Product, ...). Shown persistently: the host has to fix its context, the
7
+ * user cannot.
8
+ */
9
+ const { validationErrors } = storeToRefs(useAppContextStore())
10
+ const messages = computed(() => Object.values(validationErrors.value))
11
+ </script>
12
+
13
+ <template>
14
+ <UAlert
15
+ v-if="messages.length"
16
+ color="error"
17
+ variant="subtle"
18
+ icon="i-lucide-alert-triangle"
19
+ :title="$t('common.context_error_title')"
20
+ class="rounded-none"
21
+ >
22
+ <template #description>
23
+ <ul class="list-disc pl-4 space-y-1">
24
+ <li v-for="message in messages" :key="message">
25
+ {{ message }}
26
+ </li>
27
+ </ul>
28
+ </template>
29
+ </UAlert>
30
+ </template>
@@ -17,6 +17,8 @@ const props = withDefaults(
17
17
 
18
18
  const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
19
19
  const measuredHeight = ref(0)
20
+ // The first document comes from `srcdoc`; later ones are written in place (see the watch).
21
+ const initialHtml = props.html
20
22
 
21
23
  const heightStyle = computed(() => {
22
24
  if (!measuredHeight.value) return `${props.minHeight}px`
@@ -44,10 +46,22 @@ function onLoad() {
44
46
  observeContent()
45
47
  }
46
48
 
49
+ /**
50
+ * Replace the document in place instead of re-binding `srcdoc`: a `srcdoc`
51
+ * change is a navigation, which blanks the frame and drops the height for a
52
+ * few frames. Writing keeps the frame painted and the last measured height
53
+ * until the new content is measured.
54
+ */
47
55
  watch(
48
56
  () => props.html,
49
- () => {
50
- measuredHeight.value = 0
57
+ (html) => {
58
+ const doc = iframeRef.value?.contentDocument
59
+ if (!doc) return
60
+ doc.open()
61
+ doc.write(html)
62
+ doc.close()
63
+ fitToContent()
64
+ observeContent()
51
65
  },
52
66
  )
53
67
 
@@ -59,7 +73,7 @@ onBeforeUnmount(() => {
59
73
  <template>
60
74
  <iframe
61
75
  ref="iframe"
62
- :srcdoc="html"
76
+ :srcdoc="initialHtml"
63
77
  :title="title"
64
78
  sandbox="allow-same-origin"
65
79
  :style="{ height: heightStyle }"
@@ -1,9 +1,41 @@
1
1
  <script setup lang="ts">
2
- import type { RenderType, MessageStatus } from '@dev.smartpricing/message-composer-utils/types'
3
- import { useMessageGroupsQuery } from '~/queries/messageGroups'
2
+ import type { MessageStatus, RenderType } from '@dev.smartpricing/message-composer-utils/types'
3
+ import { usePagedMessageGroups } from '~/composables/usePagedMessageGroups'
4
+ import { useMessageGroupQuery } from '~/queries/messageGroups'
5
+ import { useAppContextStore } from '~/stores/appContext'
4
6
 
5
7
  defineOptions({ name: 'MessageGroupSelect' })
6
8
 
9
+ type MessageGroupEligibility = 'active' | 'sendable'
10
+
11
+ /** Per mode: the selectability rule and the copy shown for groups that fail it. */
12
+ const ELIGIBILITY_RULES: Record<
13
+ MessageGroupEligibility,
14
+ {
15
+ isEligible: (messages: Array<{ status: MessageStatus }>) => boolean
16
+ tooltipKey: string
17
+ hideFilterKey: string
18
+ }
19
+ > = {
20
+ active: {
21
+ isEligible: (messages) => messages.length > 0 && messages.every((m) => m.status === 'approved'),
22
+ tooltipKey: 'common.messageGroupSelect.inactive_tooltip',
23
+ hideFilterKey: 'common.messageGroupSelect.hide_inactive',
24
+ },
25
+ sendable: {
26
+ isEligible: (messages) => messages.some((m) => m.status === 'approved'),
27
+ tooltipKey: 'common.messageGroupSelect.not_sendable_tooltip',
28
+ hideFilterKey: 'common.messageGroupSelect.hide_not_sendable',
29
+ },
30
+ }
31
+
32
+ /**
33
+ * In-composer Message Group picker (ADR 0012). Always lists the context
34
+ * Product's groups only: search is server-side, debounced and bound to the
35
+ * menu's search input; pages of 100 load on demand through "Load more".
36
+ * The selected group stays in the menu even when the loaded pages no longer
37
+ * contain it (search cleared, paging reset, or preselected beyond page one).
38
+ */
7
39
  const props = withDefaults(
8
40
  defineProps<{
9
41
  renderType?: RenderType
@@ -21,10 +53,22 @@ const props = withDefaults(
21
53
  disabledGroupReasons?: Record<string, string>
22
54
  /** Overrides the "hide inactive" filter label. Defaults to the built-in translation. */
23
55
  hideFilterLabel?: string
56
+ /**
57
+ * Which groups are selectable: `active` (every Message approved, the
58
+ * composer's own rule) or `sendable` (at least one approved Message, the
59
+ * Message Send Flow's rule). The disabled tooltip and the hide filter
60
+ * follow the mode.
61
+ */
62
+ eligibility?: MessageGroupEligibility
63
+ /** Lists this Organization's groups instead of the resolved Organization's. */
64
+ organizationId?: string
65
+ /** Lists only the WhatsApp groups on this WABA (ADR 0015); needs `renderType: 'whatsapp_v1'`. */
66
+ wabaId?: string
24
67
  }>(),
25
68
  {
26
69
  placeholder: 'Select a message group',
27
70
  searchPlaceholder: 'Search...',
71
+ eligibility: 'active',
28
72
  },
29
73
  )
30
74
 
@@ -34,20 +78,102 @@ const emit = defineEmits<{
34
78
  select: [item: { id: string; name: string }]
35
79
  }>()
36
80
 
81
+ /** What the picker needs from a group, whichever endpoint it came from. */
82
+ interface PickerGroup {
83
+ id: string
84
+ name: string
85
+ messages: Array<{ status: MessageStatus }>
86
+ }
87
+
88
+ const PER_PAGE = 100
89
+
37
90
  const { t } = useI18n()
91
+ const appContext = useAppContextStore()
38
92
 
39
- const { data: messageGroups, isLoading } = useMessageGroupsQuery(() => ({
40
- renderType: props.renderType,
41
- }))
93
+ // Server-side search: the menu's own client filter is off (`ignore-filter`),
94
+ // the typed term reaches the list request once it settles. A new search,
95
+ // render type, context Product or WABA starts over from page one.
96
+ const searchTerm = ref('')
97
+ const page = ref(1)
42
98
 
43
- const hideInactive = ref(false)
99
+ // Without a context Product the server would answer with every Product's
100
+ // groups (ADR 0011): never ask. The host's missing Product is already reported
101
+ // by the app-context validation.
102
+ // Only WhatsApp groups are on a WABA: with any other render type the filter would empty the list.
103
+ const wabaFilter = computed(() =>
104
+ props.renderType === 'whatsapp_v1' ? props.wabaId || undefined : undefined,
105
+ )
44
106
 
45
- function isGroupActive(messages: Array<{ status: MessageStatus }>): boolean {
46
- return messages.length > 0 && messages.every((m) => m.status === 'approved')
47
- }
107
+ const {
108
+ query: { data: envelope, isPlaceholderData },
109
+ loading,
110
+ } = usePagedMessageGroups({
111
+ search: searchTerm,
112
+ page,
113
+ perPage: PER_PAGE,
114
+ renderType: () => props.renderType,
115
+ filters: () => ({
116
+ product: appContext.product,
117
+ visibility: appContext.isAdmin ? 'all' : 'public',
118
+ waba_id: wabaFilter.value,
119
+ }),
120
+ resetOn: [() => appContext.product, wabaFilter],
121
+ enabled: () => appContext.product != null,
122
+ organizationId: () => props.organizationId,
123
+ })
124
+
125
+ // Pages loaded so far, indexed by page number: page one replaces everything,
126
+ // later pages slot in at their index (a refetch replaces, never duplicates).
127
+ const pages = shallowRef<PickerGroup[][]>([])
128
+ watch(
129
+ envelope,
130
+ (value) => {
131
+ if (!value) return
132
+ const index = value.meta.current_page - 1
133
+ pages.value = index === 0 ? [value.data] : [...pages.value.slice(0, index), value.data]
134
+ },
135
+ { immediate: true },
136
+ )
137
+ const loadedGroups = computed(() => pages.value.flat())
138
+
139
+ // The selected group is pinned on top of the loaded pages when they do not
140
+ // contain it: remembered from the last pick, or fetched by id when preselected.
141
+ const lastPicked = shallowRef<PickerGroup>()
142
+ const missingSelectedId = computed(() => {
143
+ const id = modelValue.value
144
+ if (!id || loadedGroups.value.some((g) => g.id === id)) return undefined
145
+ return lastPicked.value?.id === id ? undefined : id
146
+ })
147
+ const { data: fetchedSelected } = useMessageGroupQuery(() => missingSelectedId.value ?? '', {
148
+ enabled: () => missingSelectedId.value != null,
149
+ })
150
+ const messageGroups = computed<PickerGroup[]>(() => {
151
+ const id = modelValue.value
152
+ if (!id || loadedGroups.value.some((g) => g.id === id)) return loadedGroups.value
153
+ const pinned =
154
+ lastPicked.value?.id === id
155
+ ? lastPicked.value
156
+ : fetchedSelected.value?.id === id
157
+ ? fetchedSelected.value
158
+ : undefined
159
+ return pinned ? [pinned, ...loadedGroups.value] : loadedGroups.value
160
+ })
161
+
162
+ const hasMore = computed(
163
+ () =>
164
+ !isPlaceholderData.value &&
165
+ envelope.value != null &&
166
+ envelope.value.meta.current_page < envelope.value.meta.last_page,
167
+ )
168
+ const isLoadingFirstPage = computed(() => page.value === 1 && loading.value)
169
+ const isLoadingMore = computed(() => page.value > 1 && loading.value)
170
+
171
+ const hideIneligible = ref(false)
172
+
173
+ const rule = computed(() => ELIGIBILITY_RULES[props.eligibility])
174
+ const hideFilterLabel = computed(() => props.hideFilterLabel ?? t(rule.value.hideFilterKey))
48
175
 
49
176
  const items = computed(() => {
50
- if (!messageGroups.value) return []
51
177
  return messageGroups.value.reduce<
52
178
  Array<{
53
179
  label: string
@@ -58,20 +184,18 @@ const items = computed(() => {
58
184
  }>
59
185
  >((acc, group) => {
60
186
  const externalReason = props.disabledGroupReasons?.[group.id]
61
- const externallyUsed = externalReason != null
62
- const active = isGroupActive(group.messages)
63
- const disabled = !active || externallyUsed
64
- if (hideInactive.value && disabled) return acc
187
+ const eligible = rule.value.isEligible(group.messages)
188
+ const disabled = !eligible || externalReason != null
189
+ if (hideIneligible.value && disabled) return acc
190
+ // External reason wins over the mode's own tooltip when both apply.
191
+ let tooltip: string | undefined
192
+ if (externalReason != null) tooltip = externalReason
193
+ else if (!eligible) tooltip = t(rule.value.tooltipKey)
65
194
  acc.push({
66
195
  label: group.name,
67
196
  value: group.id,
68
197
  disabled,
69
- // External reason wins over the inactive tooltip when both apply.
70
- tooltip: externallyUsed
71
- ? externalReason
72
- : !active
73
- ? t('common.messageGroupSelect.inactive_tooltip')
74
- : undefined,
198
+ tooltip,
75
199
  chip: { color: disabled ? 'neutral' : 'success' },
76
200
  })
77
201
  return acc
@@ -80,18 +204,21 @@ const items = computed(() => {
80
204
 
81
205
  function handleUpdate(value: string) {
82
206
  modelValue.value = value
83
- const group = messageGroups.value?.find((g) => g.id === value)
207
+ const group = messageGroups.value.find((g) => g.id === value)
208
+ lastPicked.value = group
84
209
  emit('select', { id: value, name: group?.name ?? '' })
85
210
  }
86
211
  </script>
87
212
 
88
213
  <template>
89
214
  <USelectMenu
215
+ v-model:search-term="searchTerm"
90
216
  :model-value="modelValue"
91
217
  :items="items"
92
218
  value-key="value"
93
- :loading="isLoading"
219
+ :loading="isLoadingFirstPage"
94
220
  searchable
221
+ ignore-filter
95
222
  :placeholder="placeholder"
96
223
  :search-input="{ placeholder: searchPlaceholder }"
97
224
  :data-testid="messageGroupSelectTestIds.root"
@@ -106,10 +233,21 @@ function handleUpdate(value: string) {
106
233
 
107
234
  <template #content-top>
108
235
  <div class="flex items-center gap-2 px-2 py-1.5 border-b border-default">
109
- <USwitch v-model="hideInactive" size="xs" />
110
- <span class="text-xs text-muted">
111
- {{ hideFilterLabel ?? t('common.messageGroupSelect.hide_inactive') }}
112
- </span>
236
+ <USwitch v-model="hideIneligible" size="xs" />
237
+ <span class="text-xs text-muted">{{ hideFilterLabel }}</span>
238
+ </div>
239
+ </template>
240
+
241
+ <template v-if="hasMore || isLoadingMore" #content-bottom>
242
+ <div class="flex justify-center px-2 py-1.5 border-t border-default">
243
+ <UButton
244
+ variant="link"
245
+ size="xs"
246
+ :loading="isLoadingMore"
247
+ :label="t('common.messageGroupSelect.load_more')"
248
+ :data-testid="messageGroupSelectTestIds.loadMore"
249
+ @click="page++"
250
+ />
113
251
  </div>
114
252
  </template>
115
253
  </USelectMenu>
@@ -14,6 +14,8 @@ import { useEmailEditorStore } from '~/stores/emailEditor'
14
14
  interface Props {
15
15
  message: PostEntity<Message> | MessageFromDb | null | undefined
16
16
  messageGroupId?: string
17
+ /** WhatsApp only, forwarded to `WhatsappPreview`: operator-typed values overriding the resolved ones. No effect on the email preview. */
18
+ parameters?: Record<string, string>
17
19
  }
18
20
 
19
21
  const props = defineProps<Props>()
@@ -72,6 +74,7 @@ const isEmail = computed(() => renderType.value === 'email_v1')
72
74
  v-if="isWhatsapp && messageGroupId"
73
75
  :message="message as WhatsappMessage"
74
76
  :message-group-id="messageGroupId"
77
+ :parameters="parameters"
75
78
  />
76
79
 
77
80
  <!-- Email Inline Preview (client-driven, reflects unsaved changes) -->
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Holds its content back until there is an Organization to work on, so no
4
+ * resource request leaves before it is resolved (ADR 0014).
5
+ */
6
+ const organization = useEffectiveOrganizationStore()
7
+ const { t } = useI18n()
8
+
9
+ const failure = computed(() =>
10
+ organization.status === 'none'
11
+ ? {
12
+ title: t('common.organization.none_title'),
13
+ description: t('common.organization.none_description'),
14
+ }
15
+ : {
16
+ title: t('common.organization.lookup_failed_title'),
17
+ description: t('common.organization.lookup_failed_description'),
18
+ },
19
+ )
20
+ </script>
21
+
22
+ <template>
23
+ <slot v-if="organization.status === 'resolved'" />
24
+
25
+ <div
26
+ v-else-if="organization.status === 'resolving'"
27
+ class="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-muted"
28
+ role="status"
29
+ >
30
+ <UIcon name="i-heroicons-arrow-path" class="animate-spin h-8 w-8" />
31
+ <span class="text-sm">{{ t('common.organization.resolving') }}</span>
32
+ </div>
33
+
34
+ <div v-else class="p-4">
35
+ <UAlert
36
+ color="error"
37
+ variant="subtle"
38
+ icon="i-lucide-alert-triangle"
39
+ :title="failure.title"
40
+ :description="failure.description"
41
+ :actions="[
42
+ {
43
+ label: t('common.organization.retry'),
44
+ color: 'error',
45
+ variant: 'outline',
46
+ onClick: () => void organization.retry(),
47
+ },
48
+ ]"
49
+ />
50
+ </div>
51
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Organization selector of the list pages (Message Groups, Brands, images):
4
+ * filters what is listed and decides where new resources are created. Renders
5
+ * nothing when the Host supplied the Organization or the user reaches only one.
6
+ */
7
+ const organization = useEffectiveOrganizationStore()
8
+ const { t } = useI18n()
9
+
10
+ const items = computed(() =>
11
+ organization.reachableOrganizations.map((o) => ({ label: o.name, value: o.id })),
12
+ )
13
+ </script>
14
+
15
+ <template>
16
+ <USelectMenu
17
+ v-if="organization.isSelectable"
18
+ :model-value="organization.resolvedOrganizationId ?? undefined"
19
+ :items="items"
20
+ value-key="value"
21
+ icon="ph:buildings"
22
+ :search-input="{ placeholder: t('common.organization.search_placeholder') }"
23
+ :aria-label="t('common.organization.label')"
24
+ class="w-56"
25
+ @update:model-value="organization.select($event)"
26
+ />
27
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Tells the user that the Effective Organization has no WABA: its WhatsApp
4
+ * messages can be drafted but not submitted to Meta. Follows the Organization,
5
+ * so it comes and goes with the selector and with the resource on screen.
6
+ */
7
+ const { status } = useWhatsappConfiguration()
8
+ </script>
9
+
10
+ <template>
11
+ <UAlert
12
+ v-if="status === 'not_configured'"
13
+ color="warning"
14
+ variant="subtle"
15
+ icon="i-lucide-message-circle-off"
16
+ :title="$t('common.whatsapp_not_configured_title')"
17
+ :description="$t('common.whatsapp_not_configured_description')"
18
+ class="rounded-none"
19
+ />
20
+ </template>
@@ -39,6 +39,10 @@ const { data: messageGroupData, isPending: isFetching } =
39
39
  ? useMessageGroupQuery(props.messageGroupId)
40
40
  : { data: ref(null), isPending: ref(false) }
41
41
 
42
+ // The editor works on the Organization of the Message Group it loaded, not on
43
+ // the selector's. Claimed before the watcher below loads dependent data.
44
+ useResourceOrganization(() => messageGroupData.value?.organization_id)
45
+
42
46
  // Change tracking (edit mode only)
43
47
  const messageGroupIdRef = computed(() => store.messageGroupId ?? '')
44
48
  const changeTracking = props.mode === 'edit' ? useChangeTrackingEmail(messageGroupIdRef) : null
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
- useMessageGroupsQuery,
4
3
  useDeleteMessageGroupMutation,
5
4
  usePublishMessageGroupMutation,
6
5
  useUnpublishMessageGroupMutation,
@@ -10,7 +9,6 @@ import type {
10
9
  RenderType,
11
10
  } from '@dev.smartpricing/message-composer-utils/types'
12
11
  import type { MessageActionProperties, MessagesKind } from '@/types/tracking'
13
- import { computed } from 'vue'
14
12
  import BaseTable from '@/components/TemplateList/BaseTable.vue'
15
13
 
16
14
  const renderType = defineModel<RenderType>('renderType', { required: true })
@@ -26,12 +24,17 @@ const emit = defineEmits<{
26
24
  const { trackEvent } = useTracking()
27
25
  const { t } = useI18n()
28
26
 
29
- // Query
27
+ // List state + server-side paginated query (ADR 0012)
30
28
  const {
31
- data: messageGroups,
32
- isPending: isFetching,
29
+ state: listState,
30
+ setFilters,
31
+ setSorting,
32
+ setPage,
33
+ rows: messageGroups,
34
+ meta,
35
+ loading: isFetching,
33
36
  refetch: execute,
34
- } = useMessageGroupsQuery(computed(() => ({ renderType: renderType.value })))
37
+ } = useMessageGroupList(renderType)
35
38
 
36
39
  // Polling
37
40
  const interval = setInterval(() => execute(), 15000)
@@ -144,8 +147,15 @@ defineExpose({ execute })
144
147
  <template>
145
148
  <BaseTable
146
149
  :loading="isFetching"
147
- :message-groups="messageGroups || []"
150
+ :message-groups="messageGroups"
151
+ :meta="meta"
148
152
  :render-type="renderType"
153
+ :filters="listState"
154
+ :sorting="listState"
155
+ :page="listState.page"
156
+ @update:filters="setFilters"
157
+ @update:sorting="setSorting"
158
+ @update:page="setPage"
149
159
  @edit="navigate"
150
160
  @delete="askDeleteGroup"
151
161
  @duplicate="handleDuplicate"
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import type { OrganizationWaba } from '@dev.smartpricing/message-composer-utils/types'
3
+
4
+ /**
5
+ * Asks which WABA a Message Group is submitted to, at its first submission,
6
+ * when its Organization has several (ADR 0015). Opened through `useOverlay`:
7
+ * closes with the chosen WABA id, or with nothing when the user gives up.
8
+ */
9
+ const props = defineProps<{
10
+ wabas: OrganizationWaba[]
11
+ /** The Host's WABA, already checked to be one of `wabas`. */
12
+ preselected?: string
13
+ }>()
14
+
15
+ const emit = defineEmits<{ close: [wabaId?: string] }>()
16
+
17
+ const selected = ref<string | undefined>(props.preselected)
18
+
19
+ const items = computed(() =>
20
+ props.wabas.map((waba) => ({
21
+ value: waba.id,
22
+ label: waba.name || waba.id,
23
+ numbers: waba.phone_numbers.join(', '),
24
+ // Not `id`: the radio group gives each item one of its own.
25
+ wabaId: waba.id,
26
+ })),
27
+ )
28
+ </script>
29
+
30
+ <template>
31
+ <UModal
32
+ :title="$t('dialogs.select_waba.title')"
33
+ :description="$t('dialogs.select_waba.description')"
34
+ close-icon="ph:x"
35
+ :close="{ onClick: () => emit('close') }"
36
+ :dismissible="false"
37
+ data-testid="waba-select-modal"
38
+ >
39
+ <template #body>
40
+ <URadioGroup
41
+ v-model="selected"
42
+ :items="items"
43
+ variant="table"
44
+ class="w-full"
45
+ :ui="{ item: 'w-full' }"
46
+ data-testid="waba-select-options"
47
+ >
48
+ <template #description="{ item }">
49
+ <span class="flex flex-col">
50
+ <span>{{ item.numbers || $t('dialogs.select_waba.no_numbers') }}</span>
51
+ <span class="text-xs text-dimmed font-mono">{{ item.wabaId }}</span>
52
+ </span>
53
+ </template>
54
+ </URadioGroup>
55
+ </template>
56
+
57
+ <template #footer>
58
+ <div class="flex gap-2 justify-end w-full">
59
+ <UButton
60
+ variant="outline"
61
+ color="neutral"
62
+ :label="$t('common.actions.cancel')"
63
+ data-testid="waba-select-cancel"
64
+ @click="emit('close')"
65
+ />
66
+ <UButton
67
+ :label="$t('dialogs.select_waba.confirm')"
68
+ :disabled="!selected"
69
+ data-testid="waba-select-confirm"
70
+ @click="emit('close', selected)"
71
+ />
72
+ </div>
73
+ </template>
74
+ </UModal>
75
+ </template>