@antelopejs/dms-mailing 0.4.1 → 0.5.1

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 (86) hide show
  1. package/README.md +61 -339
  2. package/dist/data/templates-table.js +2 -10
  3. package/dist/data/templates-table.js.map +1 -1
  4. package/dist/db/models/index.d.ts +1 -0
  5. package/dist/db/models/index.js +1 -0
  6. package/dist/db/models/index.js.map +1 -1
  7. package/dist/db/models/send-events.model.d.ts +1 -0
  8. package/dist/db/models/sends.model.d.ts +1 -0
  9. package/dist/db/models/settings.model.d.ts +1 -0
  10. package/dist/db/models/suppressions.model.d.ts +1 -0
  11. package/dist/db/models/template-slugs.model.d.ts +27 -0
  12. package/dist/db/models/template-slugs.model.js +43 -0
  13. package/dist/db/models/template-slugs.model.js.map +1 -0
  14. package/dist/db/models/template-versions.model.d.ts +1 -0
  15. package/dist/db/models/templates.model.d.ts +1 -0
  16. package/dist/db/models/webhook-status.model.d.ts +1 -0
  17. package/dist/db/tables/index.d.ts +1 -0
  18. package/dist/db/tables/index.js +1 -0
  19. package/dist/db/tables/index.js.map +1 -1
  20. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  21. package/dist/db/tables/template-slugs.table.js +35 -0
  22. package/dist/db/tables/template-slugs.table.js.map +1 -0
  23. package/dist/routes/templates.js +1 -0
  24. package/dist/routes/templates.js.map +1 -1
  25. package/dist/services/templates.d.ts +2 -0
  26. package/dist/services/templates.js +26 -0
  27. package/dist/services/templates.js.map +1 -1
  28. package/frontend-vue/app/components/CategoriesInput.vue +18 -24
  29. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  30. package/frontend-vue/app/components/Editor.vue +3 -3
  31. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  32. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  33. package/frontend-vue/app/components/PageHeader.vue +8 -12
  34. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  35. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  36. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  37. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  38. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  39. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  40. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  41. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  42. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  43. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  44. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  45. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  46. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  47. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  48. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  49. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  50. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  51. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  52. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  53. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  54. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  55. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  56. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  57. package/frontend-vue/app/utils/gallery.ts +19 -7
  58. package/frontend-vue/app/utils/period.ts +0 -5
  59. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  60. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  61. package/frontend-vue/package.json +4 -3
  62. package/frontend-vue/pnpm-lock.yaml +6 -13
  63. package/frontend-vue/tests/categories-input.test.ts +43 -13
  64. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  65. package/frontend-vue/tests/editor-session.test.ts +4 -0
  66. package/frontend-vue/tests/gallery.test.ts +16 -0
  67. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  68. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  69. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  70. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  71. package/frontend-vue/tests/page-reads.test.ts +157 -0
  72. package/frontend-vue/tests/period.test.ts +0 -9
  73. package/frontend-vue/tests/retention-input.test.ts +30 -9
  74. package/frontend-vue/tests/send-modals.test.ts +8 -1
  75. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  76. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  77. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  78. package/package.json +31 -14
  79. package/skills/dms-mailing/SKILL.md +63 -0
  80. package/src/data/templates-table.ts +7 -27
  81. package/src/db/models/index.ts +1 -0
  82. package/src/db/models/template-slugs.model.ts +61 -0
  83. package/src/db/tables/index.ts +1 -0
  84. package/src/db/tables/template-slugs.table.ts +29 -0
  85. package/src/routes/templates.ts +2 -0
  86. package/src/services/templates.ts +49 -0
@@ -0,0 +1,159 @@
1
+ import { useSortable } from '@vueuse/integrations/useSortable'
2
+ import { toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
3
+
4
+ /** What a drop did to a list, as vuedraggable's `change` event said it. */
5
+ export interface SortableListChange<Item> {
6
+ added?: { element: Item; newIndex: number }
7
+ removed?: { element: Item; oldIndex: number }
8
+ moved?: { element: Item; oldIndex: number; newIndex: number }
9
+ }
10
+
11
+ export interface SortableGroup {
12
+ name: string
13
+ /** `'clone'`: the list keeps its items and hands a copy to the other list. */
14
+ pull?: boolean | 'clone'
15
+ /** `false`: no item of another list lands here. */
16
+ put?: boolean
17
+ }
18
+
19
+ export interface SortableListOptions<Item> {
20
+ /** Lists sharing a group name exchange items. */
21
+ group?: string | SortableGroup
22
+ /** Read again whenever it changes. */
23
+ disabled?: MaybeRefOrGetter<boolean>
24
+ /** Only an item grabbed by an element matching this selector moves. */
25
+ handle?: string
26
+ /**
27
+ * The children that are items, when the list element holds others (a
28
+ * footer): the indexes SortableJS reports count these only.
29
+ */
30
+ draggable?: string
31
+ /** `false`: the items cannot be reordered within the list. */
32
+ sort?: boolean
33
+ /** Class of the slot the dragged item will land in. */
34
+ ghostClass?: string
35
+ /** What another list receives in place of the dragged item. */
36
+ clone?: (item: Item) => unknown
37
+ onChange?: (change: SortableListChange<Item>) => void
38
+ /** After every drag, whatever it changed. */
39
+ onEnd?: () => void
40
+ }
41
+
42
+ /** What the list reads of SortableJS's events. */
43
+ interface SortableEvent {
44
+ item: HTMLElement
45
+ clone: HTMLElement
46
+ oldIndex?: number
47
+ newIndex?: number
48
+ pullMode?: boolean | 'clone'
49
+ }
50
+
51
+ /**
52
+ * The options the list sets. SortableJS keeps its default only for an option
53
+ * absent from the object: a key set to `undefined` replaces it (an undefined
54
+ * `draggable` makes no child an item, so nothing drags).
55
+ */
56
+ function defined<Options extends object>(options: Options): Partial<Options> {
57
+ return Object.fromEntries(
58
+ Object.entries(options).filter(([, value]) => value !== undefined),
59
+ ) as Partial<Options>
60
+ }
61
+
62
+ /**
63
+ * The item being dragged, by group. A drop on another list only names the
64
+ * element, which belongs to the list it left: the list it lands in reads the
65
+ * item the other one stored when the drag started.
66
+ */
67
+ const dragged = new Map<string, unknown>()
68
+
69
+ /**
70
+ * Lets the user reorder the items of `list` rendered in `root`, or move them
71
+ * to another list of the same group: SortableJS, through VueUse's
72
+ * `useSortable`.
73
+ *
74
+ * SortableJS moves the dragged element in the DOM itself. Vue owns these
75
+ * elements, so each drop puts the element back where Vue rendered it and
76
+ * changes `list` in place instead, from which Vue renders the new order.
77
+ */
78
+ export function useSortableList<Item>(
79
+ root: Ref<HTMLElement | null>,
80
+ list: () => Item[],
81
+ options: SortableListOptions<Item> = {},
82
+ ): void {
83
+ const groupName =
84
+ typeof options.group === 'string'
85
+ ? options.group
86
+ : (options.group?.name ?? '')
87
+
88
+ /**
89
+ * Where the dragged element stood: next to the node that followed it, not
90
+ * at an index, as Vue removes an item by the markers around it.
91
+ */
92
+ let origin: { parent: Node; next: Node | null } | undefined
93
+
94
+ function restore(item: HTMLElement): void {
95
+ if (origin) origin.parent.insertBefore(item, origin.next)
96
+ }
97
+
98
+ const { option } = useSortable(root, list, {
99
+ ...defined({
100
+ group: options.group,
101
+ handle: options.handle,
102
+ draggable: options.draggable,
103
+ ghostClass: options.ghostClass,
104
+ }),
105
+ disabled: toValue(options.disabled) ?? false,
106
+ sort: options.sort ?? true,
107
+ onStart: (event: SortableEvent) => {
108
+ const { parentNode, nextSibling } = event.item
109
+ origin = parentNode
110
+ ? { parent: parentNode, next: nextSibling }
111
+ : undefined
112
+ const element = list()[event.oldIndex ?? -1]
113
+ if (element === undefined) return
114
+ dragged.set(groupName, options.clone ? options.clone(element) : element)
115
+ },
116
+ onUpdate: (event: SortableEvent) => {
117
+ const { oldIndex, newIndex } = event
118
+ if (oldIndex === undefined || newIndex === undefined) return
119
+ restore(event.item)
120
+ const items = list()
121
+ const [element] = items.splice(oldIndex, 1)
122
+ if (element === undefined) return
123
+ items.splice(newIndex, 0, element)
124
+ options.onChange?.({ moved: { element, oldIndex, newIndex } })
125
+ },
126
+ onAdd: (event: SortableEvent) => {
127
+ const element = dragged.get(groupName) as Item | undefined
128
+ const { newIndex } = event
129
+ if (element === undefined || newIndex === undefined) return
130
+ // The element goes back to the list it left, below.
131
+ event.item.remove()
132
+ list().splice(newIndex, 0, element)
133
+ options.onChange?.({ added: { element, newIndex } })
134
+ },
135
+ onRemove: (event: SortableEvent) => {
136
+ restore(event.item)
137
+ // A copy left: the list keeps its item, and SortableJS's stand-in goes.
138
+ if (event.pullMode === 'clone') {
139
+ event.clone.remove()
140
+ return
141
+ }
142
+ const { oldIndex } = event
143
+ if (oldIndex === undefined) return
144
+ const [element] = list().splice(oldIndex, 1)
145
+ if (element === undefined) return
146
+ options.onChange?.({ removed: { element, oldIndex } })
147
+ },
148
+ onEnd: () => {
149
+ origin = undefined
150
+ dragged.delete(groupName)
151
+ options.onEnd?.()
152
+ },
153
+ })
154
+
155
+ watch(
156
+ () => toValue(options.disabled) ?? false,
157
+ (disabled) => option('disabled', disabled),
158
+ )
159
+ }
@@ -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
  }
@@ -9,6 +9,8 @@ export const PALETTE_GROUP = {
9
9
  put: false,
10
10
  }
11
11
  export const DRAG_HANDLE = '.mailing-drag-handle'
12
+ /** The blocks of a list, not the insert point that ends it. */
13
+ export const DRAG_ITEM = '>[data-drag-item]'
12
14
 
13
15
  /** The block a tile dropped on the canvas becomes, shaped by its template. */
14
16
  export function cloneFromPalette(
@@ -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()
@@ -804,7 +804,7 @@
804
804
  },
805
805
  "webhookUrl": {
806
806
  "label": "Webhook URL",
807
- "description": "Your provider, or the relay that translates its events, posts them here with the secret below. The module README describes the format."
807
+ "description": "Your provider, or the relay that translates its events, posts them here with the secret below. The module documentation describes the format."
808
808
  },
809
809
  "webhookStatus": {
810
810
  "label": "Webhook status",
@@ -804,7 +804,7 @@
804
804
  },
805
805
  "webhookUrl": {
806
806
  "label": "URL du webhook",
807
- "description": "Votre fournisseur, ou le relais qui traduit ses événements, les envoie ici avec le secret ci-dessous. Le README du module décrit le format."
807
+ "description": "Votre fournisseur, ou le relais qui traduit ses événements, les envoie ici avec le secret ci-dessous. La documentation du module décrit le format."
808
808
  },
809
809
  "webhookStatus": {
810
810
  "label": "État du webhook",
@@ -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.3 <0.7.0"
10
10
  },
11
11
  "scripts": {
12
12
  "format": "prettier --write .",
@@ -16,8 +16,9 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@vueuse/core": "^14.3.0",
19
- "vue": "^3.5.18",
20
- "vuedraggable": "^4.1.0"
19
+ "@vueuse/integrations": "^14.3.0",
20
+ "sortablejs": "^1.14.0",
21
+ "vue": "^3.5.18"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@nuxt/ui": "4.10.0",
@@ -11,12 +11,15 @@ importers:
11
11
  '@vueuse/core':
12
12
  specifier: ^14.3.0
13
13
  version: 14.4.0(vue@3.5.42(typescript@5.9.3))
14
+ '@vueuse/integrations':
15
+ specifier: ^14.3.0
16
+ version: 14.4.0(fuse.js@7.5.0)(sortablejs@1.14.0)(vue@3.5.42(typescript@5.9.3))
17
+ sortablejs:
18
+ specifier: ^1.14.0
19
+ version: 1.14.0
14
20
  vue:
15
21
  specifier: ^3.5.18
16
22
  version: 3.5.42(typescript@5.9.3)
17
- vuedraggable:
18
- specifier: ^4.1.0
19
- version: 4.1.0(vue@3.5.42(typescript@5.9.3))
20
23
  devDependencies:
21
24
  '@nuxt/ui':
22
25
  specifier: 4.10.0
@@ -3154,11 +3157,6 @@ packages:
3154
3157
  typescript:
3155
3158
  optional: true
3156
3159
 
3157
- vuedraggable@4.1.0:
3158
- resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
3159
- peerDependencies:
3160
- vue: ^3.0.1
3161
-
3162
3160
  w3c-keyname@2.2.8:
3163
3161
  resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
3164
3162
 
@@ -6245,11 +6243,6 @@ snapshots:
6245
6243
  optionalDependencies:
6246
6244
  typescript: 5.9.3
6247
6245
 
6248
- vuedraggable@4.1.0(vue@3.5.42(typescript@5.9.3)):
6249
- dependencies:
6250
- sortablejs: 1.14.0
6251
- vue: 3.5.42(typescript@5.9.3)
6252
-
6253
6246
  w3c-keyname@2.2.8: {}
6254
6247
 
6255
6248
  w3c-xmlserializer@5.0.0:
@@ -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(() => {
@@ -12,6 +12,24 @@ import EditorBlockList from '../app/components/editor/EditorBlockList.vue'
12
12
  import type { Block } from '../app/types/mailing'
13
13
 
14
14
  const editor = vi.hoisted(() => ({ touch: vi.fn(), select: vi.fn() }))
15
+ const sortables = vi.hoisted(
16
+ () => [] as { el: HTMLElement; options: Record<string, unknown> }[],
17
+ )
18
+
19
+ // What SortableJS would be handed: the test calls it as SortableJS does.
20
+ vi.mock('@vueuse/integrations/useSortable', async () => {
21
+ const { onMounted, unref } = await import('vue')
22
+ return {
23
+ useSortable: (
24
+ target: { value: HTMLElement },
25
+ _list: unknown,
26
+ options: Record<string, unknown>,
27
+ ) => {
28
+ onMounted(() => sortables.push({ el: unref(target), options }))
29
+ return { option: () => {} }
30
+ },
31
+ }
32
+ })
15
33
 
16
34
  vi.mock('../app/composables/useEditorContext', () => ({
17
35
  useEditorContext: () => ({ editor }),
@@ -60,14 +78,36 @@ describe('editor draggable items', () => {
60
78
  })
61
79
  it('selects a block dropped into the list', async () => {
62
80
  vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
63
- const list = reactive<Block[]>([])
64
- const app = createApp(EditorBlockList, { list })
81
+ sortables.length = 0
82
+ const source = reactive<Block[]>([
83
+ { id: 'dropped', type: 'divider', visibleIf: null },
84
+ ])
85
+ const target = reactive<Block[]>([])
86
+ const app = createApp({
87
+ render: () => [
88
+ h(EditorBlockList, { list: source }),
89
+ h(EditorBlockList, { list: target }),
90
+ ],
91
+ })
65
92
  app.mount(document.createElement('div'))
66
93
  try {
67
- const draggable = app._instance?.subTree.props as Record<string, unknown>
68
- const onChange = draggable.onChange as (change: unknown) => void
69
- onChange({ added: { element: { id: 'dropped' }, newIndex: 0 } })
70
- onChange({ moved: { element: { id: 'other' } } })
94
+ const [from, to] = sortables
95
+ expect(from!.options).toMatchObject({
96
+ group: { name: 'mailing-blocks' },
97
+ handle: '.mailing-drag-handle',
98
+ draggable: '>[data-drag-item]',
99
+ })
100
+ const item = from!.el.querySelector('[data-drag-item]') as HTMLElement
101
+ const handler = (sortable: typeof from, name: string) =>
102
+ sortable!.options[name] as (event: unknown) => void
103
+ handler(from, 'onStart')({ item, oldIndex: 0 })
104
+ to!.el.prepend(item)
105
+ handler(to, 'onAdd')({ item, newIndex: 0 })
106
+ handler(from, 'onRemove')({ item, oldIndex: 0 })
107
+ handler(from, 'onEnd')({})
108
+ await nextTick()
109
+ expect(source).toEqual([])
110
+ expect(target.map((block) => block.id)).toEqual(['dropped'])
71
111
  expect(editor.touch).toHaveBeenCalledTimes(2)
72
112
  expect(editor.select).toHaveBeenCalledExactlyOnceWith('dropped')
73
113
  } finally {
@@ -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('')