@antelopejs/dms-mailing 0.3.4 → 0.4.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 +232 -22
- package/dist/automation/email-event-trigger.js +2 -2
- package/dist/automation/email-event-trigger.js.map +1 -1
- package/dist/automation/send-template-action.js +2 -2
- package/dist/automation/send-template-action.js.map +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +10 -1
- package/dist/constants.js +15 -3
- package/dist/constants.js.map +1 -1
- package/dist/crons/attention.d.ts +3 -0
- package/dist/crons/attention.js +19 -0
- package/dist/crons/attention.js.map +1 -0
- package/dist/crons/index.d.ts +2 -0
- package/dist/crons/index.js +2 -0
- package/dist/crons/index.js.map +1 -1
- package/dist/crons/jobs.d.ts +12 -0
- package/dist/crons/jobs.js +27 -0
- package/dist/crons/jobs.js.map +1 -0
- package/dist/crons/retention.d.ts +2 -4
- package/dist/crons/retention.js +7 -22
- package/dist/crons/retention.js.map +1 -1
- package/dist/data/displays.d.ts +5 -0
- package/dist/data/displays.js +23 -0
- package/dist/data/displays.js.map +1 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/index.js.map +1 -1
- package/dist/data/locale-fallback.d.ts +3 -0
- package/dist/data/locale-fallback.js +20 -0
- package/dist/data/locale-fallback.js.map +1 -0
- package/dist/data/read-only-routes.d.ts +7 -0
- package/dist/data/read-only-routes.js +13 -0
- package/dist/data/read-only-routes.js.map +1 -0
- package/dist/data/sends-table.d.ts +19 -5
- package/dist/data/sends-table.js +185 -17
- package/dist/data/sends-table.js.map +1 -1
- package/dist/data/suppressions-table.d.ts +24 -0
- package/dist/data/suppressions-table.js +120 -0
- package/dist/data/suppressions-table.js.map +1 -0
- package/dist/data/templates-table.d.ts +20 -2
- package/dist/data/templates-table.js +188 -31
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +11 -13
- package/dist/data-types/categories-type.js +3 -3
- package/dist/data-types/categories-type.js.map +1 -1
- package/dist/data-types/category-type.js +1 -1
- package/dist/data-types/category-type.js.map +1 -1
- package/dist/db/models/index.d.ts +3 -0
- package/dist/db/models/index.js +3 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/sends.model.d.ts +12 -1
- package/dist/db/models/sends.model.js +64 -0
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +0 -1
- package/dist/db/models/settings.model.js +0 -7
- package/dist/db/models/settings.model.js.map +1 -1
- package/dist/db/models/suppressions.model.d.ts +28 -0
- package/dist/db/models/suppressions.model.js +46 -0
- package/dist/db/models/suppressions.model.js.map +1 -0
- package/dist/db/models/template-versions.model.d.ts +24 -0
- package/dist/db/models/template-versions.model.js +17 -0
- package/dist/db/models/template-versions.model.js.map +1 -0
- package/dist/db/models/templates.model.d.ts +2 -0
- package/dist/db/models/templates.model.js +3 -0
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/models/webhook-status.model.d.ts +24 -0
- package/dist/db/models/webhook-status.model.js +21 -0
- package/dist/db/models/webhook-status.model.js.map +1 -0
- package/dist/db/tables/index.d.ts +3 -0
- package/dist/db/tables/index.js +3 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/send-events.table.d.ts +2 -2
- package/dist/db/tables/send-events.table.js.map +1 -1
- package/dist/db/tables/sends.table.d.ts +10 -2
- package/dist/db/tables/sends.table.js +34 -1
- package/dist/db/tables/sends.table.js.map +1 -1
- package/dist/db/tables/suppressions.table.d.ts +12 -0
- package/dist/db/tables/suppressions.table.js +53 -0
- package/dist/db/tables/suppressions.table.js.map +1 -0
- package/dist/db/tables/template-versions.table.d.ts +13 -0
- package/dist/db/tables/template-versions.table.js +59 -0
- package/dist/db/tables/template-versions.table.js.map +1 -0
- package/dist/db/tables/templates.table.d.ts +11 -0
- package/dist/db/tables/templates.table.js +45 -0
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/db/tables/webhook-status.table.d.ts +14 -0
- package/dist/db/tables/webhook-status.table.js +58 -0
- package/dist/db/tables/webhook-status.table.js.map +1 -0
- package/dist/engine/changes.d.ts +12 -0
- package/dist/engine/changes.js +107 -0
- package/dist/engine/changes.js.map +1 -0
- package/dist/engine/conditions.js +0 -2
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/coverage.d.ts +1 -0
- package/dist/engine/coverage.js +6 -2
- package/dist/engine/coverage.js.map +1 -1
- package/dist/engine/fallback-html.d.ts +1 -1
- package/dist/engine/fallback-html.js +51 -34
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/index.d.ts +3 -0
- package/dist/engine/index.js +3 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/plain-text.d.ts +3 -0
- package/dist/engine/plain-text.js +60 -0
- package/dist/engine/plain-text.js.map +1 -0
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +17 -9
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/system-variables.d.ts +10 -0
- package/dist/engine/system-variables.js +70 -0
- package/dist/engine/system-variables.js.map +1 -0
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js +6 -2
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/tenant-export.js +13 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -1
- package/dist/implementations/dms-mailing/index.js +2 -2
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +13 -11
- package/dist/index.js.map +1 -1
- package/dist/pages/editor.js +9 -2
- package/dist/pages/editor.js.map +1 -1
- package/dist/pages/index.d.ts +1 -0
- package/dist/pages/index.js +1 -0
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/module.d.ts +2 -0
- package/dist/pages/module.js +26 -2
- package/dist/pages/module.js.map +1 -1
- package/dist/pages/overview.d.ts +2 -6
- package/dist/pages/overview.js +109 -52
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/sends.d.ts +3 -4
- package/dist/pages/sends.js +197 -60
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/settings/form.js +114 -53
- package/dist/pages/settings/form.js.map +1 -1
- package/dist/pages/settings/page.d.ts +0 -1
- package/dist/pages/settings/page.js +8 -11
- package/dist/pages/settings/page.js.map +1 -1
- package/dist/pages/suppressions.d.ts +9 -0
- package/dist/pages/suppressions.js +151 -0
- package/dist/pages/suppressions.js.map +1 -0
- package/dist/pages/templates.d.ts +0 -1
- package/dist/pages/templates.js +114 -40
- package/dist/pages/templates.js.map +1 -1
- package/dist/permissions.js +2 -2
- package/dist/permissions.js.map +1 -1
- package/dist/quick-actions.js +7 -4
- package/dist/quick-actions.js.map +1 -1
- package/dist/routes/events.d.ts +8 -5
- package/dist/routes/events.js +55 -24
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/metrics.d.ts +60 -23
- package/dist/routes/metrics.js +231 -83
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.d.ts +2 -1
- package/dist/routes/period.js +9 -3
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.d.ts +49 -8
- package/dist/routes/sends.js +239 -24
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +44 -5
- package/dist/routes/settings.js +190 -15
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/suppressions.d.ts +16 -0
- package/dist/routes/suppressions.js +92 -0
- package/dist/routes/suppressions.js.map +1 -0
- package/dist/routes/template-versions.d.ts +28 -0
- package/dist/routes/template-versions.js +87 -0
- package/dist/routes/template-versions.js.map +1 -0
- package/dist/routes/templates.d.ts +60 -6
- package/dist/routes/templates.js +204 -35
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/attention.d.ts +19 -6
- package/dist/services/attention.js +177 -12
- package/dist/services/attention.js.map +1 -1
- package/dist/services/catalog.d.ts +3 -0
- package/dist/services/catalog.js +25 -0
- package/dist/services/catalog.js.map +1 -0
- package/dist/services/categories.d.ts +8 -32
- package/dist/services/categories.js +14 -25
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.d.ts +9 -0
- package/dist/services/checked-send.js +54 -0
- package/dist/services/checked-send.js.map +1 -0
- package/dist/services/events.d.ts +5 -0
- package/dist/services/events.js +102 -9
- package/dist/services/events.js.map +1 -1
- package/dist/services/metrics.d.ts +17 -6
- package/dist/services/metrics.js +57 -18
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/notify.d.ts +18 -10
- package/dist/services/notify.js +82 -37
- package/dist/services/notify.js.map +1 -1
- package/dist/services/provider-banner.d.ts +14 -0
- package/dist/services/provider-banner.js +115 -0
- package/dist/services/provider-banner.js.map +1 -0
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-stat-items.d.ts +7 -0
- package/dist/services/send-stat-items.js +127 -0
- package/dist/services/send-stat-items.js.map +1 -0
- package/dist/services/send-stats.d.ts +23 -0
- package/dist/services/send-stats.js +63 -0
- package/dist/services/send-stats.js.map +1 -0
- package/dist/services/send.d.ts +18 -3
- package/dist/services/send.js +81 -17
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +5 -0
- package/dist/services/settings.js +28 -7
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +15 -0
- package/dist/services/starters.js +264 -0
- package/dist/services/starters.js.map +1 -0
- package/dist/services/suppressions.d.ts +20 -0
- package/dist/services/suppressions.js +53 -0
- package/dist/services/suppressions.js.map +1 -0
- package/dist/services/template-attention.d.ts +4 -0
- package/dist/services/template-attention.js +43 -0
- package/dist/services/template-attention.js.map +1 -0
- package/dist/services/template-stats.d.ts +27 -0
- package/dist/services/template-stats.js +59 -0
- package/dist/services/template-stats.js.map +1 -0
- package/dist/services/templates.d.ts +31 -4
- package/dist/services/templates.js +240 -21
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +27 -0
- package/dist/services/webhook-status.js +134 -0
- package/dist/services/webhook-status.js.map +1 -0
- package/dist/types/blocks.d.ts +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/sends.d.ts +10 -1
- package/dist/types/sends.js +35 -2
- package/dist/types/sends.js.map +1 -1
- package/dist/types/suppressions.d.ts +9 -0
- package/dist/types/suppressions.js +10 -0
- package/dist/types/suppressions.js.map +1 -0
- package/dist/types/webhook.d.ts +17 -0
- package/dist/types/webhook.js +3 -0
- package/dist/types/webhook.js.map +1 -0
- package/dist/validation/settings.schema.d.ts +144 -9
- package/dist/validation/settings.schema.js +113 -16
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +137 -10
- package/dist/validation/templates.schema.js +28 -2
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/FormFieldRow.vue +90 -0
- package/frontend-vue/app/build/components/FormRows.vue +26 -0
- package/frontend-vue/app/build/components/RequiredLegend.vue +11 -0
- package/frontend-vue/app/components/CategoriesInput.vue +323 -77
- package/frontend-vue/app/components/CategoryInput.vue +13 -14
- package/frontend-vue/app/components/CategoryLabel.vue +34 -0
- package/frontend-vue/app/components/DuplicateModal.vue +201 -0
- package/frontend-vue/app/components/Editor.vue +55 -20
- package/frontend-vue/app/components/FixAddressModal.vue +133 -0
- package/frontend-vue/app/components/FunnelCard.vue +146 -43
- package/frontend-vue/app/components/LocaleSelect.vue +98 -0
- package/frontend-vue/app/components/NewTemplateModal.vue +388 -102
- package/frontend-vue/app/components/NewTemplateSourceTile.vue +70 -0
- package/frontend-vue/app/components/OverviewGate.vue +220 -0
- package/frontend-vue/app/components/PageHeader.vue +98 -0
- package/frontend-vue/app/components/RealSendModal.vue +555 -0
- package/frontend-vue/app/components/RecipientsField.vue +41 -0
- package/frontend-vue/app/components/RenderingModal.vue +189 -0
- package/frontend-vue/app/components/RetentionInput.vue +212 -0
- package/frontend-vue/app/components/SendDrawer.vue +542 -209
- package/frontend-vue/app/components/StarterGallery.vue +153 -0
- package/frontend-vue/app/components/SystemVariables.vue +63 -0
- package/frontend-vue/app/components/TemplateCard.vue +218 -57
- package/frontend-vue/app/components/TemplateDrawer.vue +785 -274
- package/frontend-vue/app/components/TemplateGallery.vue +312 -56
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +112 -22
- package/frontend-vue/app/components/TestSendModal.vue +266 -95
- package/frontend-vue/app/components/TokenText.vue +61 -10
- package/frontend-vue/app/components/VariablePath.vue +20 -0
- package/frontend-vue/app/components/VersionHistory.vue +196 -0
- package/frontend-vue/app/components/VersionPreview.vue +164 -0
- package/frontend-vue/app/components/WebhookStatus.vue +130 -0
- package/frontend-vue/app/components/blocks/Button.vue +1 -1
- package/frontend-vue/app/components/blocks/Code.vue +1 -1
- package/frontend-vue/app/components/blocks/Footer.vue +56 -7
- package/frontend-vue/app/components/blocks/Heading.vue +1 -1
- package/frontend-vue/app/components/blocks/Hero.vue +13 -7
- package/frontend-vue/app/components/blocks/List.vue +50 -6
- package/frontend-vue/app/components/blocks/Paragraph.vue +1 -1
- package/frontend-vue/app/components/blocks/Total.vue +3 -3
- package/frontend-vue/app/components/editor/EditorAutocompleteMenu.vue +50 -0
- package/frontend-vue/app/components/editor/EditorBlock.vue +37 -90
- package/frontend-vue/app/components/editor/EditorBlockFields.vue +140 -0
- package/frontend-vue/app/components/editor/EditorBlockInspector.vue +92 -0
- package/frontend-vue/app/components/editor/EditorBlockList.vue +32 -25
- package/frontend-vue/app/components/editor/EditorBlockPicker.vue +58 -0
- package/frontend-vue/app/components/editor/EditorBlockTools.vue +71 -0
- package/frontend-vue/app/components/editor/EditorCanvas.vue +105 -70
- package/frontend-vue/app/components/editor/EditorCompare.vue +211 -0
- package/frontend-vue/app/components/editor/EditorConditionFields.vue +56 -46
- package/frontend-vue/app/components/editor/EditorConditionInspector.vue +54 -0
- package/frontend-vue/app/components/editor/EditorEnvelope.vue +77 -0
- package/frontend-vue/app/components/editor/EditorIfBlock.vue +36 -86
- package/frontend-vue/app/components/editor/EditorInsertPoint.vue +64 -0
- package/frontend-vue/app/components/editor/EditorInspector.vue +80 -0
- package/frontend-vue/app/components/editor/EditorLeftPane.vue +103 -0
- package/frontend-vue/app/components/editor/EditorLifecycle.vue +67 -0
- package/frontend-vue/app/components/editor/EditorLoading.vue +23 -0
- package/frontend-vue/app/components/editor/EditorLocaleSwitch.vue +61 -0
- package/frontend-vue/app/components/editor/EditorMissingLocale.vue +72 -48
- package/frontend-vue/app/components/editor/EditorMissingPolicy.vue +42 -0
- package/frontend-vue/app/components/editor/EditorNotice.vue +127 -0
- package/frontend-vue/app/components/editor/EditorOutline.vue +65 -0
- package/frontend-vue/app/components/editor/EditorPalette.vue +95 -42
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +66 -0
- package/frontend-vue/app/components/editor/EditorPublishReview.vue +102 -0
- package/frontend-vue/app/components/editor/EditorSaveState.vue +88 -0
- package/frontend-vue/app/components/editor/EditorShortcutHint.vue +17 -0
- package/frontend-vue/app/components/editor/EditorSimChip.vue +51 -0
- package/frontend-vue/app/components/editor/EditorTemplatePane.vue +131 -0
- package/frontend-vue/app/components/editor/EditorTestDataPane.vue +98 -0
- package/frontend-vue/app/components/editor/EditorTitle.vue +82 -0
- package/frontend-vue/app/components/editor/EditorTokenField.vue +224 -0
- package/frontend-vue/app/components/editor/EditorToolbar.vue +34 -134
- package/frontend-vue/app/components/editor/EditorVariableForm.vue +100 -0
- package/frontend-vue/app/components/editor/EditorVariableRow.vue +87 -0
- package/frontend-vue/app/components/editor/EditorVariablesPane.vue +161 -0
- package/frontend-vue/app/components/editor/EditorViewSwitches.vue +76 -0
- package/frontend-vue/app/components/editor/EditorVisibility.vue +101 -0
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +62 -149
- package/frontend-vue/app/composables/useBlockContext.ts +16 -0
- package/frontend-vue/app/composables/useEditorActions.ts +381 -0
- package/frontend-vue/app/composables/useEditorAnnouncements.ts +51 -0
- package/frontend-vue/app/composables/useEditorContext.ts +30 -23
- package/frontend-vue/app/composables/useEditorSession.ts +299 -0
- package/frontend-vue/app/composables/useEditorShortcuts.ts +81 -0
- package/frontend-vue/app/composables/useLocalizedDefaults.ts +17 -0
- package/frontend-vue/app/composables/useMailingApi.ts +142 -27
- package/frontend-vue/app/composables/useOverviewSetup.ts +13 -0
- package/frontend-vue/app/composables/useReplayToast.ts +72 -0
- package/frontend-vue/app/composables/useTemplateCategories.ts +35 -0
- package/frontend-vue/app/composables/useTemplateDrawer.ts +196 -62
- package/frontend-vue/app/composables/useTemplateEditor.ts +318 -86
- package/frontend-vue/app/emails/EmailMailingTemplate.vue +16 -4
- package/frontend-vue/app/emails/components/MailingBlocks.vue +96 -38
- package/frontend-vue/app/emails/components/MailingTextLines.vue +20 -0
- package/frontend-vue/app/plugins/column-displays.ts +65 -0
- package/frontend-vue/app/plugins/{template-gallery-display.client.ts → template-gallery-display.ts} +1 -1
- package/frontend-vue/app/types/mailing.ts +193 -18
- package/frontend-vue/app/utils/api-error.ts +33 -0
- package/frontend-vue/app/utils/autocomplete.ts +120 -0
- package/frontend-vue/app/utils/blocks/components.ts +10 -10
- package/frontend-vue/app/utils/blocks/dnd.ts +7 -3
- package/frontend-vue/app/utils/blocks/factory.ts +199 -38
- package/frontend-vue/app/utils/blocks/fields.ts +39 -19
- package/frontend-vue/app/utils/blocks/index.ts +1 -0
- package/frontend-vue/app/utils/blocks/outline.ts +91 -0
- package/frontend-vue/app/utils/blocks/tree.ts +114 -2
- package/frontend-vue/app/utils/categories.ts +201 -15
- package/frontend-vue/app/utils/changes.ts +152 -0
- package/frontend-vue/app/utils/conditions.ts +25 -19
- package/frontend-vue/app/utils/editor-checks.ts +266 -0
- package/frontend-vue/app/utils/editor-route.ts +43 -4
- package/frontend-vue/app/utils/funnel.ts +74 -0
- package/frontend-vue/app/utils/gallery.ts +179 -1
- package/frontend-vue/app/utils/history.ts +66 -0
- package/frontend-vue/app/utils/lifecycle.ts +185 -0
- package/frontend-vue/app/utils/locales.ts +107 -2
- package/frontend-vue/app/utils/overview-setup.ts +83 -0
- package/frontend-vue/app/utils/pages.ts +5 -0
- package/frontend-vue/app/utils/paths.ts +51 -15
- package/frontend-vue/app/utils/period.ts +62 -22
- package/frontend-vue/app/utils/problems.ts +181 -0
- package/frontend-vue/app/utils/recipients.ts +49 -0
- package/frontend-vue/app/utils/rendering-title.ts +23 -0
- package/frontend-vue/app/utils/send-actions.ts +207 -0
- package/frontend-vue/app/utils/send-link.ts +28 -0
- package/frontend-vue/app/utils/send-snippet.ts +97 -0
- package/frontend-vue/app/utils/send-stats.ts +24 -0
- package/frontend-vue/app/utils/send-values.ts +156 -0
- package/frontend-vue/app/utils/shortcuts.ts +113 -0
- package/frontend-vue/app/utils/slug.ts +82 -0
- package/frontend-vue/app/utils/starters.ts +23 -0
- package/frontend-vue/app/utils/suppression.ts +112 -0
- package/frontend-vue/app/utils/system-variables.ts +165 -0
- package/frontend-vue/app/utils/template-attention.ts +104 -0
- package/frontend-vue/app/utils/timeline.ts +221 -19
- package/frontend-vue/app/utils/tokens.ts +115 -13
- package/frontend-vue/app/utils/variable-types.ts +23 -0
- package/frontend-vue/app/utils/variables.ts +208 -1
- package/frontend-vue/app/utils/version-history.ts +53 -0
- package/frontend-vue/app/utils/webhook-status.ts +170 -0
- package/frontend-vue/dms.frontend.ts +19 -11
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1173 -161
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1220 -208
- package/frontend-vue/package.json +2 -2
- package/frontend-vue/pnpm-lock.yaml +46 -161
- package/frontend-vue/tests/api-error.test.ts +27 -0
- package/frontend-vue/tests/autocomplete.test.ts +84 -0
- package/frontend-vue/tests/block-components.test.ts +1 -1
- package/frontend-vue/tests/block-fields.test.ts +12 -2
- package/frontend-vue/tests/blocks-factory.test.ts +46 -0
- package/frontend-vue/tests/blocks-outline.test.ts +102 -0
- package/frontend-vue/tests/blocks-tree.test.ts +28 -1
- package/frontend-vue/tests/categories-input.test.ts +139 -0
- package/frontend-vue/tests/categories.test.ts +148 -15
- package/frontend-vue/tests/changes.test.ts +136 -0
- package/frontend-vue/tests/column-displays.test.ts +73 -0
- package/frontend-vue/tests/conditions.test.ts +49 -19
- package/frontend-vue/tests/dnd.test.ts +10 -0
- package/frontend-vue/tests/editor-block-list.test.ts +24 -2
- package/frontend-vue/tests/editor-checks.test.ts +178 -0
- package/frontend-vue/tests/editor-route.test.ts +37 -9
- package/frontend-vue/tests/editor-session.test.ts +206 -0
- package/frontend-vue/tests/footer-block.test.ts +88 -0
- package/frontend-vue/tests/funnel-card.test.ts +116 -0
- package/frontend-vue/tests/funnel.test.ts +81 -0
- package/frontend-vue/tests/gallery.test.ts +124 -2
- package/frontend-vue/tests/helpers/nuxt-globals.ts +232 -0
- package/frontend-vue/tests/history.test.ts +48 -0
- package/frontend-vue/tests/i18n-editor.test.ts +38 -0
- package/frontend-vue/tests/i18n.test.ts +55 -0
- package/frontend-vue/tests/lifecycle.test.ts +145 -0
- package/frontend-vue/tests/locales.test.ts +60 -0
- package/frontend-vue/tests/localized-defaults.test.ts +54 -0
- package/frontend-vue/tests/new-template-modal.test.ts +210 -0
- package/frontend-vue/tests/overview-gate.test.ts +146 -0
- package/frontend-vue/tests/overview-setup.test.ts +78 -0
- package/frontend-vue/tests/paths.test.ts +23 -0
- package/frontend-vue/tests/period.test.ts +59 -10
- package/frontend-vue/tests/problems.test.ts +153 -0
- package/frontend-vue/tests/recipients.test.ts +47 -0
- package/frontend-vue/tests/rendering-title.test.ts +24 -0
- package/frontend-vue/tests/replay-toast.test.ts +67 -0
- package/frontend-vue/tests/retention-input.test.ts +129 -0
- package/frontend-vue/tests/send-actions.test.ts +128 -0
- package/frontend-vue/tests/send-modals.test.ts +415 -0
- package/frontend-vue/tests/send-snippet.test.ts +51 -0
- package/frontend-vue/tests/send-stats.test.ts +12 -0
- package/frontend-vue/tests/send-values.test.ts +119 -0
- package/frontend-vue/tests/shortcuts.test.ts +75 -0
- package/frontend-vue/tests/slug.test.ts +88 -0
- package/frontend-vue/tests/suppression.test.ts +159 -0
- package/frontend-vue/tests/system-variables.test.ts +133 -0
- package/frontend-vue/tests/template-attention.test.ts +123 -0
- package/frontend-vue/tests/template-drawer.test.ts +449 -0
- package/frontend-vue/tests/template-editor.test.ts +218 -9
- package/frontend-vue/tests/template-gallery-display.test.ts +32 -0
- package/frontend-vue/tests/template-gallery.test.ts +370 -0
- package/frontend-vue/tests/template-overlays.test.ts +125 -0
- package/frontend-vue/tests/timeline.test.ts +175 -20
- package/frontend-vue/tests/tokens.test.ts +79 -1
- package/frontend-vue/tests/variable-path.test.ts +22 -0
- package/frontend-vue/tests/variable-types.test.ts +20 -0
- package/frontend-vue/tests/variables.test.ts +90 -2
- package/frontend-vue/tests/version-history.test.ts +41 -0
- package/frontend-vue/tests/webhook-status.test.ts +108 -0
- package/package.json +11 -9
- package/src/automation/email-event-trigger.ts +3 -3
- package/src/automation/send-template-action.ts +7 -5
- package/src/config.ts +3 -1
- package/src/constants.ts +18 -2
- package/src/crons/attention.ts +21 -0
- package/src/crons/index.ts +2 -0
- package/src/crons/jobs.ts +35 -0
- package/src/crons/retention.ts +7 -22
- package/src/data/displays.ts +18 -0
- package/src/data/index.ts +1 -0
- package/src/data/locale-fallback.ts +26 -0
- package/src/data/read-only-routes.ts +14 -0
- package/src/data/sends-table.ts +204 -19
- package/src/data/suppressions-table.ts +127 -0
- package/src/data/templates-table.ts +295 -63
- package/src/data-types/categories-type.ts +4 -5
- package/src/data-types/category-type.ts +1 -1
- package/src/db/models/index.ts +3 -0
- package/src/db/models/sends.model.ts +102 -1
- package/src/db/models/settings.model.ts +2 -12
- package/src/db/models/suppressions.model.ts +53 -0
- package/src/db/models/template-versions.model.ts +23 -0
- package/src/db/models/templates.model.ts +17 -0
- package/src/db/models/webhook-status.model.ts +25 -0
- package/src/db/tables/index.ts +3 -0
- package/src/db/tables/send-events.table.ts +6 -2
- package/src/db/tables/sends.table.ts +37 -2
- package/src/db/tables/suppressions.table.ts +46 -0
- package/src/db/tables/template-versions.table.ts +44 -0
- package/src/db/tables/templates.table.ts +67 -5
- package/src/db/tables/webhook-status.table.ts +44 -0
- package/src/engine/changes.ts +198 -0
- package/src/engine/conditions.ts +1 -7
- package/src/engine/coverage.ts +7 -2
- package/src/engine/fallback-html.ts +80 -42
- package/src/engine/index.ts +3 -0
- package/src/engine/plain-text.ts +86 -0
- package/src/engine/resolve.ts +49 -15
- package/src/engine/system-variables.ts +124 -0
- package/src/engine/tokens.ts +13 -1
- package/src/hooks/tenant-export.ts +21 -2
- package/src/implementations/dms-mailing/index.ts +1 -1
- package/src/index.ts +29 -13
- package/src/pages/editor.ts +13 -2
- package/src/pages/index.ts +6 -3
- package/src/pages/module.ts +31 -2
- package/src/pages/overview.ts +187 -128
- package/src/pages/sends.ts +271 -112
- package/src/pages/settings/form.ts +144 -54
- package/src/pages/settings/page.ts +9 -17
- package/src/pages/suppressions.ts +157 -0
- package/src/pages/templates.ts +139 -42
- package/src/permissions.ts +6 -4
- package/src/quick-actions.ts +12 -5
- package/src/routes/events.ts +110 -33
- package/src/routes/index.ts +2 -0
- package/src/routes/metrics.ts +348 -98
- package/src/routes/period.ts +23 -6
- package/src/routes/sends.ts +413 -41
- package/src/routes/settings.ts +291 -26
- package/src/routes/suppressions.ts +87 -0
- package/src/routes/template-versions.ts +116 -0
- package/src/routes/templates.ts +334 -48
- package/src/services/attention.ts +283 -30
- package/src/services/catalog.ts +28 -0
- package/src/services/categories.ts +28 -27
- package/src/services/checked-send.ts +123 -0
- package/src/services/events.ts +174 -20
- package/src/services/metrics.ts +137 -28
- package/src/services/notify.ts +126 -44
- package/src/services/provider-banner.ts +165 -0
- package/src/services/render.ts +1 -0
- package/src/services/send-stat-items.ts +160 -0
- package/src/services/send-stats.ts +122 -0
- package/src/services/send.ts +193 -31
- package/src/services/settings.ts +52 -8
- package/src/services/starters.ts +383 -0
- package/src/services/suppressions.ts +116 -0
- package/src/services/template-attention.ts +82 -0
- package/src/services/template-stats.ts +97 -0
- package/src/services/templates.ts +400 -37
- package/src/services/webhook-status.ts +202 -0
- package/src/types/blocks.ts +6 -0
- package/src/types/index.ts +2 -0
- package/src/types/sends.ts +64 -2
- package/src/types/suppressions.ts +27 -0
- package/src/types/webhook.ts +29 -0
- package/src/validation/settings.schema.ts +173 -17
- package/src/validation/templates.schema.ts +53 -1
- package/dist/antelope-modules.d.ts +0 -32
- package/dist/antelope-modules.js +0 -36
- package/dist/antelope-modules.js.map +0 -1
- package/dist/test/antelope.test.d.ts +0 -2
- package/dist/test/antelope.test.js +0 -180
- package/dist/test/antelope.test.js.map +0 -1
- package/dist/test/helpers/http.d.ts +0 -4
- package/dist/test/helpers/http.js +0 -28
- package/dist/test/helpers/http.js.map +0 -1
- package/dist/test/helpers/owner.d.ts +0 -5
- package/dist/test/helpers/owner.js +0 -30
- package/dist/test/helpers/owner.js.map +0 -1
- package/dist/test/integration/events.test.d.ts +0 -1
- package/dist/test/integration/events.test.js +0 -143
- package/dist/test/integration/events.test.js.map +0 -1
- package/dist/test/integration/retention.test.d.ts +0 -1
- package/dist/test/integration/retention.test.js +0 -291
- package/dist/test/integration/retention.test.js.map +0 -1
- package/dist/test/integration/sends.test.d.ts +0 -1
- package/dist/test/integration/sends.test.js +0 -210
- package/dist/test/integration/sends.test.js.map +0 -1
- package/dist/test/integration/settings.test.d.ts +0 -1
- package/dist/test/integration/settings.test.js +0 -137
- package/dist/test/integration/settings.test.js.map +0 -1
- package/dist/test/integration/setup.test.d.ts +0 -1
- package/dist/test/integration/setup.test.js +0 -32
- package/dist/test/integration/setup.test.js.map +0 -1
- package/dist/test/integration/templates.test.d.ts +0 -1
- package/dist/test/integration/templates.test.js +0 -233
- package/dist/test/integration/templates.test.js.map +0 -1
- package/dist/test/integration/tenant-export.test.d.ts +0 -1
- package/dist/test/integration/tenant-export.test.js +0 -85
- package/dist/test/integration/tenant-export.test.js.map +0 -1
- package/dist/test/unit/attention.test.d.ts +0 -1
- package/dist/test/unit/attention.test.js +0 -109
- package/dist/test/unit/attention.test.js.map +0 -1
- package/dist/test/unit/automation.test.d.ts +0 -1
- package/dist/test/unit/automation.test.js +0 -104
- package/dist/test/unit/automation.test.js.map +0 -1
- package/dist/test/unit/categories.test.d.ts +0 -1
- package/dist/test/unit/categories.test.js +0 -38
- package/dist/test/unit/categories.test.js.map +0 -1
- package/dist/test/unit/engine/conditions.test.d.ts +0 -1
- package/dist/test/unit/engine/conditions.test.js +0 -23
- package/dist/test/unit/engine/conditions.test.js.map +0 -1
- package/dist/test/unit/engine/coverage.test.d.ts +0 -1
- package/dist/test/unit/engine/coverage.test.js +0 -106
- package/dist/test/unit/engine/coverage.test.js.map +0 -1
- package/dist/test/unit/engine/fallback-html.test.d.ts +0 -1
- package/dist/test/unit/engine/fallback-html.test.js +0 -78
- package/dist/test/unit/engine/fallback-html.test.js.map +0 -1
- package/dist/test/unit/engine/paths.test.d.ts +0 -1
- package/dist/test/unit/engine/paths.test.js +0 -33
- package/dist/test/unit/engine/paths.test.js.map +0 -1
- package/dist/test/unit/engine/resolve.test.d.ts +0 -1
- package/dist/test/unit/engine/resolve.test.js +0 -157
- package/dist/test/unit/engine/resolve.test.js.map +0 -1
- package/dist/test/unit/engine/tokens.test.d.ts +0 -1
- package/dist/test/unit/engine/tokens.test.js +0 -26
- package/dist/test/unit/engine/tokens.test.js.map +0 -1
- package/dist/test/unit/events.test.d.ts +0 -1
- package/dist/test/unit/events.test.js +0 -13
- package/dist/test/unit/events.test.js.map +0 -1
- package/dist/test/unit/metrics.test.d.ts +0 -1
- package/dist/test/unit/metrics.test.js +0 -108
- package/dist/test/unit/metrics.test.js.map +0 -1
- package/dist/test/unit/notify.test.d.ts +0 -1
- package/dist/test/unit/notify.test.js +0 -49
- package/dist/test/unit/notify.test.js.map +0 -1
- package/dist/test/unit/period.test.d.ts +0 -1
- package/dist/test/unit/period.test.js +0 -61
- package/dist/test/unit/period.test.js.map +0 -1
- package/dist/test/unit/permissions.test.d.ts +0 -1
- package/dist/test/unit/permissions.test.js +0 -27
- package/dist/test/unit/permissions.test.js.map +0 -1
- package/dist/test/unit/provider.test.d.ts +0 -1
- package/dist/test/unit/provider.test.js +0 -47
- package/dist/test/unit/provider.test.js.map +0 -1
- package/dist/test/unit/realtime.test.d.ts +0 -1
- package/dist/test/unit/realtime.test.js +0 -13
- package/dist/test/unit/realtime.test.js.map +0 -1
- package/dist/test/unit/render.test.d.ts +0 -1
- package/dist/test/unit/render.test.js +0 -23
- package/dist/test/unit/render.test.js.map +0 -1
- package/dist/test/unit/retention.test.d.ts +0 -1
- package/dist/test/unit/retention.test.js +0 -66
- package/dist/test/unit/retention.test.js.map +0 -1
- package/dist/test/unit/send-batch.test.d.ts +0 -1
- package/dist/test/unit/send-batch.test.js +0 -46
- package/dist/test/unit/send-batch.test.js.map +0 -1
- package/dist/test/unit/send.test.d.ts +0 -1
- package/dist/test/unit/send.test.js +0 -120
- package/dist/test/unit/send.test.js.map +0 -1
- package/dist/test/unit/smoke.test.d.ts +0 -1
- package/dist/test/unit/smoke.test.js +0 -10
- package/dist/test/unit/smoke.test.js.map +0 -1
- package/dist/test/unit/status.test.d.ts +0 -1
- package/dist/test/unit/status.test.js +0 -16
- package/dist/test/unit/status.test.js.map +0 -1
- package/dist/test/unit/templates.test.d.ts +0 -1
- package/dist/test/unit/templates.test.js +0 -48
- package/dist/test/unit/templates.test.js.map +0 -1
- package/dist/test/unit/tenant-export.test.d.ts +0 -1
- package/dist/test/unit/tenant-export.test.js +0 -59
- package/dist/test/unit/tenant-export.test.js.map +0 -1
- package/dist/test/unit/webhook-secret.test.d.ts +0 -1
- package/dist/test/unit/webhook-secret.test.js +0 -43
- package/dist/test/unit/webhook-secret.test.js.map +0 -1
- package/frontend-vue/app/components/AttentionCard.vue +0 -78
- package/frontend-vue/app/components/HtmlPreviewModal.vue +0 -20
- package/frontend-vue/app/components/ProviderChip.vue +0 -39
- package/frontend-vue/app/components/editor/EditorBlockSettings.vue +0 -186
- package/frontend-vue/app/components/editor/EditorDataPanel.vue +0 -130
- package/frontend-vue/app/components/editor/EditorDraftBar.vue +0 -35
- package/frontend-vue/app/components/editor/EditorRail.vue +0 -58
- package/frontend-vue/app/components/editor/EditorSubjectSettings.vue +0 -37
- package/frontend-vue/app/components/editor/EditorTemplateSettings.vue +0 -87
- package/frontend-vue/app/components/editor/EditorTestSendModal.vue +0 -66
- package/frontend-vue/app/components/editor/EditorVariables.vue +0 -163
- package/frontend-vue/app/composables/useMailingPeriodData.ts +0 -39
- package/src/antelope-modules.ts +0 -48
- package/src/test/antelope.test.ts +0 -190
- package/src/test/helpers/http.ts +0 -23
- package/src/test/helpers/owner.ts +0 -37
- package/src/test/integration/events.test.ts +0 -159
- package/src/test/integration/retention.test.ts +0 -363
- package/src/test/integration/sends.test.ts +0 -233
- package/src/test/integration/settings.test.ts +0 -128
- package/src/test/integration/setup.test.ts +0 -35
- package/src/test/integration/templates.test.ts +0 -272
- package/src/test/integration/tenant-export.test.ts +0 -125
- package/src/test/unit/attention.test.ts +0 -129
- package/src/test/unit/automation.test.ts +0 -130
- package/src/test/unit/categories.test.ts +0 -51
- package/src/test/unit/engine/conditions.test.ts +0 -35
- package/src/test/unit/engine/coverage.test.ts +0 -118
- package/src/test/unit/engine/fallback-html.test.ts +0 -86
- package/src/test/unit/engine/paths.test.ts +0 -39
- package/src/test/unit/engine/resolve.test.ts +0 -176
- package/src/test/unit/engine/tokens.test.ts +0 -26
- package/src/test/unit/events.test.ts +0 -19
- package/src/test/unit/metrics.test.ts +0 -170
- package/src/test/unit/notify.test.ts +0 -71
- package/src/test/unit/period.test.ts +0 -91
- package/src/test/unit/permissions.test.ts +0 -30
- package/src/test/unit/provider.test.ts +0 -57
- package/src/test/unit/realtime.test.ts +0 -11
- package/src/test/unit/render.test.ts +0 -23
- package/src/test/unit/retention.test.ts +0 -91
- package/src/test/unit/send-batch.test.ts +0 -72
- package/src/test/unit/send.test.ts +0 -161
- package/src/test/unit/smoke.test.ts +0 -8
- package/src/test/unit/status.test.ts +0 -14
- package/src/test/unit/templates.test.ts +0 -52
- package/src/test/unit/tenant-export.test.ts +0 -88
- package/src/test/unit/webhook-secret.test.ts +0 -51
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Block,
|
|
3
|
+
IfBlock,
|
|
4
|
+
LocaleContent,
|
|
5
|
+
TemplateContent,
|
|
6
|
+
VariableDefinition,
|
|
7
|
+
} from '../types/mailing'
|
|
8
|
+
import { flattenBlocks } from './blocks/tree'
|
|
9
|
+
import { evaluateCondition } from './conditions'
|
|
10
|
+
import { pickLocale } from './locales'
|
|
11
|
+
import { isPresent, readPath } from './paths'
|
|
12
|
+
import { splitTokens } from './tokens'
|
|
13
|
+
|
|
14
|
+
export type EditorCheckState = 'ok' | 'warn' | 'info'
|
|
15
|
+
|
|
16
|
+
/** A check before the i18n pass: its key and the values it interpolates. */
|
|
17
|
+
export interface EditorCheck {
|
|
18
|
+
id: string
|
|
19
|
+
state: EditorCheckState
|
|
20
|
+
key: string
|
|
21
|
+
params: Record<string, string | number>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the checks read: declarations, used paths, test data, blocks. */
|
|
25
|
+
export interface CheckInput {
|
|
26
|
+
variables: VariableDefinition[]
|
|
27
|
+
detected: string[]
|
|
28
|
+
data: Record<string, unknown>
|
|
29
|
+
blocks: Block[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CHECK_KEY_PREFIX = 'dms_mailing.editor.checks'
|
|
33
|
+
const WARNING_STATES: EditorCheckState[] = ['warn']
|
|
34
|
+
|
|
35
|
+
const check = (
|
|
36
|
+
id: string,
|
|
37
|
+
state: EditorCheckState,
|
|
38
|
+
key: string,
|
|
39
|
+
params: Record<string, string | number> = {},
|
|
40
|
+
): EditorCheck => ({ id, state, key: `${CHECK_KEY_PREFIX}.${key}`, params })
|
|
41
|
+
|
|
42
|
+
/** Paths the content uses that no declaration covers. */
|
|
43
|
+
export function undeclaredPaths(
|
|
44
|
+
detected: string[],
|
|
45
|
+
variables: VariableDefinition[],
|
|
46
|
+
): string[] {
|
|
47
|
+
const declared = new Set(variables.map((variable) => variable.path))
|
|
48
|
+
return detected.filter((path) => !declared.has(path))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Required declarations the data set does not provide. */
|
|
52
|
+
export function missingRequired(
|
|
53
|
+
variables: VariableDefinition[],
|
|
54
|
+
data: Record<string, unknown>,
|
|
55
|
+
): string[] {
|
|
56
|
+
return variables
|
|
57
|
+
.filter((variable) => variable.required)
|
|
58
|
+
.map((variable) => variable.path)
|
|
59
|
+
.filter((path) => !isPresent(readPath(data, path)))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the content checks read: the draft and the workspace locales. */
|
|
63
|
+
export interface ContentCheckInput {
|
|
64
|
+
content: TemplateContent
|
|
65
|
+
/** The workspace locales, each of which recipients may ask for. */
|
|
66
|
+
locales: string[]
|
|
67
|
+
fallback: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const FOOTER_TYPE = 'footer'
|
|
71
|
+
|
|
72
|
+
const footerCount = (locale: LocaleContent): number =>
|
|
73
|
+
flattenBlocks(locale.blocks).filter((block) => block.type === FOOTER_TYPE)
|
|
74
|
+
.length
|
|
75
|
+
|
|
76
|
+
/** A rule a locale's body breaks: the check key it reports under. */
|
|
77
|
+
interface BodyRule {
|
|
78
|
+
key: string
|
|
79
|
+
isBroken: (locale: LocaleContent) => boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const BODY_RULES: BodyRule[] = [
|
|
83
|
+
{
|
|
84
|
+
key: 'no_blocks',
|
|
85
|
+
isBroken: (locale) =>
|
|
86
|
+
!locale.blocks.some((block) => block.type !== FOOTER_TYPE),
|
|
87
|
+
},
|
|
88
|
+
{ key: 'no_footer', isBroken: (locale) => footerCount(locale) === 0 },
|
|
89
|
+
{ key: 'many_footers', isBroken: (locale) => footerCount(locale) > 1 },
|
|
90
|
+
{
|
|
91
|
+
key: 'footer_not_last',
|
|
92
|
+
isBroken: (locale) =>
|
|
93
|
+
footerCount(locale) > 0 && locale.blocks.at(-1)?.type !== FOOTER_TYPE,
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const localeEntries = (content: TemplateContent): [string, LocaleContent][] =>
|
|
98
|
+
Object.entries(content.locales)
|
|
99
|
+
|
|
100
|
+
/** Locales a publish is refused for: a send would go out without a subject. */
|
|
101
|
+
export function publishBlockers(content: TemplateContent): EditorCheck[] {
|
|
102
|
+
return localeEntries(content)
|
|
103
|
+
.filter(([, locale]) => !locale.subject.trim())
|
|
104
|
+
.map(([code]) =>
|
|
105
|
+
check(`subject-${code}`, 'warn', 'empty_subject', {
|
|
106
|
+
locale: code.toUpperCase(),
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function bodyChecks(content: TemplateContent): EditorCheck[] {
|
|
112
|
+
return localeEntries(content).flatMap(([code, locale]) =>
|
|
113
|
+
BODY_RULES.filter((rule) => rule.isBroken(locale)).map((rule) =>
|
|
114
|
+
check(`${rule.key}-${code}`, 'warn', rule.key, {
|
|
115
|
+
locale: code.toUpperCase(),
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every text a locale shows: its envelope and each block's string fields. */
|
|
122
|
+
function localeTexts(locale: LocaleContent): string[] {
|
|
123
|
+
const fields = flattenBlocks(locale.blocks).flatMap((block) =>
|
|
124
|
+
Object.values(block as unknown as Record<string, unknown>),
|
|
125
|
+
)
|
|
126
|
+
return [
|
|
127
|
+
locale.subject,
|
|
128
|
+
locale.preheader,
|
|
129
|
+
...fields.filter((value): value is string => typeof value === 'string'),
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function tokensOf(locale: LocaleContent, kind: 'token' | 'invalid'): string[] {
|
|
134
|
+
return localeTexts(locale)
|
|
135
|
+
.flatMap(splitTokens)
|
|
136
|
+
.filter((part) => part.kind === kind)
|
|
137
|
+
.map((part) => part.value)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `{{…}}` look-alikes the engine leaves as typed: customers would see them. */
|
|
141
|
+
function malformedTokenChecks(content: TemplateContent): EditorCheck[] {
|
|
142
|
+
return localeEntries(content).flatMap(([code, locale]) =>
|
|
143
|
+
[...new Set(tokensOf(locale, 'invalid'))].map((token) =>
|
|
144
|
+
check(`token-${code}-${token}`, 'warn', 'invalid_token', {
|
|
145
|
+
locale: code.toUpperCase(),
|
|
146
|
+
token,
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const isStructured = (value: unknown): boolean =>
|
|
153
|
+
value !== null && typeof value === 'object' && !(value instanceof Date)
|
|
154
|
+
|
|
155
|
+
/** Tokens whose test value is an object or a list: they print as raw data. */
|
|
156
|
+
function structuredTokenChecks(
|
|
157
|
+
content: TemplateContent,
|
|
158
|
+
data: Record<string, unknown>,
|
|
159
|
+
): EditorCheck[] {
|
|
160
|
+
const paths = localeEntries(content).flatMap(([, locale]) =>
|
|
161
|
+
tokensOf(locale, 'token'),
|
|
162
|
+
)
|
|
163
|
+
return [...new Set(paths)]
|
|
164
|
+
.filter((path) => isStructured(readPath(data, path)))
|
|
165
|
+
.map((path) =>
|
|
166
|
+
check(`structured-${path}`, 'warn', 'token_not_text', { path }),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What the publish review warns about the content's `{{…}}` tokens. */
|
|
171
|
+
export function tokenChecks(
|
|
172
|
+
content: TemplateContent,
|
|
173
|
+
data: Record<string, unknown>,
|
|
174
|
+
): EditorCheck[] {
|
|
175
|
+
return [
|
|
176
|
+
...malformedTokenChecks(content),
|
|
177
|
+
...structuredTokenChecks(content, data),
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function missingLocaleChecks(input: ContentCheckInput): EditorCheck[] {
|
|
182
|
+
const present = Object.keys(input.content.locales)
|
|
183
|
+
if (!present.length) return []
|
|
184
|
+
return input.locales
|
|
185
|
+
.filter((code) => !input.content.locales[code])
|
|
186
|
+
.map((code) =>
|
|
187
|
+
check(`locale-${code}`, 'warn', 'missing_locale', {
|
|
188
|
+
locale: code.toUpperCase(),
|
|
189
|
+
fallback: pickLocale(present, code, input.fallback).toUpperCase(),
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** What the publish review warns about the draft's content itself. */
|
|
195
|
+
export function contentChecks(input: ContentCheckInput): EditorCheck[] {
|
|
196
|
+
return [...bodyChecks(input.content), ...missingLocaleChecks(input)]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The failed checks the publish review warns about. */
|
|
200
|
+
export function publishChecks(input: CheckInput): EditorCheck[] {
|
|
201
|
+
return [
|
|
202
|
+
...undeclaredPaths(input.detected, input.variables).map((path) =>
|
|
203
|
+
check(`undeclared-${path}`, 'warn', 'undeclared', { path }),
|
|
204
|
+
),
|
|
205
|
+
...missingRequired(input.variables, input.data).map((path) =>
|
|
206
|
+
check(`required-${path}`, 'warn', 'required_missing', { path }),
|
|
207
|
+
),
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function requiredCheck(input: CheckInput): EditorCheck[] {
|
|
212
|
+
const required = input.variables.filter((variable) => variable.required)
|
|
213
|
+
if (!required.length) return []
|
|
214
|
+
const missing = missingRequired(input.variables, input.data)
|
|
215
|
+
if (!missing.length)
|
|
216
|
+
return [
|
|
217
|
+
check('required', 'ok', 'required_covered', { count: required.length }),
|
|
218
|
+
]
|
|
219
|
+
return missing
|
|
220
|
+
.filter((path) => !input.detected.includes(path))
|
|
221
|
+
.map((path) =>
|
|
222
|
+
check(`required-${path}`, 'warn', 'required_missing', { path }),
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function coverageChecks(input: CheckInput): EditorCheck[] {
|
|
227
|
+
const undeclared = new Set(undeclaredPaths(input.detected, input.variables))
|
|
228
|
+
return input.detected
|
|
229
|
+
.filter((path) => !isPresent(readPath(input.data, path)))
|
|
230
|
+
.map((path) =>
|
|
231
|
+
check(
|
|
232
|
+
`missing-${path}`,
|
|
233
|
+
'warn',
|
|
234
|
+
undeclared.has(path) ? 'missing_undeclared' : 'missing',
|
|
235
|
+
{ path },
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function conditionChecks(input: CheckInput): EditorCheck[] {
|
|
241
|
+
return flattenBlocks(input.blocks)
|
|
242
|
+
.filter((block): block is IfBlock => block.type === 'if')
|
|
243
|
+
.map((block) => {
|
|
244
|
+
const result = evaluateCondition(block.condition, input.data)
|
|
245
|
+
return check(
|
|
246
|
+
`condition-${block.id}`,
|
|
247
|
+
'info',
|
|
248
|
+
result ? 'condition_true' : 'condition_false',
|
|
249
|
+
{ path: block.condition.path },
|
|
250
|
+
)
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The Test data tab's checks: coverage, then how each condition evaluates. */
|
|
255
|
+
export function testDataChecks(input: CheckInput): EditorCheck[] {
|
|
256
|
+
return [
|
|
257
|
+
...requiredCheck(input),
|
|
258
|
+
...coverageChecks(input),
|
|
259
|
+
...conditionChecks(input),
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** How many checks need a look: the Test data tab's badge. */
|
|
264
|
+
export function openIssueCount(checks: EditorCheck[]): number {
|
|
265
|
+
return checks.filter((entry) => WARNING_STATES.includes(entry.state)).length
|
|
266
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { TEMPLATES_PATH } from './pages'
|
|
2
|
+
|
|
3
|
+
const PATH_SEPARATOR = '/'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The DMS state key the breadcrumb reads the record label from (the same one
|
|
7
|
+
* the core's `usePageRecordLabel` writes).
|
|
8
|
+
*/
|
|
9
|
+
export const RECORD_LABEL_STATE_KEY = 'dms-page-record-label'
|
|
2
10
|
|
|
3
11
|
export interface EditorRouteQuery extends Record<string, string | undefined> {
|
|
4
|
-
id: string
|
|
5
12
|
locale?: string
|
|
6
13
|
}
|
|
7
14
|
|
|
@@ -10,9 +17,41 @@ export interface EditorRoute {
|
|
|
10
17
|
query: EditorRouteQuery
|
|
11
18
|
}
|
|
12
19
|
|
|
20
|
+
/** Where the editor of `templateId` lives, optionally on one locale. */
|
|
13
21
|
export function editorRoute(templateId: string, locale?: string): EditorRoute {
|
|
14
22
|
return {
|
|
15
|
-
path:
|
|
16
|
-
query: locale ? {
|
|
23
|
+
path: `${TEMPLATES_PATH}${PATH_SEPARATOR}${encodeURIComponent(templateId)}`,
|
|
24
|
+
query: locale ? { locale } : {},
|
|
17
25
|
}
|
|
18
26
|
}
|
|
27
|
+
|
|
28
|
+
export interface EditorRouteParams {
|
|
29
|
+
id?: string | number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The template id of the editor page: the `:id` route param the DMS hands the
|
|
34
|
+
* component, else the last segment of the current path.
|
|
35
|
+
*/
|
|
36
|
+
export function templateIdFrom(
|
|
37
|
+
params: EditorRouteParams | undefined,
|
|
38
|
+
path: string,
|
|
39
|
+
): string {
|
|
40
|
+
if (params?.id !== undefined && params.id !== '') return String(params.id)
|
|
41
|
+
const last = path.split(PATH_SEPARATOR).filter(Boolean).at(-1) ?? ''
|
|
42
|
+
return decodeURIComponent(last)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The record label the breadcrumb ends with: the template's name. */
|
|
46
|
+
export interface RecordLabel {
|
|
47
|
+
path: string
|
|
48
|
+
label: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Builds the breadcrumb label for the page at `path`; no name clears it. */
|
|
52
|
+
export function recordLabelFor(
|
|
53
|
+
path: string,
|
|
54
|
+
name: string | undefined,
|
|
55
|
+
): RecordLabel | null {
|
|
56
|
+
return name ? { path, label: name } : null
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { FunnelKind, FunnelStep } from '../types/mailing'
|
|
2
|
+
|
|
3
|
+
/** The step that leaks out of the funnel rather than going down it. */
|
|
4
|
+
export const FUNNEL_LEAK_STEP = 'unsubscribed'
|
|
5
|
+
|
|
6
|
+
export const FUNNEL_KINDS: FunnelKind[] = ['all', 'transactional', 'marketing']
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A step as `GET /metrics/funnel` answers it: `rate` is `null` on an empty
|
|
10
|
+
* base, `isMeasured` false when nothing reports the events it counts.
|
|
11
|
+
*/
|
|
12
|
+
export interface FunnelStepReading extends Omit<FunnelStep, 'rate'> {
|
|
13
|
+
rate: number | null
|
|
14
|
+
isMeasured?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FunnelDrop {
|
|
18
|
+
from: FunnelStepReading
|
|
19
|
+
to: FunnelStepReading
|
|
20
|
+
/** Share of the sends lost between the two steps, in points. */
|
|
21
|
+
points: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the card's footer says about the funnel. */
|
|
25
|
+
export type FunnelTakeaway = 'empty' | 'untracked' | 'no_loss' | 'drop'
|
|
26
|
+
|
|
27
|
+
const RATE_DECIMALS = 1
|
|
28
|
+
const PERCENT = 100
|
|
29
|
+
const ABSENT_RATE = '—'
|
|
30
|
+
|
|
31
|
+
/** `false` only when the backend says nothing reports the step's events. */
|
|
32
|
+
export const isMeasured = (step: FunnelStepReading): boolean =>
|
|
33
|
+
step.isMeasured !== false
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The biggest fall between two consecutive steps of the funnel (the leak and
|
|
37
|
+
* the unmeasured steps excluded), measured on the sends lost. `null` when
|
|
38
|
+
* nothing was sent or no step loses anything.
|
|
39
|
+
*/
|
|
40
|
+
export function biggestDrop(steps: FunnelStepReading[]): FunnelDrop | null {
|
|
41
|
+
const path = steps.filter(
|
|
42
|
+
(step) => step.id !== FUNNEL_LEAK_STEP && isMeasured(step),
|
|
43
|
+
)
|
|
44
|
+
const total = path[0]?.count ?? 0
|
|
45
|
+
if (!total) return null
|
|
46
|
+
const drops = path.slice(1).map((step, index) => {
|
|
47
|
+
const from = path[index] as FunnelStepReading
|
|
48
|
+
return {
|
|
49
|
+
from,
|
|
50
|
+
to: step,
|
|
51
|
+
points: ((from.count - step.count) / total) * PERCENT,
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
return drops.reduce<FunnelDrop | null>(
|
|
55
|
+
(best, drop) => (drop.points > (best?.points ?? 0) ? drop : best),
|
|
56
|
+
null,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Which sentence the footer writes: there may be no drop for three reasons. */
|
|
61
|
+
export function funnelTakeaway(steps: FunnelStepReading[]): FunnelTakeaway {
|
|
62
|
+
if (!steps[0]?.count) return 'empty'
|
|
63
|
+
if (biggestDrop(steps)) return 'drop'
|
|
64
|
+
return steps.some((step) => !isMeasured(step)) ? 'untracked' : 'no_loss'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A funnel rate as the card prints it: one decimal, none when whole. */
|
|
68
|
+
export function formatRate(rate: number | null, locale: string): string {
|
|
69
|
+
if (rate === null) return ABSENT_RATE
|
|
70
|
+
return new Intl.NumberFormat(locale, {
|
|
71
|
+
style: 'percent',
|
|
72
|
+
maximumFractionDigits: RATE_DECIMALS,
|
|
73
|
+
}).format(rate / PERCENT)
|
|
74
|
+
}
|
|
@@ -6,7 +6,7 @@ export interface CategoryGroup<T> {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export interface CategorizedRow {
|
|
9
|
-
category?: string
|
|
9
|
+
category?: string | null
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const UNCATEGORISED_GROUP_ID = 'uncategorised'
|
|
@@ -39,3 +39,181 @@ export function groupByCategory<T extends CategorizedRow>(
|
|
|
39
39
|
}
|
|
40
40
|
return [...known, uncategorised].filter((group) => group.items.length > 0)
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/** The slice of the DMS `TableViewDisplayPagination` the gallery reads. */
|
|
44
|
+
export interface GalleryPagination {
|
|
45
|
+
pageIndex: number
|
|
46
|
+
pageSize: number
|
|
47
|
+
total: number
|
|
48
|
+
setPage: (index: number) => void
|
|
49
|
+
mode?: string
|
|
50
|
+
hasMore?: boolean
|
|
51
|
+
loadMore?: () => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The slice of the DMS `TableViewDisplayActions` the gallery reads. */
|
|
55
|
+
export interface GalleryActions<T> {
|
|
56
|
+
open: (item: T) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The slice of the TanStack table the gallery reaches for its reset. */
|
|
60
|
+
export interface GalleryTable {
|
|
61
|
+
setGlobalFilter: (value: string) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The slice of the DMS `TableViewDisplayContext` the gallery display reads,
|
|
66
|
+
* declared structurally: the layer does not import from the dms-ui layer.
|
|
67
|
+
*/
|
|
68
|
+
export interface GalleryContext<T> {
|
|
69
|
+
items: T[]
|
|
70
|
+
loading: boolean
|
|
71
|
+
pagination: GalleryPagination
|
|
72
|
+
actions?: GalleryActions<T>
|
|
73
|
+
/** Active filters, search and sort of the table, without paging. */
|
|
74
|
+
query?: Record<string, unknown>
|
|
75
|
+
refresh: () => Promise<void> | void
|
|
76
|
+
table?: GalleryTable
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SEARCH_KEY = 'search'
|
|
80
|
+
const FILTER_PREFIX = 'filter_'
|
|
81
|
+
/** The "Needs attention" tab filters on this column. */
|
|
82
|
+
const ATTENTION_FILTER_KEY = `${FILTER_PREFIX}needsAttention`
|
|
83
|
+
/** The tabs filter on these columns: a tab is not a user filter. */
|
|
84
|
+
const TAB_FILTER_KEYS = new Set([
|
|
85
|
+
`${FILTER_PREFIX}status`,
|
|
86
|
+
ATTENTION_FILTER_KEY,
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
/** The search typed in the table, trimmed; empty without one. */
|
|
90
|
+
export function searchTermOf(
|
|
91
|
+
query: Record<string, unknown> | undefined,
|
|
92
|
+
): string {
|
|
93
|
+
const value = query?.[SEARCH_KEY]
|
|
94
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The filters the user set (quick filters, filter rows), not the tab. */
|
|
98
|
+
export function userFilterKeys(
|
|
99
|
+
query: Record<string, unknown> | undefined,
|
|
100
|
+
): string[] {
|
|
101
|
+
return Object.keys(query ?? {}).filter(
|
|
102
|
+
(key) => key.startsWith(FILTER_PREFIX) && !TAB_FILTER_KEYS.has(key),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether the table shows its "Needs attention" tab. */
|
|
107
|
+
export function isAttentionView(
|
|
108
|
+
query: Record<string, unknown> | undefined,
|
|
109
|
+
): boolean {
|
|
110
|
+
return ATTENTION_FILTER_KEY in (query ?? {})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether a search or a filter narrows what the gallery lists. */
|
|
114
|
+
export function isNarrowed(
|
|
115
|
+
query: Record<string, unknown> | undefined,
|
|
116
|
+
): boolean {
|
|
117
|
+
return Boolean(searchTermOf(query)) || userFilterKeys(query).length > 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether `row` holds `search`, whatever its case, in one of `fields`. */
|
|
121
|
+
export function matchesSearch(
|
|
122
|
+
row: object,
|
|
123
|
+
search: string,
|
|
124
|
+
fields: string[],
|
|
125
|
+
): boolean {
|
|
126
|
+
const term = search.trim().toLowerCase()
|
|
127
|
+
const cellOf = (field: string) =>
|
|
128
|
+
String((row as Record<string, unknown>)[field] ?? '').toLowerCase()
|
|
129
|
+
return !term || fields.some((field) => cellOf(field).includes(term))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SORT_KEY = 'sortKey'
|
|
133
|
+
|
|
134
|
+
/** The table's sort column; absent without one. */
|
|
135
|
+
export function sortKeyOf(
|
|
136
|
+
query: Record<string, unknown> | undefined,
|
|
137
|
+
): string | undefined {
|
|
138
|
+
const key = query?.[SORT_KEY]
|
|
139
|
+
return typeof key === 'string' && key ? key : undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const MS_PER_HOUR = 3_600_000
|
|
143
|
+
const MS_PER_MINUTE = 60_000
|
|
144
|
+
const HOURS_PER_DAY = 24
|
|
145
|
+
|
|
146
|
+
/** Words a sentence puts around an absolute date ("le 27 sept." in French). */
|
|
147
|
+
export type OnDate = (date: string) => string
|
|
148
|
+
|
|
149
|
+
const bareDate: OnDate = (date) => date
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A date as a card reads it: "2 hr. ago" / "5 min. ago" within a day, "Sep 27"
|
|
153
|
+
* after. `onDate` dresses the absolute one only, so a sentence written around
|
|
154
|
+
* it ("Edited {date}") reads right in both cases. Empty for an unreadable date.
|
|
155
|
+
*/
|
|
156
|
+
export function formatCardDate(
|
|
157
|
+
date: string | null | undefined,
|
|
158
|
+
now: Date,
|
|
159
|
+
locale: string,
|
|
160
|
+
onDate: OnDate = bareDate,
|
|
161
|
+
): string {
|
|
162
|
+
const time = date ? new Date(date).getTime() : Number.NaN
|
|
163
|
+
if (Number.isNaN(time)) return ''
|
|
164
|
+
const elapsed = Math.max(0, now.getTime() - time)
|
|
165
|
+
const relative = new Intl.RelativeTimeFormat(locale, {
|
|
166
|
+
numeric: 'auto',
|
|
167
|
+
style: 'short',
|
|
168
|
+
})
|
|
169
|
+
if (elapsed < MS_PER_HOUR) {
|
|
170
|
+
return relative.format(-Math.round(elapsed / MS_PER_MINUTE), 'minute')
|
|
171
|
+
}
|
|
172
|
+
if (elapsed < HOURS_PER_DAY * MS_PER_HOUR) {
|
|
173
|
+
return relative.format(-Math.round(elapsed / MS_PER_HOUR), 'hour')
|
|
174
|
+
}
|
|
175
|
+
return onDate(
|
|
176
|
+
new Intl.DateTimeFormat(locale, {
|
|
177
|
+
month: 'short',
|
|
178
|
+
day: '2-digit',
|
|
179
|
+
}).format(new Date(time)),
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* How long ago `date` was, the way "live since…" reads it: a duration within a
|
|
185
|
+
* day ("1 hr", "5 min"; "1 h" in French), the date after ("Sep 27"). `null`
|
|
186
|
+
* for an unreadable date.
|
|
187
|
+
*/
|
|
188
|
+
export interface CardSince {
|
|
189
|
+
kind: 'duration' | 'date'
|
|
190
|
+
text: string
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatCardSince(
|
|
194
|
+
date: string | null | undefined,
|
|
195
|
+
now: Date,
|
|
196
|
+
locale: string,
|
|
197
|
+
): CardSince | null {
|
|
198
|
+
const time = date ? new Date(date).getTime() : Number.NaN
|
|
199
|
+
if (Number.isNaN(time)) return null
|
|
200
|
+
const elapsed = Math.max(0, now.getTime() - time)
|
|
201
|
+
if (elapsed >= HOURS_PER_DAY * MS_PER_HOUR)
|
|
202
|
+
return { kind: 'date', text: formatCardDate(date, now, locale) }
|
|
203
|
+
const isHours = elapsed >= MS_PER_HOUR
|
|
204
|
+
const amount = Math.max(
|
|
205
|
+
1,
|
|
206
|
+
Math.round(elapsed / (isHours ? MS_PER_HOUR : MS_PER_MINUTE)),
|
|
207
|
+
)
|
|
208
|
+
const text = new Intl.NumberFormat(locale, {
|
|
209
|
+
style: 'unit',
|
|
210
|
+
unit: isHours ? 'hour' : 'minute',
|
|
211
|
+
unitDisplay: 'short',
|
|
212
|
+
}).format(amount)
|
|
213
|
+
return { kind: 'duration', text }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The first word of a person's name ("Hugo Bernard" → "Hugo"). */
|
|
217
|
+
export function firstName(fullName: string | null | undefined): string {
|
|
218
|
+
return (fullName ?? '').trim().split(/\s+/)[0] ?? ''
|
|
219
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Undo / redo over serialized content snapshots. */
|
|
2
|
+
export interface EditorHistory {
|
|
3
|
+
past: string[]
|
|
4
|
+
future: string[]
|
|
5
|
+
present: string
|
|
6
|
+
lastChangeAt: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Snapshots kept behind the present one. */
|
|
10
|
+
export const HISTORY_LIMIT = 50
|
|
11
|
+
|
|
12
|
+
/** Changes closer than this merge into one step (a word typed, a drag). */
|
|
13
|
+
export const HISTORY_COALESCE_MS = 700
|
|
14
|
+
|
|
15
|
+
export function createHistory(snapshot: string): EditorHistory {
|
|
16
|
+
return { past: [], future: [], present: snapshot, lastChangeAt: 0 }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** No change merges into a step closed with this timestamp. */
|
|
20
|
+
const CLOSED_STEP = Number.NEGATIVE_INFINITY
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Records the content after a change. A change within the coalescing window
|
|
24
|
+
* of the previous one extends its step instead of opening a new one; the
|
|
25
|
+
* oldest steps fall off past the limit. A discrete change (a deletion, a
|
|
26
|
+
* move) always is a step of its own, so one undo reverts exactly it.
|
|
27
|
+
*/
|
|
28
|
+
export function recordChange(
|
|
29
|
+
history: EditorHistory,
|
|
30
|
+
snapshot: string,
|
|
31
|
+
now: number,
|
|
32
|
+
isDiscrete = false,
|
|
33
|
+
): void {
|
|
34
|
+
if (snapshot === history.present) return
|
|
35
|
+
const isNewStep =
|
|
36
|
+
isDiscrete ||
|
|
37
|
+
now - history.lastChangeAt >= HISTORY_COALESCE_MS ||
|
|
38
|
+
history.past.length === 0
|
|
39
|
+
if (isNewStep) {
|
|
40
|
+
history.past.push(history.present)
|
|
41
|
+
if (history.past.length > HISTORY_LIMIT) history.past.shift()
|
|
42
|
+
}
|
|
43
|
+
history.present = snapshot
|
|
44
|
+
history.future = []
|
|
45
|
+
history.lastChangeAt = isDiscrete ? CLOSED_STEP : now
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Steps back; the snapshot to restore, or `null` at the oldest one. */
|
|
49
|
+
export function undoChange(history: EditorHistory): string | null {
|
|
50
|
+
const previous = history.past.pop()
|
|
51
|
+
if (previous === undefined) return null
|
|
52
|
+
history.future.push(history.present)
|
|
53
|
+
history.present = previous
|
|
54
|
+
history.lastChangeAt = 0
|
|
55
|
+
return previous
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Steps forward again; `null` when nothing was undone. */
|
|
59
|
+
export function redoChange(history: EditorHistory): string | null {
|
|
60
|
+
const next = history.future.pop()
|
|
61
|
+
if (next === undefined) return null
|
|
62
|
+
history.past.push(history.present)
|
|
63
|
+
history.present = next
|
|
64
|
+
history.lastChangeAt = 0
|
|
65
|
+
return next
|
|
66
|
+
}
|