@antelopejs/dms-mailing 0.4.1 → 0.5.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/README.md +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +7 -14
- package/frontend-vue/app/components/DuplicateModal.vue +9 -3
- package/frontend-vue/app/components/Editor.vue +3 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
- package/frontend-vue/app/components/OverviewGate.vue +20 -29
- package/frontend-vue/app/components/PageHeader.vue +8 -12
- package/frontend-vue/app/components/RealSendModal.vue +3 -1
- package/frontend-vue/app/components/RenderingModal.vue +5 -7
- package/frontend-vue/app/components/RetentionInput.vue +13 -23
- package/frontend-vue/app/components/SendDrawer.vue +5 -2
- package/frontend-vue/app/components/StarterGallery.vue +11 -9
- package/frontend-vue/app/components/TemplateCard.vue +19 -4
- package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
- package/frontend-vue/app/components/TemplateGallery.vue +25 -20
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
- package/frontend-vue/app/components/TestSendModal.vue +3 -1
- package/frontend-vue/app/components/VersionHistory.vue +3 -3
- package/frontend-vue/app/components/WebhookStatus.vue +20 -17
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
- package/frontend-vue/app/composables/useEditorActions.ts +3 -1
- package/frontend-vue/app/composables/useEditorSession.ts +6 -4
- package/frontend-vue/app/composables/useMailingApi.ts +5 -58
- package/frontend-vue/app/composables/useMailingReads.ts +229 -0
- package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
- package/frontend-vue/app/utils/gallery.ts +19 -7
- package/frontend-vue/app/utils/period.ts +0 -5
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/categories-input.test.ts +43 -13
- package/frontend-vue/tests/editor-session.test.ts +4 -0
- package/frontend-vue/tests/gallery.test.ts +16 -0
- package/frontend-vue/tests/helpers/api-data.ts +173 -0
- package/frontend-vue/tests/new-template-modal.test.ts +17 -5
- package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
- package/frontend-vue/tests/overview-gate.test.ts +59 -21
- package/frontend-vue/tests/page-reads.test.ts +157 -0
- package/frontend-vue/tests/period.test.ts +0 -9
- package/frontend-vue/tests/retention-input.test.ts +30 -9
- package/frontend-vue/tests/send-modals.test.ts +8 -1
- package/frontend-vue/tests/template-drawer.test.ts +30 -14
- package/frontend-vue/tests/template-gallery.test.ts +45 -24
- package/package.json +2 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { toValue, type MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
mailingPath,
|
|
4
|
+
TEMPLATE_LIST_LIMIT,
|
|
5
|
+
type CategoriesResponse,
|
|
6
|
+
type CategoryUsageResponse,
|
|
7
|
+
type ChangesResponse,
|
|
8
|
+
type StarterPreviewResponse,
|
|
9
|
+
type StartersResponse,
|
|
10
|
+
type TemplateListResponse,
|
|
11
|
+
type TemplateOverviewResponse,
|
|
12
|
+
type VersionsResponse,
|
|
13
|
+
} from './useMailingApi'
|
|
14
|
+
import type { SetupFacts } from '../utils/overview-setup'
|
|
15
|
+
import type { WebhookStatus } from '../utils/webhook-status'
|
|
16
|
+
import type {
|
|
17
|
+
ProviderInfo,
|
|
18
|
+
RetentionPreview,
|
|
19
|
+
SendDetailResponse,
|
|
20
|
+
SendHtmlResponse,
|
|
21
|
+
TemplateContentResponse,
|
|
22
|
+
TemplatePerformance,
|
|
23
|
+
} from '../types/mailing'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The module's reads, through `useApiData`. The ones a page renders: a server
|
|
27
|
+
* render reads them and hands them to the browser with the page, and the
|
|
28
|
+
* components showing one request share it. The ones a drawer, a dialog or the
|
|
29
|
+
* editor makes when it opens or asks: `ReadOnDemand`. Writes go through
|
|
30
|
+
* `useMailingApi`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The e-mail provider the page header names; nothing read while `enabled` is false. */
|
|
34
|
+
export function useProviderInfo(
|
|
35
|
+
enabled: MaybeRefOrGetter<boolean>,
|
|
36
|
+
): ApiDataResult<ProviderInfo> {
|
|
37
|
+
return useApiData<ProviderInfo>(() =>
|
|
38
|
+
toValue(enabled) ? mailingPath('provider') : null,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the overview's setup checklist is made of. */
|
|
43
|
+
export function useSetupFacts(): ApiDataResult<SetupFacts> {
|
|
44
|
+
return useApiData<SetupFacts>(mailingPath('metrics', 'setup'))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The webhook's last event and last refused call. */
|
|
48
|
+
export function useWebhookStatus(): ApiDataResult<WebhookStatus> {
|
|
49
|
+
return useApiData<WebhookStatus>(mailingPath('settings', 'webhook-status'))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a retention of `days` deletes, and when; nothing read without days. */
|
|
53
|
+
export function useRetentionPreview(
|
|
54
|
+
days: MaybeRefOrGetter<number | null>,
|
|
55
|
+
): ApiDataResult<RetentionPreview> {
|
|
56
|
+
return useApiData<RetentionPreview>(
|
|
57
|
+
() => (toValue(days) ? mailingPath('settings', 'retention-preview') : null),
|
|
58
|
+
{ query: () => ({ days: String(toValue(days)) }) },
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How many templates each category holds. */
|
|
63
|
+
export function useCategoryUsage(): ApiDataResult<CategoryUsageResponse> {
|
|
64
|
+
return useApiData<CategoryUsageResponse>(
|
|
65
|
+
mailingPath('settings', 'category-usage'),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The tenant's template categories, as stored. */
|
|
70
|
+
export function useStoredCategories(): ApiDataResult<CategoriesResponse> {
|
|
71
|
+
return useApiData<CategoriesResponse>(mailingPath('templates', 'categories'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Each template's sends and rates, for the gallery's cards: read again with
|
|
76
|
+
* the gallery's rows, rather than with the page.
|
|
77
|
+
*/
|
|
78
|
+
export function useTemplatesOverview(): ApiDataResult<TemplateOverviewResponse> {
|
|
79
|
+
return useApiData<TemplateOverviewResponse>(
|
|
80
|
+
mailingPath('templates', 'overview'),
|
|
81
|
+
{ refreshOnPageRefresh: false },
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How many templates the tenant has, whatever the gallery's filters: the
|
|
87
|
+
* list route's total, without its rows. Read again with the gallery's rows.
|
|
88
|
+
*/
|
|
89
|
+
export function useTemplateTotal(): ApiDataResult<
|
|
90
|
+
Pick<TemplateListResponse, 'total'>
|
|
91
|
+
> {
|
|
92
|
+
return useApiData<Pick<TemplateListResponse, 'total'>>(
|
|
93
|
+
mailingPath('tables', 'templates', 'list'),
|
|
94
|
+
{ query: { limit: '1' }, pick: ['total'], refreshOnPageRefresh: false },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The starter templates the module ships. */
|
|
99
|
+
export function useStarters(): ApiDataResult<StartersResponse> {
|
|
100
|
+
return useApiData<StartersResponse>(mailingPath('templates', 'starters'))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The query of a read made on demand. */
|
|
104
|
+
type ReadQuery = MaybeRefOrGetter<Record<string, string> | undefined>
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A read a drawer, a dialog, a preview frame or the editor makes when it
|
|
108
|
+
* opens or asks, through `useApiData` with `immediate: false`: the call reads
|
|
109
|
+
* the current URL and resolves with its answer, or rejects with what the read
|
|
110
|
+
* failed with. The caller keeps the answer, so the page's refreshes leave it.
|
|
111
|
+
*/
|
|
112
|
+
export type ReadOnDemand<T> = () => Promise<T>
|
|
113
|
+
|
|
114
|
+
function useReadOnDemand<T>(
|
|
115
|
+
url: MaybeRefOrGetter<string | null>,
|
|
116
|
+
query?: ReadQuery,
|
|
117
|
+
): ReadOnDemand<T> {
|
|
118
|
+
const read = useApiData<T>(url, {
|
|
119
|
+
query,
|
|
120
|
+
immediate: false,
|
|
121
|
+
refreshOnPageRefresh: false,
|
|
122
|
+
})
|
|
123
|
+
return async () => {
|
|
124
|
+
await read.execute({ dedupe: 'defer' })
|
|
125
|
+
if (read.error.value) throw read.error.value
|
|
126
|
+
return read.data.value as T
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A template's path below `/templates/<id>`; none without an id. */
|
|
131
|
+
const templatePath =
|
|
132
|
+
(id: MaybeRefOrGetter<string>, segment: string) => (): string | null =>
|
|
133
|
+
toValue(id) ? mailingPath('templates', toValue(id), segment) : null
|
|
134
|
+
|
|
135
|
+
/** The template's record, its draft and published content, for a drawer or the editor. */
|
|
136
|
+
export function useTemplateContentRead(
|
|
137
|
+
id: MaybeRefOrGetter<string>,
|
|
138
|
+
): ReadOnDemand<TemplateContentResponse> {
|
|
139
|
+
return useReadOnDemand(templatePath(id, 'content'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The template's sends and rates. */
|
|
143
|
+
export function useTemplatePerformanceRead(
|
|
144
|
+
id: MaybeRefOrGetter<string>,
|
|
145
|
+
): ReadOnDemand<TemplatePerformance> {
|
|
146
|
+
return useReadOnDemand(templatePath(id, 'performance'))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What publishing the template's draft would change. */
|
|
150
|
+
export function useTemplateChangesRead(
|
|
151
|
+
id: MaybeRefOrGetter<string>,
|
|
152
|
+
): ReadOnDemand<ChangesResponse> {
|
|
153
|
+
return useReadOnDemand(templatePath(id, 'changes'))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The template's published versions. */
|
|
157
|
+
export function useTemplateVersionsRead(
|
|
158
|
+
id: MaybeRefOrGetter<string>,
|
|
159
|
+
): ReadOnDemand<VersionsResponse> {
|
|
160
|
+
return useReadOnDemand(templatePath(id, 'versions'))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Every template of the tenant, by name, for the slugs a new one must avoid. */
|
|
164
|
+
export function useTemplateListRead(): ReadOnDemand<TemplateListResponse> {
|
|
165
|
+
return useReadOnDemand(mailingPath('tables', 'templates', 'list'), {
|
|
166
|
+
limit: TEMPLATE_LIST_LIMIT,
|
|
167
|
+
sortKey: 'name',
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The starter templates, for a dialog. */
|
|
172
|
+
export function useStartersRead(): ReadOnDemand<StartersResponse> {
|
|
173
|
+
return useReadOnDemand(mailingPath('templates', 'starters'))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A starter's preview, in `locale` when given. */
|
|
177
|
+
export function useStarterPreviewRead(
|
|
178
|
+
starterId: MaybeRefOrGetter<string | undefined>,
|
|
179
|
+
locale: MaybeRefOrGetter<string | undefined>,
|
|
180
|
+
): ReadOnDemand<StarterPreviewResponse> {
|
|
181
|
+
return useReadOnDemand(
|
|
182
|
+
() => {
|
|
183
|
+
const id = toValue(starterId)
|
|
184
|
+
return id ? mailingPath('templates', 'starters', id, 'preview') : null
|
|
185
|
+
},
|
|
186
|
+
() => {
|
|
187
|
+
const value = toValue(locale)
|
|
188
|
+
return value ? { locale: value } : undefined
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A send's path below `/sends`; none without an id. */
|
|
194
|
+
const sendPath =
|
|
195
|
+
(id: MaybeRefOrGetter<string>, ...segments: string[]) =>
|
|
196
|
+
(): string | null =>
|
|
197
|
+
toValue(id) ? mailingPath('sends', toValue(id), ...segments) : null
|
|
198
|
+
|
|
199
|
+
/** A send with its events and the template it used. */
|
|
200
|
+
export function useSendRead(
|
|
201
|
+
id: MaybeRefOrGetter<string>,
|
|
202
|
+
): ReadOnDemand<SendDetailResponse> {
|
|
203
|
+
return useReadOnDemand(sendPath(id))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The e-mail a send rendered. */
|
|
207
|
+
export function useSendHtmlRead(
|
|
208
|
+
id: MaybeRefOrGetter<string>,
|
|
209
|
+
): ReadOnDemand<SendHtmlResponse> {
|
|
210
|
+
return useReadOnDemand(sendPath(id, 'html'))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The e-mail provider and the tracking it reports, for a drawer. */
|
|
214
|
+
export function useProviderRead(): ReadOnDemand<ProviderInfo> {
|
|
215
|
+
return useReadOnDemand(mailingPath('provider'))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The reads the editor makes when it asks: what publishing would change, and the template's figures. */
|
|
219
|
+
export interface EditorReads {
|
|
220
|
+
changes: ReadOnDemand<ChangesResponse>
|
|
221
|
+
performance: ReadOnDemand<TemplatePerformance>
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function useEditorReads(id: MaybeRefOrGetter<string>): EditorReads {
|
|
225
|
+
return {
|
|
226
|
+
changes: useTemplateChangesRead(id),
|
|
227
|
+
performance: useTemplatePerformanceRead(id),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import { useSetupFacts } from './useMailingReads'
|
|
3
|
+
import { needsSetup } from '../utils/overview-setup'
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/** The period scope of the Overview page, whose dashboard the setup gates. */
|
|
6
|
+
const OVERVIEW_PERIOD_SCOPE = 'mailing-overview'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Whether the page header should hold its period selector back for `scope`:
|
|
7
|
-
* the setup card stands in for the dashboard,
|
|
8
|
-
* period to filter.
|
|
9
|
-
*
|
|
10
|
+
* on the Overview, while the setup card stands in for the dashboard, there is
|
|
11
|
+
* no figure for a period to filter. Read from the setup facts the Overview
|
|
12
|
+
* gate shows (one request for both), so a server render hides it as the
|
|
13
|
+
* browser does.
|
|
10
14
|
*/
|
|
11
|
-
export function useSetupHidesPeriod(
|
|
12
|
-
|
|
15
|
+
export function useSetupHidesPeriod(
|
|
16
|
+
scope: string | undefined,
|
|
17
|
+
): ComputedRef<boolean> {
|
|
18
|
+
if (scope !== OVERVIEW_PERIOD_SCOPE) return computed(() => false)
|
|
19
|
+
const { data } = useSetupFacts()
|
|
20
|
+
return computed(() => !!data.value && needsSetup(data.value))
|
|
13
21
|
}
|
|
@@ -1,35 +1,16 @@
|
|
|
1
1
|
import { useLocalizedDefaults } from './useLocalizedDefaults'
|
|
2
|
-
import {
|
|
3
|
-
import type { TemplateCategory } from '../types/mailing'
|
|
2
|
+
import { useStoredCategories } from './useMailingReads'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The tenant's template categories, named in the interface's language: one
|
|
6
|
+
* read the components showing them share (a table's category cells, the
|
|
7
|
+
* gallery), none when it failed.
|
|
9
8
|
*/
|
|
10
|
-
let pending: Promise<TemplateCategory[]> | null = null
|
|
11
|
-
|
|
12
|
-
/** The tenant's template categories, loaded once mounted. */
|
|
13
9
|
export function useTemplateCategories() {
|
|
14
|
-
const api = useMailingApi()
|
|
15
10
|
const localized = useLocalizedDefaults()
|
|
16
|
-
const
|
|
17
|
-
const categories = computed(() =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
pending ??= api
|
|
21
|
-
.listCategories()
|
|
22
|
-
.then((response) => response.categories)
|
|
23
|
-
.catch(() => [])
|
|
24
|
-
.finally(() => {
|
|
25
|
-
pending = null
|
|
26
|
-
})
|
|
27
|
-
return pending
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
onMounted(async () => {
|
|
31
|
-
stored.value = await listCategories()
|
|
32
|
-
})
|
|
33
|
-
|
|
11
|
+
const { data, error } = useStoredCategories()
|
|
12
|
+
const categories = computed(() =>
|
|
13
|
+
localized.categories(error.value ? [] : (data.value?.categories ?? [])),
|
|
14
|
+
)
|
|
34
15
|
return { categories }
|
|
35
16
|
}
|
|
@@ -148,16 +148,29 @@ export type OnDate = (date: string) => string
|
|
|
148
148
|
|
|
149
149
|
const bareDate: OnDate = (date) => date
|
|
150
150
|
|
|
151
|
+
/** How a card writes a date more than a day old ("Sep 27"). */
|
|
152
|
+
export const CARD_DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
|
153
|
+
month: 'short',
|
|
154
|
+
day: '2-digit',
|
|
155
|
+
}
|
|
156
|
+
|
|
151
157
|
/**
|
|
152
158
|
* A date as a card reads it: "2 hr. ago" / "5 min. ago" within a day, "Sep 27"
|
|
153
159
|
* after. `onDate` dresses the absolute one only, so a sentence written around
|
|
154
160
|
* it ("Edited {date}") reads right in both cases. Empty for an unreadable date.
|
|
161
|
+
* `dateFormat` writes the absolute one: a component passes the DMS's regional
|
|
162
|
+
* one (`useRegionalFormat().dateTimeFormat(CARD_DATE_FORMAT)`), whose day is
|
|
163
|
+
* the reader's, on a server render too.
|
|
155
164
|
*/
|
|
156
165
|
export function formatCardDate(
|
|
157
166
|
date: string | null | undefined,
|
|
158
167
|
now: Date,
|
|
159
168
|
locale: string,
|
|
160
169
|
onDate: OnDate = bareDate,
|
|
170
|
+
dateFormat: Intl.DateTimeFormat = new Intl.DateTimeFormat(
|
|
171
|
+
locale,
|
|
172
|
+
CARD_DATE_FORMAT,
|
|
173
|
+
),
|
|
161
174
|
): string {
|
|
162
175
|
const time = date ? new Date(date).getTime() : Number.NaN
|
|
163
176
|
if (Number.isNaN(time)) return ''
|
|
@@ -172,12 +185,7 @@ export function formatCardDate(
|
|
|
172
185
|
if (elapsed < HOURS_PER_DAY * MS_PER_HOUR) {
|
|
173
186
|
return relative.format(-Math.round(elapsed / MS_PER_HOUR), 'hour')
|
|
174
187
|
}
|
|
175
|
-
return onDate(
|
|
176
|
-
new Intl.DateTimeFormat(locale, {
|
|
177
|
-
month: 'short',
|
|
178
|
-
day: '2-digit',
|
|
179
|
-
}).format(new Date(time)),
|
|
180
|
-
)
|
|
188
|
+
return onDate(dateFormat.format(new Date(time)))
|
|
181
189
|
}
|
|
182
190
|
|
|
183
191
|
/**
|
|
@@ -194,12 +202,16 @@ export function formatCardSince(
|
|
|
194
202
|
date: string | null | undefined,
|
|
195
203
|
now: Date,
|
|
196
204
|
locale: string,
|
|
205
|
+
dateFormat?: Intl.DateTimeFormat,
|
|
197
206
|
): CardSince | null {
|
|
198
207
|
const time = date ? new Date(date).getTime() : Number.NaN
|
|
199
208
|
if (Number.isNaN(time)) return null
|
|
200
209
|
const elapsed = Math.max(0, now.getTime() - time)
|
|
201
210
|
if (elapsed >= HOURS_PER_DAY * MS_PER_HOUR)
|
|
202
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
kind: 'date',
|
|
213
|
+
text: formatCardDate(date, now, locale, undefined, dateFormat),
|
|
214
|
+
}
|
|
203
215
|
const isHours = elapsed >= MS_PER_HOUR
|
|
204
216
|
const amount = Math.max(
|
|
205
217
|
1,
|
|
@@ -25,11 +25,6 @@ export function minutesSince(
|
|
|
25
25
|
return elapsed > 0 ? Math.floor(elapsed / MINUTE_MS) : 0
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** Formats a date as HH:MM in `locale`. */
|
|
29
|
-
export function formatClock(value: Date | string, locale: string): string {
|
|
30
|
-
return new Date(value).toLocaleTimeString(locale, CLOCK_FORMAT)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
28
|
/** Whether `at` falls within the next 24 hours: "tonight" rather than a date. */
|
|
34
29
|
export function isWithinADay(at: Date | string, now: Date): boolean {
|
|
35
30
|
const delay = new Date(at).getTime() - now.getTime()
|
|
@@ -9,14 +9,9 @@ import {
|
|
|
9
9
|
stubNuxtGlobals,
|
|
10
10
|
type Mounted,
|
|
11
11
|
} from './helpers/nuxt-globals'
|
|
12
|
+
import { stubApiData, type ApiDataStub } from './helpers/api-data'
|
|
12
13
|
import type { TemplateCategory } from '../app/types/mailing'
|
|
13
14
|
|
|
14
|
-
vi.mock('../app/composables/useMailingApi', () => ({
|
|
15
|
-
useMailingApi: () => ({
|
|
16
|
-
categoryUsage: async () => ({ counts: {} }),
|
|
17
|
-
}),
|
|
18
|
-
}))
|
|
19
|
-
|
|
20
15
|
vi.mock('../app/composables/useLocalizedDefaults', () => ({
|
|
21
16
|
useLocalizedDefaults: () => ({
|
|
22
17
|
categories: (list: TemplateCategory[]) => list,
|
|
@@ -70,17 +65,19 @@ const ButtonStub = defineComponent({
|
|
|
70
65
|
h('button', { ...attrs, 'data-icon': props.icon }),
|
|
71
66
|
})
|
|
72
67
|
|
|
68
|
+
const GLOBALS = {
|
|
69
|
+
UFormField: FormFieldStub,
|
|
70
|
+
UButton: ButtonStub,
|
|
71
|
+
UPopover: slotStub('UPopover'),
|
|
72
|
+
DmsEyebrow: slotStub('DmsEyebrow'),
|
|
73
|
+
DmsInputText: slotStub('DmsInputText'),
|
|
74
|
+
}
|
|
75
|
+
|
|
73
76
|
function mountInput(): Mounted {
|
|
74
77
|
return mount(
|
|
75
78
|
CategoriesInput,
|
|
76
79
|
{ modelValue: CATEGORIES, initialValue: CATEGORIES },
|
|
77
|
-
|
|
78
|
-
UFormField: FormFieldStub,
|
|
79
|
-
UButton: ButtonStub,
|
|
80
|
-
UPopover: slotStub('UPopover'),
|
|
81
|
-
DmsEyebrow: slotStub('DmsEyebrow'),
|
|
82
|
-
DmsInputText: slotStub('DmsInputText'),
|
|
83
|
-
},
|
|
80
|
+
GLOBALS,
|
|
84
81
|
)
|
|
85
82
|
}
|
|
86
83
|
|
|
@@ -90,10 +87,43 @@ function alerts(host: HTMLElement): HTMLElement[] {
|
|
|
90
87
|
|
|
91
88
|
describe('CategoriesInput', () => {
|
|
92
89
|
let mounted: Mounted | undefined
|
|
90
|
+
let api: ApiDataStub
|
|
93
91
|
|
|
94
92
|
beforeEach(() => {
|
|
95
93
|
stubNuxtGlobals()
|
|
96
94
|
formErrors.clear()
|
|
95
|
+
api = stubApiData()
|
|
96
|
+
api.respond(() => ({ counts: {} }))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('shows how many templates each category holds, read again once saved', async () => {
|
|
100
|
+
api.respond(() => ({ counts: { orders: 2 } }))
|
|
101
|
+
const saved = reactive({ value: CATEGORIES })
|
|
102
|
+
mounted = mount(
|
|
103
|
+
defineComponent({
|
|
104
|
+
setup: () => () =>
|
|
105
|
+
h(CategoriesInput, {
|
|
106
|
+
modelValue: saved.value,
|
|
107
|
+
initialValue: saved.value,
|
|
108
|
+
}),
|
|
109
|
+
}),
|
|
110
|
+
{},
|
|
111
|
+
GLOBALS,
|
|
112
|
+
)
|
|
113
|
+
await flush()
|
|
114
|
+
expect(mounted.host.textContent).toContain(
|
|
115
|
+
'dms_mailing.settings.categories.usage count=2',
|
|
116
|
+
)
|
|
117
|
+
expect(api.reads).toEqual([
|
|
118
|
+
{ url: '/api/mailing/settings/category-usage', query: {} },
|
|
119
|
+
])
|
|
120
|
+
api.respond(() => ({ counts: { orders: 3 } }))
|
|
121
|
+
saved.value = [...CATEGORIES]
|
|
122
|
+
await flush()
|
|
123
|
+
expect(api.reads).toHaveLength(2)
|
|
124
|
+
expect(mounted.host.textContent).toContain(
|
|
125
|
+
'dms_mailing.settings.categories.usage count=3',
|
|
126
|
+
)
|
|
97
127
|
})
|
|
98
128
|
|
|
99
129
|
afterEach(() => {
|
|
@@ -73,6 +73,10 @@ function setup(): Harness {
|
|
|
73
73
|
harness.session = useEditorSession({
|
|
74
74
|
editor: harness.editor,
|
|
75
75
|
api: api as unknown as MailingApi,
|
|
76
|
+
reads: {
|
|
77
|
+
changes: () => api.changes('t1'),
|
|
78
|
+
performance: async () => null as never,
|
|
79
|
+
},
|
|
76
80
|
templateId: 't1',
|
|
77
81
|
loaded: LOADED,
|
|
78
82
|
onError: (error) => errors.push(error),
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import {
|
|
3
3
|
UNCATEGORISED_GROUP_ID,
|
|
4
4
|
firstName,
|
|
5
|
+
CARD_DATE_FORMAT,
|
|
5
6
|
formatCardDate,
|
|
6
7
|
formatCardSince,
|
|
7
8
|
groupByCategory,
|
|
@@ -92,6 +93,21 @@ describe('formatCardDate', () => {
|
|
|
92
93
|
expect(formatCardDate('2026-09-27T09:00:00Z', now, 'en')).toBe('Sep 27')
|
|
93
94
|
})
|
|
94
95
|
|
|
96
|
+
it("writes the date with the formatter given: a server writes the reader's day", () => {
|
|
97
|
+
const late = '2026-09-27T02:00:00Z'
|
|
98
|
+
const inZone = (timeZone: string) =>
|
|
99
|
+
new Intl.DateTimeFormat('en', { ...CARD_DATE_FORMAT, timeZone })
|
|
100
|
+
expect(
|
|
101
|
+
formatCardDate(late, now, 'en', undefined, inZone('Europe/Paris')),
|
|
102
|
+
).toBe('Sep 27')
|
|
103
|
+
expect(
|
|
104
|
+
formatCardDate(late, now, 'en', undefined, inZone('America/New_York')),
|
|
105
|
+
).toBe('Sep 26')
|
|
106
|
+
expect(
|
|
107
|
+
formatCardSince(late, now, 'en', inZone('America/New_York')),
|
|
108
|
+
).toEqual({ kind: 'date', text: 'Sep 26' })
|
|
109
|
+
})
|
|
110
|
+
|
|
95
111
|
it('answers nothing for an unreadable date', () => {
|
|
96
112
|
expect(formatCardDate('', now, 'en')).toBe('')
|
|
97
113
|
expect(formatCardDate('nope', now, 'en')).toBe('')
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** A backend read a component made through `useApiData`. */
|
|
5
|
+
export interface ApiRead {
|
|
6
|
+
url: string
|
|
7
|
+
query: Record<string, unknown>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ApiBackend = (read: ApiRead) => unknown
|
|
11
|
+
|
|
12
|
+
interface ApiDataOptions {
|
|
13
|
+
query?: MaybeRefOrGetter<Record<string, unknown> | undefined>
|
|
14
|
+
pick?: string[]
|
|
15
|
+
immediate?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ApiDataStub {
|
|
19
|
+
/** Every read made, in order. */
|
|
20
|
+
reads: ApiRead[]
|
|
21
|
+
/** Answers the next reads; a thrown error or a rejection fails them. */
|
|
22
|
+
respond: (backend: ApiBackend) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function picked(value: unknown, keys: string[] | undefined): unknown {
|
|
26
|
+
if (!keys || !value || typeof value !== 'object') return value
|
|
27
|
+
const source = value as Record<string, unknown>
|
|
28
|
+
return Object.fromEntries(keys.map((key) => [key, source[key]]))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stands in for the DMS's `useApiData` (an auto-import the layer's tests do
|
|
33
|
+
* not have): reads the URL, and again whenever it or its query changes, from
|
|
34
|
+
* `respond`'s backend, with the result's `data`, `error`, `status` and
|
|
35
|
+
* `refresh()`; with `immediate: false`, only once `execute()` asks. Whether the server renders it and hands it over is the DMS's
|
|
36
|
+
* and the renderer's, proven on the playground.
|
|
37
|
+
*/
|
|
38
|
+
export function stubApiData(): ApiDataStub {
|
|
39
|
+
const stub: ApiDataStub = {
|
|
40
|
+
reads: [],
|
|
41
|
+
respond: (next) => {
|
|
42
|
+
backend = next
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
let backend: ApiBackend = () => ({})
|
|
46
|
+
vi.stubGlobal(
|
|
47
|
+
'useApiData',
|
|
48
|
+
(
|
|
49
|
+
url: MaybeRefOrGetter<string | null | undefined>,
|
|
50
|
+
options: ApiDataOptions = {},
|
|
51
|
+
) => {
|
|
52
|
+
const data = ref<unknown>(null)
|
|
53
|
+
const error = ref<unknown>(null)
|
|
54
|
+
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle')
|
|
55
|
+
const request = () => ({
|
|
56
|
+
url: toValue(url),
|
|
57
|
+
query: toValue(options.query) ?? {},
|
|
58
|
+
})
|
|
59
|
+
async function refresh(): Promise<void> {
|
|
60
|
+
const { url: path, query } = request()
|
|
61
|
+
if (!path) return
|
|
62
|
+
const read = { url: path, query }
|
|
63
|
+
stub.reads.push(read)
|
|
64
|
+
status.value = 'pending'
|
|
65
|
+
try {
|
|
66
|
+
data.value = picked(await backend(read), options.pick)
|
|
67
|
+
error.value = null
|
|
68
|
+
status.value = 'success'
|
|
69
|
+
} catch (failure) {
|
|
70
|
+
error.value = failure
|
|
71
|
+
status.value = 'error'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// A read made on demand (`immediate: false`) waits for `execute()`.
|
|
75
|
+
if (options.immediate !== false)
|
|
76
|
+
watch(
|
|
77
|
+
() => JSON.stringify(request()),
|
|
78
|
+
() => void refresh(),
|
|
79
|
+
{
|
|
80
|
+
immediate: true,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
return {
|
|
84
|
+
data,
|
|
85
|
+
error,
|
|
86
|
+
status,
|
|
87
|
+
pending: computed(() => status.value === 'pending'),
|
|
88
|
+
refresh,
|
|
89
|
+
execute: refresh,
|
|
90
|
+
clear: () => {
|
|
91
|
+
data.value = null
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
return stub
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The DMS's regional formatter, in the zone the tests run in. */
|
|
100
|
+
export function stubRegionalFormat(): void {
|
|
101
|
+
vi.stubGlobal('useRegionalFormat', () => ({
|
|
102
|
+
dateTimeFormat: (options: Intl.DateTimeFormatOptions) =>
|
|
103
|
+
new Intl.DateTimeFormat('en', options),
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The read routes of the mailing API, as a component's tests mock them. */
|
|
108
|
+
export interface MailingReadMocks {
|
|
109
|
+
templateContent?: (id: string) => unknown
|
|
110
|
+
performance?: (id: string) => unknown
|
|
111
|
+
changes?: (id: string) => unknown
|
|
112
|
+
versions?: (id: string) => unknown
|
|
113
|
+
listTemplates?: () => unknown
|
|
114
|
+
starters?: () => unknown
|
|
115
|
+
starterPreview?: (id: string, locale?: string) => unknown
|
|
116
|
+
send?: (id: string) => unknown
|
|
117
|
+
sendHtml?: (id: string) => unknown
|
|
118
|
+
provider?: () => unknown
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type ReadRoute = (
|
|
122
|
+
mocks: MailingReadMocks,
|
|
123
|
+
match: RegExpExecArray,
|
|
124
|
+
read: ApiRead,
|
|
125
|
+
) => unknown
|
|
126
|
+
|
|
127
|
+
const READ_ROUTES: Array<[RegExp, ReadRoute]> = [
|
|
128
|
+
[/^\/api\/mailing\/templates\/starters$/, (m) => m.starters?.()],
|
|
129
|
+
[
|
|
130
|
+
/^\/api\/mailing\/templates\/starters\/([^/]+)\/preview$/,
|
|
131
|
+
(m, [, id], { query }) =>
|
|
132
|
+
m.starterPreview?.(id!, query.locale as string | undefined),
|
|
133
|
+
],
|
|
134
|
+
[
|
|
135
|
+
/^\/api\/mailing\/templates\/([^/]+)\/content$/,
|
|
136
|
+
(m, [, id]) => m.templateContent?.(id!),
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
/^\/api\/mailing\/templates\/([^/]+)\/performance$/,
|
|
140
|
+
(m, [, id]) => m.performance?.(id!),
|
|
141
|
+
],
|
|
142
|
+
[
|
|
143
|
+
/^\/api\/mailing\/templates\/([^/]+)\/changes$/,
|
|
144
|
+
(m, [, id]) => m.changes?.(id!),
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
/^\/api\/mailing\/templates\/([^/]+)\/versions$/,
|
|
148
|
+
(m, [, id]) => m.versions?.(id!),
|
|
149
|
+
],
|
|
150
|
+
[/^\/api\/mailing\/tables\/templates\/list$/, (m) => m.listTemplates?.()],
|
|
151
|
+
[/^\/api\/mailing\/sends\/([^/]+)\/html$/, (m, [, id]) => m.sendHtml?.(id!)],
|
|
152
|
+
[/^\/api\/mailing\/sends\/([^/]+)$/, (m, [, id]) => m.send?.(id!)],
|
|
153
|
+
[/^\/api\/mailing\/provider$/, (m) => m.provider?.()],
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A backend answering the reads a component makes on demand with the
|
|
158
|
+
* test's mocks of them, and the others (or a read with no mock) with
|
|
159
|
+
* `fallback`.
|
|
160
|
+
*/
|
|
161
|
+
export function mailingReads(
|
|
162
|
+
mocks: MailingReadMocks,
|
|
163
|
+
fallback: ApiBackend = () => ({}),
|
|
164
|
+
): ApiBackend {
|
|
165
|
+
return (read) => {
|
|
166
|
+
for (const [pattern, route] of READ_ROUTES) {
|
|
167
|
+
const match = pattern.exec(read.url)
|
|
168
|
+
const answer = match ? route(mocks, match, read) : undefined
|
|
169
|
+
if (answer !== undefined) return answer
|
|
170
|
+
}
|
|
171
|
+
return fallback(read)
|
|
172
|
+
}
|
|
173
|
+
}
|