@antelopejs/dms-mailing 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/dist/crons/jobs.d.ts +1 -1
- package/dist/data/body.js +2 -1
- package/dist/data/body.js.map +1 -1
- package/dist/data/templates-table.js +4 -3
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.js +2 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/engine/changes.js +2 -1
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.js +2 -3
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/fallback-html.js +8 -8
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/formats.js +6 -6
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.js +2 -3
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/system-variables.js +1 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +2 -2
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/metrics.js +6 -5
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/sends.js +1 -1
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -6
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.js +4 -4
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/attention.js +2 -1
- package/dist/services/attention.js.map +1 -1
- package/dist/services/categories.js +6 -6
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.js +16 -9
- package/dist/services/checked-send.js.map +1 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +11 -7
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +2 -1
- package/dist/services/localized-text.js +2 -1
- package/dist/services/localized-text.js.map +1 -1
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-import.js +7 -3
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +4 -3
- package/dist/services/send.js.map +1 -1
- package/dist/services/starters.js +5 -5
- package/dist/services/starters.js.map +1 -1
- package/dist/services/template-stats.js +5 -2
- package/dist/services/template-stats.js.map +1 -1
- package/dist/services/templates.js +6 -2
- package/dist/services/templates.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +16 -16
- package/dist/validation/declarations.schema.js +2 -2
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +4 -0
- package/dist/validation/guards.js +19 -0
- package/dist/validation/guards.js.map +1 -0
- package/dist/validation/send-event-type.schema.d.ts +2 -0
- package/dist/validation/send-event-type.schema.js +11 -0
- package/dist/validation/send-event-type.schema.js.map +1 -0
- package/dist/validation/settings.schema.js +1 -1
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +19 -18
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
- package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
- package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
- package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
- package/frontend-vue/app/components/DuplicateModal.vue +3 -11
- package/frontend-vue/app/components/FixAddressModal.vue +2 -1
- package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
- package/frontend-vue/app/components/RealSendModal.vue +6 -17
- package/frontend-vue/app/components/RenderingModal.vue +2 -1
- package/frontend-vue/app/components/SendDrawer.vue +5 -4
- package/frontend-vue/app/components/StarterGallery.vue +3 -11
- package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
- package/frontend-vue/app/components/TestSendModal.vue +7 -18
- package/frontend-vue/app/components/VersionHistory.vue +2 -7
- package/frontend-vue/app/components/WebhookStatus.vue +2 -1
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
- package/frontend-vue/build/backend-boundary.ts +122 -0
- package/frontend-vue/dms.frontend.build.ts +7 -0
- package/frontend-vue/eslint.config.mjs +13 -0
- package/frontend-vue/package.json +4 -2
- package/frontend-vue/pnpm-lock.yaml +210 -61
- package/frontend-vue/tests/backend-boundary.test.ts +203 -0
- package/frontend-vue/tests/dnd.test.ts +1 -2
- package/frontend-vue/tests/editor-block-list.test.ts +136 -53
- package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
- package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
- package/frontend-vue/tests/new-template-modal.test.ts +41 -6
- package/frontend-vue/tests/page-reads.test.ts +26 -6
- package/frontend-vue/tests/send-actions.test.ts +0 -1
- package/frontend-vue/tests/send-drawer.test.ts +2 -6
- package/frontend-vue/tests/send-modals.test.ts +8 -5
- package/frontend-vue/tests/sortable-list.test.ts +54 -62
- package/frontend-vue/tests/suppression.test.ts +1 -2
- package/frontend-vue/tests/template-drawer.test.ts +27 -18
- package/frontend-vue/tests/template-gallery.test.ts +7 -5
- package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
- package/package.json +3 -8
- package/skills/dms-mailing/SKILL.md +3 -3
- package/skills/dms-mailing/cases.json +49 -0
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +2 -1
- package/src/data/templates-table.ts +12 -7
- package/src/db/models/template-versions.model.ts +5 -1
- package/src/db/models/templates.model.ts +6 -3
- package/src/engine/changes.ts +3 -2
- package/src/engine/conditions.ts +2 -4
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +6 -6
- package/src/engine/paths.ts +4 -3
- package/src/engine/system-variables.ts +4 -3
- package/src/engine/tokens.ts +1 -2
- package/src/pages/sends.ts +5 -3
- package/src/pages/templates.ts +1 -1
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +3 -6
- package/src/routes/metrics.ts +7 -5
- package/src/routes/sends.ts +10 -18
- package/src/routes/settings.ts +8 -9
- package/src/routes/suppressions.ts +1 -1
- package/src/routes/templates.ts +5 -5
- package/src/services/attention.ts +4 -3
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declarations.ts +3 -5
- package/src/services/events.ts +16 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +2 -2
- package/src/services/notify.ts +1 -1
- package/src/services/render.ts +5 -1
- package/src/services/send-import.ts +16 -11
- package/src/services/send.ts +7 -7
- package/src/services/starters.ts +6 -6
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +10 -8
- package/src/validation/declarations.schema.ts +2 -2
- package/src/validation/guards.ts +25 -0
- package/src/validation/send-event-type.schema.ts +10 -0
- package/src/validation/settings.schema.ts +7 -2
- package/src/validation/templates.schema.ts +2 -1
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- 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 {
|
|
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
|
|
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
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
61
|
-
app.component(
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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] =
|
|
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:
|
|
102
|
-
sortable
|
|
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(
|
|
112
|
-
expect(
|
|
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>) =>
|
|
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' },
|