@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.
- package/README.md +61 -339
- package/dist/data/templates-table.js +2 -10
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +1 -0
- package/dist/db/models/sends.model.d.ts +1 -0
- package/dist/db/models/settings.model.d.ts +1 -0
- package/dist/db/models/suppressions.model.d.ts +1 -0
- package/dist/db/models/template-slugs.model.d.ts +27 -0
- package/dist/db/models/template-slugs.model.js +43 -0
- package/dist/db/models/template-slugs.model.js.map +1 -0
- package/dist/db/models/template-versions.model.d.ts +1 -0
- package/dist/db/models/templates.model.d.ts +1 -0
- package/dist/db/models/webhook-status.model.d.ts +1 -0
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/template-slugs.table.d.ts +7 -0
- package/dist/db/tables/template-slugs.table.js +35 -0
- package/dist/db/tables/template-slugs.table.js.map +1 -0
- package/dist/routes/templates.js +1 -0
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/templates.d.ts +2 -0
- package/dist/services/templates.js +26 -0
- package/dist/services/templates.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +18 -24
- 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/EditorBlockList.vue +33 -30
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
- 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/useSortableList.ts +159 -0
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
- package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
- package/frontend-vue/app/utils/gallery.ts +19 -7
- package/frontend-vue/app/utils/period.ts +0 -5
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +6 -13
- package/frontend-vue/tests/categories-input.test.ts +43 -13
- package/frontend-vue/tests/editor-block-list.test.ts +46 -6
- 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/sortable-list.test.ts +253 -0
- package/frontend-vue/tests/template-drawer.test.ts +30 -14
- package/frontend-vue/tests/template-gallery.test.ts +45 -24
- package/package.json +31 -14
- package/skills/dms-mailing/SKILL.md +63 -0
- package/src/data/templates-table.ts +7 -27
- package/src/db/models/index.ts +1 -0
- package/src/db/models/template-slugs.model.ts +61 -0
- package/src/db/tables/index.ts +1 -0
- package/src/db/tables/template-slugs.table.ts +29 -0
- package/src/routes/templates.ts +2 -0
- 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 {
|
|
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
|
}
|
|
@@ -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 {
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
20
|
-
"
|
|
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
|
-
|
|
64
|
-
const
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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('')
|