@dev.smartpricing/message-composer-layer 4.3.1 → 4.3.2-chat.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.
@@ -1,8 +1,11 @@
1
- import type { DynamicValuesResponse } from '@dev.smartpricing/message-composer-utils/types'
1
+ import type {
2
+ DynamicValuesResponse,
3
+ MessageGroupProduct,
4
+ } from '@dev.smartpricing/message-composer-utils/types'
2
5
  import { apiClient } from './client'
3
6
 
4
7
  export const dynamicValuesApi = {
5
- get: (product: string, language: string) => {
8
+ get: (product: MessageGroupProduct, language: string) => {
6
9
  const params = new URLSearchParams()
7
10
  params.set('product', product)
8
11
  params.set('language', language)
@@ -1,27 +1,38 @@
1
1
  import type {
2
2
  MessageGroupFromDb,
3
+ PaginatedResponse,
3
4
  MessageGroupWithMessages,
4
5
  MessageGroupWithMessageSummary,
5
6
  MessageGroupValidationResponse,
6
7
  PostEntity,
7
8
  MessageGroup,
8
- RenderType,
9
9
  DuplicateMessageGroupBody,
10
+ ListMessageGroupsQuery,
10
11
  } from '@dev.smartpricing/message-composer-utils/types'
11
12
  import { apiClient } from './client'
12
13
 
14
+ /**
15
+ * Query params of `GET /message-groups`, straight from the shared schema
16
+ * (ADR 0007): snake_case, as on the wire. `visibility` other than `public`
17
+ * is admin-only (403 otherwise); every field is optional here, the server
18
+ * applies the defaults.
19
+ */
20
+ export type ListMessageGroupsOptions = Partial<ListMessageGroupsQuery>
21
+
22
+ function listQueryString(options: ListMessageGroupsOptions = {}): string {
23
+ const params = new URLSearchParams()
24
+ for (const [name, value] of Object.entries(options)) {
25
+ if (value !== undefined && value !== '') params.set(name, String(value))
26
+ }
27
+ return params.size ? `?${params.toString()}` : ''
28
+ }
29
+
13
30
  export const messageGroupsApi = {
14
- getAll: (options?: { renderType?: RenderType; tags?: string[]; tagsMode?: 'and' | 'or' }) => {
15
- const params = new URLSearchParams()
16
- if (options?.renderType) params.set('render_type', options.renderType)
17
- if (options?.tags && options.tags.length > 0) {
18
- options.tags.forEach((tag) => params.append('tags', tag))
19
- }
20
- if (options?.tagsMode) params.set('tags_mode', options.tagsMode)
21
-
22
- const query = params.size ? `?${params.toString()}` : ''
23
- return apiClient<MessageGroupWithMessageSummary[]>(`/message-groups${query}`)
24
- },
31
+ /** One page of the server-side filtered list, in the Laravel envelope (ADR 0012). */
32
+ list: (options?: ListMessageGroupsOptions) =>
33
+ apiClient<PaginatedResponse<MessageGroupWithMessageSummary>>(
34
+ `/message-groups${listQueryString(options)}`,
35
+ ),
25
36
 
26
37
  getById: (id: string) => apiClient<MessageGroupWithMessages>(`/message-groups/${id}`),
27
38
 
@@ -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, missing Meta config, ...). Shown persistently: the host has to fix
7
+ * its context, the 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>
@@ -1,9 +1,18 @@
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
+ /**
10
+ * In-composer Message Group picker (ADR 0012). Always lists the context
11
+ * Product's groups only: search is server-side, debounced and bound to the
12
+ * menu's search input; pages of 100 load on demand through "Load more".
13
+ * The selected group stays in the menu even when the loaded pages no longer
14
+ * contain it (search cleared, paging reset, or preselected beyond page one).
15
+ */
7
16
  const props = withDefaults(
8
17
  defineProps<{
9
18
  renderType?: RenderType
@@ -34,11 +43,88 @@ const emit = defineEmits<{
34
43
  select: [item: { id: string; name: string }]
35
44
  }>()
36
45
 
46
+ /** What the picker needs from a group, whichever endpoint it came from. */
47
+ interface PickerGroup {
48
+ id: string
49
+ name: string
50
+ messages: Array<{ status: MessageStatus }>
51
+ }
52
+
53
+ const PER_PAGE = 100
54
+
37
55
  const { t } = useI18n()
56
+ const appContext = useAppContextStore()
38
57
 
39
- const { data: messageGroups, isLoading } = useMessageGroupsQuery(() => ({
40
- renderType: props.renderType,
41
- }))
58
+ // Server-side search: the menu's own client filter is off (`ignore-filter`),
59
+ // the typed term reaches the list request once it settles. A new search,
60
+ // render type or context Product starts over from page one.
61
+ const searchTerm = ref('')
62
+ const page = ref(1)
63
+
64
+ // Without a context Product the server would answer with every Product's
65
+ // groups (ADR 0011): never ask. The host's missing Product is already reported
66
+ // by the app-context validation.
67
+ const {
68
+ query: { data: envelope, isPlaceholderData },
69
+ loading,
70
+ } = usePagedMessageGroups({
71
+ search: searchTerm,
72
+ page,
73
+ perPage: PER_PAGE,
74
+ renderType: () => props.renderType,
75
+ filters: () => ({
76
+ product: appContext.product,
77
+ visibility: appContext.isAdmin ? 'all' : 'public',
78
+ }),
79
+ resetOn: [() => appContext.product],
80
+ enabled: () => appContext.product != null,
81
+ })
82
+
83
+ // Pages loaded so far, indexed by page number: page one replaces everything,
84
+ // later pages slot in at their index (a refetch replaces, never duplicates).
85
+ const pages = shallowRef<PickerGroup[][]>([])
86
+ watch(
87
+ envelope,
88
+ (value) => {
89
+ if (!value) return
90
+ const index = value.meta.current_page - 1
91
+ pages.value = index === 0 ? [value.data] : [...pages.value.slice(0, index), value.data]
92
+ },
93
+ { immediate: true },
94
+ )
95
+ const loadedGroups = computed(() => pages.value.flat())
96
+
97
+ // The selected group is pinned on top of the loaded pages when they do not
98
+ // contain it: remembered from the last pick, or fetched by id when preselected.
99
+ const lastPicked = shallowRef<PickerGroup>()
100
+ const missingSelectedId = computed(() => {
101
+ const id = modelValue.value
102
+ if (!id || loadedGroups.value.some((g) => g.id === id)) return undefined
103
+ return lastPicked.value?.id === id ? undefined : id
104
+ })
105
+ const { data: fetchedSelected } = useMessageGroupQuery(() => missingSelectedId.value ?? '', {
106
+ enabled: () => missingSelectedId.value != null,
107
+ })
108
+ const messageGroups = computed<PickerGroup[]>(() => {
109
+ const id = modelValue.value
110
+ if (!id || loadedGroups.value.some((g) => g.id === id)) return loadedGroups.value
111
+ const pinned =
112
+ lastPicked.value?.id === id
113
+ ? lastPicked.value
114
+ : fetchedSelected.value?.id === id
115
+ ? fetchedSelected.value
116
+ : undefined
117
+ return pinned ? [pinned, ...loadedGroups.value] : loadedGroups.value
118
+ })
119
+
120
+ const hasMore = computed(
121
+ () =>
122
+ !isPlaceholderData.value &&
123
+ envelope.value != null &&
124
+ envelope.value.meta.current_page < envelope.value.meta.last_page,
125
+ )
126
+ const isLoadingFirstPage = computed(() => page.value === 1 && loading.value)
127
+ const isLoadingMore = computed(() => page.value > 1 && loading.value)
42
128
 
43
129
  const hideInactive = ref(false)
44
130
 
@@ -47,7 +133,6 @@ function isGroupActive(messages: Array<{ status: MessageStatus }>): boolean {
47
133
  }
48
134
 
49
135
  const items = computed(() => {
50
- if (!messageGroups.value) return []
51
136
  return messageGroups.value.reduce<
52
137
  Array<{
53
138
  label: string
@@ -80,18 +165,21 @@ const items = computed(() => {
80
165
 
81
166
  function handleUpdate(value: string) {
82
167
  modelValue.value = value
83
- const group = messageGroups.value?.find((g) => g.id === value)
168
+ const group = messageGroups.value.find((g) => g.id === value)
169
+ lastPicked.value = group
84
170
  emit('select', { id: value, name: group?.name ?? '' })
85
171
  }
86
172
  </script>
87
173
 
88
174
  <template>
89
175
  <USelectMenu
176
+ v-model:search-term="searchTerm"
90
177
  :model-value="modelValue"
91
178
  :items="items"
92
179
  value-key="value"
93
- :loading="isLoading"
180
+ :loading="isLoadingFirstPage"
94
181
  searchable
182
+ ignore-filter
95
183
  :placeholder="placeholder"
96
184
  :search-input="{ placeholder: searchPlaceholder }"
97
185
  :data-testid="messageGroupSelectTestIds.root"
@@ -112,5 +200,18 @@ function handleUpdate(value: string) {
112
200
  </span>
113
201
  </div>
114
202
  </template>
203
+
204
+ <template v-if="hasMore || isLoadingMore" #content-bottom>
205
+ <div class="flex justify-center px-2 py-1.5 border-t border-default">
206
+ <UButton
207
+ variant="link"
208
+ size="xs"
209
+ :loading="isLoadingMore"
210
+ :label="t('common.messageGroupSelect.load_more')"
211
+ :data-testid="messageGroupSelectTestIds.loadMore"
212
+ @click="page++"
213
+ />
214
+ </div>
215
+ </template>
115
216
  </USelectMenu>
116
217
  </template>
@@ -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"