@masterteam/system-settings 0.0.22 → 0.0.23
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/assets/i18n/ar.json +601 -442
- package/assets/i18n/en.json +601 -442
- package/assets/system-settings.css +1 -1
- package/fesm2022/{masterteam-system-settings-email-delivery-shell-oTWOzKWj.mjs → masterteam-system-settings-email-delivery-shell-D9NShoTc.mjs} +260 -17
- package/fesm2022/masterteam-system-settings-email-delivery-shell-D9NShoTc.mjs.map +1 -0
- package/fesm2022/{masterteam-system-settings-masterteam-system-settings-7I4TfgwP.mjs → masterteam-system-settings-masterteam-system-settings-C51hZw-Q.mjs} +1251 -13
- package/fesm2022/masterteam-system-settings-masterteam-system-settings-C51hZw-Q.mjs.map +1 -0
- package/fesm2022/masterteam-system-settings.mjs +1 -1
- package/package.json +3 -3
- package/types/masterteam-system-settings.d.ts +308 -5
- package/fesm2022/masterteam-system-settings-email-delivery-shell-oTWOzKWj.mjs.map +0 -1
- package/fesm2022/masterteam-system-settings-masterteam-system-settings-7I4TfgwP.mjs.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { CommonModule } from '@angular/common';
|
|
2
|
-
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
1
|
+
import { CommonModule, NgTemplateOutlet } from '@angular/common';
|
|
2
|
+
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
|
|
3
3
|
import * as i0 from '@angular/core';
|
|
4
|
-
import { input, ChangeDetectionStrategy, Component, inject, computed, effect, output, signal, InjectionToken, makeEnvironmentProviders, Injectable, DestroyRef } from '@angular/core';
|
|
4
|
+
import { input, ChangeDetectionStrategy, Component, inject, computed, effect, output, signal, InjectionToken, makeEnvironmentProviders, Injectable, DestroyRef, viewChild, untracked, forwardRef } from '@angular/core';
|
|
5
5
|
import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
6
6
|
import * as i1 from '@angular/forms';
|
|
7
|
-
import { ReactiveFormsModule, FormGroup, FormControl, Validators } from '@angular/forms';
|
|
7
|
+
import { ReactiveFormsModule, FormGroup, FormControl, Validators, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
8
8
|
import { Router } from '@angular/router';
|
|
9
9
|
import { TranslocoDirective, TranslocoService } from '@jsverse/transloco';
|
|
10
10
|
import { Breadcrumb } from '@masterteam/components/breadcrumb';
|
|
@@ -24,9 +24,13 @@ import { TextField } from '@masterteam/components/text-field';
|
|
|
24
24
|
import { Table } from '@masterteam/components/table';
|
|
25
25
|
import { MultiSelectField } from '@masterteam/components/multi-select-field';
|
|
26
26
|
import { NumberField } from '@masterteam/components/number-field';
|
|
27
|
-
import { of, map, tap, catchError, throwError, finalize, shareReplay, mergeMap, switchMap, forkJoin, timer } from 'rxjs';
|
|
27
|
+
import { of, map, tap, catchError, throwError, finalize, shareReplay, mergeMap, switchMap, forkJoin, timer, Observable } from 'rxjs';
|
|
28
28
|
import { TextareaField } from '@masterteam/components/textarea-field';
|
|
29
29
|
import { EntityStatus } from '@masterteam/components/entities';
|
|
30
|
+
import { ConfirmationService } from '@masterteam/components/confirmation';
|
|
31
|
+
import { Tabs } from '@masterteam/components/tabs';
|
|
32
|
+
import { Tooltip } from '@masterteam/components/tooltip';
|
|
33
|
+
import { Icon } from '@masterteam/icons';
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* "Experience & Appearance" — product-wide application behaviour that is not
|
|
@@ -192,7 +196,7 @@ class SystemSettingsHolidaysSection {
|
|
|
192
196
|
], ...(ngDevMode ? [{ debugName: "rowActions" }] : /* istanbul ignore next */ []));
|
|
193
197
|
hasHolidays = computed(() => this.holidays().length > 0, ...(ngDevMode ? [{ debugName: "hasHolidays" }] : /* istanbul ignore next */ []));
|
|
194
198
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SystemSettingsHolidaysSection, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
195
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: SystemSettingsHolidaysSection, isStandalone: true, selector: "mt-system-settings-holidays-section", inputs: { holidays: { classPropertyName: "holidays", publicName: "holidays", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { addHoliday: "addHoliday", editHoliday: "editHoliday", deleteHoliday: "deleteHoliday" }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'system-settings'\">\n <mt-card class=\"block\">\n <div class=\"space-y-6\">\n <div\n class=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\"\n >\n <div class=\"space-y-1\">\n <h4 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"holidays-title\") }}\n </h4>\n <p class=\"text-sm text-neutral-600\">\n {{ t(\"holidays-description\") }}\n </p>\n </div>\n\n <mt-button\n [label]=\"t('add-holiday')\"\n icon=\"general.plus\"\n (onClick)=\"addHoliday.emit()\"\n />\n </div>\n\n @if (hasHolidays()) {\n <mt-table\n noCard\n [data]=\"holidays()\"\n [columns]=\"columns()\"\n [rowActions]=\"rowActions()\"\n [generalSearch]=\"false\"\n [showFilters]=\"false\"\n [dataKey]=\"'rowKey'\"\n storageKey=\"admin-system-settings-holidays-table\"\n >\n </mt-table>\n } @else {\n <div\n class=\"rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-6 py-10 text-center\"\n >\n <h5 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"no-holidays-title\") }}\n </h5>\n <p class=\"mt-2 text-sm text-neutral-600\">\n {{ t(\"no-holidays-description\") }}\n </p>\n </div>\n }\n </div>\n </mt-card>\n</ng-container>\n", styles: [":host{display:block}\n"], dependencies: [{ kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { kind: "component", type: Table, selector: "mt-table", inputs: ["filters", "data", "columns", "rowActions", "size", "showGridlines", "stripedRows", "selectableRows", "clickableRows", "generalSearch", "lazyLocalSearch", "showFilters", "filterMode", "loading", "updating", "lazy", "lazyLocalSort", "lazyTotalRecords", "reorderableColumns", "reorderableRows", "dataKey", "storageKey", "storageMode", "persistStateExclude", "exportable", "printable", "groupable", "groupCountMap", "cellClickFilter", "freezeActions", "virtualScroll", "virtualScrollItemSize", "scrollHeight", "printTitle", "exportFilename", "actionShape", "rowActionsLoadingFn", "tableLayout", "noCard", "tabs", "tabsOptionLabel", "tabsOptionValue", "activeTab", "actions", "captionStartTemplate", "captionEndTemplate", "emptyTitle", "emptyDescription", "emptyActionLabel", "emptyActionIcon", "paginatorPosition", "alwaysShowPaginator", "rowsPerPageOptions", "pageSize", "currentPage", "first", "filterTerm", "groupBy", "sortField", "sortDirection"], outputs: ["selectionChange", "cellChange", "lazyLoad", "columnReorder", "rowReorder", "rowClick", "emptyAction", "rowActionsRequested", "filtersChange", "activeTabChange", "onTabChange", "pageSizeChange", "currentPageChange", "firstChange", "filterTermChange", "groupByChange", "sortFieldChange", "sortDirectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
199
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: SystemSettingsHolidaysSection, isStandalone: true, selector: "mt-system-settings-holidays-section", inputs: { holidays: { classPropertyName: "holidays", publicName: "holidays", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { addHoliday: "addHoliday", editHoliday: "editHoliday", deleteHoliday: "deleteHoliday" }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'system-settings'\">\n <mt-card class=\"block\">\n <div class=\"space-y-6\">\n <div\n class=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\"\n >\n <div class=\"space-y-1\">\n <h4 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"holidays-title\") }}\n </h4>\n <p class=\"text-sm text-neutral-600\">\n {{ t(\"holidays-description\") }}\n </p>\n </div>\n\n <mt-button\n [label]=\"t('add-holiday')\"\n icon=\"general.plus\"\n (onClick)=\"addHoliday.emit()\"\n />\n </div>\n\n @if (hasHolidays()) {\n <mt-table\n noCard\n [data]=\"holidays()\"\n [columns]=\"columns()\"\n [rowActions]=\"rowActions()\"\n [generalSearch]=\"false\"\n [showFilters]=\"false\"\n [dataKey]=\"'rowKey'\"\n storageKey=\"admin-system-settings-holidays-table\"\n >\n </mt-table>\n } @else {\n <div\n class=\"rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-6 py-10 text-center\"\n >\n <h5 class=\"text-base font-semibold text-neutral-900\">\n {{ t(\"no-holidays-title\") }}\n </h5>\n <p class=\"mt-2 text-sm text-neutral-600\">\n {{ t(\"no-holidays-description\") }}\n </p>\n </div>\n }\n </div>\n </mt-card>\n</ng-container>\n", styles: [":host{display:block}\n"], dependencies: [{ kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { kind: "component", type: Table, selector: "mt-table", inputs: ["filters", "data", "columns", "rowActions", "size", "showGridlines", "stripedRows", "selectableRows", "clickableRows", "generalSearch", "lazyLocalSearch", "showFilters", "filterMode", "filterCloseOnOutsideClick", "loading", "updating", "lazy", "lazyLocalSort", "lazyTotalRecords", "reorderableColumns", "reorderableRows", "dataKey", "storageKey", "storageMode", "persistStateExclude", "exportable", "printable", "groupable", "groupCountMap", "cellClickFilter", "freezeActions", "virtualScroll", "virtualScrollItemSize", "scrollHeight", "printTitle", "exportFilename", "actionShape", "rowActionsLoadingFn", "tableLayout", "noCard", "tabs", "tabsOptionLabel", "tabsOptionValue", "activeTab", "actions", "captionStartTemplate", "captionEndTemplate", "emptyTitle", "emptyDescription", "emptyActionLabel", "emptyActionIcon", "externalFiltersActive", "paginatorPosition", "alwaysShowPaginator", "rowsPerPageOptions", "pageSize", "currentPage", "first", "filterTerm", "groupBy", "sortField", "sortDirection"], outputs: ["selectionChange", "cellChange", "lazyLoad", "columnReorder", "rowReorder", "rowClick", "emptyAction", "filtersCleared", "rowActionsRequested", "filtersChange", "activeTabChange", "onTabChange", "pageSizeChange", "currentPageChange", "firstChange", "filterTermChange", "groupByChange", "sortFieldChange", "sortDirectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
196
200
|
}
|
|
197
201
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SystemSettingsHolidaysSection, decorators: [{
|
|
198
202
|
type: Component,
|
|
@@ -2679,6 +2683,12 @@ class EmailUserTestDialog {
|
|
|
2679
2683
|
sourceLabel = input('', ...(ngDevMode ? [{ debugName: "sourceLabel" }] : /* istanbul ignore next */ []));
|
|
2680
2684
|
placeholderKeys = input([], ...(ngDevMode ? [{ debugName: "placeholderKeys" }] : /* istanbul ignore next */ []));
|
|
2681
2685
|
placeholderFields = input([], ...(ngDevMode ? [{ debugName: "placeholderFields" }] : /* istanbul ignore next */ []));
|
|
2686
|
+
/**
|
|
2687
|
+
* When given, these values fill the placeholders and the per-placeholder
|
|
2688
|
+
* fields are not shown (the notification editor sends the same readable
|
|
2689
|
+
* samples as its preview).
|
|
2690
|
+
*/
|
|
2691
|
+
sampleValues = input(null, ...(ngDevMode ? [{ debugName: "sampleValues" }] : /* istanbul ignore next */ []));
|
|
2682
2692
|
modal = inject(ModalService);
|
|
2683
2693
|
ref = inject(ModalRef);
|
|
2684
2694
|
destroyRef = inject(DestroyRef);
|
|
@@ -2755,7 +2765,11 @@ class EmailUserTestDialog {
|
|
|
2755
2765
|
const request = {
|
|
2756
2766
|
testRequestId: this.testRequestId(),
|
|
2757
2767
|
emails: parseEmailTestRecipients(this.recipients.value),
|
|
2758
|
-
metadata:
|
|
2768
|
+
metadata: this.sampleValues() ??
|
|
2769
|
+
Object.fromEntries(this.metadataControls().map(({ key, control }) => [
|
|
2770
|
+
key,
|
|
2771
|
+
control.value,
|
|
2772
|
+
])),
|
|
2759
2773
|
};
|
|
2760
2774
|
const operation = this.operation(request);
|
|
2761
2775
|
if (!operation)
|
|
@@ -2858,7 +2872,7 @@ class EmailUserTestDialog {
|
|
|
2858
2872
|
return this.transloco.translate(`email-delivery.user-test.${key}`);
|
|
2859
2873
|
}
|
|
2860
2874
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailUserTestDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2861
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: EmailUserTestDialog, isStandalone: true, selector: "mt-email-user-test-dialog", inputs: { kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: true, transformFunction: null }, eventId: { classPropertyName: "eventId", publicName: "eventId", isSignal: true, isRequired: false, transformFunction: null }, templateId: { classPropertyName: "templateId", publicName: "templateId", isSignal: true, isRequired: false, transformFunction: null }, alertId: { classPropertyName: "alertId", publicName: "alertId", isSignal: true, isRequired: false, transformFunction: null }, sourceLabel: { classPropertyName: "sourceLabel", publicName: "sourceLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholderKeys: { classPropertyName: "placeholderKeys", publicName: "placeholderKeys", isSignal: true, isRequired: false, transformFunction: null }, placeholderFields: { classPropertyName: "placeholderFields", publicName: "placeholderFields", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{
|
|
2875
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: EmailUserTestDialog, isStandalone: true, selector: "mt-email-user-test-dialog", inputs: { kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: true, transformFunction: null }, eventId: { classPropertyName: "eventId", publicName: "eventId", isSignal: true, isRequired: false, transformFunction: null }, templateId: { classPropertyName: "templateId", publicName: "templateId", isSignal: true, isRequired: false, transformFunction: null }, alertId: { classPropertyName: "alertId", publicName: "alertId", isSignal: true, isRequired: false, transformFunction: null }, sourceLabel: { classPropertyName: "sourceLabel", publicName: "sourceLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholderKeys: { classPropertyName: "placeholderKeys", publicName: "placeholderKeys", isSignal: true, isRequired: false, transformFunction: null }, placeholderFields: { classPropertyName: "placeholderFields", publicName: "placeholderFields", isSignal: true, isRequired: false, transformFunction: null }, sampleValues: { classPropertyName: "sampleValues", publicName: "sampleValues", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{\r\n t(sampleValues() ? \"real-email-warning-samples\" : \"real-email-warning\")\r\n }}\r\n </div>\r\n\r\n <!-- The test request ID is still generated and sent (it makes a retry\r\n safe); it is not something the user acts on, so it is not shown. -->\r\n @if (sourceLabel()) {\r\n <p class=\"mb-4 text-sm font-medium\">{{ sourceLabel() }}</p>\r\n }\r\n\r\n @if (!result()) {\r\n <div class=\"grid gap-4\">\r\n <mt-textarea-field\r\n [formControl]=\"recipients\"\r\n [label]=\"t('recipients')\"\r\n [placeholder]=\"t('recipients-placeholder')\"\r\n [hint]=\"t('recipients-hint')\"\r\n rows=\"3\"\r\n [required]=\"true\"\r\n />\r\n\r\n @if (sampleValues()) {\r\n <!-- The host fills the placeholders with sample values. -->\r\n } @else if (metadataControls().length > 0) {\r\n <div class=\"rounded-md border border-surface-200 p-4\">\r\n <p class=\"m-0 text-sm font-semibold\">\r\n {{ t(\"placeholder-values\") }}\r\n </p>\r\n <p class=\"mt-1 mb-4 text-xs text-muted-color\">\r\n {{ t(\"placeholder-values-hint\") }}\r\n </p>\r\n <div class=\"grid gap-4 md:grid-cols-2\">\r\n @for (item of metadataControls(); track item.key) {\r\n <mt-text-field\r\n [formControl]=\"item.control\"\r\n [label]=\"item.label\"\r\n [placeholder]=\"t('placeholder-value')\"\r\n />\r\n }\r\n </div>\r\n </div>\r\n } @else {\r\n <p class=\"m-0 text-sm text-muted-color\">\r\n {{ t(\"no-placeholders\") }}\r\n </p>\r\n }\r\n </div>\r\n }\r\n\r\n @if (polling() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-surface-200 bg-surface-50 px-4 py-3\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ t(\"pending-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"pending-description\") }}\r\n </p>\r\n </div>\r\n } @else if (timedOut() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-orange-200 bg-orange-50 px-4 py-3\"\r\n role=\"alert\"\r\n >\r\n <p class=\"m-0 font-semibold text-orange-950\">\r\n {{ t(\"timeout-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-orange-900\">\r\n {{ t(\"timeout-description\") }}\r\n </p>\r\n </div>\r\n }\r\n\r\n @if (result(); as dispatch) {\r\n <div\r\n class=\"rounded-md border border-surface-200 bg-surface-50 px-4 py-4\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ dispatch.allAccepted ? t(\"accepted\") : t(\"not-accepted\") }}\r\n </p>\r\n @if (dispatch.expectedRecipientCount !== null) {\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"recipient-count\") }}: {{ dispatch.expectedRecipientCount }}\r\n </p>\r\n }\r\n @if (dispatch.allAccepted) {\r\n <p class=\"mt-3 mb-0 text-sm font-medium text-amber-800\">\r\n {{ t(\"accepted-unconfirmed\") }}\r\n </p>\r\n }\r\n\r\n @for (item of dispatch.results; track $index) {\r\n <div\r\n class=\"mt-3 rounded-md border border-surface-200 bg-surface-0 p-3\"\r\n >\r\n <mt-email-delivery-status kind=\"attempt\" [value]=\"item.outcome\" />\r\n <p class=\"mt-1 mb-0 text-xs text-muted-color\">\r\n {{ facade.describeCode(item.code) }}\r\n </p>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <div [class]=\"modal.footerClass\">\r\n <mt-button\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [label]=\"t('close')\"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"close()\"\r\n />\r\n @if ((polling() || timedOut()) && !result()) {\r\n <mt-button\r\n [label]=\"t('retrieve')\"\r\n icon=\"arrow.refresh-cw-01\"\r\n [disabled]=\"polling()\"\r\n [loading]=\"\r\n polling() || facade.mutationLoading() === 'retrieve-user-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"retrieve()\"\r\n />\r\n } @else if (!result()) {\r\n <mt-button\r\n [label]=\"t('send')\"\r\n icon=\"communication.send-01\"\r\n [disabled]=\"!canSubmit()\"\r\n [loading]=\"\r\n facade.mutationLoading() === 'template-test' ||\r\n facade.mutationLoading() === 'alert-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"submit()\"\r\n />\r\n }\r\n </div>\r\n</ng-container>\r\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: TextareaField, selector: "mt-textarea-field", inputs: ["field", "hint", "label", "placeholder", "class", "readonly", "noErrorStyle", "pInputs", "rows", "required", "maxLength"] }, { kind: "component", type: TextField, selector: "mt-text-field", inputs: ["field", "hint", "label", "placeholder", "class", "type", "readonly", "pInputs", "required", "maxLength", "icon", "iconPosition"] }, { kind: "component", type: EmailDeliveryStatus, selector: "mt-email-delivery-status", inputs: ["value", "kind"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2862
2876
|
}
|
|
2863
2877
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailUserTestDialog, decorators: [{
|
|
2864
2878
|
type: Component,
|
|
@@ -2869,8 +2883,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
|
|
|
2869
2883
|
TextareaField,
|
|
2870
2884
|
TextField,
|
|
2871
2885
|
EmailDeliveryStatus,
|
|
2872
|
-
], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{
|
|
2873
|
-
}], ctorParameters: () => [], propDecorators: { kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "kind", required: true }] }], eventId: [{ type: i0.Input, args: [{ isSignal: true, alias: "eventId", required: false }] }], templateId: [{ type: i0.Input, args: [{ isSignal: true, alias: "templateId", required: false }] }], alertId: [{ type: i0.Input, args: [{ isSignal: true, alias: "alertId", required: false }] }], sourceLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceLabel", required: false }] }], placeholderKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderKeys", required: false }] }], placeholderFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderFields", required: false }] }] } });
|
|
2886
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.user-test'\">\r\n <div [class]=\"modal.contentClass + ' p-4 sm:p-5'\">\r\n <div\r\n class=\"mb-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950\"\r\n role=\"note\"\r\n >\r\n <strong>{{ t(\"real-email-warning-title\") }}</strong>\r\n {{\r\n t(sampleValues() ? \"real-email-warning-samples\" : \"real-email-warning\")\r\n }}\r\n </div>\r\n\r\n <!-- The test request ID is still generated and sent (it makes a retry\r\n safe); it is not something the user acts on, so it is not shown. -->\r\n @if (sourceLabel()) {\r\n <p class=\"mb-4 text-sm font-medium\">{{ sourceLabel() }}</p>\r\n }\r\n\r\n @if (!result()) {\r\n <div class=\"grid gap-4\">\r\n <mt-textarea-field\r\n [formControl]=\"recipients\"\r\n [label]=\"t('recipients')\"\r\n [placeholder]=\"t('recipients-placeholder')\"\r\n [hint]=\"t('recipients-hint')\"\r\n rows=\"3\"\r\n [required]=\"true\"\r\n />\r\n\r\n @if (sampleValues()) {\r\n <!-- The host fills the placeholders with sample values. -->\r\n } @else if (metadataControls().length > 0) {\r\n <div class=\"rounded-md border border-surface-200 p-4\">\r\n <p class=\"m-0 text-sm font-semibold\">\r\n {{ t(\"placeholder-values\") }}\r\n </p>\r\n <p class=\"mt-1 mb-4 text-xs text-muted-color\">\r\n {{ t(\"placeholder-values-hint\") }}\r\n </p>\r\n <div class=\"grid gap-4 md:grid-cols-2\">\r\n @for (item of metadataControls(); track item.key) {\r\n <mt-text-field\r\n [formControl]=\"item.control\"\r\n [label]=\"item.label\"\r\n [placeholder]=\"t('placeholder-value')\"\r\n />\r\n }\r\n </div>\r\n </div>\r\n } @else {\r\n <p class=\"m-0 text-sm text-muted-color\">\r\n {{ t(\"no-placeholders\") }}\r\n </p>\r\n }\r\n </div>\r\n }\r\n\r\n @if (polling() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-surface-200 bg-surface-50 px-4 py-3\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ t(\"pending-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"pending-description\") }}\r\n </p>\r\n </div>\r\n } @else if (timedOut() && !result()) {\r\n <div\r\n class=\"mt-4 rounded-md border border-orange-200 bg-orange-50 px-4 py-3\"\r\n role=\"alert\"\r\n >\r\n <p class=\"m-0 font-semibold text-orange-950\">\r\n {{ t(\"timeout-title\") }}\r\n </p>\r\n <p class=\"mt-1 mb-0 text-sm text-orange-900\">\r\n {{ t(\"timeout-description\") }}\r\n </p>\r\n </div>\r\n }\r\n\r\n @if (result(); as dispatch) {\r\n <div\r\n class=\"rounded-md border border-surface-200 bg-surface-50 px-4 py-4\"\r\n aria-live=\"polite\"\r\n >\r\n <p class=\"m-0 font-semibold\">\r\n {{ dispatch.allAccepted ? t(\"accepted\") : t(\"not-accepted\") }}\r\n </p>\r\n @if (dispatch.expectedRecipientCount !== null) {\r\n <p class=\"mt-1 mb-0 text-sm text-muted-color\">\r\n {{ t(\"recipient-count\") }}: {{ dispatch.expectedRecipientCount }}\r\n </p>\r\n }\r\n @if (dispatch.allAccepted) {\r\n <p class=\"mt-3 mb-0 text-sm font-medium text-amber-800\">\r\n {{ t(\"accepted-unconfirmed\") }}\r\n </p>\r\n }\r\n\r\n @for (item of dispatch.results; track $index) {\r\n <div\r\n class=\"mt-3 rounded-md border border-surface-200 bg-surface-0 p-3\"\r\n >\r\n <mt-email-delivery-status kind=\"attempt\" [value]=\"item.outcome\" />\r\n <p class=\"mt-1 mb-0 text-xs text-muted-color\">\r\n {{ facade.describeCode(item.code) }}\r\n </p>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <div [class]=\"modal.footerClass\">\r\n <mt-button\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [label]=\"t('close')\"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"close()\"\r\n />\r\n @if ((polling() || timedOut()) && !result()) {\r\n <mt-button\r\n [label]=\"t('retrieve')\"\r\n icon=\"arrow.refresh-cw-01\"\r\n [disabled]=\"polling()\"\r\n [loading]=\"\r\n polling() || facade.mutationLoading() === 'retrieve-user-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"retrieve()\"\r\n />\r\n } @else if (!result()) {\r\n <mt-button\r\n [label]=\"t('send')\"\r\n icon=\"communication.send-01\"\r\n [disabled]=\"!canSubmit()\"\r\n [loading]=\"\r\n facade.mutationLoading() === 'template-test' ||\r\n facade.mutationLoading() === 'alert-test'\r\n \"\r\n styleClass=\"w-full sm:w-auto\"\r\n (onClick)=\"submit()\"\r\n />\r\n }\r\n </div>\r\n</ng-container>\r\n" }]
|
|
2887
|
+
}], ctorParameters: () => [], propDecorators: { kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "kind", required: true }] }], eventId: [{ type: i0.Input, args: [{ isSignal: true, alias: "eventId", required: false }] }], templateId: [{ type: i0.Input, args: [{ isSignal: true, alias: "templateId", required: false }] }], alertId: [{ type: i0.Input, args: [{ isSignal: true, alias: "alertId", required: false }] }], sourceLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceLabel", required: false }] }], placeholderKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderKeys", required: false }] }], placeholderFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderFields", required: false }] }], sampleValues: [{ type: i0.Input, args: [{ isSignal: true, alias: "sampleValues", required: false }] }] } });
|
|
2874
2888
|
const POLL_DELAYS_MS = [1_000, 2_000, 4_000, 5_000, 5_000, 5_000];
|
|
2875
2889
|
const TEST_REQUEST_CONFLICT_CODE = 'email.delivery_test_request_conflict';
|
|
2876
2890
|
function isAmbiguousTimeout(error) {
|
|
@@ -2886,17 +2900,1241 @@ function isTestRequestConflict(error) {
|
|
|
2886
2900
|
value.errors?.code === TEST_REQUEST_CONFLICT_CODE);
|
|
2887
2901
|
}
|
|
2888
2902
|
|
|
2903
|
+
/**
|
|
2904
|
+
* State for the Email design tab. The working draft lives here rather than in
|
|
2905
|
+
* the tab so switching between Connections / Activity / Email design keeps
|
|
2906
|
+
* unsaved edits; leaving the page asks first (`emailDesignLeaveGuard`).
|
|
2907
|
+
*
|
|
2908
|
+
* There is no server-side draft: the base is only ever previewed (render-only)
|
|
2909
|
+
* or published with the revision it was read at.
|
|
2910
|
+
*/
|
|
2911
|
+
class EmailLayoutFacade {
|
|
2912
|
+
http = inject(HttpClient);
|
|
2913
|
+
options = inject(EMAIL_DELIVERY_OPTIONS);
|
|
2914
|
+
transloco = inject(TranslocoService);
|
|
2915
|
+
// Failures arrive as a bare stable code; the tab translates it itself.
|
|
2916
|
+
headers = new HttpHeaders({ noMessage: 'true' });
|
|
2917
|
+
snapshot = signal(null, ...(ngDevMode ? [{ debugName: "snapshot" }] : /* istanbul ignore next */ []));
|
|
2918
|
+
draft = signal('', ...(ngDevMode ? [{ debugName: "draft" }] : /* istanbul ignore next */ []));
|
|
2919
|
+
loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
2920
|
+
loadErrorCode = signal(null, ...(ngDevMode ? [{ debugName: "loadErrorCode" }] : /* istanbul ignore next */ []));
|
|
2921
|
+
loadFailed = signal(false, ...(ngDevMode ? [{ debugName: "loadFailed" }] : /* istanbul ignore next */ []));
|
|
2922
|
+
saving = signal(null, ...(ngDevMode ? [{ debugName: "saving" }] : /* istanbul ignore next */ []));
|
|
2923
|
+
/** Someone else published since this draft's baseline was read. */
|
|
2924
|
+
conflict = signal(false, ...(ngDevMode ? [{ debugName: "conflict" }] : /* istanbul ignore next */ []));
|
|
2925
|
+
dirty = computed(() => {
|
|
2926
|
+
const snapshot = this.snapshot();
|
|
2927
|
+
return snapshot !== null && this.draft() !== snapshot.body;
|
|
2928
|
+
}, ...(ngDevMode ? [{ debugName: "dirty" }] : /* istanbul ignore next */ []));
|
|
2929
|
+
load() {
|
|
2930
|
+
this.loading.set(true);
|
|
2931
|
+
this.loadFailed.set(false);
|
|
2932
|
+
this.loadErrorCode.set(null);
|
|
2933
|
+
return this.http
|
|
2934
|
+
.get(this.url(), { headers: this.headers })
|
|
2935
|
+
.pipe(map((response) => response.data), tap((snapshot) => {
|
|
2936
|
+
// Never overwrite edits the user has not saved yet.
|
|
2937
|
+
if (!this.dirty())
|
|
2938
|
+
this.draft.set(snapshot.body);
|
|
2939
|
+
this.snapshot.set(snapshot);
|
|
2940
|
+
}), catchError((error) => {
|
|
2941
|
+
this.loadFailed.set(true);
|
|
2942
|
+
this.loadErrorCode.set(readLayoutErrorCode(error));
|
|
2943
|
+
return throwError(() => error);
|
|
2944
|
+
}), finalize(() => this.loading.set(false)));
|
|
2945
|
+
}
|
|
2946
|
+
/** Render-only: the server supplies a fixed bilingual sample; nothing is saved or sent. */
|
|
2947
|
+
preview(body) {
|
|
2948
|
+
return this.http
|
|
2949
|
+
.post(this.url('preview'), { body }, { headers: this.headers })
|
|
2950
|
+
.pipe(map((response) => response.data));
|
|
2951
|
+
}
|
|
2952
|
+
publish() {
|
|
2953
|
+
const snapshot = this.snapshot();
|
|
2954
|
+
if (!snapshot)
|
|
2955
|
+
return throwError(() => new Error('Layout not loaded'));
|
|
2956
|
+
this.saving.set('publish');
|
|
2957
|
+
return this.http
|
|
2958
|
+
.put(this.url(), { body: this.draft(), expectedRevision: snapshot.revision }, { headers: this.headers })
|
|
2959
|
+
.pipe(map((response) => response.data), tap((published) => this.adopt(published)), catchError((error) => this.onWriteError(error)), finalize(() => this.saving.set(null)));
|
|
2960
|
+
}
|
|
2961
|
+
restorePrevious() {
|
|
2962
|
+
const snapshot = this.snapshot();
|
|
2963
|
+
if (!snapshot)
|
|
2964
|
+
return throwError(() => new Error('Layout not loaded'));
|
|
2965
|
+
this.saving.set('restore');
|
|
2966
|
+
return this.http
|
|
2967
|
+
.post(this.url('restore'), { expectedRevision: snapshot.revision }, { headers: this.headers })
|
|
2968
|
+
.pipe(map((response) => response.data), tap((restored) => this.adopt(restored)), catchError((error) => this.onWriteError(error)), finalize(() => this.saving.set(null)));
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Re-reads the live design after a conflict. `keepDraft` keeps the user's
|
|
2972
|
+
* edits on top of the newer baseline, so the next publish applies them
|
|
2973
|
+
* deliberately; otherwise the draft is replaced by what is live.
|
|
2974
|
+
*/
|
|
2975
|
+
reloadLatest(keepDraft) {
|
|
2976
|
+
const draft = this.draft();
|
|
2977
|
+
return this.load().pipe(tap((snapshot) => {
|
|
2978
|
+
this.draft.set(keepDraft ? draft : snapshot.body);
|
|
2979
|
+
this.conflict.set(false);
|
|
2980
|
+
}));
|
|
2981
|
+
}
|
|
2982
|
+
discard() {
|
|
2983
|
+
const snapshot = this.snapshot();
|
|
2984
|
+
if (snapshot)
|
|
2985
|
+
this.draft.set(snapshot.body);
|
|
2986
|
+
}
|
|
2987
|
+
describeCode(code) {
|
|
2988
|
+
if (code) {
|
|
2989
|
+
const key = `email-delivery.design.codes.${code}`;
|
|
2990
|
+
const translated = this.transloco.translate(key);
|
|
2991
|
+
if (translated !== key)
|
|
2992
|
+
return translated;
|
|
2993
|
+
}
|
|
2994
|
+
return this.transloco.translate('email-delivery.design.codes.generic', {
|
|
2995
|
+
code: code ?? '—',
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
describeDiagnostic(diagnostic) {
|
|
2999
|
+
const key = `email-delivery.design.codes.${diagnostic.code}`;
|
|
3000
|
+
const translated = this.transloco.translate(key);
|
|
3001
|
+
const message = translated === key ? diagnostic.code : translated;
|
|
3002
|
+
return diagnostic.path ? `${message} (${diagnostic.path})` : message;
|
|
3003
|
+
}
|
|
3004
|
+
readErrorCode(error) {
|
|
3005
|
+
return readLayoutErrorCode(error);
|
|
3006
|
+
}
|
|
3007
|
+
adopt(snapshot) {
|
|
3008
|
+
// The server returns the sanitized body it stored; show exactly that.
|
|
3009
|
+
this.snapshot.set(snapshot);
|
|
3010
|
+
this.draft.set(snapshot.body);
|
|
3011
|
+
this.conflict.set(false);
|
|
3012
|
+
}
|
|
3013
|
+
onWriteError(error) {
|
|
3014
|
+
if ((error instanceof HttpErrorResponse && error.status === 409) ||
|
|
3015
|
+
readLayoutErrorCode(error) === 'EMAIL_REVISION_CONFLICT') {
|
|
3016
|
+
this.conflict.set(true);
|
|
3017
|
+
}
|
|
3018
|
+
return throwError(() => error);
|
|
3019
|
+
}
|
|
3020
|
+
url(path = '') {
|
|
3021
|
+
const base = this.options.notificationApiBasePath.replace(/\/$/, '');
|
|
3022
|
+
return path ? `${base}/email-layout/${path}` : `${base}/email-layout`;
|
|
3023
|
+
}
|
|
3024
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailLayoutFacade, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3025
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailLayoutFacade, providedIn: 'root' });
|
|
3026
|
+
}
|
|
3027
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailLayoutFacade, decorators: [{
|
|
3028
|
+
type: Injectable,
|
|
3029
|
+
args: [{ providedIn: 'root' }]
|
|
3030
|
+
}] });
|
|
3031
|
+
function readLayoutErrorCode(error) {
|
|
3032
|
+
const body = error instanceof HttpErrorResponse ? error.error : error;
|
|
3033
|
+
const code = body?.errors?.code;
|
|
3034
|
+
return typeof code === 'string' ? code : null;
|
|
3035
|
+
}
|
|
3036
|
+
function readLayoutErrorDiagnostics(error) {
|
|
3037
|
+
const body = error instanceof HttpErrorResponse ? error.error : error;
|
|
3038
|
+
const details = body?.errors
|
|
3039
|
+
?.details;
|
|
3040
|
+
return Array.isArray(details)
|
|
3041
|
+
? details.filter((item) => typeof item === 'object' &&
|
|
3042
|
+
item !== null &&
|
|
3043
|
+
typeof item.code === 'string')
|
|
3044
|
+
: [];
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
/**
|
|
3048
|
+
* Leaving the Email page with an unpublished design draft asks once. Switching
|
|
3049
|
+
* tabs inside the page is not a navigation away — the draft lives in
|
|
3050
|
+
* `EmailLayoutFacade` and is still there when the tab is reopened.
|
|
3051
|
+
*/
|
|
3052
|
+
const emailDesignLeaveGuard = () => {
|
|
3053
|
+
const facade = inject(EmailLayoutFacade);
|
|
3054
|
+
if (facade.saving())
|
|
3055
|
+
return false;
|
|
3056
|
+
if (!facade.dirty())
|
|
3057
|
+
return true;
|
|
3058
|
+
const confirmation = inject(ConfirmationService);
|
|
3059
|
+
const transloco = inject(TranslocoService);
|
|
3060
|
+
const t = (key) => transloco.translate(`email-delivery.design.leave.${key}`);
|
|
3061
|
+
return new Observable((subscriber) => {
|
|
3062
|
+
confirmation.confirm({
|
|
3063
|
+
header: t('header'),
|
|
3064
|
+
message: t('message'),
|
|
3065
|
+
icon: 'alert.alert-triangle',
|
|
3066
|
+
acceptLabel: t('discard'),
|
|
3067
|
+
rejectLabel: t('stay'),
|
|
3068
|
+
acceptButton: { severity: 'danger' },
|
|
3069
|
+
accept: () => {
|
|
3070
|
+
facade.discard();
|
|
3071
|
+
subscriber.next(true);
|
|
3072
|
+
subscriber.complete();
|
|
3073
|
+
},
|
|
3074
|
+
reject: () => {
|
|
3075
|
+
subscriber.next(false);
|
|
3076
|
+
subscriber.complete();
|
|
3077
|
+
},
|
|
3078
|
+
});
|
|
3079
|
+
});
|
|
3080
|
+
};
|
|
3081
|
+
|
|
2889
3082
|
const EMAIL_DELIVERY_ROUTES = [
|
|
2890
3083
|
{
|
|
2891
3084
|
path: '',
|
|
2892
|
-
|
|
3085
|
+
canDeactivate: [emailDesignLeaveGuard],
|
|
3086
|
+
loadComponent: () => import('./masterteam-system-settings-email-delivery-shell-D9NShoTc.mjs').then((module) => module.EmailDeliveryShell),
|
|
2893
3087
|
},
|
|
2894
3088
|
{ path: '**', redirectTo: '' },
|
|
2895
3089
|
];
|
|
2896
3090
|
|
|
3091
|
+
/** The one literal slot every base must contain exactly once. */
|
|
3092
|
+
const EMAIL_LAYOUT_CONTENT_SLOT = '{content}';
|
|
3093
|
+
/** UTF-8 byte limit for the base HTML. */
|
|
3094
|
+
const EMAIL_LAYOUT_MAX_BYTES = 128 * 1024;
|
|
3095
|
+
|
|
3096
|
+
/**
|
|
3097
|
+
* Renders server-composed notification HTML without letting it touch the app.
|
|
3098
|
+
*
|
|
3099
|
+
* The frame is an opaque origin (`sandbox=""`: no scripts, forms, popups,
|
|
3100
|
+
* same-origin or top navigation), sends no referrer, and the document carries
|
|
3101
|
+
* its own CSP: nothing loads except inline styles, and images only from the
|
|
3102
|
+
* approved origins once the viewer opts in. Links are stripped from this
|
|
3103
|
+
* preview-only copy so a click cannot navigate the frame; scrolling and
|
|
3104
|
+
* keyboard focus still work. The source is never injected into the app DOM.
|
|
3105
|
+
*/
|
|
3106
|
+
class EmailHtmlPreview {
|
|
3107
|
+
html = input('', ...(ngDevMode ? [{ debugName: "html" }] : /* istanbul ignore next */ []));
|
|
3108
|
+
showImages = input(false, ...(ngDevMode ? [{ debugName: "showImages" }] : /* istanbul ignore next */ []));
|
|
3109
|
+
approvedImageOrigins = input([], ...(ngDevMode ? [{ debugName: "approvedImageOrigins" }] : /* istanbul ignore next */ []));
|
|
3110
|
+
fragment = input(false, ...(ngDevMode ? [{ debugName: "fragment" }] : /* istanbul ignore next */ []));
|
|
3111
|
+
direction = input(null, ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
|
|
3112
|
+
title = input('', ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
|
|
3113
|
+
height = input('28rem', ...(ngDevMode ? [{ debugName: "height" }] : /* istanbul ignore next */ []));
|
|
3114
|
+
frame = viewChild.required('frame');
|
|
3115
|
+
constructor() {
|
|
3116
|
+
effect(() => {
|
|
3117
|
+
this.frame().nativeElement.srcdoc = buildEmailPreviewDocument(this.html(), {
|
|
3118
|
+
showImages: this.showImages(),
|
|
3119
|
+
approvedImageOrigins: this.approvedImageOrigins(),
|
|
3120
|
+
fragment: this.fragment(),
|
|
3121
|
+
direction: this.direction(),
|
|
3122
|
+
});
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailHtmlPreview, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3126
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.8", type: EmailHtmlPreview, isStandalone: true, selector: "mt-email-html-preview", inputs: { html: { classPropertyName: "html", publicName: "html", isSignal: true, isRequired: false, transformFunction: null }, showImages: { classPropertyName: "showImages", publicName: "showImages", isSignal: true, isRequired: false, transformFunction: null }, approvedImageOrigins: { classPropertyName: "approvedImageOrigins", publicName: "approvedImageOrigins", isSignal: true, isRequired: false, transformFunction: null }, fragment: { classPropertyName: "fragment", publicName: "fragment", isSignal: true, isRequired: false, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block min-w-0" }, viewQueries: [{ propertyName: "frame", first: true, predicate: ["frame"], descendants: true, isSignal: true }], ngImport: i0, template: `<iframe
|
|
3127
|
+
#frame
|
|
3128
|
+
sandbox=""
|
|
3129
|
+
referrerpolicy="no-referrer"
|
|
3130
|
+
[attr.title]="title()"
|
|
3131
|
+
class="block w-full border-0 bg-white"
|
|
3132
|
+
[style.height]="height()"
|
|
3133
|
+
></iframe>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3134
|
+
}
|
|
3135
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailHtmlPreview, decorators: [{
|
|
3136
|
+
type: Component,
|
|
3137
|
+
args: [{
|
|
3138
|
+
selector: 'mt-email-html-preview',
|
|
3139
|
+
template: `<iframe
|
|
3140
|
+
#frame
|
|
3141
|
+
sandbox=""
|
|
3142
|
+
referrerpolicy="no-referrer"
|
|
3143
|
+
[attr.title]="title()"
|
|
3144
|
+
class="block w-full border-0 bg-white"
|
|
3145
|
+
[style.height]="height()"
|
|
3146
|
+
></iframe>`,
|
|
3147
|
+
host: { class: 'block min-w-0' },
|
|
3148
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
3149
|
+
}]
|
|
3150
|
+
}], ctorParameters: () => [], propDecorators: { html: [{ type: i0.Input, args: [{ isSignal: true, alias: "html", required: false }] }], showImages: [{ type: i0.Input, args: [{ isSignal: true, alias: "showImages", required: false }] }], approvedImageOrigins: [{ type: i0.Input, args: [{ isSignal: true, alias: "approvedImageOrigins", required: false }] }], fragment: [{ type: i0.Input, args: [{ isSignal: true, alias: "fragment", required: false }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], frame: [{ type: i0.ViewChild, args: ['frame', { isSignal: true }] }] } });
|
|
3151
|
+
const BLOCKED_ELEMENTS = 'script, iframe, frame, object, embed, form, link, base, meta';
|
|
3152
|
+
const ORIGIN_PATTERN = /^https:\/\/[a-z0-9.-]+(:\d{1,5})?$/i;
|
|
3153
|
+
function buildEmailPreviewDocument(html, options) {
|
|
3154
|
+
const source = options.fragment
|
|
3155
|
+
? `<!doctype html><html><head></head><body>${html}</body></html>`
|
|
3156
|
+
: html;
|
|
3157
|
+
const doc = new DOMParser().parseFromString(source, 'text/html');
|
|
3158
|
+
doc.querySelectorAll(BLOCKED_ELEMENTS).forEach((node) => node.remove());
|
|
3159
|
+
doc.querySelectorAll('*').forEach((element) => {
|
|
3160
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
3161
|
+
if (attribute.name.toLowerCase().startsWith('on')) {
|
|
3162
|
+
element.removeAttribute(attribute.name);
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
});
|
|
3166
|
+
doc.querySelectorAll('a').forEach((anchor) => {
|
|
3167
|
+
anchor.removeAttribute('href');
|
|
3168
|
+
anchor.removeAttribute('target');
|
|
3169
|
+
anchor.setAttribute('aria-disabled', 'true');
|
|
3170
|
+
});
|
|
3171
|
+
const origins = options.approvedImageOrigins.filter((origin) => ORIGIN_PATTERN.test(origin));
|
|
3172
|
+
const imageSources = options.showImages && origins.length > 0 ? origins.join(' ') : "'none'";
|
|
3173
|
+
const csp = doc.createElement('meta');
|
|
3174
|
+
csp.setAttribute('http-equiv', 'Content-Security-Policy');
|
|
3175
|
+
csp.setAttribute('content', `default-src 'none'; style-src 'unsafe-inline'; img-src ${imageSources}; font-src 'none'; form-action 'none'`);
|
|
3176
|
+
const referrer = doc.createElement('meta');
|
|
3177
|
+
referrer.setAttribute('name', 'referrer');
|
|
3178
|
+
referrer.setAttribute('content', 'no-referrer');
|
|
3179
|
+
doc.head.prepend(csp, referrer);
|
|
3180
|
+
if (options.fragment) {
|
|
3181
|
+
const style = doc.createElement('style');
|
|
3182
|
+
style.textContent =
|
|
3183
|
+
'body{margin:16px;font-family:Tahoma,Arial,sans-serif;font-size:14px;line-height:1.6;color:#1f2937;background:#fff}';
|
|
3184
|
+
doc.head.append(style);
|
|
3185
|
+
if (options.direction) {
|
|
3186
|
+
doc.body.setAttribute('dir', options.direction);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
return `<!doctype html>${doc.documentElement.outerHTML}`;
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
/**
|
|
3193
|
+
* Pure helpers for `mt-email-visual-editor`: building the editing document and
|
|
3194
|
+
* turning it back into the exact source format the backend expects.
|
|
3195
|
+
*
|
|
3196
|
+
* In the canvas, `{{token}}` placeholders and the layout's `{content}` slot are
|
|
3197
|
+
* shown as non-editable chips; serialization turns every chip back into its
|
|
3198
|
+
* literal text, so the stored HTML never contains editor markup.
|
|
3199
|
+
*/
|
|
3200
|
+
const TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
|
|
3201
|
+
/** Canvas-only highlight of the image being edited; never serialized. */
|
|
3202
|
+
const SELECTED_CLASS = 'mt-selected';
|
|
3203
|
+
const TOKEN_ATTRIBUTE = 'data-mt-token';
|
|
3204
|
+
const SLOT_ATTRIBUTE = 'data-mt-slot';
|
|
3205
|
+
const STRIPPED_ELEMENTS = 'script, iframe, frame, object, embed, form, link, base, meta, style';
|
|
3206
|
+
/** A full HTML document cannot round-trip through a body-only canvas. */
|
|
3207
|
+
function isFullDocument(source) {
|
|
3208
|
+
return /<\s*(html|head|body)\b/i.test(source);
|
|
3209
|
+
}
|
|
3210
|
+
/**
|
|
3211
|
+
* Images load in the canvas only from the origins the server approves (the
|
|
3212
|
+
* same ones an email may use); anything else stays a sized placeholder box.
|
|
3213
|
+
*/
|
|
3214
|
+
function buildEditingDocument(source, editable, direction = null, imageOrigins = []) {
|
|
3215
|
+
const dir = direction ? ` dir="${direction}"` : '';
|
|
3216
|
+
const origins = canvasImageOrigins(imageOrigins);
|
|
3217
|
+
const imgSrc = origins.length > 0 ? origins.join(' ') : "'none'";
|
|
3218
|
+
const loaded = origins.map((origin) => `img[src^="${origin}/"]`).join(',');
|
|
3219
|
+
const style = loaded
|
|
3220
|
+
? `${CANVAS_STYLE}${loaded}{background:none;outline:none}${CANVAS_IMAGE_STATE_STYLE}`
|
|
3221
|
+
: `${CANVAS_STYLE}${CANVAS_IMAGE_STATE_STYLE}`;
|
|
3222
|
+
return `<!doctype html><html${dir}><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src ${imgSrc}; font-src 'none'; form-action 'none'"><style>${style}</style></head><body contenteditable="${editable}" spellcheck="true">${source}</body></html>`;
|
|
3223
|
+
}
|
|
3224
|
+
/** Only well-formed HTTPS origins reach the canvas CSP and style. */
|
|
3225
|
+
function canvasImageOrigins(origins) {
|
|
3226
|
+
const result = [];
|
|
3227
|
+
for (const origin of origins) {
|
|
3228
|
+
try {
|
|
3229
|
+
const url = new URL(origin.trim());
|
|
3230
|
+
if (url.protocol === 'https:' && !result.includes(url.origin)) {
|
|
3231
|
+
result.push(url.origin);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
catch {
|
|
3235
|
+
// Not an origin; leave its images blocked.
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
return result;
|
|
3239
|
+
}
|
|
3240
|
+
/** Strips anything active and turns tokens / the slot into chips. */
|
|
3241
|
+
function decorateCanvas(doc, placeholders, slotLabel) {
|
|
3242
|
+
doc.body.querySelectorAll(STRIPPED_ELEMENTS).forEach((node) => node.remove());
|
|
3243
|
+
doc.body.querySelectorAll('*').forEach((element) => {
|
|
3244
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
3245
|
+
if (attribute.name.toLowerCase().startsWith('on')) {
|
|
3246
|
+
element.removeAttribute(attribute.name);
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
});
|
|
3250
|
+
const labels = new Map(placeholders.map((item) => [item.key, item.label]));
|
|
3251
|
+
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
|
3252
|
+
const textNodes = [];
|
|
3253
|
+
while (walker.nextNode())
|
|
3254
|
+
textNodes.push(walker.currentNode);
|
|
3255
|
+
for (const node of textNodes) {
|
|
3256
|
+
const text = node.data;
|
|
3257
|
+
if (!text.includes('{'))
|
|
3258
|
+
continue;
|
|
3259
|
+
const pattern = new RegExp(`${TOKEN_PATTERN.source}|${escapeRegExp(EMAIL_LAYOUT_CONTENT_SLOT)}`, 'g');
|
|
3260
|
+
if (!pattern.test(text))
|
|
3261
|
+
continue;
|
|
3262
|
+
pattern.lastIndex = 0;
|
|
3263
|
+
const fragment = doc.createDocumentFragment();
|
|
3264
|
+
let last = 0;
|
|
3265
|
+
for (const match of text.matchAll(pattern)) {
|
|
3266
|
+
const index = match.index ?? 0;
|
|
3267
|
+
if (index > last)
|
|
3268
|
+
fragment.append(text.slice(last, index));
|
|
3269
|
+
fragment.append(match[0] === EMAIL_LAYOUT_CONTENT_SLOT
|
|
3270
|
+
? createSlotChip(doc, slotLabel)
|
|
3271
|
+
: createTokenChip(doc, match[1], labels.get(match[1]) ?? match[1]));
|
|
3272
|
+
last = index + match[0].length;
|
|
3273
|
+
}
|
|
3274
|
+
if (last < text.length)
|
|
3275
|
+
fragment.append(text.slice(last));
|
|
3276
|
+
node.replaceWith(fragment);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
function createTokenChip(doc, key, label) {
|
|
3280
|
+
const chip = doc.createElement('span');
|
|
3281
|
+
chip.setAttribute(TOKEN_ATTRIBUTE, key);
|
|
3282
|
+
chip.setAttribute('contenteditable', 'false');
|
|
3283
|
+
chip.className = 'mt-token';
|
|
3284
|
+
chip.textContent = label;
|
|
3285
|
+
return chip;
|
|
3286
|
+
}
|
|
3287
|
+
function createSlotChip(doc, label) {
|
|
3288
|
+
const chip = doc.createElement('span');
|
|
3289
|
+
chip.setAttribute(SLOT_ATTRIBUTE, '');
|
|
3290
|
+
chip.setAttribute('contenteditable', 'false');
|
|
3291
|
+
chip.className = 'mt-slot';
|
|
3292
|
+
chip.textContent = label;
|
|
3293
|
+
return chip;
|
|
3294
|
+
}
|
|
3295
|
+
/** The canvas body back to source: chips become their literal text again. */
|
|
3296
|
+
function serializeCanvas(body) {
|
|
3297
|
+
const clone = body.cloneNode(true);
|
|
3298
|
+
const doc = body.ownerDocument;
|
|
3299
|
+
clone.querySelectorAll(`[${TOKEN_ATTRIBUTE}]`).forEach((chip) => {
|
|
3300
|
+
chip.replaceWith(doc.createTextNode(`{{${chip.getAttribute(TOKEN_ATTRIBUTE)}}}`));
|
|
3301
|
+
});
|
|
3302
|
+
clone.querySelectorAll(`[${SLOT_ATTRIBUTE}]`).forEach((chip) => {
|
|
3303
|
+
chip.replaceWith(doc.createTextNode(EMAIL_LAYOUT_CONTENT_SLOT));
|
|
3304
|
+
});
|
|
3305
|
+
clone.querySelectorAll(`.${SELECTED_CLASS}`).forEach((element) => {
|
|
3306
|
+
element.classList.remove(SELECTED_CLASS);
|
|
3307
|
+
if (element.classList.length === 0)
|
|
3308
|
+
element.removeAttribute('class');
|
|
3309
|
+
});
|
|
3310
|
+
return clone.innerHTML;
|
|
3311
|
+
}
|
|
3312
|
+
/** Static links must be HTTPS; the only dynamic link is the whole `{{webUrl}}`. */
|
|
3313
|
+
function isAllowedLink(value) {
|
|
3314
|
+
const trimmed = value.trim();
|
|
3315
|
+
if (/^\{\{\s*webUrl\s*\}\}$/i.test(trimmed))
|
|
3316
|
+
return true;
|
|
3317
|
+
try {
|
|
3318
|
+
return new URL(trimmed).protocol === 'https:';
|
|
3319
|
+
}
|
|
3320
|
+
catch {
|
|
3321
|
+
return false;
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
const EMAIL_IMAGE_MIN_SIZE = 16;
|
|
3325
|
+
const EMAIL_IMAGE_MAX_SIZE = 1200;
|
|
3326
|
+
/**
|
|
3327
|
+
* The backend image policy, checked before insertion so the user is told why
|
|
3328
|
+
* instead of seeing the image silently removed on save: an approved HTTPS
|
|
3329
|
+
* origin, no query or fragment, descriptive alt text, and explicit width and
|
|
3330
|
+
* height between 16 and 1200. Inline image styles are never written.
|
|
3331
|
+
*/
|
|
3332
|
+
function validateEmailImage(image, approvedOrigins) {
|
|
3333
|
+
if (approvedOrigins.length === 0)
|
|
3334
|
+
return 'no-origins';
|
|
3335
|
+
let url;
|
|
3336
|
+
try {
|
|
3337
|
+
url = new URL(image.src.trim());
|
|
3338
|
+
}
|
|
3339
|
+
catch {
|
|
3340
|
+
return 'url';
|
|
3341
|
+
}
|
|
3342
|
+
if (url.protocol !== 'https:' || url.search || url.hash)
|
|
3343
|
+
return 'url';
|
|
3344
|
+
const approved = approvedOrigins.some((origin) => origin.replace(/\/$/, '').toLowerCase() === url.origin.toLowerCase());
|
|
3345
|
+
if (!approved)
|
|
3346
|
+
return 'origin';
|
|
3347
|
+
if (!image.alt.trim())
|
|
3348
|
+
return 'alt';
|
|
3349
|
+
const inRange = (value) => value !== null &&
|
|
3350
|
+
Number.isInteger(value) &&
|
|
3351
|
+
value >= EMAIL_IMAGE_MIN_SIZE &&
|
|
3352
|
+
value <= EMAIL_IMAGE_MAX_SIZE;
|
|
3353
|
+
if (!inRange(image.width) || !inRange(image.height))
|
|
3354
|
+
return 'size';
|
|
3355
|
+
return null;
|
|
3356
|
+
}
|
|
3357
|
+
function buildEmailImage(doc, image) {
|
|
3358
|
+
const element = doc.createElement('img');
|
|
3359
|
+
applyEmailImage(element, image);
|
|
3360
|
+
return element;
|
|
3361
|
+
}
|
|
3362
|
+
function applyEmailImage(element, image) {
|
|
3363
|
+
element.setAttribute('src', image.src.trim());
|
|
3364
|
+
element.setAttribute('alt', image.alt.trim());
|
|
3365
|
+
element.setAttribute('width', String(image.width));
|
|
3366
|
+
element.setAttribute('height', String(image.height));
|
|
3367
|
+
element.removeAttribute('style');
|
|
3368
|
+
}
|
|
3369
|
+
const TEXT_COLORS = [
|
|
3370
|
+
{ key: 'black', value: '#111827' },
|
|
3371
|
+
{ key: 'gray', value: '#6b7280' },
|
|
3372
|
+
{ key: 'red', value: '#b91c1c' },
|
|
3373
|
+
{ key: 'orange', value: '#c2410c' },
|
|
3374
|
+
{ key: 'amber', value: '#a16207' },
|
|
3375
|
+
{ key: 'green', value: '#15803d' },
|
|
3376
|
+
{ key: 'teal', value: '#0f766e' },
|
|
3377
|
+
{ key: 'blue', value: '#1d4ed8' },
|
|
3378
|
+
{ key: 'purple', value: '#6d28d9' },
|
|
3379
|
+
{ key: 'pink', value: '#be185d' },
|
|
3380
|
+
];
|
|
3381
|
+
const HIGHLIGHT_COLORS = [
|
|
3382
|
+
{ key: 'yellow', value: '#fef08a' },
|
|
3383
|
+
{ key: 'green', value: '#bbf7d0' },
|
|
3384
|
+
{ key: 'teal', value: '#99f6e4' },
|
|
3385
|
+
{ key: 'blue', value: '#bfdbfe' },
|
|
3386
|
+
{ key: 'purple', value: '#e9d5ff' },
|
|
3387
|
+
{ key: 'pink', value: '#fbcfe8' },
|
|
3388
|
+
{ key: 'red', value: '#fecaca' },
|
|
3389
|
+
{ key: 'orange', value: '#fed7aa' },
|
|
3390
|
+
{ key: 'gray', value: '#e5e7eb' },
|
|
3391
|
+
];
|
|
3392
|
+
/** `normal` clears the size, so the text follows the shared design. */
|
|
3393
|
+
const TEXT_SIZES = [
|
|
3394
|
+
{ key: 'small', px: 13 },
|
|
3395
|
+
{ key: 'normal', px: null },
|
|
3396
|
+
{ key: 'large', px: 20 },
|
|
3397
|
+
{ key: 'huge', px: 26 },
|
|
3398
|
+
];
|
|
3399
|
+
/**
|
|
3400
|
+
* `execCommand('fontSize')` can only write `<font size>`, which the policy
|
|
3401
|
+
* strips; the editor applies it with a marker size and swaps each marker for a
|
|
3402
|
+
* span with a pixel size.
|
|
3403
|
+
*/
|
|
3404
|
+
const FONT_SIZE_MARKER = '7';
|
|
3405
|
+
function replaceFontSizeMarkers(doc, px) {
|
|
3406
|
+
doc.body
|
|
3407
|
+
.querySelectorAll(`font[size="${FONT_SIZE_MARKER}"]`)
|
|
3408
|
+
.forEach((font) => {
|
|
3409
|
+
const span = doc.createElement('span');
|
|
3410
|
+
span.style.fontSize = `${px}px`;
|
|
3411
|
+
while (font.firstChild)
|
|
3412
|
+
span.append(font.firstChild);
|
|
3413
|
+
// The new size replaces any size already applied inside the selection.
|
|
3414
|
+
span.querySelectorAll('span').forEach((inner) => {
|
|
3415
|
+
const element = inner;
|
|
3416
|
+
element.style.removeProperty('font-size');
|
|
3417
|
+
unwrapIfBare(element);
|
|
3418
|
+
});
|
|
3419
|
+
font.replaceWith(span);
|
|
3420
|
+
});
|
|
3421
|
+
}
|
|
3422
|
+
/**
|
|
3423
|
+
* The browser writes strikethrough as `<strike>` (or, with CSS, as
|
|
3424
|
+
* `text-decoration-line`); the policy keeps neither, but keeps `<s>`.
|
|
3425
|
+
*/
|
|
3426
|
+
function replaceStrikeTags(doc) {
|
|
3427
|
+
doc.body.querySelectorAll('strike').forEach((strike) => {
|
|
3428
|
+
const s = doc.createElement('s');
|
|
3429
|
+
while (strike.firstChild)
|
|
3430
|
+
s.append(strike.firstChild);
|
|
3431
|
+
strike.replaceWith(s);
|
|
3432
|
+
});
|
|
3433
|
+
}
|
|
3434
|
+
/**
|
|
3435
|
+
* Removes one inline style (`color`, `background-color`, `font-size`) from the
|
|
3436
|
+
* spans the range touches; a span left with no attributes is unwrapped.
|
|
3437
|
+
*/
|
|
3438
|
+
function clearInlineStyle(doc, range, property) {
|
|
3439
|
+
const touched = Array.from(doc.body.querySelectorAll('span, font')).filter((element) => range.intersectsNode(element));
|
|
3440
|
+
for (const element of touched) {
|
|
3441
|
+
const html = element;
|
|
3442
|
+
html.style.removeProperty(property);
|
|
3443
|
+
if (property === 'color' && element.tagName === 'FONT') {
|
|
3444
|
+
element.removeAttribute('color');
|
|
3445
|
+
}
|
|
3446
|
+
unwrapIfBare(html);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
function unwrapIfBare(element) {
|
|
3450
|
+
// Removing the last style property leaves `style=""` behind.
|
|
3451
|
+
if (element.getAttribute('style')?.trim() === '') {
|
|
3452
|
+
element.removeAttribute('style');
|
|
3453
|
+
}
|
|
3454
|
+
if (element.attributes.length > 0 || !element.parentNode)
|
|
3455
|
+
return;
|
|
3456
|
+
if (element.tagName !== 'SPAN' && element.tagName !== 'FONT')
|
|
3457
|
+
return;
|
|
3458
|
+
element.replaceWith(...Array.from(element.childNodes));
|
|
3459
|
+
}
|
|
3460
|
+
const BILINGUAL_STARTER = '<div lang="en" dir="ltr"><p><br></p></div><div lang="ar" dir="rtl"><p><br></p></div>';
|
|
3461
|
+
function escapeRegExp(value) {
|
|
3462
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3463
|
+
}
|
|
3464
|
+
const CANVAS_STYLE = [
|
|
3465
|
+
'html{background:#fff}',
|
|
3466
|
+
'body{margin:0;padding:16px 20px;min-height:calc(100vh - 32px);font-family:Tahoma,Arial,sans-serif;font-size:15px;line-height:1.7;color:#1f2937;outline:none;overflow-wrap:anywhere}',
|
|
3467
|
+
'h1{font-size:24px;margin:0 0 12px}h2{font-size:20px;margin:0 0 12px}h3{font-size:17px;margin:0 0 10px}',
|
|
3468
|
+
'p{margin:0 0 12px}ul,ol{margin:0 0 12px;padding-inline-start:24px}',
|
|
3469
|
+
'a{color:#1d4ed8;text-decoration:underline;cursor:text}',
|
|
3470
|
+
// Cell outlines only on hover: always-on outlines made layouts look broken.
|
|
3471
|
+
'table{border-collapse:collapse}td:hover,th:hover{outline:1px dashed #cbd5e1;outline-offset:-1px}',
|
|
3472
|
+
// An image from an origin that is not approved never loads (CSP); it shows
|
|
3473
|
+
// as a sized box with its description.
|
|
3474
|
+
'img{display:inline-block;max-width:100%;background:#f1f5f9;outline:1px dashed #94a3b8;outline-offset:-1px;color:#475569;font:12px/1.4 system-ui,sans-serif;cursor:pointer}',
|
|
3475
|
+
'.mt-token{display:inline-block;margin:0 1px;padding:0 6px;border:1px solid #c7d2fe;border-radius:4px;background:#eef2ff;color:#3730a3;font:12px/1.7 system-ui,sans-serif;white-space:nowrap;vertical-align:baseline;user-select:all}',
|
|
3476
|
+
'.mt-slot{display:block;margin:4px 0;padding:14px;border:1px dashed #94a3b8;border-radius:6px;background:#f8fafc;color:#475569;font:13px/1.5 system-ui,sans-serif;text-align:center;user-select:none}',
|
|
3477
|
+
].join('');
|
|
3478
|
+
/** After the per-origin rules, so hover and selection always show. */
|
|
3479
|
+
const CANVAS_IMAGE_STATE_STYLE = 'img:hover{outline:1px dashed #94a3b8}img.mt-selected{outline:2px solid #2563eb}';
|
|
3480
|
+
|
|
3481
|
+
/**
|
|
3482
|
+
* Visual editor for notification email HTML (event content and the shared
|
|
3483
|
+
* base layout), with an HTML tab for the exact source.
|
|
3484
|
+
*
|
|
3485
|
+
* The canvas is a sandboxed, script-free iframe (`sandbox="allow-same-origin"`
|
|
3486
|
+
* so the host can edit it; nothing inside it can run) with its own CSP —
|
|
3487
|
+
* images and network stay blocked, as the contract asks. The browser's editing
|
|
3488
|
+
* DOM keeps tables, inline styles and `lang` / `dir`; `{{tokens}}` and the
|
|
3489
|
+
* layout's `{content}` slot are shown as chips and written back as literal
|
|
3490
|
+
* text. Opening content, or switching between Visual and HTML, never emits a
|
|
3491
|
+
* value: only a real edit does, so nothing is rewritten or marked unsaved just
|
|
3492
|
+
* by looking at it.
|
|
3493
|
+
*/
|
|
3494
|
+
class EmailVisualEditor {
|
|
3495
|
+
transloco = inject(TranslocoService);
|
|
3496
|
+
destroyRef = inject(DestroyRef);
|
|
3497
|
+
label = input(...(ngDevMode ? [undefined, { debugName: "label" }] : /* istanbul ignore next */ []));
|
|
3498
|
+
hint = input(...(ngDevMode ? [undefined, { debugName: "hint" }] : /* istanbul ignore next */ []));
|
|
3499
|
+
/** Placeholders the content may use; their labels name the chips. */
|
|
3500
|
+
placeholders = input([], ...(ngDevMode ? [{ debugName: "placeholders" }] : /* istanbul ignore next */ []));
|
|
3501
|
+
/** Editing the shared base layout: `{content}` is shown as a protected slot. */
|
|
3502
|
+
layout = input(false, ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
|
|
3503
|
+
height = input('24rem', ...(ngDevMode ? [{ debugName: "height" }] : /* istanbul ignore next */ []));
|
|
3504
|
+
/** Reading direction of the whole canvas (a single-language field). */
|
|
3505
|
+
direction = input(null, ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
|
|
3506
|
+
/** Offer the English + Arabic starter on an empty email. */
|
|
3507
|
+
starter = input(true, ...(ngDevMode ? [{ debugName: "starter" }] : /* istanbul ignore next */ []));
|
|
3508
|
+
/** The canvas or the HTML source received focus. */
|
|
3509
|
+
focused = output();
|
|
3510
|
+
/**
|
|
3511
|
+
* HTTPS origins the server accepts images from (`approvedImageOrigins` on the
|
|
3512
|
+
* layout read / preview). Empty means images are not allowed at all.
|
|
3513
|
+
*/
|
|
3514
|
+
approvedImageOrigins = input([], ...(ngDevMode ? [{ debugName: "approvedImageOrigins" }] : /* istanbul ignore next */ []));
|
|
3515
|
+
imageOpen = signal(false, ...(ngDevMode ? [{ debugName: "imageOpen" }] : /* istanbul ignore next */ []));
|
|
3516
|
+
imageError = signal(null, ...(ngDevMode ? [{ debugName: "imageError" }] : /* istanbul ignore next */ []));
|
|
3517
|
+
editingImage = signal(null, ...(ngDevMode ? [{ debugName: "editingImage" }] : /* istanbul ignore next */ []));
|
|
3518
|
+
imageForm = {
|
|
3519
|
+
src: new FormControl('', { nonNullable: true }),
|
|
3520
|
+
alt: new FormControl('', { nonNullable: true }),
|
|
3521
|
+
width: new FormControl(null),
|
|
3522
|
+
height: new FormControl(null),
|
|
3523
|
+
};
|
|
3524
|
+
imageMin = EMAIL_IMAGE_MIN_SIZE;
|
|
3525
|
+
imageMax = EMAIL_IMAGE_MAX_SIZE;
|
|
3526
|
+
imageOriginsLabel = computed(() => this.approvedImageOrigins().join(', '), ...(ngDevMode ? [{ debugName: "imageOriginsLabel" }] : /* istanbul ignore next */ []));
|
|
3527
|
+
imageErrorText = computed(() => {
|
|
3528
|
+
const error = this.imageError();
|
|
3529
|
+
if (!error)
|
|
3530
|
+
return '';
|
|
3531
|
+
this.lang();
|
|
3532
|
+
return this.transloco.translate(`email-delivery.editor.image.errors.${error}`, {
|
|
3533
|
+
origins: this.imageOriginsLabel(),
|
|
3534
|
+
min: EMAIL_IMAGE_MIN_SIZE,
|
|
3535
|
+
max: EMAIL_IMAGE_MAX_SIZE,
|
|
3536
|
+
});
|
|
3537
|
+
}, ...(ngDevMode ? [{ debugName: "imageErrorText" }] : /* istanbul ignore next */ []));
|
|
3538
|
+
mode = signal('design', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
|
|
3539
|
+
value = signal('', ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
3540
|
+
disabled = signal(false, ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
|
|
3541
|
+
linkOpen = signal(false, ...(ngDevMode ? [{ debugName: "linkOpen" }] : /* istanbul ignore next */ []));
|
|
3542
|
+
/** The colour / size row under the toolbar. */
|
|
3543
|
+
stylePanel = signal(null, ...(ngDevMode ? [{ debugName: "stylePanel" }] : /* istanbul ignore next */ []));
|
|
3544
|
+
textColors = TEXT_COLORS;
|
|
3545
|
+
highlightColors = HIGHLIGHT_COLORS;
|
|
3546
|
+
textSizes = TEXT_SIZES;
|
|
3547
|
+
linkError = signal(false, ...(ngDevMode ? [{ debugName: "linkError" }] : /* istanbul ignore next */ []));
|
|
3548
|
+
linkControl = new FormControl('', { nonNullable: true });
|
|
3549
|
+
sourceControl = new FormControl('', { nonNullable: true });
|
|
3550
|
+
designBlocked = computed(() => isFullDocument(this.value()), ...(ngDevMode ? [{ debugName: "designBlocked" }] : /* istanbul ignore next */ []));
|
|
3551
|
+
effectiveMode = computed(() => this.designBlocked() ? 'html' : this.mode(), ...(ngDevMode ? [{ debugName: "effectiveMode" }] : /* istanbul ignore next */ []));
|
|
3552
|
+
isEmpty = computed(() => this.value().replace(/<[^>]*>| |\s/g, '') === '', ...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
|
|
3553
|
+
modeOptions = computed(() => {
|
|
3554
|
+
this.lang();
|
|
3555
|
+
return [
|
|
3556
|
+
{ label: this.t('design'), value: 'design', icon: 'editor.pencil-01' },
|
|
3557
|
+
{ label: this.t('html'), value: 'html', icon: 'dev.code-02' },
|
|
3558
|
+
];
|
|
3559
|
+
}, ...(ngDevMode ? [{ debugName: "modeOptions" }] : /* istanbul ignore next */ []));
|
|
3560
|
+
blockActions = [
|
|
3561
|
+
{
|
|
3562
|
+
key: 'paragraph',
|
|
3563
|
+
icon: 'editor.pilcrow-01',
|
|
3564
|
+
run: () => this.formatBlock('p'),
|
|
3565
|
+
},
|
|
3566
|
+
{ key: 'heading', text: 'H2', run: () => this.formatBlock('h2') },
|
|
3567
|
+
{ key: 'subheading', text: 'H3', run: () => this.formatBlock('h3') },
|
|
3568
|
+
{ key: 'quote', text: '“', run: () => this.formatBlock('blockquote') },
|
|
3569
|
+
];
|
|
3570
|
+
inlineActions = [
|
|
3571
|
+
{ key: 'bold', icon: 'editor.bold-01', run: () => this.exec('bold') },
|
|
3572
|
+
{ key: 'italic', icon: 'editor.italic-01', run: () => this.exec('italic') },
|
|
3573
|
+
{
|
|
3574
|
+
key: 'underline',
|
|
3575
|
+
icon: 'editor.underline-01',
|
|
3576
|
+
run: () => this.exec('underline'),
|
|
3577
|
+
},
|
|
3578
|
+
{
|
|
3579
|
+
key: 'strike',
|
|
3580
|
+
icon: 'editor.strikethrough-01',
|
|
3581
|
+
run: () => this.strike(),
|
|
3582
|
+
},
|
|
3583
|
+
];
|
|
3584
|
+
styleActions = [
|
|
3585
|
+
{
|
|
3586
|
+
key: 'text-color',
|
|
3587
|
+
icon: 'editor.palette',
|
|
3588
|
+
run: () => this.toggleStylePanel('color'),
|
|
3589
|
+
},
|
|
3590
|
+
{
|
|
3591
|
+
key: 'highlight',
|
|
3592
|
+
icon: 'editor.roller-brush',
|
|
3593
|
+
run: () => this.toggleStylePanel('highlight'),
|
|
3594
|
+
},
|
|
3595
|
+
{
|
|
3596
|
+
key: 'size',
|
|
3597
|
+
icon: 'editor.type-01',
|
|
3598
|
+
run: () => this.toggleStylePanel('size'),
|
|
3599
|
+
},
|
|
3600
|
+
];
|
|
3601
|
+
alignActions = [
|
|
3602
|
+
{
|
|
3603
|
+
key: 'align-left',
|
|
3604
|
+
icon: 'editor.align-left',
|
|
3605
|
+
run: () => this.execWithCss('justifyLeft'),
|
|
3606
|
+
},
|
|
3607
|
+
{
|
|
3608
|
+
key: 'align-center',
|
|
3609
|
+
icon: 'editor.align-center',
|
|
3610
|
+
run: () => this.execWithCss('justifyCenter'),
|
|
3611
|
+
},
|
|
3612
|
+
{
|
|
3613
|
+
key: 'align-right',
|
|
3614
|
+
icon: 'editor.align-right',
|
|
3615
|
+
run: () => this.execWithCss('justifyRight'),
|
|
3616
|
+
},
|
|
3617
|
+
];
|
|
3618
|
+
listActions = [
|
|
3619
|
+
{
|
|
3620
|
+
key: 'bullets',
|
|
3621
|
+
icon: 'editor.dotpoints-01',
|
|
3622
|
+
run: () => this.exec('insertUnorderedList'),
|
|
3623
|
+
},
|
|
3624
|
+
{
|
|
3625
|
+
key: 'numbers',
|
|
3626
|
+
icon: 'layout.list',
|
|
3627
|
+
run: () => this.exec('insertOrderedList'),
|
|
3628
|
+
},
|
|
3629
|
+
{
|
|
3630
|
+
key: 'divider',
|
|
3631
|
+
icon: 'general.minus',
|
|
3632
|
+
run: () => this.exec('insertHorizontalRule'),
|
|
3633
|
+
},
|
|
3634
|
+
];
|
|
3635
|
+
directionActions = [
|
|
3636
|
+
{ key: 'ltr', text: 'EN', run: () => this.setDirection('ltr') },
|
|
3637
|
+
{ key: 'rtl', text: 'ع', run: () => this.setDirection('rtl') },
|
|
3638
|
+
];
|
|
3639
|
+
historyActions = [
|
|
3640
|
+
{
|
|
3641
|
+
key: 'clear',
|
|
3642
|
+
icon: 'editor.eraser',
|
|
3643
|
+
run: () => this.exec('removeFormat'),
|
|
3644
|
+
},
|
|
3645
|
+
{ key: 'undo', icon: 'arrow.flip-backward', run: () => this.exec('undo') },
|
|
3646
|
+
{ key: 'redo', icon: 'arrow.flip-forward', run: () => this.exec('redo') },
|
|
3647
|
+
];
|
|
3648
|
+
frame = viewChild('frame', ...(ngDevMode ? [{ debugName: "frame" }] : /* istanbul ignore next */ []));
|
|
3649
|
+
sourceHost = viewChild('sourceHost', ...(ngDevMode ? [{ debugName: "sourceHost" }] : /* istanbul ignore next */ []));
|
|
3650
|
+
lang = signal(this.transloco.getActiveLang(), ...(ngDevMode ? [{ debugName: "lang" }] : /* istanbul ignore next */ []));
|
|
3651
|
+
/** The last value this editor emitted — writes of it are not re-rendered. */
|
|
3652
|
+
lastEmitted = null;
|
|
3653
|
+
renderedFrame = null;
|
|
3654
|
+
renderedKey = '';
|
|
3655
|
+
savedRange = null;
|
|
3656
|
+
/** Set while a multi-step command runs, so it emits once at the end. */
|
|
3657
|
+
batching = false;
|
|
3658
|
+
onChange = () => undefined;
|
|
3659
|
+
onTouched = () => undefined;
|
|
3660
|
+
detachCanvas = null;
|
|
3661
|
+
constructor() {
|
|
3662
|
+
const langSub = this.transloco.langChanges$.subscribe((lang) => this.lang.set(lang));
|
|
3663
|
+
this.destroyRef.onDestroy(() => {
|
|
3664
|
+
langSub.unsubscribe();
|
|
3665
|
+
this.detachCanvas?.();
|
|
3666
|
+
});
|
|
3667
|
+
// (Re)build the canvas for a new frame, when the chip labels change (the
|
|
3668
|
+
// field catalog arrives after the content, or the language switches) and
|
|
3669
|
+
// when the approved image origins arrive.
|
|
3670
|
+
effect(() => {
|
|
3671
|
+
const frame = this.frame()?.nativeElement ?? null;
|
|
3672
|
+
if (!frame || this.effectiveMode() !== 'design')
|
|
3673
|
+
return;
|
|
3674
|
+
const renderKey = `${this.lang()}|${this.placeholders()
|
|
3675
|
+
.map((item) => `${item.key}=${item.label}`)
|
|
3676
|
+
.join(',')}|${this.approvedImageOrigins().join(',')}`;
|
|
3677
|
+
untracked(() => {
|
|
3678
|
+
if (frame !== this.renderedFrame || renderKey !== this.renderedKey) {
|
|
3679
|
+
this.renderedKey = renderKey;
|
|
3680
|
+
this.renderCanvas(frame);
|
|
3681
|
+
}
|
|
3682
|
+
});
|
|
3683
|
+
});
|
|
3684
|
+
this.sourceControl.valueChanges
|
|
3685
|
+
.pipe(takeUntilDestroyed())
|
|
3686
|
+
.subscribe((value) => this.emit(value));
|
|
3687
|
+
}
|
|
3688
|
+
// ==========================================================================
|
|
3689
|
+
// ControlValueAccessor
|
|
3690
|
+
// ==========================================================================
|
|
3691
|
+
writeValue(value) {
|
|
3692
|
+
const next = value ?? '';
|
|
3693
|
+
this.value.set(next);
|
|
3694
|
+
if (this.sourceControl.value !== next) {
|
|
3695
|
+
this.sourceControl.setValue(next, { emitEvent: false });
|
|
3696
|
+
}
|
|
3697
|
+
if (next === this.lastEmitted)
|
|
3698
|
+
return;
|
|
3699
|
+
this.lastEmitted = null;
|
|
3700
|
+
const frame = this.frame()?.nativeElement;
|
|
3701
|
+
if (frame && this.effectiveMode() === 'design')
|
|
3702
|
+
this.renderCanvas(frame);
|
|
3703
|
+
}
|
|
3704
|
+
registerOnChange(fn) {
|
|
3705
|
+
this.onChange = fn;
|
|
3706
|
+
}
|
|
3707
|
+
registerOnTouched(fn) {
|
|
3708
|
+
this.onTouched = fn;
|
|
3709
|
+
}
|
|
3710
|
+
setDisabledState(disabled) {
|
|
3711
|
+
this.disabled.set(disabled);
|
|
3712
|
+
const body = this.renderedFrame?.contentDocument?.body;
|
|
3713
|
+
if (body)
|
|
3714
|
+
body.contentEditable = String(!disabled);
|
|
3715
|
+
if (disabled) {
|
|
3716
|
+
this.sourceControl.disable({ emitEvent: false });
|
|
3717
|
+
}
|
|
3718
|
+
else {
|
|
3719
|
+
this.sourceControl.enable({ emitEvent: false });
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
// ==========================================================================
|
|
3723
|
+
// Public API
|
|
3724
|
+
// ==========================================================================
|
|
3725
|
+
/** Inserts `{{key}}` where the cursor is (as a chip in Visual, as text in HTML). */
|
|
3726
|
+
insertToken(key, label) {
|
|
3727
|
+
if (this.disabled())
|
|
3728
|
+
return;
|
|
3729
|
+
const text = `{{${key}}}`;
|
|
3730
|
+
if (this.effectiveMode() === 'html') {
|
|
3731
|
+
const textarea = this.sourceHost()?.nativeElement.querySelector('textarea');
|
|
3732
|
+
const current = this.sourceControl.value;
|
|
3733
|
+
const start = textarea?.selectionStart ?? current.length;
|
|
3734
|
+
const end = textarea?.selectionEnd ?? start;
|
|
3735
|
+
this.sourceControl.setValue(current.slice(0, start) + text + current.slice(end));
|
|
3736
|
+
setTimeout(() => {
|
|
3737
|
+
textarea?.focus();
|
|
3738
|
+
textarea?.setSelectionRange(start + text.length, start + text.length);
|
|
3739
|
+
});
|
|
3740
|
+
return;
|
|
3741
|
+
}
|
|
3742
|
+
const doc = this.canvasDocument();
|
|
3743
|
+
if (!doc)
|
|
3744
|
+
return;
|
|
3745
|
+
const chip = createTokenChip(doc, key, label ??
|
|
3746
|
+
this.placeholders().find((item) => item.key === key)?.label ??
|
|
3747
|
+
key);
|
|
3748
|
+
this.restoreSelection();
|
|
3749
|
+
doc.execCommand('insertHTML', false, `${chip.outerHTML} `);
|
|
3750
|
+
}
|
|
3751
|
+
// ==========================================================================
|
|
3752
|
+
// Toolbar
|
|
3753
|
+
// ==========================================================================
|
|
3754
|
+
onModeChange(mode) {
|
|
3755
|
+
if (mode !== 'design' && mode !== 'html')
|
|
3756
|
+
return;
|
|
3757
|
+
if (mode === this.mode())
|
|
3758
|
+
return;
|
|
3759
|
+
this.mode.set(mode);
|
|
3760
|
+
// A fresh frame is created for Visual; the effect renders it.
|
|
3761
|
+
this.renderedFrame = null;
|
|
3762
|
+
}
|
|
3763
|
+
startBilingual() {
|
|
3764
|
+
const doc = this.canvasDocument();
|
|
3765
|
+
if (!doc)
|
|
3766
|
+
return;
|
|
3767
|
+
doc.body.innerHTML = BILINGUAL_STARTER;
|
|
3768
|
+
this.placeCaret(doc, doc.body.querySelector('p'));
|
|
3769
|
+
this.emitFromCanvas();
|
|
3770
|
+
}
|
|
3771
|
+
toggleStylePanel(panel) {
|
|
3772
|
+
this.linkOpen.set(false);
|
|
3773
|
+
this.imageOpen.set(false);
|
|
3774
|
+
this.stylePanel.update((open) => (open === panel ? null : panel));
|
|
3775
|
+
}
|
|
3776
|
+
applyColor(swatch) {
|
|
3777
|
+
const panel = this.stylePanel();
|
|
3778
|
+
this.execWithCss(panel === 'highlight' ? 'hiliteColor' : 'foreColor', swatch.value);
|
|
3779
|
+
this.stylePanel.set(null);
|
|
3780
|
+
}
|
|
3781
|
+
removeColor() {
|
|
3782
|
+
const property = this.stylePanel() === 'highlight' ? 'background-color' : 'color';
|
|
3783
|
+
this.clearStyle(property);
|
|
3784
|
+
this.stylePanel.set(null);
|
|
3785
|
+
}
|
|
3786
|
+
applySize(size) {
|
|
3787
|
+
const px = this.textSizes.find((item) => item.key === size)?.px ?? null;
|
|
3788
|
+
if (px === null) {
|
|
3789
|
+
this.clearStyle('font-size');
|
|
3790
|
+
}
|
|
3791
|
+
else {
|
|
3792
|
+
const doc = this.canvasDocument();
|
|
3793
|
+
if (!doc || this.disabled())
|
|
3794
|
+
return;
|
|
3795
|
+
this.restoreSelection();
|
|
3796
|
+
this.batching = true;
|
|
3797
|
+
try {
|
|
3798
|
+
doc.execCommand('styleWithCSS', false, 'false');
|
|
3799
|
+
doc.execCommand('fontSize', false, FONT_SIZE_MARKER);
|
|
3800
|
+
replaceFontSizeMarkers(doc, px);
|
|
3801
|
+
}
|
|
3802
|
+
finally {
|
|
3803
|
+
this.batching = false;
|
|
3804
|
+
}
|
|
3805
|
+
this.emitFromCanvas();
|
|
3806
|
+
}
|
|
3807
|
+
this.stylePanel.set(null);
|
|
3808
|
+
}
|
|
3809
|
+
swatchLabel(swatch) {
|
|
3810
|
+
return this.t(`style.colors.${swatch.key}`);
|
|
3811
|
+
}
|
|
3812
|
+
toggleLink() {
|
|
3813
|
+
this.stylePanel.set(null);
|
|
3814
|
+
this.imageOpen.set(false);
|
|
3815
|
+
this.linkError.set(false);
|
|
3816
|
+
this.linkControl.setValue('');
|
|
3817
|
+
this.linkOpen.update((open) => !open);
|
|
3818
|
+
}
|
|
3819
|
+
useRecordLink() {
|
|
3820
|
+
this.linkControl.setValue('{{webUrl}}');
|
|
3821
|
+
this.applyLink();
|
|
3822
|
+
}
|
|
3823
|
+
applyLink() {
|
|
3824
|
+
const url = this.linkControl.value.trim();
|
|
3825
|
+
if (!isAllowedLink(url)) {
|
|
3826
|
+
this.linkError.set(true);
|
|
3827
|
+
return;
|
|
3828
|
+
}
|
|
3829
|
+
const canonical = /^\{\{\s*webUrl\s*\}\}$/i.test(url) ? '{{webUrl}}' : url;
|
|
3830
|
+
this.exec('createLink', canonical);
|
|
3831
|
+
this.linkOpen.set(false);
|
|
3832
|
+
}
|
|
3833
|
+
removeLink() {
|
|
3834
|
+
this.exec('unlink');
|
|
3835
|
+
this.linkOpen.set(false);
|
|
3836
|
+
}
|
|
3837
|
+
/**
|
|
3838
|
+
* Opens the image row: for the image selected in the canvas (to change it),
|
|
3839
|
+
* otherwise for a new image at the cursor.
|
|
3840
|
+
*/
|
|
3841
|
+
toggleImage() {
|
|
3842
|
+
if (this.imageOpen()) {
|
|
3843
|
+
this.imageOpen.set(false);
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
this.linkOpen.set(false);
|
|
3847
|
+
this.stylePanel.set(null);
|
|
3848
|
+
this.imageError.set(this.approvedImageOrigins().length === 0 ? 'no-origins' : null);
|
|
3849
|
+
const image = this.editingImage();
|
|
3850
|
+
this.imageForm.src.setValue(image?.getAttribute('src') ?? '');
|
|
3851
|
+
this.imageForm.alt.setValue(image?.getAttribute('alt') ?? '');
|
|
3852
|
+
this.imageForm.width.setValue(numberAttribute(image, 'width'));
|
|
3853
|
+
this.imageForm.height.setValue(numberAttribute(image, 'height'));
|
|
3854
|
+
this.imageOpen.set(true);
|
|
3855
|
+
}
|
|
3856
|
+
applyImage() {
|
|
3857
|
+
const draft = {
|
|
3858
|
+
src: this.imageForm.src.value,
|
|
3859
|
+
alt: this.imageForm.alt.value,
|
|
3860
|
+
width: this.imageForm.width.value,
|
|
3861
|
+
height: this.imageForm.height.value,
|
|
3862
|
+
};
|
|
3863
|
+
const error = validateEmailImage(draft, this.approvedImageOrigins());
|
|
3864
|
+
this.imageError.set(error);
|
|
3865
|
+
if (error)
|
|
3866
|
+
return;
|
|
3867
|
+
const doc = this.canvasDocument();
|
|
3868
|
+
if (!doc)
|
|
3869
|
+
return;
|
|
3870
|
+
const editing = this.editingImage();
|
|
3871
|
+
if (editing && doc.body.contains(editing)) {
|
|
3872
|
+
applyEmailImage(editing, draft);
|
|
3873
|
+
this.emitFromCanvas();
|
|
3874
|
+
}
|
|
3875
|
+
else {
|
|
3876
|
+
this.restoreSelection();
|
|
3877
|
+
doc.execCommand('insertHTML', false, buildEmailImage(doc, draft).outerHTML);
|
|
3878
|
+
}
|
|
3879
|
+
this.closeImage();
|
|
3880
|
+
}
|
|
3881
|
+
removeImage() {
|
|
3882
|
+
const editing = this.editingImage();
|
|
3883
|
+
if (editing) {
|
|
3884
|
+
editing.remove();
|
|
3885
|
+
this.emitFromCanvas();
|
|
3886
|
+
}
|
|
3887
|
+
this.closeImage();
|
|
3888
|
+
}
|
|
3889
|
+
closeImage() {
|
|
3890
|
+
this.imageOpen.set(false);
|
|
3891
|
+
this.selectImage(null);
|
|
3892
|
+
}
|
|
3893
|
+
actionLabel(key) {
|
|
3894
|
+
return this.t(`actions.${key}`);
|
|
3895
|
+
}
|
|
3896
|
+
// ==========================================================================
|
|
3897
|
+
// Canvas
|
|
3898
|
+
// ==========================================================================
|
|
3899
|
+
renderCanvas(frame) {
|
|
3900
|
+
this.detachCanvas?.();
|
|
3901
|
+
this.detachCanvas = null;
|
|
3902
|
+
this.renderedFrame = frame;
|
|
3903
|
+
this.savedRange = null;
|
|
3904
|
+
// Elements of the previous canvas document are gone.
|
|
3905
|
+
this.editingImage.set(null);
|
|
3906
|
+
this.imageOpen.set(false);
|
|
3907
|
+
this.stylePanel.set(null);
|
|
3908
|
+
const onLoad = () => this.attachCanvas(frame);
|
|
3909
|
+
frame.addEventListener('load', onLoad, { once: true });
|
|
3910
|
+
frame.srcdoc = buildEditingDocument(this.value(), !this.disabled(), this.direction(), this.approvedImageOrigins());
|
|
3911
|
+
}
|
|
3912
|
+
attachCanvas(frame) {
|
|
3913
|
+
const doc = frame.contentDocument;
|
|
3914
|
+
if (!doc || frame !== this.renderedFrame)
|
|
3915
|
+
return;
|
|
3916
|
+
decorateCanvas(doc, this.placeholders(), this.t('slot'));
|
|
3917
|
+
doc.execCommand('defaultParagraphSeparator', false, 'p');
|
|
3918
|
+
const onInput = () => {
|
|
3919
|
+
if (!this.batching)
|
|
3920
|
+
this.emitFromCanvas();
|
|
3921
|
+
};
|
|
3922
|
+
const onSelection = () => this.saveSelection();
|
|
3923
|
+
const onBlur = () => this.onTouched();
|
|
3924
|
+
const onFocus = () => this.focused.emit();
|
|
3925
|
+
const onClick = (event) => {
|
|
3926
|
+
const target = event.target;
|
|
3927
|
+
// A link in the canvas is text being edited, never a navigation.
|
|
3928
|
+
if (target?.closest('a'))
|
|
3929
|
+
event.preventDefault();
|
|
3930
|
+
// Clicking an image selects it, so the Image button edits it. Canvas
|
|
3931
|
+
// nodes live in the iframe's realm, so `instanceof` would never match.
|
|
3932
|
+
this.selectImage(target?.tagName === 'IMG' ? target : null);
|
|
3933
|
+
};
|
|
3934
|
+
const onPaste = (event) => {
|
|
3935
|
+
// Pasted documents bring foreign styles; keep plain text only.
|
|
3936
|
+
event.preventDefault();
|
|
3937
|
+
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
3938
|
+
doc.execCommand('insertText', false, text);
|
|
3939
|
+
};
|
|
3940
|
+
doc.addEventListener('input', onInput);
|
|
3941
|
+
doc.addEventListener('selectionchange', onSelection);
|
|
3942
|
+
doc.addEventListener('click', onClick);
|
|
3943
|
+
doc.addEventListener('paste', onPaste);
|
|
3944
|
+
doc.body.addEventListener('blur', onBlur);
|
|
3945
|
+
doc.addEventListener('focusin', onFocus);
|
|
3946
|
+
this.detachCanvas = () => {
|
|
3947
|
+
doc.removeEventListener('input', onInput);
|
|
3948
|
+
doc.removeEventListener('selectionchange', onSelection);
|
|
3949
|
+
doc.removeEventListener('click', onClick);
|
|
3950
|
+
doc.removeEventListener('paste', onPaste);
|
|
3951
|
+
doc.body.removeEventListener('blur', onBlur);
|
|
3952
|
+
doc.removeEventListener('focusin', onFocus);
|
|
3953
|
+
};
|
|
3954
|
+
}
|
|
3955
|
+
emitFromCanvas() {
|
|
3956
|
+
const doc = this.canvasDocument();
|
|
3957
|
+
if (!doc)
|
|
3958
|
+
return;
|
|
3959
|
+
const html = serializeCanvas(doc.body);
|
|
3960
|
+
this.sourceControl.setValue(html, { emitEvent: false });
|
|
3961
|
+
this.emit(html);
|
|
3962
|
+
}
|
|
3963
|
+
emit(value) {
|
|
3964
|
+
if (value === this.value())
|
|
3965
|
+
return;
|
|
3966
|
+
this.lastEmitted = value;
|
|
3967
|
+
this.value.set(value);
|
|
3968
|
+
this.onChange(value);
|
|
3969
|
+
}
|
|
3970
|
+
exec(command, argument) {
|
|
3971
|
+
const doc = this.canvasDocument();
|
|
3972
|
+
if (!doc || this.disabled())
|
|
3973
|
+
return;
|
|
3974
|
+
this.restoreSelection();
|
|
3975
|
+
doc.execCommand(command, false, argument);
|
|
3976
|
+
}
|
|
3977
|
+
/** Commands whose CSS form survives the email policy (spans with styles). */
|
|
3978
|
+
execWithCss(command, argument) {
|
|
3979
|
+
const doc = this.canvasDocument();
|
|
3980
|
+
if (!doc || this.disabled())
|
|
3981
|
+
return;
|
|
3982
|
+
this.restoreSelection();
|
|
3983
|
+
doc.execCommand('styleWithCSS', false, 'true');
|
|
3984
|
+
try {
|
|
3985
|
+
doc.execCommand(command, false, argument);
|
|
3986
|
+
}
|
|
3987
|
+
finally {
|
|
3988
|
+
doc.execCommand('styleWithCSS', false, 'false');
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
/** Written as `<s>`: the policy strips `<strike>` and `text-decoration-line`. */
|
|
3992
|
+
strike() {
|
|
3993
|
+
const doc = this.canvasDocument();
|
|
3994
|
+
if (!doc || this.disabled())
|
|
3995
|
+
return;
|
|
3996
|
+
this.restoreSelection();
|
|
3997
|
+
this.batching = true;
|
|
3998
|
+
try {
|
|
3999
|
+
doc.execCommand('strikeThrough');
|
|
4000
|
+
replaceStrikeTags(doc);
|
|
4001
|
+
}
|
|
4002
|
+
finally {
|
|
4003
|
+
this.batching = false;
|
|
4004
|
+
}
|
|
4005
|
+
this.emitFromCanvas();
|
|
4006
|
+
}
|
|
4007
|
+
clearStyle(property) {
|
|
4008
|
+
const doc = this.canvasDocument();
|
|
4009
|
+
if (!doc || this.disabled())
|
|
4010
|
+
return;
|
|
4011
|
+
this.restoreSelection();
|
|
4012
|
+
const selection = doc.getSelection();
|
|
4013
|
+
if (!selection || selection.rangeCount === 0)
|
|
4014
|
+
return;
|
|
4015
|
+
clearInlineStyle(doc, selection.getRangeAt(0), property);
|
|
4016
|
+
this.emitFromCanvas();
|
|
4017
|
+
}
|
|
4018
|
+
formatBlock(tag) {
|
|
4019
|
+
this.exec('formatBlock', tag);
|
|
4020
|
+
}
|
|
4021
|
+
/** Sets the reading direction (and language) of the block under the cursor. */
|
|
4022
|
+
setDirection(direction) {
|
|
4023
|
+
const doc = this.canvasDocument();
|
|
4024
|
+
if (!doc || this.disabled())
|
|
4025
|
+
return;
|
|
4026
|
+
this.restoreSelection();
|
|
4027
|
+
const anchor = doc.getSelection()?.anchorNode ?? null;
|
|
4028
|
+
// nodeType, not `instanceof`: canvas nodes belong to the iframe's realm.
|
|
4029
|
+
const start = anchor?.nodeType === Node.ELEMENT_NODE
|
|
4030
|
+
? anchor
|
|
4031
|
+
: (anchor?.parentElement ?? null);
|
|
4032
|
+
const block = start?.closest('p, h1, h2, h3, li, td, th, div');
|
|
4033
|
+
if (!block || block === doc.body || !doc.body.contains(block))
|
|
4034
|
+
return;
|
|
4035
|
+
block.setAttribute('dir', direction);
|
|
4036
|
+
block.setAttribute('lang', direction === 'rtl' ? 'ar' : 'en');
|
|
4037
|
+
this.emitFromCanvas();
|
|
4038
|
+
}
|
|
4039
|
+
/** Highlights (canvas-only class, never serialized) and selects one image. */
|
|
4040
|
+
selectImage(image) {
|
|
4041
|
+
const previous = this.editingImage();
|
|
4042
|
+
if (previous && previous !== image) {
|
|
4043
|
+
previous.classList.remove(SELECTED_CLASS);
|
|
4044
|
+
if (previous.classList.length === 0)
|
|
4045
|
+
previous.removeAttribute('class');
|
|
4046
|
+
}
|
|
4047
|
+
this.editingImage.set(image);
|
|
4048
|
+
if (!image)
|
|
4049
|
+
return;
|
|
4050
|
+
image.classList.add(SELECTED_CLASS);
|
|
4051
|
+
const doc = image.ownerDocument;
|
|
4052
|
+
const range = doc.createRange();
|
|
4053
|
+
range.selectNode(image);
|
|
4054
|
+
const selection = doc.getSelection();
|
|
4055
|
+
selection?.removeAllRanges();
|
|
4056
|
+
selection?.addRange(range);
|
|
4057
|
+
this.savedRange = range.cloneRange();
|
|
4058
|
+
}
|
|
4059
|
+
saveSelection() {
|
|
4060
|
+
const doc = this.canvasDocument();
|
|
4061
|
+
const selection = doc?.getSelection();
|
|
4062
|
+
if (!doc || !selection || selection.rangeCount === 0)
|
|
4063
|
+
return;
|
|
4064
|
+
const range = selection.getRangeAt(0);
|
|
4065
|
+
if (doc.body.contains(range.commonAncestorContainer)) {
|
|
4066
|
+
this.savedRange = range.cloneRange();
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
restoreSelection() {
|
|
4070
|
+
const frame = this.renderedFrame;
|
|
4071
|
+
const doc = this.canvasDocument();
|
|
4072
|
+
if (!frame || !doc)
|
|
4073
|
+
return;
|
|
4074
|
+
frame.contentWindow?.focus();
|
|
4075
|
+
doc.body.focus();
|
|
4076
|
+
if (!this.savedRange)
|
|
4077
|
+
return;
|
|
4078
|
+
const selection = doc.getSelection();
|
|
4079
|
+
selection?.removeAllRanges();
|
|
4080
|
+
selection?.addRange(this.savedRange);
|
|
4081
|
+
}
|
|
4082
|
+
placeCaret(doc, element) {
|
|
4083
|
+
if (!element)
|
|
4084
|
+
return;
|
|
4085
|
+
const range = doc.createRange();
|
|
4086
|
+
range.selectNodeContents(element);
|
|
4087
|
+
range.collapse(true);
|
|
4088
|
+
const selection = doc.getSelection();
|
|
4089
|
+
selection?.removeAllRanges();
|
|
4090
|
+
selection?.addRange(range);
|
|
4091
|
+
this.savedRange = range.cloneRange();
|
|
4092
|
+
}
|
|
4093
|
+
canvasDocument() {
|
|
4094
|
+
return this.effectiveMode() === 'design'
|
|
4095
|
+
? (this.renderedFrame?.contentDocument ?? null)
|
|
4096
|
+
: null;
|
|
4097
|
+
}
|
|
4098
|
+
t(key) {
|
|
4099
|
+
return this.transloco.translate(`email-delivery.editor.${key}`);
|
|
4100
|
+
}
|
|
4101
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailVisualEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4102
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: EmailVisualEditor, isStandalone: true, selector: "mt-email-visual-editor", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, placeholders: { classPropertyName: "placeholders", publicName: "placeholders", isSignal: true, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, starter: { classPropertyName: "starter", publicName: "starter", isSignal: true, isRequired: false, transformFunction: null }, approvedImageOrigins: { classPropertyName: "approvedImageOrigins", publicName: "approvedImageOrigins", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { focused: "focused" }, host: { classAttribute: "block min-w-0" }, providers: [
|
|
4103
|
+
{
|
|
4104
|
+
provide: NG_VALUE_ACCESSOR,
|
|
4105
|
+
useExisting: forwardRef(() => EmailVisualEditor),
|
|
4106
|
+
multi: true,
|
|
4107
|
+
},
|
|
4108
|
+
], viewQueries: [{ propertyName: "frame", first: true, predicate: ["frame"], descendants: true, isSignal: true }, { propertyName: "sourceHost", first: true, predicate: ["sourceHost"], descendants: true, isSignal: true }], ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.editor'\">\n <div class=\"flex min-w-0 flex-col gap-1\">\n @if (label() || hint()) {\n <div class=\"flex items-center gap-1 text-sm\">\n @if (label()) {\n <span class=\"font-medium\">{{ label() }}</span>\n }\n @if (hint()) {\n <span class=\"inline-flex text-surface-400\" [mtTooltip]=\"hint()\">\n <mt-icon icon=\"general.help-circle\" />\n </span>\n }\n </div>\n }\n\n <div\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <!-- Toolbar -->\n <div\n class=\"flex flex-wrap items-center justify-between gap-2 border-b border-surface bg-surface-50 px-2 py-1.5\"\n >\n <div\n class=\"flex min-w-0 flex-wrap items-center gap-0.5\"\n role=\"toolbar\"\n [attr.aria-label]=\"t('toolbar')\"\n >\n @if (effectiveMode() === \"design\") {\n @for (action of blockActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of inlineActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of styleActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of alignActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of listActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <mt-button\n icon=\"general.link-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"t('actions.link')\"\n [ariaLabel]=\"t('actions.link')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleLink()\"\n />\n <mt-button\n icon=\"image.image-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"\n editingImage() ? t('actions.image-edit') : t('actions.image')\n \"\n [ariaLabel]=\"t('actions.image')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleImage()\"\n />\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of directionActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of historyActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n }\n </div>\n @if (!designBlocked()) {\n <!-- ms-auto keeps the switch at the end when the toolbar wraps. -->\n <mt-tabs\n class=\"ms-auto\"\n size=\"small\"\n [options]=\"modeOptions()\"\n [active]=\"mode()\"\n (activeChange)=\"onModeChange($event)\"\n />\n }\n </div>\n\n @if (stylePanel() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-center gap-2 border-b border-surface px-3 py-2\"\n role=\"group\"\n [attr.aria-label]=\"\n t(\n 'style.' +\n (stylePanel() === 'color' ? 'text-color' : stylePanel())\n )\n \"\n >\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\n \"style.\" +\n (stylePanel() === \"color\" ? \"text-color\" : stylePanel())\n )\n }}</span>\n @if (stylePanel() === \"size\") {\n @for (size of textSizes; track size.key) {\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('style.sizes.' + size.key)\"\n (onClick)=\"applySize(size.key)\"\n />\n }\n } @else {\n <div class=\"flex flex-wrap items-center gap-1\">\n @for (\n swatch of stylePanel() === \"highlight\"\n ? highlightColors\n : textColors;\n track swatch.key\n ) {\n <!-- The swatch colour is data, not a theme token. -->\n <button\n type=\"button\"\n class=\"size-6 rounded-md border border-surface outline-offset-2 hover:scale-110 focus-visible:outline-2 focus-visible:outline-primary\"\n [style.background-color]=\"swatch.value\"\n [attr.aria-label]=\"swatchLabel(swatch)\"\n [mtTooltip]=\"swatchLabel(swatch)\"\n (click)=\"applyColor(swatch)\"\n ></button>\n }\n </div>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n icon=\"general.x-close\"\n [label]=\"t('style.remove-color')\"\n (onClick)=\"removeColor()\"\n />\n }\n </div>\n }\n\n @if (imageOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-col gap-2 border-b border-surface px-3 py-2\"\n [attr.aria-label]=\"t('actions.image')\"\n >\n <div class=\"grid grid-cols-1 gap-2 md:grid-cols-2\">\n <mt-text-field\n [formControl]=\"imageForm.src\"\n [label]=\"t('image.src')\"\n placeholder=\"https://\"\n dir=\"ltr\"\n />\n <mt-text-field\n [formControl]=\"imageForm.alt\"\n [label]=\"t('image.alt')\"\n [hint]=\"t('image.alt-hint')\"\n />\n <mt-number-field\n [formControl]=\"imageForm.width\"\n [label]=\"t('image.width')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n <mt-number-field\n [formControl]=\"imageForm.height\"\n [label]=\"t('image.height')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n </div>\n @if (imageError(); as error) {\n <p\n class=\"rounded-md border px-3 py-2 text-xs\"\n [class]=\"\n error === 'no-origins'\n ? 'border-amber-200 bg-amber-50 text-amber-700'\n : 'border-red-200 bg-red-50 text-red-700'\n \"\n role=\"alert\"\n >\n {{ imageErrorText() }}\n </p>\n } @else {\n <p class=\"text-xs text-muted-color\">\n {{ t(\"image.hint\", { origins: imageOriginsLabel() }) }}\n </p>\n }\n <div class=\"flex flex-wrap gap-2\">\n <mt-button\n size=\"small\"\n [label]=\"editingImage() ? t('image.update') : t('image.insert')\"\n [disabled]=\"imageError() === 'no-origins'\"\n (onClick)=\"applyImage()\"\n />\n @if (editingImage()) {\n <mt-button\n size=\"small\"\n severity=\"danger\"\n variant=\"text\"\n [label]=\"t('image.remove')\"\n (onClick)=\"removeImage()\"\n />\n }\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('image.cancel')\"\n (onClick)=\"closeImage()\"\n />\n </div>\n </div>\n }\n\n @if (linkOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-start gap-2 border-b border-surface px-3 py-2\"\n >\n <mt-text-field\n class=\"min-w-0 flex-1\"\n [formControl]=\"linkControl\"\n [placeholder]=\"t('link.placeholder')\"\n dir=\"ltr\"\n />\n <mt-button\n size=\"small\"\n [label]=\"t('link.apply')\"\n (onClick)=\"applyLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('link.record')\"\n (onClick)=\"useRecordLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('link.remove')\"\n (onClick)=\"removeLink()\"\n />\n @if (linkError()) {\n <p class=\"w-full text-xs text-red-600\" role=\"alert\">\n {{ t(\"link.invalid\") }}\n </p>\n }\n </div>\n }\n\n @if (designBlocked()) {\n <p\n class=\"border-b border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700\"\n >\n {{ t(\"design-blocked\") }}\n </p>\n }\n\n @if (effectiveMode() === \"design\") {\n @if (starter() && isEmpty() && !disabled()) {\n <div\n class=\"flex items-center justify-between gap-2 border-b border-surface px-3 py-2 text-xs text-muted-color\"\n >\n <span>{{ t(\"empty\") }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('bilingual')\"\n (onClick)=\"startBilingual()\"\n />\n </div>\n }\n <iframe\n #frame\n sandbox=\"allow-same-origin\"\n referrerpolicy=\"no-referrer\"\n [attr.title]=\"label() || t('canvas')\"\n class=\"block w-full border-0 bg-white\"\n [style.height]=\"height()\"\n ></iframe>\n } @else {\n <div #sourceHost class=\"p-2\" dir=\"ltr\">\n <textarea\n class=\"block w-full resize-y rounded-md border border-surface bg-content p-2 font-mono text-xs leading-5 outline-none focus:border-primary\"\n (focus)=\"focused.emit()\"\n [style.height]=\"height()\"\n [formControl]=\"sourceControl\"\n [attr.aria-label]=\"label() || t('html')\"\n spellcheck=\"false\"\n ></textarea>\n </div>\n }\n </div>\n </div>\n\n <ng-template #toolbarButton let-action>\n <mt-button\n [icon]=\"action.icon\"\n [label]=\"action.text\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"actionLabel(action.key)\"\n [ariaLabel]=\"actionLabel(action.key)\"\n [disabled]=\"disabled()\"\n (onClick)=\"action.run()\"\n />\n </ng-template>\n</ng-container>\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "component", type: Tabs, selector: "mt-tabs", inputs: ["options", "optionLabel", "optionValue", "active", "mode", "moreLabel", "defaultIcon", "size", "fluid", "disabled", "searchThreshold"], outputs: ["activeChange", "onChange"] }, { kind: "component", type: TextField, selector: "mt-text-field", inputs: ["field", "hint", "label", "placeholder", "class", "type", "readonly", "pInputs", "required", "maxLength", "icon", "iconPosition"] }, { kind: "component", type: NumberField, selector: "mt-number-field", inputs: ["field", "hint", "label", "placeholder", "class", "readonly", "pInputs", "format", "useGrouping", "maxFractionDigits", "min", "max", "required"] }, { kind: "directive", type: Tooltip, selector: "[mtTooltip]" }, { kind: "component", type: Icon, selector: "mt-icon", inputs: ["icon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4109
|
+
}
|
|
4110
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EmailVisualEditor, decorators: [{
|
|
4111
|
+
type: Component,
|
|
4112
|
+
args: [{ selector: 'mt-email-visual-editor', imports: [
|
|
4113
|
+
NgTemplateOutlet,
|
|
4114
|
+
TranslocoDirective,
|
|
4115
|
+
ReactiveFormsModule,
|
|
4116
|
+
Button,
|
|
4117
|
+
Tabs,
|
|
4118
|
+
TextField,
|
|
4119
|
+
NumberField,
|
|
4120
|
+
Tooltip,
|
|
4121
|
+
Icon,
|
|
4122
|
+
], host: { class: 'block min-w-0' }, providers: [
|
|
4123
|
+
{
|
|
4124
|
+
provide: NG_VALUE_ACCESSOR,
|
|
4125
|
+
useExisting: forwardRef(() => EmailVisualEditor),
|
|
4126
|
+
multi: true,
|
|
4127
|
+
},
|
|
4128
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'email-delivery.editor'\">\n <div class=\"flex min-w-0 flex-col gap-1\">\n @if (label() || hint()) {\n <div class=\"flex items-center gap-1 text-sm\">\n @if (label()) {\n <span class=\"font-medium\">{{ label() }}</span>\n }\n @if (hint()) {\n <span class=\"inline-flex text-surface-400\" [mtTooltip]=\"hint()\">\n <mt-icon icon=\"general.help-circle\" />\n </span>\n }\n </div>\n }\n\n <div\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <!-- Toolbar -->\n <div\n class=\"flex flex-wrap items-center justify-between gap-2 border-b border-surface bg-surface-50 px-2 py-1.5\"\n >\n <div\n class=\"flex min-w-0 flex-wrap items-center gap-0.5\"\n role=\"toolbar\"\n [attr.aria-label]=\"t('toolbar')\"\n >\n @if (effectiveMode() === \"design\") {\n @for (action of blockActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of inlineActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of styleActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of alignActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of listActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <mt-button\n icon=\"general.link-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"t('actions.link')\"\n [ariaLabel]=\"t('actions.link')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleLink()\"\n />\n <mt-button\n icon=\"image.image-01\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"\n editingImage() ? t('actions.image-edit') : t('actions.image')\n \"\n [ariaLabel]=\"t('actions.image')\"\n [disabled]=\"disabled()\"\n (onClick)=\"toggleImage()\"\n />\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of directionActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n <span\n class=\"mx-1 h-5 w-px bg-surface-200\"\n aria-hidden=\"true\"\n ></span>\n @for (action of historyActions; track action.key) {\n <ng-container\n [ngTemplateOutlet]=\"toolbarButton\"\n [ngTemplateOutletContext]=\"{ $implicit: action }\"\n />\n }\n }\n </div>\n @if (!designBlocked()) {\n <!-- ms-auto keeps the switch at the end when the toolbar wraps. -->\n <mt-tabs\n class=\"ms-auto\"\n size=\"small\"\n [options]=\"modeOptions()\"\n [active]=\"mode()\"\n (activeChange)=\"onModeChange($event)\"\n />\n }\n </div>\n\n @if (stylePanel() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-center gap-2 border-b border-surface px-3 py-2\"\n role=\"group\"\n [attr.aria-label]=\"\n t(\n 'style.' +\n (stylePanel() === 'color' ? 'text-color' : stylePanel())\n )\n \"\n >\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\n \"style.\" +\n (stylePanel() === \"color\" ? \"text-color\" : stylePanel())\n )\n }}</span>\n @if (stylePanel() === \"size\") {\n @for (size of textSizes; track size.key) {\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('style.sizes.' + size.key)\"\n (onClick)=\"applySize(size.key)\"\n />\n }\n } @else {\n <div class=\"flex flex-wrap items-center gap-1\">\n @for (\n swatch of stylePanel() === \"highlight\"\n ? highlightColors\n : textColors;\n track swatch.key\n ) {\n <!-- The swatch colour is data, not a theme token. -->\n <button\n type=\"button\"\n class=\"size-6 rounded-md border border-surface outline-offset-2 hover:scale-110 focus-visible:outline-2 focus-visible:outline-primary\"\n [style.background-color]=\"swatch.value\"\n [attr.aria-label]=\"swatchLabel(swatch)\"\n [mtTooltip]=\"swatchLabel(swatch)\"\n (click)=\"applyColor(swatch)\"\n ></button>\n }\n </div>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n icon=\"general.x-close\"\n [label]=\"t('style.remove-color')\"\n (onClick)=\"removeColor()\"\n />\n }\n </div>\n }\n\n @if (imageOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-col gap-2 border-b border-surface px-3 py-2\"\n [attr.aria-label]=\"t('actions.image')\"\n >\n <div class=\"grid grid-cols-1 gap-2 md:grid-cols-2\">\n <mt-text-field\n [formControl]=\"imageForm.src\"\n [label]=\"t('image.src')\"\n placeholder=\"https://\"\n dir=\"ltr\"\n />\n <mt-text-field\n [formControl]=\"imageForm.alt\"\n [label]=\"t('image.alt')\"\n [hint]=\"t('image.alt-hint')\"\n />\n <mt-number-field\n [formControl]=\"imageForm.width\"\n [label]=\"t('image.width')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n <mt-number-field\n [formControl]=\"imageForm.height\"\n [label]=\"t('image.height')\"\n [min]=\"imageMin\"\n [max]=\"imageMax\"\n [maxFractionDigits]=\"0\"\n />\n </div>\n @if (imageError(); as error) {\n <p\n class=\"rounded-md border px-3 py-2 text-xs\"\n [class]=\"\n error === 'no-origins'\n ? 'border-amber-200 bg-amber-50 text-amber-700'\n : 'border-red-200 bg-red-50 text-red-700'\n \"\n role=\"alert\"\n >\n {{ imageErrorText() }}\n </p>\n } @else {\n <p class=\"text-xs text-muted-color\">\n {{ t(\"image.hint\", { origins: imageOriginsLabel() }) }}\n </p>\n }\n <div class=\"flex flex-wrap gap-2\">\n <mt-button\n size=\"small\"\n [label]=\"editingImage() ? t('image.update') : t('image.insert')\"\n [disabled]=\"imageError() === 'no-origins'\"\n (onClick)=\"applyImage()\"\n />\n @if (editingImage()) {\n <mt-button\n size=\"small\"\n severity=\"danger\"\n variant=\"text\"\n [label]=\"t('image.remove')\"\n (onClick)=\"removeImage()\"\n />\n }\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('image.cancel')\"\n (onClick)=\"closeImage()\"\n />\n </div>\n </div>\n }\n\n @if (linkOpen() && effectiveMode() === \"design\") {\n <div\n class=\"flex flex-wrap items-start gap-2 border-b border-surface px-3 py-2\"\n >\n <mt-text-field\n class=\"min-w-0 flex-1\"\n [formControl]=\"linkControl\"\n [placeholder]=\"t('link.placeholder')\"\n dir=\"ltr\"\n />\n <mt-button\n size=\"small\"\n [label]=\"t('link.apply')\"\n (onClick)=\"applyLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('link.record')\"\n (onClick)=\"useRecordLink()\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('link.remove')\"\n (onClick)=\"removeLink()\"\n />\n @if (linkError()) {\n <p class=\"w-full text-xs text-red-600\" role=\"alert\">\n {{ t(\"link.invalid\") }}\n </p>\n }\n </div>\n }\n\n @if (designBlocked()) {\n <p\n class=\"border-b border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700\"\n >\n {{ t(\"design-blocked\") }}\n </p>\n }\n\n @if (effectiveMode() === \"design\") {\n @if (starter() && isEmpty() && !disabled()) {\n <div\n class=\"flex items-center justify-between gap-2 border-b border-surface px-3 py-2 text-xs text-muted-color\"\n >\n <span>{{ t(\"empty\") }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('bilingual')\"\n (onClick)=\"startBilingual()\"\n />\n </div>\n }\n <iframe\n #frame\n sandbox=\"allow-same-origin\"\n referrerpolicy=\"no-referrer\"\n [attr.title]=\"label() || t('canvas')\"\n class=\"block w-full border-0 bg-white\"\n [style.height]=\"height()\"\n ></iframe>\n } @else {\n <div #sourceHost class=\"p-2\" dir=\"ltr\">\n <textarea\n class=\"block w-full resize-y rounded-md border border-surface bg-content p-2 font-mono text-xs leading-5 outline-none focus:border-primary\"\n (focus)=\"focused.emit()\"\n [style.height]=\"height()\"\n [formControl]=\"sourceControl\"\n [attr.aria-label]=\"label() || t('html')\"\n spellcheck=\"false\"\n ></textarea>\n </div>\n }\n </div>\n </div>\n\n <ng-template #toolbarButton let-action>\n <mt-button\n [icon]=\"action.icon\"\n [label]=\"action.text\"\n severity=\"secondary\"\n variant=\"text\"\n size=\"small\"\n [tooltip]=\"actionLabel(action.key)\"\n [ariaLabel]=\"actionLabel(action.key)\"\n [disabled]=\"disabled()\"\n (onClick)=\"action.run()\"\n />\n </ng-template>\n</ng-container>\n" }]
|
|
4129
|
+
}], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], placeholders: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholders", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], starter: [{ type: i0.Input, args: [{ isSignal: true, alias: "starter", required: false }] }], focused: [{ type: i0.Output, args: ["focused"] }], approvedImageOrigins: [{ type: i0.Input, args: [{ isSignal: true, alias: "approvedImageOrigins", required: false }] }], frame: [{ type: i0.ViewChild, args: ['frame', { isSignal: true }] }], sourceHost: [{ type: i0.ViewChild, args: ['sourceHost', { isSignal: true }] }] } });
|
|
4130
|
+
function numberAttribute(element, name) {
|
|
4131
|
+
const value = Number(element?.getAttribute(name));
|
|
4132
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
2897
4135
|
/**
|
|
2898
4136
|
* Generated bundle index. Do not edit.
|
|
2899
4137
|
*/
|
|
2900
4138
|
|
|
2901
|
-
export { DEFAULT_GENERAL_CONFIGURATION as D, EmailDeliveryFacade as E, FIXED_CURRENCIES as F, GENERAL_CONFIGURATION_SETTING_KEY as G, RATE_SOURCE_OPTIONS as R, SystemSettings as S, TIMESHEET_FREQUENCY_OPTIONS as T, WEEKEND_DAY_DEFINITIONS as W, EmailDeliveryStatus as a, EMAIL_DELIVERY_ATTEMPT_STATUSES as b, createEmailTestRequestId as c, areEmailTestRecipientsValid as d, isEmailTestRequestPending as e, EMAIL_DELIVERY_PERMISSIONS as f, getEmailDeliveryStatusMap as g,
|
|
2902
|
-
//# sourceMappingURL=masterteam-system-settings-masterteam-system-settings-
|
|
4139
|
+
export { buildEmailTemplatePlaceholderFields as A, EmailUserTestDialog as B, EMAIL_USER_INITIATED_TEST_OPERATION_KIND as C, DEFAULT_GENERAL_CONFIGURATION as D, EmailDeliveryFacade as E, FIXED_CURRENCIES as F, GENERAL_CONFIGURATION_SETTING_KEY as G, EMAIL_DELIVERY_ROUTES as H, emailDesignLeaveGuard as I, buildEmailPreviewDocument as J, RATE_SOURCE_OPTIONS as R, SystemSettings as S, TIMESHEET_FREQUENCY_OPTIONS as T, WEEKEND_DAY_DEFINITIONS as W, EmailDeliveryStatus as a, EMAIL_DELIVERY_ATTEMPT_STATUSES as b, createEmailTestRequestId as c, areEmailTestRecipientsValid as d, isEmailTestRequestPending as e, EMAIL_DELIVERY_PERMISSIONS as f, getEmailDeliveryStatusMap as g, EmailLayoutFacade as h, isConnectionTestAttempt as i, EMAIL_LAYOUT_CONTENT_SLOT as j, EMAIL_LAYOUT_MAX_BYTES as k, EmailVisualEditor as l, EmailHtmlPreview as m, EXPLORE_DEFAULT_VIEW_OPTIONS as n, DEFAULT_DATE_FORMAT_OPTIONS as o, parseEmailTestRecipients as p, DEFAULT_TIME_FORMAT_OPTIONS as q, readLayoutErrorDiagnostics as r, DOTNET_DAY_DEFINITIONS as s, SCHEDULE_ITEM_TYPE_OPTIONS as t, SystemSettingsHolidayEditor as u, DEFAULT_EMAIL_DELIVERY_OPTIONS as v, EMAIL_DELIVERY_OPTIONS as w, provideEmailDelivery as x, EmailDeliveryAccessService as y, collectEmailTemplatePlaceholders as z };
|
|
4140
|
+
//# sourceMappingURL=masterteam-system-settings-masterteam-system-settings-C51hZw-Q.mjs.map
|