@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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/frontend-vue/app/components/CategoriesInput.vue +7 -14
  3. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  4. package/frontend-vue/app/components/Editor.vue +3 -3
  5. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  6. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  7. package/frontend-vue/app/components/PageHeader.vue +8 -12
  8. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  9. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  10. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  11. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  12. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  13. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  14. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  15. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  16. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  17. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  18. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  19. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  20. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  21. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  22. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  23. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  24. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  25. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  26. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  27. package/frontend-vue/app/utils/gallery.ts +19 -7
  28. package/frontend-vue/app/utils/period.ts +0 -5
  29. package/frontend-vue/package.json +1 -1
  30. package/frontend-vue/tests/categories-input.test.ts +43 -13
  31. package/frontend-vue/tests/editor-session.test.ts +4 -0
  32. package/frontend-vue/tests/gallery.test.ts +16 -0
  33. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  34. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  35. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  36. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  37. package/frontend-vue/tests/page-reads.test.ts +157 -0
  38. package/frontend-vue/tests/period.test.ts +0 -9
  39. package/frontend-vue/tests/retention-input.test.ts +30 -9
  40. package/frontend-vue/tests/send-modals.test.ts +8 -1
  41. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  42. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  43. 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 { Ref } from 'vue'
1
+ import { computed, type ComputedRef } from 'vue'
2
+ import { useSetupFacts } from './useMailingReads'
3
+ import { needsSetup } from '../utils/overview-setup'
2
4
 
3
- const HIDES_PERIOD_KEY = 'mailing-setup-hides-period:'
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, so there is no figure for a
8
- * period to filter. Shared between the Overview gate, which sets it, and the
9
- * page header, which reads it.
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(scope: string | undefined): Ref<boolean> {
12
- return useDmsState<boolean>(`${HIDES_PERIOD_KEY}${scope ?? ''}`, () => false)
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 { useMailingApi } from './useMailingApi'
3
- import type { TemplateCategory } from '../types/mailing'
2
+ import { useStoredCategories } from './useMailingReads'
4
3
 
5
4
  /**
6
- * The request in flight: the components mounting together (a table's
7
- * category cells) share it, a later mount asks again since the settings may
8
- * have changed meanwhile.
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 stored = ref<TemplateCategory[]>([])
17
- const categories = computed(() => localized.categories(stored.value))
18
-
19
- function listCategories(): Promise<TemplateCategory[]> {
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 { kind: 'date', text: formatCardDate(date, now, locale) }
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()
@@ -6,7 +6,7 @@
6
6
  "license": "Apache-2.0",
7
7
  "packageManager": "pnpm@10.2.0",
8
8
  "engines": {
9
- "@antelopejs/dms-frontend": ">=0.5.5 <0.6.0"
9
+ "@antelopejs/dms-frontend": ">=0.6.0 <0.7.0"
10
10
  },
11
11
  "scripts": {
12
12
  "format": "prettier --write .",
@@ -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
+ }