@antelopejs/dms-mailing 0.7.1 → 0.9.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 (255) hide show
  1. package/README.md +46 -31
  2. package/dist/automation/email-event-trigger.d.ts +2 -2
  3. package/dist/automation/send-template-action.d.ts +11 -3
  4. package/dist/automation/send-template-action.js +1 -1
  5. package/dist/automation/send-template-action.js.map +1 -1
  6. package/dist/crons/jobs.d.ts +1 -1
  7. package/dist/data/body.d.ts +2 -1
  8. package/dist/data/body.js +4 -2
  9. package/dist/data/body.js.map +1 -1
  10. package/dist/data/read-only-routes.d.ts +4 -4
  11. package/dist/data/sends-table.d.ts +4 -4
  12. package/dist/data/suppressions-table.d.ts +4 -4
  13. package/dist/data/templates-table.d.ts +35 -12
  14. package/dist/data/templates-table.js +30 -24
  15. package/dist/data/templates-table.js.map +1 -1
  16. package/dist/data-types/categories-type.d.ts +1 -2
  17. package/dist/data-types/categories-type.js +3 -5
  18. package/dist/data-types/categories-type.js.map +1 -1
  19. package/dist/data-types/category-type.d.ts +1 -2
  20. package/dist/data-types/category-type.js +3 -5
  21. package/dist/data-types/category-type.js.map +1 -1
  22. package/dist/db/models/send-events.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.d.ts +7 -5
  24. package/dist/db/models/sends.model.js.map +1 -1
  25. package/dist/db/models/settings.model.d.ts +7 -5
  26. package/dist/db/models/suppressions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.d.ts +7 -5
  28. package/dist/db/models/template-versions.model.js +4 -3
  29. package/dist/db/models/template-versions.model.js.map +1 -1
  30. package/dist/db/models/templates.model.d.ts +7 -5
  31. package/dist/db/models/templates.model.js +2 -1
  32. package/dist/db/models/templates.model.js.map +1 -1
  33. package/dist/db/models/webhook-status.model.d.ts +7 -5
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -18
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +5 -5
  39. package/dist/engine/conditions.js.map +1 -1
  40. package/dist/engine/coverage.d.ts +2 -1
  41. package/dist/engine/coverage.js.map +1 -1
  42. package/dist/engine/fallback-html.js +8 -8
  43. package/dist/engine/fallback-html.js.map +1 -1
  44. package/dist/engine/formats.d.ts +4 -6
  45. package/dist/engine/formats.js +22 -8
  46. package/dist/engine/formats.js.map +1 -1
  47. package/dist/engine/paths.d.ts +4 -2
  48. package/dist/engine/paths.js +16 -5
  49. package/dist/engine/paths.js.map +1 -1
  50. package/dist/engine/plain-text.js +2 -4
  51. package/dist/engine/plain-text.js.map +1 -1
  52. package/dist/engine/resolve.d.ts +2 -1
  53. package/dist/engine/resolve.js +1 -1
  54. package/dist/engine/resolve.js.map +1 -1
  55. package/dist/engine/system-variables.d.ts +2 -1
  56. package/dist/engine/system-variables.js +1 -1
  57. package/dist/engine/system-variables.js.map +1 -1
  58. package/dist/engine/tokens.d.ts +2 -1
  59. package/dist/engine/tokens.js.map +1 -1
  60. package/dist/hooks/tenant-export.js +10 -9
  61. package/dist/hooks/tenant-export.js.map +1 -1
  62. package/dist/pages/sends.js.map +1 -1
  63. package/dist/pages/templates.js +3 -1
  64. package/dist/pages/templates.js.map +1 -1
  65. package/dist/pages/toolbar.js.map +1 -1
  66. package/dist/routes/events.js +6 -4
  67. package/dist/routes/events.js.map +1 -1
  68. package/dist/routes/index.d.ts +1 -0
  69. package/dist/routes/index.js +1 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/metrics.js +31 -31
  72. package/dist/routes/metrics.js.map +1 -1
  73. package/dist/routes/period.js +5 -5
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +4 -5
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +1 -1
  78. package/dist/routes/settings.js +16 -14
  79. package/dist/routes/settings.js.map +1 -1
  80. package/dist/routes/suppressions.js +9 -6
  81. package/dist/routes/suppressions.js.map +1 -1
  82. package/dist/routes/template-content.d.ts +27 -0
  83. package/dist/routes/template-content.js +3 -0
  84. package/dist/routes/template-content.js.map +1 -0
  85. package/dist/routes/templates.d.ts +7 -30
  86. package/dist/routes/templates.js +7 -6
  87. package/dist/routes/templates.js.map +1 -1
  88. package/dist/routes/zod-json.d.ts +3 -0
  89. package/dist/routes/zod-json.js +21 -0
  90. package/dist/routes/zod-json.js.map +1 -0
  91. package/dist/services/attention.js +16 -8
  92. package/dist/services/attention.js.map +1 -1
  93. package/dist/services/catalog.js +4 -2
  94. package/dist/services/catalog.js.map +1 -1
  95. package/dist/services/categories.js +6 -6
  96. package/dist/services/categories.js.map +1 -1
  97. package/dist/services/checked-send.js +16 -9
  98. package/dist/services/checked-send.js.map +1 -1
  99. package/dist/services/declaration-error.d.ts +1 -1
  100. package/dist/services/declaration-error.js.map +1 -1
  101. package/dist/services/declarations.js +4 -1
  102. package/dist/services/declarations.js.map +1 -1
  103. package/dist/services/events.js +13 -7
  104. package/dist/services/events.js.map +1 -1
  105. package/dist/services/localized-text.d.ts +2 -1
  106. package/dist/services/localized-text.js +2 -1
  107. package/dist/services/localized-text.js.map +1 -1
  108. package/dist/services/metrics.js +2 -5
  109. package/dist/services/metrics.js.map +1 -1
  110. package/dist/services/render.js.map +1 -1
  111. package/dist/services/send-import.js +7 -3
  112. package/dist/services/send-import.js.map +1 -1
  113. package/dist/services/send.js +7 -7
  114. package/dist/services/send.js.map +1 -1
  115. package/dist/services/settings.d.ts +4 -2
  116. package/dist/services/settings.js +5 -4
  117. package/dist/services/settings.js.map +1 -1
  118. package/dist/services/starters.d.ts +3 -2
  119. package/dist/services/starters.js +5 -5
  120. package/dist/services/starters.js.map +1 -1
  121. package/dist/services/template-stats.js +5 -2
  122. package/dist/services/template-stats.js.map +1 -1
  123. package/dist/services/templates.d.ts +2 -1
  124. package/dist/services/templates.js +8 -4
  125. package/dist/services/templates.js.map +1 -1
  126. package/dist/services/webhook-status.d.ts +2 -2
  127. package/dist/services/webhook-status.js +6 -4
  128. package/dist/services/webhook-status.js.map +1 -1
  129. package/dist/types/json.d.ts +1 -0
  130. package/dist/types/json.js +3 -0
  131. package/dist/types/json.js.map +1 -0
  132. package/dist/types/settings.d.ts +2 -2
  133. package/dist/validation/declarations.schema.d.ts +44 -44
  134. package/dist/validation/declarations.schema.js +6 -5
  135. package/dist/validation/declarations.schema.js.map +1 -1
  136. package/dist/validation/guards.d.ts +3 -0
  137. package/dist/validation/guards.js +15 -0
  138. package/dist/validation/guards.js.map +1 -0
  139. package/dist/validation/json.d.ts +5 -0
  140. package/dist/validation/json.js +54 -0
  141. package/dist/validation/json.js.map +1 -0
  142. package/dist/validation/json.schema.d.ts +2 -0
  143. package/dist/validation/json.schema.js +9 -0
  144. package/dist/validation/json.schema.js.map +1 -0
  145. package/dist/validation/send-event-type.schema.d.ts +2 -0
  146. package/dist/validation/send-event-type.schema.js +11 -0
  147. package/dist/validation/send-event-type.schema.js.map +1 -0
  148. package/dist/validation/settings.schema.js +1 -1
  149. package/dist/validation/settings.schema.js.map +1 -1
  150. package/dist/validation/templates.schema.d.ts +44 -42
  151. package/dist/validation/templates.schema.js +4 -3
  152. package/dist/validation/templates.schema.js.map +1 -1
  153. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  154. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  155. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  156. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  157. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  158. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  159. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  160. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  161. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  162. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  163. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  164. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  165. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  166. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  167. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  168. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  169. package/frontend-vue/build/backend-boundary.ts +122 -0
  170. package/frontend-vue/dms.frontend.build.ts +7 -0
  171. package/frontend-vue/eslint.config.mjs +13 -0
  172. package/frontend-vue/package.json +4 -2
  173. package/frontend-vue/pnpm-lock.yaml +250 -61
  174. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  175. package/frontend-vue/tests/dnd.test.ts +1 -2
  176. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  177. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  178. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  179. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  180. package/frontend-vue/tests/page-reads.test.ts +26 -6
  181. package/frontend-vue/tests/send-actions.test.ts +0 -1
  182. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  183. package/frontend-vue/tests/send-modals.test.ts +8 -5
  184. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  185. package/frontend-vue/tests/suppression.test.ts +1 -2
  186. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  187. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  188. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  189. package/package.json +16 -20
  190. package/skills/dms-mailing/SKILL.md +1 -1
  191. package/skills/dms-mailing/cases.json +49 -0
  192. package/src/automation/email-event-trigger.ts +2 -2
  193. package/src/automation/send-template-action.ts +33 -14
  194. package/src/crons/jobs.ts +1 -1
  195. package/src/data/body.ts +7 -3
  196. package/src/data/templates-table.ts +55 -56
  197. package/src/data-types/categories-type.ts +2 -2
  198. package/src/data-types/category-type.ts +2 -2
  199. package/src/db/models/sends.model.ts +2 -2
  200. package/src/db/models/template-versions.model.ts +10 -4
  201. package/src/db/models/templates.model.ts +6 -3
  202. package/src/db/tables/templates.table.ts +1 -1
  203. package/src/engine/changes.ts +11 -28
  204. package/src/engine/conditions.ts +14 -8
  205. package/src/engine/coverage.ts +2 -1
  206. package/src/engine/fallback-html.ts +13 -13
  207. package/src/engine/formats.ts +35 -21
  208. package/src/engine/paths.ts +41 -7
  209. package/src/engine/plain-text.ts +2 -4
  210. package/src/engine/resolve.ts +12 -4
  211. package/src/engine/system-variables.ts +6 -4
  212. package/src/engine/tokens.ts +3 -3
  213. package/src/hooks/tenant-export.ts +11 -13
  214. package/src/pages/sends.ts +5 -3
  215. package/src/pages/templates.ts +4 -2
  216. package/src/pages/toolbar.ts +4 -2
  217. package/src/routes/events.ts +10 -10
  218. package/src/routes/index.ts +1 -0
  219. package/src/routes/metrics.ts +35 -33
  220. package/src/routes/period.ts +5 -5
  221. package/src/routes/sends.ts +23 -26
  222. package/src/routes/settings.ts +21 -17
  223. package/src/routes/suppressions.ts +12 -8
  224. package/src/routes/template-content.ts +45 -0
  225. package/src/routes/templates.ts +16 -49
  226. package/src/routes/zod-json.ts +28 -0
  227. package/src/services/attention.ts +20 -18
  228. package/src/services/catalog.ts +4 -8
  229. package/src/services/categories.ts +6 -6
  230. package/src/services/checked-send.ts +17 -19
  231. package/src/services/declaration-error.ts +1 -1
  232. package/src/services/declarations.ts +7 -6
  233. package/src/services/events.ts +18 -13
  234. package/src/services/localized-text.ts +4 -2
  235. package/src/services/metrics.ts +5 -7
  236. package/src/services/notify.ts +1 -1
  237. package/src/services/render.ts +5 -1
  238. package/src/services/send-import.ts +16 -11
  239. package/src/services/send.ts +10 -13
  240. package/src/services/settings.ts +17 -16
  241. package/src/services/starters.ts +12 -8
  242. package/src/services/template-stats.ts +11 -4
  243. package/src/services/templates.ts +24 -15
  244. package/src/services/webhook-status.ts +13 -8
  245. package/src/types/json.ts +1 -0
  246. package/src/types/settings.ts +2 -2
  247. package/src/validation/declarations.schema.ts +6 -5
  248. package/src/validation/guards.ts +17 -0
  249. package/src/validation/json.schema.ts +7 -0
  250. package/src/validation/json.ts +66 -0
  251. package/src/validation/send-event-type.schema.ts +10 -0
  252. package/src/validation/settings.schema.ts +7 -2
  253. package/src/validation/templates.schema.ts +9 -4
  254. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  255. package/frontend-vue/tests/i18n.test.ts +0 -138
@@ -0,0 +1,203 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ realpathSync,
5
+ rmSync,
6
+ symlinkSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+ import { build as viteBuild, type Plugin } from 'vite'
12
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
13
+ import { backendBoundary } from '../build/backend-boundary'
14
+
15
+ /** A scratch repository: the backend package, another package, and the frontend module inside the backend package. */
16
+ let repository: string
17
+ let frontend: string
18
+
19
+ function write(file: string, text: string): void {
20
+ mkdirSync(dirname(file), { recursive: true })
21
+ writeFileSync(file, text)
22
+ }
23
+
24
+ beforeAll(() => {
25
+ repository = realpathSync(
26
+ mkdtempSync(join(tmpdir(), 'dms-mailing-boundary-')),
27
+ )
28
+ const backend = join(repository, 'packages/dms-mailing')
29
+ frontend = join(backend, 'frontend-vue')
30
+ write(
31
+ join(backend, 'package.json'),
32
+ JSON.stringify({ name: '@antelopejs/dms-mailing' }),
33
+ )
34
+ write(join(backend, 'src/backend.ts'), 'export const secret = 1\n')
35
+ write(join(backend, 'src/nested/deep.ts'), 'export const deep = 6\n')
36
+ write(join(backend, 'dist/built.js'), 'export const built = 2\n')
37
+ write(join(backend, 'dist/src/nested.js'), 'export const nested = 4\n')
38
+ write(join(backend, 'srcx/sibling.ts'), 'export const sibling = 5\n')
39
+ write(
40
+ join(repository, 'packages/other/package.json'),
41
+ JSON.stringify({ name: 'other' }),
42
+ )
43
+ write(
44
+ join(repository, 'packages/other/src/shared.ts'),
45
+ 'export const shared = 3\n',
46
+ )
47
+ mkdirSync(frontend, { recursive: true })
48
+ symlinkSync(join(backend, 'src/backend.ts'), join(frontend, 'linked.ts'))
49
+ })
50
+
51
+ afterAll(() => {
52
+ rmSync(repository, { recursive: true, force: true })
53
+ })
54
+
55
+ interface BuildOptions {
56
+ extra?: Plugin[]
57
+ alias?: Record<string, string>
58
+ preserveSymlinks?: boolean
59
+ external?: string[]
60
+ }
61
+
62
+ /** Builds a frontend entry made of `source` with the boundary plugin, and returns the bundled code. */
63
+ async function build(
64
+ source: string,
65
+ options: BuildOptions = {},
66
+ ): Promise<string> {
67
+ write(join(frontend, 'entry.ts'), source)
68
+ const result = await viteBuild({
69
+ root: frontend,
70
+ configFile: false,
71
+ logLevel: 'silent',
72
+ plugins: [backendBoundary(), ...(options.extra ?? [])],
73
+ resolve: {
74
+ alias: options.alias ?? {},
75
+ preserveSymlinks: options.preserveSymlinks ?? false,
76
+ },
77
+ build: {
78
+ write: false,
79
+ rollupOptions: { external: options.external ?? [] },
80
+ lib: { entry: join(frontend, 'entry.ts'), formats: ['es'] },
81
+ },
82
+ })
83
+ return JSON.stringify(result)
84
+ }
85
+
86
+ /** A plugin that answers the import `request` with the module id `id`. */
87
+ function resolver(request: string, id: string): Plugin {
88
+ return {
89
+ name: 'test-resolver',
90
+ resolveId: (source) => (source === request ? { id } : null),
91
+ load: (loaded) =>
92
+ loaded === id || loaded.startsWith('\0') ? 'export default 1\n' : null,
93
+ }
94
+ }
95
+
96
+ const backendFile = () =>
97
+ join(repository, 'packages/dms-mailing/src/backend.ts')
98
+
99
+ describe('the backend boundary of the frontend build', () => {
100
+ it('fails on an import of the backend source, naming both files', async () => {
101
+ const failure = build(
102
+ 'import { secret } from "../src/backend.ts"\nexport { secret }\n',
103
+ )
104
+ await expect(failure).rejects.toThrow(/Backend boundary/)
105
+ await expect(failure).rejects.toThrow(join(frontend, 'entry.ts'))
106
+ await expect(failure).rejects.toThrow(backendFile())
107
+ })
108
+
109
+ it('fails on a file deeper under the backend source', async () => {
110
+ await expect(
111
+ build('import { deep } from "../src/nested/deep.ts"\nexport { deep }\n'),
112
+ ).rejects.toThrow(/Backend boundary/)
113
+ })
114
+
115
+ it('fails on an alias that points into the backend source', async () => {
116
+ await expect(
117
+ build(
118
+ 'import { secret } from "@backend/backend.ts"\nexport { secret }\n',
119
+ {
120
+ alias: { '@backend': join(repository, 'packages/dms-mailing/src') },
121
+ },
122
+ ),
123
+ ).rejects.toThrow(/Backend boundary/)
124
+ })
125
+
126
+ it('fails on a symlink to the backend source that the resolver returns', async () => {
127
+ await expect(
128
+ build('import { secret } from "./linked.ts"\nexport { secret }\n', {
129
+ extra: [resolver('./linked.ts', join(frontend, 'linked.ts'))],
130
+ preserveSymlinks: true,
131
+ }),
132
+ ).rejects.toThrow(/Backend boundary/)
133
+ })
134
+
135
+ it('fails on an id that names the backend source with a query or a hash', async () => {
136
+ for (const suffix of ['?raw', '#fragment']) {
137
+ await expect(
138
+ build('import secret from "virtual-backend"\nexport { secret }\n', {
139
+ extra: [resolver('virtual-backend', `${backendFile()}${suffix}`)],
140
+ }),
141
+ ).rejects.toThrow(/Backend boundary/)
142
+ }
143
+ })
144
+
145
+ it('fails on a backend source import declared external', async () => {
146
+ await expect(
147
+ build('import { secret } from "../src/backend.ts"\nexport { secret }\n', {
148
+ external: [backendFile()],
149
+ }),
150
+ ).rejects.toThrow(/Backend boundary/)
151
+ })
152
+
153
+ it('fails on a worker entry under the backend source', async () => {
154
+ await expect(
155
+ build(
156
+ 'export const worker = new Worker(new URL("../src/backend.ts", import.meta.url), { type: "module" })\n',
157
+ ),
158
+ ).rejects.toThrow(/Backend boundary/)
159
+ })
160
+
161
+ it('fails on a worker of the frontend that imports the backend source', async () => {
162
+ write(
163
+ join(frontend, 'worker.ts'),
164
+ 'import { secret } from "../src/backend.ts"\npostMessage(secret)\n',
165
+ )
166
+ await expect(
167
+ build(
168
+ 'export const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" })\n',
169
+ ),
170
+ ).rejects.toThrow(/Backend boundary/)
171
+ })
172
+
173
+ it('passes the built output, a virtual module and the source of another package', async () => {
174
+ expect(
175
+ await build(
176
+ 'import { built } from "../dist/built.js"\nexport { built }\n',
177
+ ),
178
+ ).toMatch(/built/)
179
+ expect(
180
+ await build('import value from "virtual:thing"\nexport { value }\n', {
181
+ extra: [resolver('virtual:thing', '\0virtual:thing')],
182
+ }),
183
+ ).toMatch(/value/)
184
+ expect(
185
+ await build(
186
+ 'import { shared } from "../../other/src/shared.ts"\nexport { shared }\n',
187
+ ),
188
+ ).toMatch(/shared/)
189
+ })
190
+
191
+ it('passes a sibling folder named like src and a src folder inside dist', async () => {
192
+ expect(
193
+ await build(
194
+ 'import { sibling } from "../srcx/sibling.ts"\nexport { sibling }\n',
195
+ ),
196
+ ).toMatch(/sibling/)
197
+ expect(
198
+ await build(
199
+ 'import { nested } from "../dist/src/nested.js"\nexport { nested }\n',
200
+ ),
201
+ ).toMatch(/nested/)
202
+ })
203
+ })
@@ -1,12 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { DRAG_GROUP, cloneFromPalette } from '../app/utils/blocks/dnd'
2
+ import { cloneFromPalette } from '../app/utils/blocks/dnd'
3
3
 
4
4
  describe('dnd', () => {
5
5
  it('clones a palette entry into a fresh block', () => {
6
6
  const block = cloneFromPalette({ type: 'button', icon: 'x' })
7
7
  expect(block.type).toBe('button')
8
8
  expect(block.id).toMatch(/^button_/)
9
- expect(DRAG_GROUP.name).toBe('mailing-blocks')
10
9
  })
11
10
  })
12
11
 
@@ -1,5 +1,14 @@
1
1
  // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import Sortable from 'sortablejs'
3
+ import {
4
+ afterEach,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ vi,
10
+ type MockInstance,
11
+ } from 'vitest'
3
12
  import {
4
13
  createApp,
5
14
  defineAsyncComponent,
@@ -7,69 +16,130 @@ import {
7
16
  h,
8
17
  nextTick,
9
18
  reactive,
19
+ type Component,
10
20
  } from 'vue'
11
21
  import EditorBlockList from '../app/components/editor/EditorBlockList.vue'
12
- import type { Block } from '../app/types/mailing'
22
+ import { useEditorActions } from '../app/composables/useEditorActions'
23
+ import { provideEditorContext } from '../app/composables/useEditorContext'
24
+ import { useEditorSession } from '../app/composables/useEditorSession'
25
+ import { useMailingApi } from '../app/composables/useMailingApi'
26
+ import { useEditorReads } from '../app/composables/useMailingReads'
27
+ import {
28
+ createEditorState,
29
+ type EditorState,
30
+ } from '../app/composables/useTemplateEditor'
31
+ import type {
32
+ Block,
33
+ TemplateContent,
34
+ TemplateContentResponse,
35
+ } from '../app/types/mailing'
36
+ import { stubApiData } from './helpers/api-data'
37
+ import { stubAuthFetch } from './helpers/auth-fetch'
38
+ import { stubNuxtGlobals } from './helpers/nuxt-globals'
39
+
40
+ const CONTENT: TemplateContent = {
41
+ locales: { en: { subject: 'Hi', preheader: '', blocks: [] } },
42
+ }
13
43
 
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
- )
44
+ const LOADED: TemplateContentResponse = {
45
+ template: {
46
+ _id: 't1',
47
+ slug: 'welcome',
48
+ name: 'Welcome',
49
+ status: 'draft',
50
+ updatedAt: '2026-10-01T10:00:00Z',
51
+ updatedBy: 'camille',
52
+ attention: '',
53
+ },
54
+ content: CONTENT,
55
+ publishedContent: null,
56
+ hasDraft: true,
57
+ version: 0,
58
+ revision: 1,
59
+ changes: [],
60
+ variables: [],
61
+ detectedVariables: [],
62
+ testData: {},
63
+ fallbackLocale: 'en',
64
+ blockOnMissingVariables: false,
65
+ categories: [],
66
+ sender: { name: '', email: '', replyTo: '' },
67
+ }
18
68
 
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: () => {} }
69
+ /** A block renderer the DMS registers globally, still loading. */
70
+ const pending = () =>
71
+ defineAsyncComponent(() => new Promise<Component>(() => {}))
72
+
73
+ /**
74
+ * Renders `render` inside an editor built as the workspace builds it, and
75
+ * hands the editor to `onEditor` before anything reads it.
76
+ */
77
+ function inEditor(
78
+ render: () => ReturnType<typeof h>[],
79
+ onEditor: (editor: EditorState) => void = () => undefined,
80
+ ): Component {
81
+ return defineComponent({
82
+ setup() {
83
+ const editor = createEditorState(CONTENT, 'en', ['en'])
84
+ onEditor(editor)
85
+ const api = useMailingApi()
86
+ const reads = useEditorReads('t1')
87
+ const deps = { editor, api, reads, templateId: 't1', onError: () => {} }
88
+ const session = useEditorSession({ ...deps, loaded: LOADED })
89
+ const actions = useEditorActions({
90
+ ...deps,
91
+ session,
92
+ reload: async () => {},
93
+ })
94
+ provideEditorContext({ editor, session, actions, api, templateId: 't1' })
95
+ return render
30
96
  },
31
- }
32
- })
97
+ })
98
+ }
33
99
 
34
- vi.mock('../app/composables/useEditorContext', () => ({
35
- useEditorContext: () => ({ editor }),
36
- }))
100
+ /** Each list in a `section` of its own, so its element can be found. */
101
+ function lists(...blocks: Block[][]): ReturnType<typeof h>[] {
102
+ return blocks.map((list) => h('section', [h(EditorBlockList, { list })]))
103
+ }
37
104
 
38
- vi.mock('../app/components/editor/EditorInsertPoint.vue', () => ({
39
- default: defineComponent({ setup: () => () => h('span') }),
40
- }))
105
+ /** The SortableJS instance VueUse created on each list element under `host`. */
106
+ function sortablesIn(host: HTMLElement): Sortable[] {
107
+ return [...host.querySelectorAll('section')].map((section) => {
108
+ const element = section.firstElementChild
109
+ const sortable = element instanceof HTMLElement && Sortable.get(element)
110
+ if (!sortable) throw new Error('no SortableJS instance on a list')
111
+ return sortable
112
+ })
113
+ }
41
114
 
42
- vi.mock('../app/components/editor/EditorBlock.vue', () => ({
43
- default: defineComponent({
44
- props: ['block'],
45
- setup: (props) => () => h('div', { 'data-block': props.block.id }),
46
- }),
47
- }))
115
+ beforeEach(() => {
116
+ stubNuxtGlobals()
117
+ stubApiData()
118
+ stubAuthFetch()
119
+ })
48
120
 
49
121
  afterEach(() => vi.unstubAllGlobals())
50
122
 
51
123
  describe('editor draggable items', () => {
52
124
  it('mounts item elements before globally registered async blocks resolve', async () => {
53
- vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
54
125
  const list = reactive<Block[]>([
55
126
  { id: 'first', type: 'divider', visibleIf: null },
56
127
  { id: 'second', type: 'divider', visibleIf: null },
57
128
  ])
58
129
  const errors: unknown[] = []
59
130
  const host = document.createElement('div')
60
- const app = createApp(EditorBlockList, { list })
61
- app.component(
62
- 'MailingEditorBlock',
63
- defineAsyncComponent(() => new Promise(() => {})),
64
- )
131
+ const app = createApp(inEditor(() => lists(list)))
132
+ app.component('MailingEditorBlock', pending())
133
+ app.component('MailingBlockDivider', pending())
134
+ app.config.warnHandler = () => undefined
65
135
  app.config.errorHandler = (error) => errors.push(error)
66
136
  try {
67
137
  app.mount(host)
68
138
  await nextTick()
69
139
  expect(errors).toEqual([])
70
140
  expect(
71
- [...host.querySelectorAll('[data-block]')].map((item) =>
72
- item.getAttribute('data-block'),
141
+ [...host.querySelectorAll('[data-drag-item] [data-block-id]')].map(
142
+ (item) => item.getAttribute('data-block-id'),
73
143
  ),
74
144
  ).toEqual(['first', 'second'])
75
145
  } finally {
@@ -77,29 +147,41 @@ describe('editor draggable items', () => {
77
147
  }
78
148
  })
79
149
  it('selects a block dropped into the list', async () => {
80
- vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
81
- sortables.length = 0
150
+ const spies: {
151
+ touch?: MockInstance<EditorState['touch']>
152
+ select?: MockInstance<EditorState['select']>
153
+ } = {}
82
154
  const source = reactive<Block[]>([
83
155
  { id: 'dropped', type: 'divider', visibleIf: null },
84
156
  ])
85
157
  const target = reactive<Block[]>([])
86
- const app = createApp({
87
- render: () => [
88
- h(EditorBlockList, { list: source }),
89
- h(EditorBlockList, { list: target }),
90
- ],
91
- })
92
- app.mount(document.createElement('div'))
158
+ let editor: EditorState | undefined
159
+ const app = createApp(
160
+ inEditor(
161
+ () => lists(source, target),
162
+ (state) => {
163
+ editor = state
164
+ spies.touch = vi.spyOn(state, 'touch').mockImplementation(() => {})
165
+ spies.select = vi.spyOn(state, 'select')
166
+ },
167
+ ),
168
+ )
169
+ app.config.warnHandler = () => undefined
170
+ const host = document.createElement('div')
171
+ app.mount(host)
93
172
  try {
94
- const [from, to] = sortables
173
+ const [from, to] = sortablesIn(host)
95
174
  expect(from!.options).toMatchObject({
96
175
  group: { name: 'mailing-blocks' },
97
176
  handle: '.mailing-drag-handle',
98
177
  draggable: '>[data-drag-item]',
99
178
  })
100
179
  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
180
+ const handler = (sortable: Sortable | undefined, name: string) => {
181
+ const call: unknown = sortable && Reflect.get(sortable.options, name)
182
+ if (typeof call !== 'function') throw new Error(`no ${name} handler`)
183
+ return (event: unknown) => call(event)
184
+ }
103
185
  handler(from, 'onStart')({ item, oldIndex: 0 })
104
186
  to!.el.prepend(item)
105
187
  handler(to, 'onAdd')({ item, newIndex: 0 })
@@ -108,8 +190,9 @@ describe('editor draggable items', () => {
108
190
  await nextTick()
109
191
  expect(source).toEqual([])
110
192
  expect(target.map((block) => block.id)).toEqual(['dropped'])
111
- expect(editor.touch).toHaveBeenCalledTimes(2)
112
- expect(editor.select).toHaveBeenCalledExactlyOnceWith('dropped')
193
+ expect(spies.touch).toHaveBeenCalledTimes(2)
194
+ expect(spies.select).toHaveBeenCalledExactlyOnceWith('dropped')
195
+ expect(editor?.selectedId).toBe('dropped')
113
196
  } finally {
114
197
  app.unmount()
115
198
  }
@@ -0,0 +1,123 @@
1
+ import { vi } from 'vitest'
2
+
3
+ /** A request a component made through the DMS's `$authFetch`. */
4
+ export interface AuthFetchCall {
5
+ path: string
6
+ method: string
7
+ body: unknown
8
+ }
9
+
10
+ export type AuthFetchBackend = (call: AuthFetchCall) => unknown
11
+
12
+ export interface AuthFetchStub {
13
+ /** Every request made, in order. */
14
+ calls: AuthFetchCall[]
15
+ /** Answers the next requests; a thrown error or a rejection fails them. */
16
+ respond: (backend: AuthFetchBackend) => void
17
+ }
18
+
19
+ interface AuthFetchOptions {
20
+ method?: string
21
+ body?: unknown
22
+ }
23
+
24
+ /**
25
+ * Stands in for the DMS's `useAuthFetch` (an auto-import the layer's tests do
26
+ * not have), the transport `useMailingApi` writes through: records every
27
+ * request and answers it from `respond`'s backend, as an already settled
28
+ * promise. Until `respond` is called, every request fails.
29
+ */
30
+ export function stubAuthFetch(): AuthFetchStub {
31
+ let backend: AuthFetchBackend = ({ method, path }) => {
32
+ throw new Error(`unexpected request ${method} ${path}`)
33
+ }
34
+ const stub: AuthFetchStub = {
35
+ calls: [],
36
+ respond: (next) => {
37
+ backend = next
38
+ },
39
+ }
40
+ vi.stubGlobal('useAuthFetch', () => ({
41
+ $authFetch: (path: string, options: AuthFetchOptions = {}) => {
42
+ const call = {
43
+ path,
44
+ method: options.method ?? 'GET',
45
+ body: options.body,
46
+ }
47
+ stub.calls.push(call)
48
+ try {
49
+ return Promise.resolve(backend(call))
50
+ } catch (failure) {
51
+ return Promise.reject(failure)
52
+ }
53
+ },
54
+ }))
55
+ return stub
56
+ }
57
+
58
+ /**
59
+ * The write routes of the mailing API, as a component's tests mock them: a
60
+ * route under a template gets the template id, then the body sent; the
61
+ * others get the body.
62
+ */
63
+ export type MailingWriteMocks = Partial<
64
+ Record<TemplateWrite, (id: string, body: unknown) => unknown> &
65
+ Record<ModuleWrite, (body: unknown) => unknown>
66
+ >
67
+
68
+ type TemplateWrite =
69
+ | 'preview'
70
+ | 'testSend'
71
+ | 'realSend'
72
+ | 'publish'
73
+ | 'unpublish'
74
+ | 'archive'
75
+ | 'restore'
76
+ | 'duplicate'
77
+ | 'saveVariables'
78
+ | 'saveTemplateDetails'
79
+
80
+ type ModuleWrite = 'createTemplate' | 'webhookTest'
81
+
82
+ /** Method and path suffix, under `/api/mailing/templates/:id`, of each write. */
83
+ const TEMPLATE_WRITES: Array<[string, string, TemplateWrite]> = [
84
+ ['POST', 'preview', 'preview'],
85
+ ['POST', 'test-send', 'testSend'],
86
+ ['POST', 'send', 'realSend'],
87
+ ['POST', 'publish', 'publish'],
88
+ ['POST', 'unpublish', 'unpublish'],
89
+ ['POST', 'archive', 'archive'],
90
+ ['POST', 'restore', 'restore'],
91
+ ['POST', 'duplicate', 'duplicate'],
92
+ ['POST', 'variables', 'saveVariables'],
93
+ ['PUT', 'details', 'saveTemplateDetails'],
94
+ ]
95
+
96
+ /** Method and path of each write outside a template. */
97
+ const MODULE_WRITES: Array<[string, string, ModuleWrite]> = [
98
+ ['POST', '/api/mailing/tables/templates/new', 'createTemplate'],
99
+ ['POST', '/api/mailing/settings/webhook-test', 'webhookTest'],
100
+ ]
101
+
102
+ const TEMPLATE_PATH = /^\/api\/mailing\/templates\/([^/]+)\/([^/]+)$/
103
+
104
+ /**
105
+ * A backend answering the writes a component makes with the test's mocks of
106
+ * them. A write with no route or no mock fails, as calling a method the
107
+ * component was not given would.
108
+ */
109
+ export function mailingWrites(mocks: MailingWriteMocks): AuthFetchBackend {
110
+ return ({ path, method, body }) => {
111
+ const [, id, suffix] = TEMPLATE_PATH.exec(path) ?? []
112
+ for (const [verb, routeSuffix, name] of TEMPLATE_WRITES) {
113
+ const mock = mocks[name]
114
+ if (verb === method && suffix === routeSuffix && id && mock)
115
+ return mock(id, body)
116
+ }
117
+ for (const [verb, routePath, name] of MODULE_WRITES) {
118
+ const mock = mocks[name]
119
+ if (verb === method && path === routePath && mock) return mock(body)
120
+ }
121
+ throw new Error(`unexpected request ${method} ${path}`)
122
+ }
123
+ }
@@ -1,4 +1,5 @@
1
1
  import { vi } from 'vitest'
2
+ import { useApiErrorPresenter } from '../../../node_modules/@antelopejs/dms/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter'
2
3
  import {
3
4
  computed,
4
5
  createApp,
@@ -70,8 +71,23 @@ export function stubNuxtGlobals(email = 'camille@acme.com'): NuxtStubs {
70
71
  processI18n: (message: string) => message,
71
72
  }))
72
73
  vi.stubGlobal('useToast', () => ({
73
- add: (toast: Record<string, unknown>) => stubs.toasts.push(toast),
74
+ add: (toast: Record<string, unknown>) => {
75
+ stubs.toasts.push(toast)
76
+ return { id: stubs.toasts.length }
77
+ },
78
+ }))
79
+ vi.stubGlobal('getPluginI18n', () => ({
80
+ i18n: {
81
+ tm: (key: string) =>
82
+ key === 'error.read_only_demo'
83
+ ? 'Read-only demo, changes are not saved'
84
+ : key,
85
+ rt: translate,
86
+ },
74
87
  }))
88
+ const presented = ref(new WeakMap())
89
+ vi.stubGlobal('useDmsState', () => presented)
90
+ vi.stubGlobal('useApiErrorPresenter', useApiErrorPresenter)
75
91
  vi.stubGlobal('useUniqueLocales', () => ({
76
92
  uniqueLocales: ref([
77
93
  { code: 'en', name: 'English' },