@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
package/app/api/dynamicValues.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
8
|
+
get: (product: MessageGroupProduct, language: string) => {
|
|
6
9
|
const params = new URLSearchParams()
|
|
7
10
|
params.set('product', product)
|
|
8
11
|
params.set('language', language)
|
package/app/api/messageGroups.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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="
|
|
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
|
-
//
|
|
27
|
+
// List state + server-side paginated query (ADR 0012)
|
|
30
28
|
const {
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
state: listState,
|
|
30
|
+
setFilters,
|
|
31
|
+
setSorting,
|
|
32
|
+
setPage,
|
|
33
|
+
rows: messageGroups,
|
|
34
|
+
meta,
|
|
35
|
+
loading: isFetching,
|
|
33
36
|
refetch: execute,
|
|
34
|
-
} =
|
|
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"
|