@antelopejs/dms-mailing 0.6.1 → 0.7.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/dist/constants.d.ts +1 -0
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/data/templates-table.d.ts +4 -1
- package/dist/data/templates-table.js +55 -13
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +5 -5
- package/dist/db/models/templates.model.d.ts +10 -1
- package/dist/db/models/templates.model.js +27 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +7 -1
- package/dist/db/tables/templates.table.js +3 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/formats.d.ts +13 -0
- package/dist/engine/formats.js +85 -0
- package/dist/engine/formats.js.map +1 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +14 -7
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js +3 -5
- package/dist/engine/tokens.js.map +1 -1
- package/dist/frontend-module.d.ts +2 -0
- package/dist/frontend-module.js +24 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/hooks/tenant-export.js +4 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -0
- package/dist/implementations/dms-mailing/index.js +3 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +2 -19
- package/dist/index.js.map +1 -1
- package/dist/pages/overview.d.ts +1 -1
- package/dist/pages/overview.js +11 -13
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/sends.d.ts +1 -1
- package/dist/pages/sends.js +16 -8
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -8
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.d.ts +12 -0
- package/dist/pages/toolbar.js +41 -0
- package/dist/pages/toolbar.js.map +1 -0
- package/dist/realtime.d.ts +2 -2
- package/dist/realtime.js +8 -8
- package/dist/realtime.js.map +1 -1
- package/dist/routes/metrics.d.ts +9 -2
- package/dist/routes/metrics.js +90 -35
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.d.ts +1 -0
- package/dist/routes/period.js +19 -0
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +6 -3
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -7
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.d.ts +8 -4
- package/dist/routes/templates.js +40 -15
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/categories.d.ts +2 -1
- package/dist/services/categories.js +9 -0
- package/dist/services/categories.js.map +1 -1
- package/dist/services/declarations.js +2 -2
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +1 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +7 -0
- package/dist/services/localized-text.js +28 -0
- package/dist/services/localized-text.js.map +1 -0
- package/dist/services/send-stat-items.js +34 -12
- package/dist/services/send-stat-items.js.map +1 -1
- package/dist/services/send-stats.d.ts +7 -0
- package/dist/services/send-stats.js +17 -5
- package/dist/services/send-stats.js.map +1 -1
- package/dist/services/send.d.ts +2 -0
- package/dist/services/send.js +11 -12
- package/dist/services/send.js.map +1 -1
- package/dist/services/sender-declaration.d.ts +2 -0
- package/dist/services/sender-declaration.js +18 -0
- package/dist/services/sender-declaration.js.map +1 -0
- package/dist/services/starters.d.ts +2 -2
- package/dist/services/starters.js +1 -10
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +11 -5
- package/dist/services/templates.js +24 -6
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -1
- package/dist/services/webhook-status.js +6 -5
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/settings.d.ts +6 -0
- package/dist/types/settings.js +25 -5
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +25 -7
- package/dist/validation/declarations.schema.js +16 -3
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/settings.schema.d.ts +25 -25
- package/dist/validation/settings.schema.js +30 -6
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +13 -0
- package/dist/validation/templates.schema.js +6 -1
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +15 -13
- package/frontend-vue/app/components/FunnelCard.vue +46 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
- package/frontend-vue/app/components/OverviewGate.vue +0 -8
- package/frontend-vue/app/components/ProviderPill.vue +50 -0
- package/frontend-vue/app/components/SendDrawer.vue +206 -183
- package/frontend-vue/app/components/StarterGallery.vue +1 -1
- package/frontend-vue/app/components/TemplateCard.vue +4 -1
- package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
- package/frontend-vue/app/components/TemplateGallery.vue +57 -17
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
- package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
- package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
- package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
- package/frontend-vue/app/composables/useMailingApi.ts +3 -3
- package/frontend-vue/app/composables/useMailingReads.ts +2 -2
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
- package/frontend-vue/app/types/mailing.ts +18 -0
- package/frontend-vue/app/utils/categories.ts +58 -49
- package/frontend-vue/app/utils/funnel.ts +25 -0
- package/frontend-vue/app/utils/period.ts +0 -7
- package/frontend-vue/app/utils/starters.ts +3 -1
- package/frontend-vue/app/utils/tokens.ts +6 -1
- package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/categories-input.test.ts +4 -10
- package/frontend-vue/tests/categories.test.ts +55 -17
- package/frontend-vue/tests/conditions.test.ts +1 -4
- package/frontend-vue/tests/funnel-card.test.ts +27 -0
- package/frontend-vue/tests/funnel.test.ts +25 -0
- package/frontend-vue/tests/localized-defaults.test.ts +0 -29
- package/frontend-vue/tests/new-template-modal.test.ts +1 -1
- package/frontend-vue/tests/overview-gate.test.ts +1 -24
- package/frontend-vue/tests/page-reads.test.ts +9 -25
- package/frontend-vue/tests/period.test.ts +0 -12
- package/frontend-vue/tests/replay-toast.test.ts +2 -1
- package/frontend-vue/tests/send-drawer.test.ts +123 -0
- package/frontend-vue/tests/template-drawer.test.ts +1 -1
- package/frontend-vue/tests/template-gallery.test.ts +90 -3
- package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
- package/package.json +6 -5
- package/skills/dms-mailing/SKILL.md +17 -4
- package/src/constants.ts +2 -0
- package/src/data/sends-table.ts +1 -1
- package/src/data/templates-table.ts +123 -19
- package/src/db/models/templates.model.ts +61 -3
- package/src/db/tables/templates.table.ts +10 -1
- package/src/engine/formats.ts +124 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/resolve.ts +18 -7
- package/src/engine/tokens.ts +7 -5
- package/src/frontend-module.ts +36 -0
- package/src/hooks/tenant-export.ts +8 -1
- package/src/implementations/dms-mailing/index.ts +2 -0
- package/src/index.ts +2 -23
- package/src/pages/overview.ts +20 -16
- package/src/pages/sends.ts +27 -11
- package/src/pages/templates.ts +4 -8
- package/src/pages/toolbar.ts +77 -0
- package/src/realtime.ts +17 -8
- package/src/routes/metrics.ts +158 -45
- package/src/routes/period.ts +32 -0
- package/src/routes/sends.ts +13 -4
- package/src/routes/settings.ts +11 -8
- package/src/routes/templates.ts +72 -26
- package/src/services/categories.ts +14 -1
- package/src/services/declarations.ts +4 -3
- package/src/services/events.ts +1 -1
- package/src/services/localized-text.ts +53 -0
- package/src/services/send-stat-items.ts +60 -13
- package/src/services/send-stats.ts +37 -5
- package/src/services/send.ts +19 -20
- package/src/services/sender-declaration.ts +27 -0
- package/src/services/starters.ts +3 -15
- package/src/services/templates.ts +50 -19
- package/src/services/webhook-status.ts +3 -3
- package/src/types/settings.ts +35 -5
- package/src/validation/declarations.schema.ts +29 -3
- package/src/validation/settings.schema.ts +56 -14
- package/src/validation/templates.schema.ts +10 -0
- package/frontend-vue/app/components/PageHeader.vue +0 -94
- package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
|
@@ -42,7 +42,8 @@ describe('useReplayToast', () => {
|
|
|
42
42
|
const [toast] = stubs.toasts
|
|
43
43
|
expect(toast).toMatchObject({
|
|
44
44
|
color: 'warning',
|
|
45
|
-
title:
|
|
45
|
+
title:
|
|
46
|
+
'dms_mailing.suppressions.notice.not_sent.toast_title email=a@x.test',
|
|
46
47
|
description:
|
|
47
48
|
'dms_mailing.suppressions.notice.not_sent.reason reason=dms_mailing.suppressions.reasons.unsubscribe',
|
|
48
49
|
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import SendDrawer from '../app/components/SendDrawer.vue'
|
|
4
|
+
import type { SendRow } from '../app/types/mailing'
|
|
5
|
+
import { flush, mount, slotStub, stubNuxtGlobals } from './helpers/nuxt-globals'
|
|
6
|
+
import { mailingReads, stubApiData } from './helpers/api-data'
|
|
7
|
+
|
|
8
|
+
vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<object>()),
|
|
10
|
+
useMailingApi: () => ({ replay: vi.fn() }),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
const SEND: SendRow = {
|
|
14
|
+
_id: '041f125a00000000',
|
|
15
|
+
templateId: 't1',
|
|
16
|
+
templateSlug: 'invoice-reminder',
|
|
17
|
+
locale: 'fr',
|
|
18
|
+
templateVersion: 1,
|
|
19
|
+
recipientEmail: 'marc@boulangerie-lejeune.example',
|
|
20
|
+
recipientName: 'Marc Lejeune',
|
|
21
|
+
status: 'sent',
|
|
22
|
+
providerMessageId: '<f1c836b9-226e-1c5a-ab39-10b4beb00a4c@ethereal.email>',
|
|
23
|
+
latencyMs: 822,
|
|
24
|
+
source: 'automation.overdue-reminders',
|
|
25
|
+
json_variables: '{"customer":{"name":"Marc"}}',
|
|
26
|
+
opens: 0,
|
|
27
|
+
clicks: 0,
|
|
28
|
+
createdAt: '2026-10-20T08:00:02Z',
|
|
29
|
+
isTest: false,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STUB_NAMES = [
|
|
33
|
+
'USkeleton',
|
|
34
|
+
'UButton',
|
|
35
|
+
'UIcon',
|
|
36
|
+
'DmsEyebrow',
|
|
37
|
+
'DmsStatusPill',
|
|
38
|
+
'DmsStatGroup',
|
|
39
|
+
'DmsBanner',
|
|
40
|
+
'DmsActivityItem',
|
|
41
|
+
'DmsListRow',
|
|
42
|
+
'DmsKeyValueList',
|
|
43
|
+
'DmsContainerFooter',
|
|
44
|
+
]
|
|
45
|
+
const STUBS = Object.fromEntries(
|
|
46
|
+
STUB_NAMES.map((name) => [name, slotStub(name)]),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async function render(): Promise<HTMLElement> {
|
|
50
|
+
const { host } = mount(SendDrawer, { rowData: SEND }, STUBS)
|
|
51
|
+
await flush()
|
|
52
|
+
return host
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('send drawer layout', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
stubNuxtGlobals()
|
|
58
|
+
vi.stubGlobal('useApiError', () => undefined)
|
|
59
|
+
stubApiData().respond(
|
|
60
|
+
mailingReads({ send: () => ({ send: SEND, events: [] }) }),
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.unstubAllGlobals()
|
|
66
|
+
document.body.innerHTML = ''
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('adds no inset or scroller of its own inside the drawer body', async () => {
|
|
70
|
+
const host = await render()
|
|
71
|
+
const root = host.firstElementChild as HTMLElement
|
|
72
|
+
expect(root.className).not.toMatch(/(^|\s)(p|px|py|m)-\d/)
|
|
73
|
+
expect(root.className).not.toContain('h-full')
|
|
74
|
+
expect(host.querySelector('.overflow-y-auto')).toBeNull()
|
|
75
|
+
expect(host.querySelector('header')?.className).not.toMatch(/px-\d/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// The DMS drawer body is a flex column: a root that grows keeps the
|
|
79
|
+
// DmsContainerFooter, which sticks and sits flush, at the drawer's bottom.
|
|
80
|
+
it('grows in the drawer body and puts its actions in the DMS footer', async () => {
|
|
81
|
+
const host = await render()
|
|
82
|
+
const root = host.firstElementChild as HTMLElement
|
|
83
|
+
expect(root.className).toContain('flex-1')
|
|
84
|
+
expect(root.className).toContain('flex-col')
|
|
85
|
+
expect(root.style.minHeight).toBe('')
|
|
86
|
+
const footer = host.querySelector('[data-send-footer]')
|
|
87
|
+
expect(footer?.getAttribute('data-stub')).toBe('DmsContainerFooter')
|
|
88
|
+
expect(
|
|
89
|
+
footer?.querySelectorAll('[data-stub="UButton"]').length,
|
|
90
|
+
).toBeGreaterThan(0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('keeps the template / locale / latency trio on one row in a 600px drawer', async () => {
|
|
94
|
+
const group = (await render()).querySelector('[data-stub="DmsStatGroup"]')
|
|
95
|
+
expect(group?.getAttribute('columns-from')).toBe('md')
|
|
96
|
+
expect(group?.className).not.toContain('grid-cols')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('wraps the timeline so a Message-ID is never clipped', async () => {
|
|
100
|
+
const items = (await render()).querySelectorAll(
|
|
101
|
+
'[data-stub="DmsActivityItem"]',
|
|
102
|
+
)
|
|
103
|
+
expect(items.length).toBeGreaterThan(0)
|
|
104
|
+
items.forEach((item) => expect(item.hasAttribute('wrap')).toBe(true))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('lets the address give way before the date on a phone', async () => {
|
|
108
|
+
const line = (await render()).querySelector('[data-send-recipient]')
|
|
109
|
+
const [address, date] = Array.from(line?.children ?? [])
|
|
110
|
+
expect(line?.className).toContain('flex-wrap')
|
|
111
|
+
expect(line?.className).not.toContain('truncate')
|
|
112
|
+
expect(address?.className).toContain('truncate')
|
|
113
|
+
expect(address?.getAttribute('title')).toBe(SEND.recipientEmail)
|
|
114
|
+
expect(date?.className).toContain('whitespace-nowrap')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('puts the "source" label under the source, not beside it', async () => {
|
|
118
|
+
const row = (await render()).querySelector('[data-send-source]')
|
|
119
|
+
expect(row?.hasAttribute('trailing')).toBe(false)
|
|
120
|
+
expect(row?.getAttribute('meta')).toContain('source')
|
|
121
|
+
expect(row?.textContent).toContain(SEND.source)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -434,7 +434,7 @@ describe('StarterGallery', () => {
|
|
|
434
434
|
})
|
|
435
435
|
|
|
436
436
|
it('creates a starter as a draft and opens it', async () => {
|
|
437
|
-
api.createTemplate.mockResolvedValue(
|
|
437
|
+
api.createTemplate.mockResolvedValue({ _id: 'new-id' })
|
|
438
438
|
const view = mount(StarterGallery, {}, globals)
|
|
439
439
|
await flush()
|
|
440
440
|
;(view.host.querySelector('[data-stub="DmsCard"]') as HTMLElement).click()
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { reactive } from 'vue'
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import TemplateGallery from '../app/components/TemplateGallery.vue'
|
|
5
|
-
import TemplateCard from '../app/components/TemplateCard.vue'
|
|
6
5
|
import type { TemplateRow, TemplateStats } from '../app/types/mailing'
|
|
7
6
|
import {
|
|
8
7
|
flush,
|
|
@@ -22,6 +21,11 @@ const CATEGORIES_URL = '/api/mailing/templates/categories'
|
|
|
22
21
|
const OVERVIEW_URL = '/api/mailing/templates/overview'
|
|
23
22
|
const LIST_URL = '/api/mailing/tables/templates/list'
|
|
24
23
|
|
|
24
|
+
vi.mock('../app/components/TemplatePreviewFrame.vue', async () => {
|
|
25
|
+
const { slotStub } = await import('./helpers/nuxt-globals')
|
|
26
|
+
return { default: slotStub('MailingTemplatePreviewFrame') }
|
|
27
|
+
})
|
|
28
|
+
|
|
25
29
|
const RECENT = new Date(Date.now() - 3_600_000).toISOString()
|
|
26
30
|
|
|
27
31
|
function row(overrides: Partial<TemplateRow>): TemplateRow {
|
|
@@ -107,8 +111,6 @@ function context({ items, query = {} }: ContextOptions) {
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
const globals = {
|
|
110
|
-
MailingTemplateCard: TemplateCard,
|
|
111
|
-
MailingTemplatePreviewFrame: slotStub('MailingTemplatePreviewFrame'),
|
|
112
114
|
MailingStarterGallery: slotStub('MailingStarterGallery'),
|
|
113
115
|
DmsEmptyState: slotStub('DmsEmptyState'),
|
|
114
116
|
}
|
|
@@ -371,6 +373,91 @@ describe('TemplateGallery', () => {
|
|
|
371
373
|
view.unmount()
|
|
372
374
|
})
|
|
373
375
|
|
|
376
|
+
it('keeps its skeleton until the categories answered, then draws the cards in their group', async () => {
|
|
377
|
+
let answerCategories: (value: unknown) => void = () => {}
|
|
378
|
+
api.respond(({ url }) => {
|
|
379
|
+
if (url === CATEGORIES_URL)
|
|
380
|
+
return new Promise((resolve) => (answerCategories = resolve))
|
|
381
|
+
if (url === OVERVIEW_URL) return { items: [] }
|
|
382
|
+
return { results: [LIVE], total: 1 }
|
|
383
|
+
})
|
|
384
|
+
const view = mount(
|
|
385
|
+
TemplateGallery,
|
|
386
|
+
{ context: context({ items: [LIVE] }) },
|
|
387
|
+
globals,
|
|
388
|
+
)
|
|
389
|
+
await flush()
|
|
390
|
+
expect(view.host.querySelector('[data-gallery-skeleton]')).not.toBeNull()
|
|
391
|
+
expect(cardNames(view.host)).toEqual([])
|
|
392
|
+
answerCategories({
|
|
393
|
+
categories: [{ id: 'orders', label: 'Orders', icon: 'i-ph-package' }],
|
|
394
|
+
})
|
|
395
|
+
await flush()
|
|
396
|
+
expect(view.host.querySelector('[data-gallery-skeleton]')).toBeNull()
|
|
397
|
+
expect(view.host.querySelector('h2')?.textContent?.trim()).toBe('Orders')
|
|
398
|
+
view.unmount()
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('draws no skeleton once the cards are on screen, a refresh included', async () => {
|
|
402
|
+
const ctx = reactive(context({ items: [LIVE] }))
|
|
403
|
+
const view = mount(TemplateGallery, { context: ctx }, globals)
|
|
404
|
+
await flush()
|
|
405
|
+
ctx.loading = true
|
|
406
|
+
ctx.items = [LIVE]
|
|
407
|
+
await flush()
|
|
408
|
+
expect(view.host.querySelector('uskeleton')).toBeNull()
|
|
409
|
+
expect(cardNames(view.host)).toEqual(['Order confirmation'])
|
|
410
|
+
view.unmount()
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('lays its skeleton out like a group of cards, so they replace it in place', async () => {
|
|
414
|
+
api.respond(({ url }) => {
|
|
415
|
+
if (url === CATEGORIES_URL) return new Promise(() => {})
|
|
416
|
+
return { items: [], results: [LIVE], total: 1 }
|
|
417
|
+
})
|
|
418
|
+
const loading = mount(
|
|
419
|
+
TemplateGallery,
|
|
420
|
+
{ context: context({ items: [LIVE] }) },
|
|
421
|
+
globals,
|
|
422
|
+
)
|
|
423
|
+
await flush()
|
|
424
|
+
const skeleton = loading.host.querySelector(
|
|
425
|
+
'[data-gallery-skeleton]',
|
|
426
|
+
) as HTMLElement
|
|
427
|
+
const skeletonCard = skeleton.querySelector('dmscard') as HTMLElement
|
|
428
|
+
const skeletonHeading = skeleton.firstElementChild as HTMLElement
|
|
429
|
+
const skeletonBody = skeletonCard.lastElementChild as HTMLElement
|
|
430
|
+
|
|
431
|
+
api.respond(({ url }) =>
|
|
432
|
+
url === CATEGORIES_URL
|
|
433
|
+
? { categories: [] }
|
|
434
|
+
: { items: [], results: [LIVE], total: 1 },
|
|
435
|
+
)
|
|
436
|
+
const loaded = mount(
|
|
437
|
+
TemplateGallery,
|
|
438
|
+
{ context: context({ items: [MISSING_DE] }) },
|
|
439
|
+
globals,
|
|
440
|
+
)
|
|
441
|
+
await flush()
|
|
442
|
+
expect(loaded.host.querySelector('[data-gallery-skeleton]')).toBeNull()
|
|
443
|
+
const section = loaded.host.querySelector('section') as HTMLElement
|
|
444
|
+
const card = section.querySelector('article, dmscard') as HTMLElement
|
|
445
|
+
const body = card.lastElementChild as HTMLElement
|
|
446
|
+
|
|
447
|
+
expect(skeleton.className).toBe(section.className)
|
|
448
|
+
expect(skeletonHeading.className).toContain('flex items-center gap-2')
|
|
449
|
+
expect(skeletonCard.querySelector('uskeleton')?.className).toContain(
|
|
450
|
+
'h-[172px]',
|
|
451
|
+
)
|
|
452
|
+
expect(skeletonBody.className).toBe(body.className)
|
|
453
|
+
expect(skeletonBody.children).toHaveLength(body.children.length)
|
|
454
|
+
expect(skeletonBody.lastElementChild?.className).toBe(
|
|
455
|
+
body.lastElementChild?.className.replace(' text-muted', ''),
|
|
456
|
+
)
|
|
457
|
+
loading.unmount()
|
|
458
|
+
loaded.unmount()
|
|
459
|
+
})
|
|
460
|
+
|
|
374
461
|
it('opens the editor on a missing locale chip', async () => {
|
|
375
462
|
const view = mount(
|
|
376
463
|
TemplateGallery,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import TemplatePreviewFrame from '../app/components/TemplatePreviewFrame.vue'
|
|
4
|
+
import { flush, mount, stubNuxtGlobals } from './helpers/nuxt-globals'
|
|
5
|
+
import { stubApiData } from './helpers/api-data'
|
|
6
|
+
|
|
7
|
+
/** Past the frame's debounce before it reads. */
|
|
8
|
+
const DEBOUNCE_WAIT_MS = 400
|
|
9
|
+
|
|
10
|
+
const api = { preview: vi.fn() }
|
|
11
|
+
|
|
12
|
+
vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
|
|
13
|
+
...(await importOriginal<object>()),
|
|
14
|
+
useMailingApi: () => api,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const skeleton = (host: HTMLElement) => host.querySelector('uskeleton')
|
|
18
|
+
const placeholder = (host: HTMLElement) =>
|
|
19
|
+
host.querySelector('[data-preview-placeholder]')
|
|
20
|
+
const fallback = (host: HTMLElement) =>
|
|
21
|
+
host.querySelector('[data-preview-fallback]')
|
|
22
|
+
|
|
23
|
+
async function settle(): Promise<void> {
|
|
24
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_WAIT_MS)
|
|
25
|
+
await flush()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.useFakeTimers()
|
|
30
|
+
stubNuxtGlobals()
|
|
31
|
+
stubApiData()
|
|
32
|
+
api.preview.mockReset()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.useRealTimers()
|
|
37
|
+
vi.unstubAllGlobals()
|
|
38
|
+
document.body.innerHTML = ''
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('TemplatePreviewFrame', () => {
|
|
42
|
+
it('holds a skeleton from its first render until the HTML is in', async () => {
|
|
43
|
+
api.preview.mockResolvedValue({ html: '<p>Hello</p>' })
|
|
44
|
+
const view = mount(TemplatePreviewFrame, { templateId: 't1', width: 600 })
|
|
45
|
+
expect(skeleton(view.host)).not.toBeNull()
|
|
46
|
+
expect(view.host.querySelector('iframe')).toBeNull()
|
|
47
|
+
await settle()
|
|
48
|
+
expect(skeleton(view.host)).toBeNull()
|
|
49
|
+
expect(view.host.querySelector('iframe')?.getAttribute('srcdoc')).toContain(
|
|
50
|
+
'Hello',
|
|
51
|
+
)
|
|
52
|
+
view.unmount()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('waits on a blank sheet, never a skeleton, as a lazy thumbnail', async () => {
|
|
56
|
+
api.preview.mockResolvedValue({ html: '<p>Hello</p>' })
|
|
57
|
+
const view = mount(TemplatePreviewFrame, {
|
|
58
|
+
templateId: 't1',
|
|
59
|
+
width: 600,
|
|
60
|
+
lazy: true,
|
|
61
|
+
})
|
|
62
|
+
await settle()
|
|
63
|
+
expect(skeleton(view.host)).toBeNull()
|
|
64
|
+
expect(placeholder(view.host)).not.toBeNull()
|
|
65
|
+
expect(view.host.querySelector('iframe')).toBeNull()
|
|
66
|
+
view.unmount()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('shows its fallback when the preview cannot be read, not a skeleton for ever', async () => {
|
|
70
|
+
api.preview.mockRejectedValue(new Error('500'))
|
|
71
|
+
const view = mount(TemplatePreviewFrame, { templateId: 't1', width: 600 })
|
|
72
|
+
await settle()
|
|
73
|
+
expect(skeleton(view.host)).toBeNull()
|
|
74
|
+
expect(fallback(view.host)?.textContent).toContain(
|
|
75
|
+
'dms_mailing.common.preview_unavailable',
|
|
76
|
+
)
|
|
77
|
+
view.unmount()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('keeps the box its size while it waits', () => {
|
|
81
|
+
const view = mount(TemplatePreviewFrame, {
|
|
82
|
+
templateId: 't1',
|
|
83
|
+
width: 600,
|
|
84
|
+
scale: 0.4,
|
|
85
|
+
lazy: true,
|
|
86
|
+
})
|
|
87
|
+
const box = view.host.firstElementChild as HTMLElement
|
|
88
|
+
expect(box.style.width).toBe('240px')
|
|
89
|
+
expect(box.style.height).toBe('192px')
|
|
90
|
+
view.unmount()
|
|
91
|
+
})
|
|
92
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-mailing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "E-mail templates for the AntelopeJS DMS: a block editor with drafts and versions, a send log, delivery metrics and a suppression list.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antelopejs",
|
|
@@ -49,17 +49,17 @@
|
|
|
49
49
|
"@antelopejs/interface-api": ">=0.0.15 <1.0.0",
|
|
50
50
|
"@antelopejs/interface-api-util": ">=0.1.3 <1.0.0",
|
|
51
51
|
"@antelopejs/interface-core": ">=0.2.4 <1.0.0",
|
|
52
|
-
"@antelopejs/interface-data-api": ">=0.
|
|
52
|
+
"@antelopejs/interface-data-api": ">=0.3.0 <1.0.0",
|
|
53
53
|
"@antelopejs/interface-database": ">=0.1.10 <1.0.0",
|
|
54
54
|
"@antelopejs/interface-database-decorators": ">=0.1.9 <1.0.0",
|
|
55
|
-
"@antelopejs/interface-dms": ">=0.
|
|
56
|
-
"@antelopejs/interface-dms-mailing": ">=0.
|
|
55
|
+
"@antelopejs/interface-dms": ">=0.8.0 <1.0.0",
|
|
56
|
+
"@antelopejs/interface-dms-mailing": ">=0.9.0 <0.10.0",
|
|
57
57
|
"@antelopejs/interface-email": ">=0.0.6 <1.0.0",
|
|
58
58
|
"node-cron": "^4.2.1",
|
|
59
59
|
"zod": "~3.24.2"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@antelopejs/core": ">=1.
|
|
62
|
+
"@antelopejs/core": ">=1.17.0 <2",
|
|
63
63
|
"@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
|
|
64
64
|
"@types/chai": "^4.3.20",
|
|
65
65
|
"@types/mocha": "^10",
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"./skills"
|
|
91
91
|
]
|
|
92
92
|
},
|
|
93
|
+
"dmsFrontend": "dist/frontend-module.js",
|
|
93
94
|
"scripts": {
|
|
94
95
|
"build": "rimraf dist && tsc",
|
|
95
96
|
"dev": "ajs project dev -w -p playground",
|
|
@@ -25,7 +25,7 @@ https://dms.antelopejs.com/docs/modules/mailing (sending from code and the webho
|
|
|
25
25
|
(`@antelopejs/nodemailer`, `config: { ethereal: true }` in dev).
|
|
26
26
|
- Mailing 0.6 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
|
|
27
27
|
0.4 is the DMS 0.8 line. Mailing 0.6 implements `@antelopejs/interface-dms-mailing` `>=0.8.0 <0.9.0`, adding declaration and import calls;
|
|
28
|
-
`SendTemplate` / `RecordEmailEvent` callers do not change.
|
|
28
|
+
interface 0.9 adds `DeclareSender`; `SendTemplate` / `RecordEmailEvent` callers do not change.
|
|
29
29
|
- Module option: `useHtmlRender` (default `true`, DMS html-render chain; `false` = built-in standalone HTML).
|
|
30
30
|
- Set the DMS `clientBaseUrl`: the webhook URL and the e-mail logo are built on it.
|
|
31
31
|
- Per-workspace settings (sender, missing-variable guard, fallback locale, webhook secret, retention,
|
|
@@ -49,6 +49,8 @@ const result = await SendTemplate("order-confirmed", {
|
|
|
49
49
|
- Only the **published** version is sent; drafts go out only as tests (`[TEST]` subject, out of the figures).
|
|
50
50
|
- Variables are typed (`string`, `text`, `number`, `money`, `date`, `url`, `boolean`, `array`) and declared on
|
|
51
51
|
the template; pass every required one.
|
|
52
|
+
- Don't pre-format amounts and dates per locale: pass `{ value: 987420, currency: "EUR" }` (minor units) or a
|
|
53
|
+
`Date`, or an ISO string at a `date` path; the send locale formats them. Plain strings print as given.
|
|
52
54
|
- In-process events: `RecordEmailEvent(tenantId, { provider, messageId, type, details })`.
|
|
53
55
|
HTTP: `POST <clientBaseUrl>/api/mailing/events/<provider>/<tenantId>` with header `x-mailing-webhook-secret`.
|
|
54
56
|
- Automation (if `interface-dms-automation` is loaded): action `mailing.send-template`, trigger `mailing.email-event`.
|
|
@@ -56,12 +58,13 @@ const result = await SendTemplate("order-confirmed", {
|
|
|
56
58
|
## Ship templates as code
|
|
57
59
|
|
|
58
60
|
```typescript
|
|
59
|
-
import { DeclareTemplate, DeclareTemplateCategory, ImportSends } from "@antelopejs/interface-dms-mailing";
|
|
61
|
+
import { DeclareSender, DeclareTemplate, DeclareTemplateCategory, ImportSends } from "@antelopejs/interface-dms-mailing";
|
|
60
62
|
|
|
61
63
|
export async function declareMailingContent(tenantId: string) {
|
|
62
|
-
await
|
|
64
|
+
await DeclareSender({ tenantId, name: "Acme", email: "hello@acme.example" }); // a plain write: call it once, at provisioning
|
|
65
|
+
await DeclareTemplateCategory({ tenantId, id: "subscriptions", label: { en: "Subscriptions", fr: "Abonnements" }, icon: "i-ph-repeat" }); // before templates naming it
|
|
63
66
|
const { outcome } = await DeclareTemplate({
|
|
64
|
-
tenantId, slug: "subscription-renewed", name: "Subscription renewed", category: "subscriptions",
|
|
67
|
+
tenantId, slug: "subscription-renewed", name: { en: "Subscription renewed", fr: "Abonnement renouvelé" }, category: "subscriptions",
|
|
65
68
|
content: { locales: { en: { subject: "Your {{plan.name}} plan is renewed", preheader: "", blocks: [] } } },
|
|
66
69
|
variables: [{ path: "plan.name", type: "string", required: true }],
|
|
67
70
|
testData: { plan: { name: "Pro" } },
|
|
@@ -82,6 +85,10 @@ export async function declareMailingContent(tenantId: string) {
|
|
|
82
85
|
draft and still holds exactly the last declaration (fingerprint on `declaredHash`); otherwise `kept`.
|
|
83
86
|
A dashboard-made template under that slug is never taken over. Deleted → recreated next boot; archive instead.
|
|
84
87
|
- Categories: added once; a category a user relabelled/removed, or a default one, is `kept`.
|
|
88
|
+
- `name` / `label` are localized texts `{ en, fr, … }` (stored `@Localized`, `en` required as fallback), never `$`
|
|
89
|
+
i18n keys; each user reads, searches and sorts them in their language (`x-content-language`).
|
|
90
|
+
- `DeclareSender({ tenantId, email, name?, replyTo? })` writes the settings' sender (resolves `void`); call it once
|
|
91
|
+
from `Hook.TENANT_BEING_PROVISIONED`, not on every boot, since it replaces what a user set. Refusals use `invalid_sender`.
|
|
85
92
|
- Refusals reject with `error.code`: `invalid_template`, `unknown_category`, `empty_subject`,
|
|
86
93
|
`nothing_to_publish`, `invalid_category`; invalid tenant input rejects before writes. Storage errors can occur after publication.
|
|
87
94
|
- `ImportSends({ tenantId, sends: [{ importId, templateSlug, to, sentAt, events }] })` writes past sends with their own
|
|
@@ -96,6 +103,12 @@ export async function declareMailingContent(tenantId: string) {
|
|
|
96
103
|
- A missing locale falls back to the workspace's fallback locale (logged as `DE asked`).
|
|
97
104
|
- The template slug is fixed at creation: code depends on it.
|
|
98
105
|
- `mailing.send` permission gates every route that reaches recipients; writes publish on realtime topic `mailing:sends`.
|
|
106
|
+
- Overview and Sends are live pages (`live: { maxRate: 5, rememberKey: "mailing" }`) with the DMS `PageToolbar`
|
|
107
|
+
(provider pill, Direct / Pause, period) under their `header` static; the send log `liveHold`s its rows.
|
|
108
|
+
The period offers a comparison menu (`previous-period` default, `previous-year`, `none`); routes read
|
|
109
|
+
`compareFrom` / `compareTo`, and `comparison=none` drops every delta and comparison series. Every period
|
|
110
|
+
figure renders it: KPI deltas, the volume chart's dashed series, funnel steps (`previousCount`, `delta`),
|
|
111
|
+
the top lists' row `delta` and the Sends stat strip (`compared`); period blocks carry `realtimeTopic: "mailing:sends"`.
|
|
99
112
|
- 0.4+ does not read a 0.3 database and moved the webhook path (tenant in the URL).
|
|
100
113
|
- nodemailer `ethereal: true` creates its account online at startup: offline, the whole project fails to
|
|
101
114
|
start (nodemailer's `construct()` throws, before Mailing runs). Use a local SMTP server (Mailpit) offline.
|
package/src/constants.ts
CHANGED
|
@@ -8,6 +8,8 @@ export const SETTINGS_PATH = `${MODULE_BASE_PATH}/settings`;
|
|
|
8
8
|
export const NEW_TEMPLATE_BUTTON_ID = "create";
|
|
9
9
|
export const NEW_TEMPLATE_ACTION_ID = "mailing.new-template";
|
|
10
10
|
export const FRONTEND_MODULE_NAME = "@antelopejs/dms-mailing-frontend-vue";
|
|
11
|
+
/** The header a provider webhook carries the secret its route checks in. */
|
|
12
|
+
export const WEBHOOK_SECRET_HEADER = "x-mailing-webhook-secret";
|
|
11
13
|
export const EMAIL_TEMPLATE_NAME = "EmailMailingTemplate";
|
|
12
14
|
|
|
13
15
|
export const MAILING_SEND_PERMISSION = "mailing.send";
|
package/src/data/sends-table.ts
CHANGED
|
@@ -231,7 +231,7 @@ export class SendsTableAPI extends DataController(
|
|
|
231
231
|
declare requestedLocale: string;
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
|
-
* Hidden by default: the
|
|
234
|
+
* Hidden by default: the send drawer shows it under "Origin", and the room
|
|
235
235
|
* goes to the recipient and the provider's error.
|
|
236
236
|
*/
|
|
237
237
|
@Listable()
|