@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,16 +1,36 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
3
  MESSAGE_GROUP_CATEGORIES,
4
+ MESSAGE_GROUP_LIST_VISIBILITY,
5
+ MESSAGE_GROUP_PRODUCTS,
4
6
  MESSAGE_STATUS,
7
+ type MessageGroupListSort,
5
8
  type MessageGroupWithMessageSummary,
9
+ type PaginationMeta,
6
10
  type RenderType,
7
11
  } from '@dev.smartpricing/message-composer-utils/types'
8
12
  import StatusCell from '@/components/TemplateList/StatusCell.vue'
9
13
  import { useI18n } from 'vue-i18n'
10
- import { ref, watch } from 'vue'
14
+ import { computed, ref, watch } from 'vue'
11
15
  import { formatDate } from '@dev.smartpricing/message-composer-utils/utils'
12
16
  import type { TableColumn } from '#ui/types'
13
17
  import type { TrackingEventMap } from '@/types/tracking'
18
+ import {
19
+ ALL_FILTER,
20
+ type MessageGroupListFilters,
21
+ type MessageGroupListSorting,
22
+ } from '@/composables/useMessageGroupList'
23
+
24
+ const props = defineProps<{
25
+ messageGroups: MessageGroupWithMessageSummary[]
26
+ /** Envelope meta of the current page; undefined until the first page arrives. */
27
+ meta?: PaginationMeta
28
+ loading: boolean
29
+ renderType: RenderType
30
+ filters: MessageGroupListFilters
31
+ sorting: MessageGroupListSorting
32
+ page: number
33
+ }>()
14
34
 
15
35
  const emit = defineEmits<{
16
36
  edit: [MessageGroupWithMessageSummary]
@@ -18,6 +38,9 @@ const emit = defineEmits<{
18
38
  duplicate: [MessageGroupWithMessageSummary]
19
39
  publish: [MessageGroupWithMessageSummary]
20
40
  unpublish: [MessageGroupWithMessageSummary]
41
+ 'update:filters': [Partial<MessageGroupListFilters>]
42
+ 'update:sorting': [MessageGroupListSorting]
43
+ 'update:page': [number]
21
44
  }>()
22
45
 
23
46
  const appContext = useAppContextStore()
@@ -86,49 +109,40 @@ function rowActions(row: MessageGroupWithMessageSummary) {
86
109
  ]
87
110
  }
88
111
 
112
+ // Column ids of sortable columns are the server's `sort` values (ADR 0012);
113
+ // preview and status are aggregates and stay unsortable.
89
114
  const columns: TableColumn<MessageGroupWithMessageSummary>[] = [
90
115
  {
91
- id: 'name',
116
+ id: 'name' satisfies MessageGroupListSort,
92
117
  accessorKey: 'name',
93
118
  header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.name')),
94
119
  },
95
120
  {
96
- id: 'category',
121
+ id: 'category' satisfies MessageGroupListSort,
97
122
  accessorKey: 'category',
98
123
  header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.category')),
99
- filterFn: (row, columnId, filterValue) => {
100
- if (filterValue === 'all') return true
101
- return row.original.category.toLowerCase().includes(filterValue.toLowerCase())
102
- },
124
+ },
125
+ {
126
+ id: 'product',
127
+ accessorKey: 'product',
128
+ enableSorting: false,
129
+ header: () => t('pages.message_library.headers.product'),
103
130
  },
104
131
  {
105
132
  id: 'preview',
106
133
  accessorFn: (row) => getLocalizedMessage(row)?.preview,
107
- header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.preview')),
134
+ enableSorting: false,
135
+ header: () => t('pages.message_library.headers.preview'),
108
136
  },
109
137
  {
110
138
  id: 'status',
111
139
  accessorFn: (row) => row.messages.map((m) => m.status).join(', '),
112
- header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.status')),
113
- filterFn: (row, columnId, filterValue) => {
114
- if (filterValue === 'all') return true
115
- return row.original.messages.some((m) => m.status === filterValue)
116
- },
140
+ enableSorting: false,
141
+ header: () => t('pages.message_library.headers.status'),
117
142
  },
118
- // {
119
- // id: 'tags',
120
- // accessorFn: (row) => row.tags?.join(', ') || '',
121
- // header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.tags')),
122
- // filterFn: (row, _columnId, filterValue: string[]) => {
123
- // if (!filterValue || filterValue.length === 0) return true
124
- // const rowTags = row.original.tags || []
125
- // return filterValue.some((tag) => rowTags.includes(tag))
126
- // },
127
- // },
128
143
  {
129
- id: 'updated_at',
144
+ id: 'updated_at' satisfies MessageGroupListSort,
130
145
  accessorFn: (row) => formatDate(row.updated_at),
131
- // header: () => t('pages.message_library.headers.updated_at'),
132
146
  header: (ctx) => getSortableHeader(ctx, t('pages.message_library.headers.updated_at')),
133
147
  },
134
148
  {
@@ -144,77 +158,70 @@ const columns: TableColumn<MessageGroupWithMessageSummary>[] = [
144
158
  },
145
159
  ]
146
160
 
147
- const sorting = ref([
148
- {
149
- id: 'updated_at',
150
- desc: true,
151
- },
152
- ])
161
+ // The table's sorting model mirrors the server sort; TanStack never sorts locally (manualSorting).
162
+ function toTableSorting(sorting: MessageGroupListSorting) {
163
+ return [{ id: sorting.sort, desc: sorting.order === 'desc' }]
164
+ }
153
165
 
154
- const props = defineProps<{
155
- messageGroups: MessageGroupWithMessageSummary[]
156
- loading: boolean
157
- renderType: RenderType
158
- }>()
166
+ const sorting = ref(toTableSorting(props.sorting))
159
167
 
160
- const filteredMessageGroups = computed(() => {
161
- if (appContext.isAdmin) return props.messageGroups
162
- return props.messageGroups.filter((mg) => mg.visibility !== 'internal')
163
- })
168
+ watch(
169
+ () => props.sorting,
170
+ (value) => {
171
+ sorting.value = toTableSorting(value)
172
+ },
173
+ )
164
174
 
165
- const categoryItems = computed(() => {
166
- return [
167
- {
168
- label: t('common.all'),
169
- value: 'all',
170
- },
171
- ...MESSAGE_GROUP_CATEGORIES.map((category) => ({
172
- label: t(`categories.${category}`),
173
- value: category,
174
- })),
175
- ]
175
+ watch(sorting, (val) => {
176
+ const [first] = val
177
+ if (!first) return
178
+ const next: MessageGroupListSorting = {
179
+ sort: first.id as MessageGroupListSort,
180
+ order: first.desc ? 'desc' : 'asc',
181
+ }
182
+ if (next.sort === props.sorting.sort && next.order === props.sorting.order) return
183
+ emit('update:sorting', next)
184
+ trackEvent('table_sorting', {
185
+ messages_kind: messagesKind.value,
186
+ sort_column: next.sort,
187
+ })
176
188
  })
177
189
 
178
- const statusItems = computed(() => {
190
+ // Select items: the "all" entry first, then one translated entry per enum value.
191
+ function toSelectItems<T extends string>(
192
+ values: readonly T[],
193
+ keyPrefix: string,
194
+ allLabel: string,
195
+ ) {
179
196
  return [
180
- {
181
- label: t('common.all'),
182
- value: 'all',
183
- },
184
-
185
- ...MESSAGE_STATUS.map((status) => ({
186
- label: t(`status.${status}`),
187
- value: status,
188
- })),
197
+ { label: allLabel, value: ALL_FILTER },
198
+ ...values.map((value) => ({ label: t(`${keyPrefix}.${value}`), value })),
189
199
  ]
190
- })
191
-
192
- // const tagItems = computed(() => {
193
- // const allTags = new Set<string>()
194
- // messageGroups.forEach((mg) => mg.tags?.forEach((tag) => allTags.add(tag)))
195
- // return [...allTags].sort()
196
- // })
197
-
198
- // const selectedTags = ref<string[]>([])
200
+ }
199
201
 
200
- const table = useTemplateRef('table')
202
+ const categoryItems = computed(() =>
203
+ toSelectItems(MESSAGE_GROUP_CATEGORIES, 'categories', t('common.all')),
204
+ )
205
+ const statusItems = computed(() => toSelectItems(MESSAGE_STATUS, 'status', t('common.all')))
206
+ const productItems = computed(() =>
207
+ toSelectItems(MESSAGE_GROUP_PRODUCTS, 'products', t('pages.message_library.all_products')),
208
+ )
209
+
210
+ const visibilityItems = computed(() =>
211
+ MESSAGE_GROUP_LIST_VISIBILITY.map((visibility) => ({
212
+ label: t(`pages.message_library.visibility.${visibility}`),
213
+ value: visibility,
214
+ })),
215
+ )
201
216
 
202
217
  const messagesKind = computed(() => (props.renderType === 'email_v1' ? 'email' : 'whatsapp'))
203
218
 
204
- watch(sorting, (val) => {
205
- if (val[0]) {
206
- trackEvent('table_sorting', {
207
- messages_kind: messagesKind.value,
208
- sort_column: val[0].id,
209
- })
210
- }
211
- })
212
-
213
219
  function trackFilters(overrides: Partial<TrackingEventMap['change_filters']> = {}) {
214
220
  trackEvent('change_filters', {
215
- search_term: (table.value?.tableApi?.getColumn('name')?.getFilterValue() as string) || '',
216
- category: (table.value?.tableApi?.getColumn('category')?.getFilterValue() as string) || '',
217
- status: (table.value?.tableApi?.getColumn('status')?.getFilterValue() as string) || '',
221
+ search_term: props.filters.search,
222
+ category: props.filters.category,
223
+ status: props.filters.status,
224
+ product: props.filters.product,
218
225
  messages_kind: messagesKind.value,
219
226
  ...overrides,
220
227
  })
@@ -225,19 +232,24 @@ const trackSearchFilter = useDebounceFn((value: string) => {
225
232
  }, 500)
226
233
 
227
234
  function onSearchInput(value: string) {
228
- table.value?.tableApi?.getColumn('name')?.setFilterValue(value)
235
+ emit('update:filters', { search: value })
229
236
  trackSearchFilter(value)
230
237
  }
231
238
 
232
- function onCategoryFilter(value: string) {
233
- table.value?.tableApi?.getColumn('category')?.setFilterValue(value)
234
- trackFilters({ category: value })
239
+ // USelect emits the option value as a plain string; the items are built from the typed enums.
240
+ function onFilter(key: Exclude<keyof MessageGroupListFilters, 'search'>, value: string) {
241
+ emit('update:filters', { [key]: value } as Partial<MessageGroupListFilters>)
242
+ if (key !== 'visibility') trackFilters({ [key]: value })
235
243
  }
236
244
 
237
- function onStatusFilter(value: string) {
238
- table.value?.tableApi?.getColumn('status')?.setFilterValue(value)
239
- trackFilters({ status: value })
240
- }
245
+ const paginationSummary = computed(() => {
246
+ if (!props.meta || props.meta.from === null || props.meta.to === null) return ''
247
+ return t('pages.message_library.pagination_summary', {
248
+ from: props.meta.from,
249
+ to: props.meta.to,
250
+ total: props.meta.total,
251
+ })
252
+ })
241
253
  </script>
242
254
 
243
255
  <template>
@@ -247,38 +259,46 @@ function onStatusFilter(value: string) {
247
259
  >
248
260
  <div class="flex gap-2">
249
261
  <UInput
250
- :model-value="table?.tableApi?.getColumn('name')?.getFilterValue() as string"
262
+ :model-value="filters.search"
251
263
  class="max-w-xs"
252
264
  leading-icon="ph:magnifying-glass-bold"
253
265
  :placeholder="t('pages.message_library.search_placeholder')"
254
266
  :data-testid="templateListTableTestIds.searchInput"
255
- @update:model-value="onSearchInput($event)"
267
+ @update:model-value="onSearchInput(String($event))"
256
268
  />
257
269
  <USelect
258
- :model-value="table?.tableApi?.getColumn('category')?.getFilterValue() as string"
270
+ :model-value="filters.category"
259
271
  class="max-w-48"
260
272
  :placeholder="t('pages.message_library.category_placeholder')"
261
273
  :items="categoryItems"
262
274
  :data-testid="templateListTableTestIds.categorySelect"
263
- @update:model-value="onCategoryFilter($event)"
275
+ @update:model-value="onFilter('category', $event)"
264
276
  />
265
277
  <USelect
266
- :model-value="table?.tableApi?.getColumn('status')?.getFilterValue() as string"
278
+ :model-value="filters.status"
267
279
  class="max-w-48"
268
280
  :placeholder="t('pages.message_library.status_placeholder')"
269
281
  :items="statusItems"
270
282
  :data-testid="templateListTableTestIds.statusSelect"
271
- @update:model-value="onStatusFilter($event)"
283
+ @update:model-value="onFilter('status', $event)"
284
+ />
285
+ <USelect
286
+ :model-value="filters.product"
287
+ class="max-w-48"
288
+ :placeholder="t('pages.message_library.product_placeholder')"
289
+ :items="productItems"
290
+ :data-testid="templateListTableTestIds.productSelect"
291
+ @update:model-value="onFilter('product', $event)"
292
+ />
293
+ <USelect
294
+ v-if="appContext.isAdmin"
295
+ :model-value="filters.visibility"
296
+ class="max-w-48"
297
+ :placeholder="t('pages.message_library.visibility_placeholder')"
298
+ :items="visibilityItems"
299
+ :data-testid="templateListTableTestIds.visibilitySelect"
300
+ @update:model-value="onFilter('visibility', $event)"
272
301
  />
273
- <!-- <USelectMenu
274
- v-if="tagItems.length > 0"
275
- v-model="selectedTags"
276
- :items="tagItems"
277
- multiple
278
- class="max-w-48"
279
- :placeholder="t('pages.message_library.tags_placeholder')"
280
- @update:model-value="table?.tableApi?.getColumn('tags')?.setFilterValue($event)"
281
- /> -->
282
302
  </div>
283
303
 
284
304
  <UCard
@@ -295,10 +315,10 @@ function onStatusFilter(value: string) {
295
315
  tr: 'hover:bg-muted',
296
316
  root: 'overflow-visible static',
297
317
  }"
298
- :data="filteredMessageGroups"
318
+ :data="messageGroups"
299
319
  :columns="columns"
320
+ :sorting-options="{ manualSorting: true, enableSortingRemoval: false }"
300
321
  sticky
301
- ref="table"
302
322
  v-model:sorting="sorting"
303
323
  :data-testid="templateListTableTestIds.table"
304
324
  >
@@ -329,6 +349,16 @@ function onStatusFilter(value: string) {
329
349
  />
330
350
  </template>
331
351
 
352
+ <template #product-cell="{ row }">
353
+ <UBadge
354
+ :label="t(`products.${row.original.product}`)"
355
+ size="md"
356
+ color="neutral"
357
+ variant="outline"
358
+ :data-testid="`${templateListTableTestIds.rowProductBadge}-${row.original.id}`"
359
+ />
360
+ </template>
361
+
332
362
  <template #status-cell="{ row }">
333
363
  <StatusCell :items="row.original.messages" />
334
364
  </template>
@@ -349,6 +379,24 @@ function onStatusFilter(value: string) {
349
379
  />
350
380
  </template>
351
381
  </UTable>
382
+
383
+ <template v-if="meta" #footer>
384
+ <div class="flex items-center justify-between gap-4">
385
+ <span
386
+ class="text-sm text-muted"
387
+ :data-testid="templateListTableTestIds.paginationSummary"
388
+ >
389
+ {{ paginationSummary }}
390
+ </span>
391
+ <UPagination
392
+ :page="page"
393
+ :total="meta.total"
394
+ :items-per-page="meta.per_page"
395
+ :data-testid="templateListTableTestIds.pagination"
396
+ @update:page="emit('update:page', $event)"
397
+ />
398
+ </div>
399
+ </template>
352
400
  </UCard>
353
401
  </div>
354
402
  </template>
@@ -12,7 +12,8 @@ export interface ComposerContext {
12
12
  /** @deprecated No longer used — auth is cookie-based via platform layer */
13
13
  authenticationToken?: Ref<string>
14
14
  organization_id?: Ref<string>
15
- product?: Ref<AppContextConfig['product']>
15
+ /** The Product the host embeds the composer in (ADR 0011). Required. */
16
+ product: Ref<AppContextConfig['product']>
16
17
  strategy?: Ref<AppContextConfig['strategy']>
17
18
  channels?: Ref<AppContextConfig['channels']>
18
19
  languages?: Ref<string[]>
@@ -33,7 +34,8 @@ export function defineComposerContext(context: ComposerContext): void {
33
34
  useNuxtApp().vueApp.provide(COMPOSER_CONTEXT_KEY, context)
34
35
  }
35
36
 
36
- const SERVER_FALLBACK: ComposerContext = {}
37
+ // Server render never reads the context; the store reads every field defensively.
38
+ const SERVER_FALLBACK = {} as ComposerContext
37
39
 
38
40
  export function useComposerContext(): ComposerContext {
39
41
  if (import.meta.server) return SERVER_FALLBACK
@@ -0,0 +1,186 @@
1
+ import type { MaybeRefOrGetter } from 'vue'
2
+ import {
3
+ MESSAGE_GROUP_CATEGORIES,
4
+ MESSAGE_GROUP_LIST_SORT,
5
+ MESSAGE_GROUP_LIST_VISIBILITY,
6
+ MESSAGE_GROUP_PRODUCTS,
7
+ MESSAGE_STATUS,
8
+ SORT_ORDER,
9
+ type MessageGroupCategory,
10
+ type MessageGroupListSort,
11
+ type MessageGroupListVisibility,
12
+ type MessageGroupProduct,
13
+ type MessageStatus,
14
+ type RenderType,
15
+ type SortOrder,
16
+ } from '@dev.smartpricing/message-composer-utils/types'
17
+ import { usePagedMessageGroups } from '~/composables/usePagedMessageGroups'
18
+ import { useAppContextStore } from '~/stores/appContext'
19
+
20
+ /** Sentinel for "no filter" in the selects; never sent to the server. */
21
+ export const ALL_FILTER = 'all'
22
+
23
+ export interface MessageGroupListFilters {
24
+ search: string
25
+ category: MessageGroupCategory | typeof ALL_FILTER
26
+ status: MessageStatus | typeof ALL_FILTER
27
+ product: MessageGroupProduct | typeof ALL_FILTER
28
+ /** Only admins may ask for `internal` or `all`; non-admins always request `public`. */
29
+ visibility: MessageGroupListVisibility
30
+ }
31
+
32
+ export interface MessageGroupListSorting {
33
+ sort: MessageGroupListSort
34
+ order: SortOrder
35
+ }
36
+
37
+ export interface MessageGroupListState extends MessageGroupListFilters, MessageGroupListSorting {
38
+ page: number
39
+ perPage: number
40
+ }
41
+
42
+ const STORAGE_KEY = 'message_library_list_state'
43
+ const PER_PAGE = 25
44
+
45
+ function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
46
+ return typeof value === 'string' && (values as readonly string[]).includes(value)
47
+ }
48
+
49
+ type StoredState = Partial<Record<keyof MessageGroupListState | 'contextProduct', unknown>>
50
+
51
+ /**
52
+ * Reads the state saved by this composer session. State is scoped to the
53
+ * context Product it was saved under: after a host Product switch the library
54
+ * must open prefiltered on the new Product, not on a stale choice.
55
+ */
56
+ function readStoredState(contextProduct: MessageGroupProduct | undefined): StoredState {
57
+ try {
58
+ const raw = sessionStorage.getItem(STORAGE_KEY)
59
+ const parsed: unknown = raw ? JSON.parse(raw) : null
60
+ if (!parsed || typeof parsed !== 'object') return {}
61
+ const stored = parsed as StoredState
62
+ return stored.contextProduct === contextProduct ? stored : {}
63
+ } catch {
64
+ return {}
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Restores the previous list state of this composer session, field by field:
70
+ * anything missing or no longer valid falls back to the default.
71
+ */
72
+ function restoreState(
73
+ defaults: MessageGroupListState,
74
+ contextProduct: MessageGroupProduct | undefined,
75
+ ): MessageGroupListState {
76
+ const stored = readStoredState(contextProduct)
77
+ const withAll = <T extends string>(values: readonly T[]) => [...values, ALL_FILTER] as const
78
+ return {
79
+ search: typeof stored.search === 'string' ? stored.search : defaults.search,
80
+ category: isOneOf(withAll(MESSAGE_GROUP_CATEGORIES), stored.category)
81
+ ? stored.category
82
+ : defaults.category,
83
+ status: isOneOf(withAll(MESSAGE_STATUS), stored.status) ? stored.status : defaults.status,
84
+ product: isOneOf(withAll(MESSAGE_GROUP_PRODUCTS), stored.product)
85
+ ? stored.product
86
+ : defaults.product,
87
+ visibility: isOneOf(MESSAGE_GROUP_LIST_VISIBILITY, stored.visibility)
88
+ ? stored.visibility
89
+ : defaults.visibility,
90
+ sort: isOneOf(MESSAGE_GROUP_LIST_SORT, stored.sort) ? stored.sort : defaults.sort,
91
+ order: isOneOf(SORT_ORDER, stored.order) ? stored.order : defaults.order,
92
+ page:
93
+ Number.isInteger(stored.page) && (stored.page as number) >= 1 ? (stored.page as number) : 1,
94
+ perPage: defaults.perPage,
95
+ }
96
+ }
97
+
98
+ /** A select value of `all` means "no filter": the param is left out. */
99
+ function unlessAll<T extends string>(value: T | typeof ALL_FILTER): T | undefined {
100
+ return value === ALL_FILTER ? undefined : value
101
+ }
102
+
103
+ function persistState(
104
+ state: MessageGroupListState,
105
+ contextProduct: MessageGroupProduct | undefined,
106
+ ) {
107
+ try {
108
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...state, contextProduct }))
109
+ } catch {
110
+ // Storage unavailable (private mode, quota): the list still works, it just won't be restored.
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Owns the library table's state (filters, sorting, page) as component-scoped
116
+ * reactive state and exposes the server-side paginated query (ADR 0012).
117
+ *
118
+ * - The Product filter starts on the context Product and can be widened to all.
119
+ * - Any filter or sort change lands on page one; search is debounced.
120
+ * - The previous page stays visible while the next one loads.
121
+ * - State survives navigating into a group and back within the session, never the host URL.
122
+ */
123
+ export function useMessageGroupList(renderType: MaybeRefOrGetter<RenderType>) {
124
+ const appContext = useAppContextStore()
125
+
126
+ const defaults: MessageGroupListState = {
127
+ search: '',
128
+ category: ALL_FILTER,
129
+ status: ALL_FILTER,
130
+ product: appContext.product ?? ALL_FILTER,
131
+ // Admins keep seeing internal and public groups by default, as before the
132
+ // server-side list; the select lets them narrow. Non-admins never send this.
133
+ visibility: appContext.isAdmin ? 'all' : 'public',
134
+ sort: 'updated_at',
135
+ order: 'desc',
136
+ page: 1,
137
+ perPage: PER_PAGE,
138
+ }
139
+
140
+ const state = reactive<MessageGroupListState>(restoreState(defaults, appContext.product))
141
+
142
+ const { rows, meta, loading, query } = usePagedMessageGroups({
143
+ search: toRef(state, 'search'),
144
+ page: toRef(state, 'page'),
145
+ perPage: PER_PAGE,
146
+ renderType,
147
+ filters: () => ({
148
+ category: unlessAll(state.category),
149
+ status: unlessAll(state.status),
150
+ product: unlessAll(state.product),
151
+ visibility: appContext.isAdmin ? state.visibility : 'public',
152
+ sort: state.sort,
153
+ order: state.order,
154
+ }),
155
+ })
156
+
157
+ watch(state, (value) => persistState(value, appContext.product), { deep: true })
158
+
159
+ function setFilters(patch: Partial<MessageGroupListFilters>) {
160
+ Object.assign(state, patch)
161
+ // Search resets the page only once the debounced value reaches the query.
162
+ const { search: _search, ...immediate } = patch
163
+ if (Object.keys(immediate).length > 0) state.page = 1
164
+ }
165
+
166
+ function setSorting(sorting: MessageGroupListSorting) {
167
+ state.sort = sorting.sort
168
+ state.order = sorting.order
169
+ state.page = 1
170
+ }
171
+
172
+ function setPage(page: number) {
173
+ state.page = page
174
+ }
175
+
176
+ return {
177
+ state: readonly(state),
178
+ setFilters,
179
+ setSorting,
180
+ setPage,
181
+ rows,
182
+ meta,
183
+ loading,
184
+ refetch: query.refetch,
185
+ }
186
+ }
@@ -0,0 +1,66 @@
1
+ import type { MaybeRefOrGetter, Ref, WatchSource } from 'vue'
2
+ import type { RenderType } from '@dev.smartpricing/message-composer-utils/types'
3
+ import type { ListMessageGroupsOptions } from '~/api/messageGroups'
4
+ import { useMessageGroupListQuery } from '~/queries/messageGroups'
5
+
6
+ export const SEARCH_DEBOUNCE_MS = 300
7
+
8
+ type PagedListFilters = Omit<
9
+ ListMessageGroupsOptions,
10
+ 'search' | 'page' | 'per_page' | 'render_type'
11
+ >
12
+
13
+ export interface PagedMessageGroupsOptions {
14
+ /** Raw search term as typed; it is debounced and trimmed before it reaches the request. */
15
+ search: Ref<string>
16
+ /** Current page. Lands on one whenever the search settles or a `resetOn` source changes. */
17
+ page: Ref<number>
18
+ perPage: number
19
+ renderType: MaybeRefOrGetter<RenderType | undefined>
20
+ /** Remaining filters and sorting, merged into the request as they are. */
21
+ filters?: MaybeRefOrGetter<PagedListFilters>
22
+ /** Further sources whose change lands on page one, besides the search and render type. */
23
+ resetOn?: WatchSource[]
24
+ /** When false no request is made (the query stays pending). */
25
+ enabled?: MaybeRefOrGetter<boolean>
26
+ }
27
+
28
+ /**
29
+ * One server-side paginated Message Group list (ADR 0012) as a reactive source:
30
+ * debounced search, page reset, request options and the query itself. The
31
+ * library table and the in-composer picker both build on it and own only what
32
+ * differs (filters, page size, how rows are shown).
33
+ */
34
+ export function usePagedMessageGroups(options: PagedMessageGroupsOptions) {
35
+ const debouncedSearch = refDebounced(options.search, SEARCH_DEBOUNCE_MS)
36
+
37
+ const listOptions = computed<ListMessageGroupsOptions>(() => ({
38
+ ...toValue(options.filters),
39
+ render_type: toValue(options.renderType),
40
+ search: debouncedSearch.value.trim() || undefined,
41
+ page: options.page.value,
42
+ per_page: options.perPage,
43
+ }))
44
+
45
+ const query = useMessageGroupListQuery(listOptions, { enabled: options.enabled })
46
+
47
+ // The reset happens in the same tick as the change (sync flush) so the query
48
+ // sees one key change, not two, and the user never opens an empty page.
49
+ watch(
50
+ [debouncedSearch, () => toValue(options.renderType), ...(options.resetOn ?? [])],
51
+ () => {
52
+ options.page.value = 1
53
+ },
54
+ { flush: 'sync' },
55
+ )
56
+
57
+ return {
58
+ debouncedSearch,
59
+ listOptions,
60
+ query,
61
+ rows: computed(() => query.data.value?.data ?? []),
62
+ meta: computed(() => query.data.value?.meta),
63
+ /** True on the first load and while a new page or filter set is fetching over previous data. */
64
+ loading: computed(() => query.isPending.value || query.isPlaceholderData.value),
65
+ }
66
+ }