@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.
- package/app/api/dynamicValues.ts +5 -2
- package/app/api/messageGroups.ts +23 -12
- package/app/components/Common/AppContextErrors.vue +30 -0
- package/app/components/Common/MessageGroupSelect.vue +109 -8
- package/app/components/Composer/MessageLibrary.vue +17 -7
- package/app/components/TemplateList/BaseTable.vue +156 -108
- package/app/composables/composerContext.ts +4 -2
- package/app/composables/useMessageGroupList.ts +186 -0
- package/app/composables/usePagedMessageGroups.ts +66 -0
- package/app/composables/useTemplateOperations.ts +13 -3
- package/app/layouts/default.vue +1 -0
- package/app/queries/dynamicValues.ts +8 -2
- package/app/queries/messageGroups.ts +18 -16
- package/app/stores/appContext.ts +12 -6
- package/app/types/tracking.ts +4 -0
- package/app/utils/testIds.const.ts +6 -0
- package/i18n/locales/de.ts +18 -2
- package/i18n/locales/en.ts +18 -2
- package/i18n/locales/es.ts +18 -2
- package/i18n/locales/fr.ts +18 -2
- package/i18n/locales/it.ts +18 -2
- package/package.json +2 -2
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
155
|
-
messageGroups: MessageGroupWithMessageSummary[]
|
|
156
|
-
loading: boolean
|
|
157
|
-
renderType: RenderType
|
|
158
|
-
}>()
|
|
166
|
+
const sorting = ref(toTableSorting(props.sorting))
|
|
159
167
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
watch(
|
|
169
|
+
() => props.sorting,
|
|
170
|
+
(value) => {
|
|
171
|
+
sorting.value = toTableSorting(value)
|
|
172
|
+
},
|
|
173
|
+
)
|
|
164
174
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
216
|
-
category:
|
|
217
|
-
status:
|
|
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
|
-
|
|
235
|
+
emit('update:filters', { search: value })
|
|
229
236
|
trackSearchFilter(value)
|
|
230
237
|
}
|
|
231
238
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
275
|
+
@update:model-value="onFilter('category', $event)"
|
|
264
276
|
/>
|
|
265
277
|
<USelect
|
|
266
|
-
:model-value="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|