@masterteam/notification 0.0.28 → 0.0.29

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.
@@ -1,114 +1,73 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, input, Component, Injectable, computed, signal, effect, untracked, ChangeDetectionStrategy } from '@angular/core';
3
- import { toSignal } from '@angular/core/rxjs-interop';
4
- import { CommonModule } from '@angular/common';
2
+ import { inject, Injectable, computed, input, ChangeDetectionStrategy, Component, output, signal, DestroyRef, effect, untracked, ElementRef, viewChild, HostListener } from '@angular/core';
3
+ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
5
4
  import * as i1 from '@angular/forms';
6
- import { FormControl, Validators, ReactiveFormsModule, FormGroup } from '@angular/forms';
7
- import * as i2 from '@jsverse/transloco';
8
- import { TranslocoModule, TranslocoService } from '@jsverse/transloco';
9
- import { Card } from '@masterteam/components/card';
5
+ import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
6
+ import { TranslocoService, TranslocoDirective } from '@jsverse/transloco';
10
7
  import { Button } from '@masterteam/components/button';
8
+ import { ConfirmationService } from '@masterteam/components/confirmation';
9
+ import { EmptyState } from '@masterteam/components/empty-state';
10
+ import { ModalService } from '@masterteam/components/modal';
11
11
  import { SelectField } from '@masterteam/components/select-field';
12
+ import { Tabs } from '@masterteam/components/tabs';
12
13
  import { TextField } from '@masterteam/components/text-field';
13
14
  import { TextareaField } from '@masterteam/components/textarea-field';
14
- import { EditorField } from '@masterteam/components/editor-field';
15
- import { ToggleField } from '@masterteam/components/toggle-field';
16
- import { Tabs } from '@masterteam/components/tabs';
15
+ import { ToastService } from '@masterteam/components/toast';
16
+ import { Tooltip } from '@masterteam/components/tooltip';
17
+ import { FormulaBuilder } from '@masterteam/formula-builder';
17
18
  import { Icon } from '@masterteam/icons';
18
- import { ConfirmationService } from '@masterteam/components/confirmation';
19
- import { ModalService } from '@masterteam/components/modal';
19
+ import { EmailHtmlPreview, EmailVisualEditor, EmailUserTestDialog } from '@masterteam/system-settings';
20
20
  import { Skeleton } from 'primeng/skeleton';
21
- import { EmailUserTestDialog, buildEmailTemplatePlaceholderFields, collectEmailTemplatePlaceholders } from '@masterteam/system-settings';
22
- import { ToastService } from '@masterteam/components/toast';
23
- import { ModalRef } from '@masterteam/components/dialog';
24
- import { HttpClient, HttpHeaders } from '@angular/common/http';
21
+ import { map, tap, Subject, merge, debounceTime, filter, switchMap, of, catchError, startWith, Observable } from 'rxjs';
22
+ import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
25
23
  import { Action, Selector, State, Store, select } from '@ngxs/store';
26
- import { CrudStateBase, handleApiRequest } from '@masterteam/components';
27
-
28
- class AddTemplateDialog {
29
- modalService = inject(ModalService);
30
- ref = inject(ModalRef);
31
- data = input(null, ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
32
- templateNameControl = new FormControl('', [Validators.required]);
33
- onSave() {
34
- if (this.templateNameControl.valid) {
35
- this.ref.close({ name: this.templateNameControl.value?.trim() });
36
- }
37
- }
38
- onCancel() {
39
- this.ref.close();
40
- }
41
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AddTemplateDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
42
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.8", type: AddTemplateDialog, isStandalone: true, selector: "mt-add-template-dialog", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div [class]=\"modalService.contentClass\" class=\"p-4\">\r\n <mt-text-field\r\n [formControl]=\"templateNameControl\"\r\n [label]=\"'notification.templateName' | transloco\"\r\n [placeholder]=\"'notification.enterTemplateName' | transloco\"\r\n [required]=\"true\"\r\n />\r\n</div>\r\n\r\n<div [class]=\"modalService.footerClass\">\r\n <mt-button\r\n [label]=\"'notification.cancel' | transloco\"\r\n variant=\"outlined\"\r\n (onClick)=\"onCancel()\"\r\n />\r\n <mt-button\r\n [label]=\"'notification.save' | transloco\"\r\n [disabled]=\"!templateNameControl.valid\"\r\n (onClick)=\"onSave()\"\r\n />\r\n</div>\r\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { 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: "ngmodule", type: TranslocoModule }, { kind: "component", type: TextField, selector: "mt-text-field", inputs: ["field", "hint", "label", "placeholder", "class", "type", "readonly", "pInputs", "required", "maxLength", "icon", "iconPosition"] }, { 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: "pipe", type: i2.TranslocoPipe, name: "transloco" }] });
43
- }
44
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AddTemplateDialog, decorators: [{
45
- type: Component,
46
- args: [{ selector: 'mt-add-template-dialog', standalone: true, imports: [
47
- CommonModule,
48
- ReactiveFormsModule,
49
- TranslocoModule,
50
- TextField,
51
- Button,
52
- ], template: "<div [class]=\"modalService.contentClass\" class=\"p-4\">\r\n <mt-text-field\r\n [formControl]=\"templateNameControl\"\r\n [label]=\"'notification.templateName' | transloco\"\r\n [placeholder]=\"'notification.enterTemplateName' | transloco\"\r\n [required]=\"true\"\r\n />\r\n</div>\r\n\r\n<div [class]=\"modalService.footerClass\">\r\n <mt-button\r\n [label]=\"'notification.cancel' | transloco\"\r\n variant=\"outlined\"\r\n (onClick)=\"onCancel()\"\r\n />\r\n <mt-button\r\n [label]=\"'notification.save' | transloco\"\r\n [disabled]=\"!templateNameControl.valid\"\r\n (onClick)=\"onSave()\"\r\n />\r\n</div>\r\n" }]
53
- }], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }] } });
24
+ import { handleApiRequest } from '@masterteam/components';
25
+ import { ModalRef } from '@masterteam/components/dialog';
26
+ import { RadioCardsField } from '@masterteam/components/radio-cards-field';
27
+ import { ToggleField } from '@masterteam/components/toggle-field';
28
+ import { DatePipe } from '@angular/common';
54
29
 
55
30
  // ============================================================================
56
- // Action Keys Enum
31
+ // Action Keys
57
32
  // ============================================================================
58
33
  var NotificationActionKey;
59
34
  (function (NotificationActionKey) {
60
35
  NotificationActionKey["GetEvents"] = "getEvents";
61
- NotificationActionKey["GetReceivers"] = "getReceivers";
62
- NotificationActionKey["GetProperties"] = "getProperties";
36
+ NotificationActionKey["GetContext"] = "getContext";
37
+ NotificationActionKey["GetDefaults"] = "getDefaults";
38
+ NotificationActionKey["GetPublications"] = "getPublications";
39
+ NotificationActionKey["CreateTemplate"] = "createTemplate";
40
+ NotificationActionKey["SaveTemplate"] = "saveTemplate";
63
41
  NotificationActionKey["AddReceivers"] = "addReceivers";
64
42
  NotificationActionKey["DeleteReceiver"] = "deleteReceiver";
65
- NotificationActionKey["AddTemplate"] = "addTemplate";
66
- NotificationActionKey["UpdateTemplate"] = "updateTemplate";
67
43
  NotificationActionKey["DeleteTemplate"] = "deleteTemplate";
44
+ NotificationActionKey["PublishTemplate"] = "publishTemplate";
45
+ NotificationActionKey["SetActivation"] = "setActivation";
46
+ NotificationActionKey["RestorePublication"] = "restorePublication";
47
+ NotificationActionKey["InitializeDefaults"] = "initializeDefaults";
48
+ NotificationActionKey["RestoreDefault"] = "restoreDefault";
68
49
  })(NotificationActionKey || (NotificationActionKey = {}));
69
- const CHANNEL_CONFIGS = [
70
- {
71
- key: 'email',
72
- labelKey: 'notification.channel.email',
73
- supportsBilingual: false,
74
- bilingualOptional: false,
75
- useRichEditor: true,
76
- templateKey: 'emailTemplate',
77
- },
78
- {
79
- key: 'sms',
80
- labelKey: 'notification.channel.sms',
81
- supportsBilingual: false,
82
- bilingualOptional: false,
83
- useRichEditor: false,
84
- templateKey: 'smsTemplate',
85
- },
86
- {
87
- key: 'app',
88
- labelKey: 'notification.channel.app',
89
- supportsBilingual: true,
90
- bilingualOptional: false,
91
- useRichEditor: true,
92
- translatableKey: 'appTemplate',
93
- },
50
+ /** Writes that must never run twice at once (the contract forbids double submits). */
51
+ const NOTIFICATION_MUTATION_KEYS = [
52
+ NotificationActionKey.CreateTemplate,
53
+ NotificationActionKey.SaveTemplate,
54
+ NotificationActionKey.AddReceivers,
55
+ NotificationActionKey.DeleteReceiver,
56
+ NotificationActionKey.DeleteTemplate,
57
+ NotificationActionKey.PublishTemplate,
58
+ NotificationActionKey.SetActivation,
59
+ NotificationActionKey.RestorePublication,
60
+ NotificationActionKey.InitializeDefaults,
61
+ NotificationActionKey.RestoreDefault,
62
+ ];
63
+ const NOTIFICATION_CHANNELS = [
64
+ 'App',
65
+ 'Email',
66
+ 'Sms',
94
67
  ];
95
- /** Helper to get channel config by key */
96
- function getChannelConfig(key) {
97
- return CHANNEL_CONFIGS.find((c) => c.key === key) ?? CHANNEL_CONFIGS[0];
98
- }
99
- /** Build form control name for a channel field */
100
- function getFormControlName(ch, type) {
101
- if (type === 'single')
102
- return ch.templateKey ?? null;
103
- if (!ch.translatableKey)
104
- return null;
105
- return type === 'en'
106
- ? `${ch.translatableKey}.en`
107
- : `${ch.translatableKey}.ar`;
108
- }
109
68
 
110
69
  // ============================================================================
111
- // Module Configuration
70
+ // Scope
112
71
  // ============================================================================
113
72
  class SetModuleInfo {
114
73
  moduleType;
@@ -125,56 +84,45 @@ class SetModuleInfo {
125
84
  this.parentPath = parentPath;
126
85
  }
127
86
  }
87
+ class ResetNotificationState {
88
+ static type = '[Notification] Reset State';
89
+ }
128
90
  // ============================================================================
129
- // Data Loading Actions
91
+ // Loading
130
92
  // ============================================================================
93
+ /** Loads (or re-reads) the scope's events, keeping the current selection when it still exists. */
131
94
  class GetEvents {
132
95
  static type = '[Notification] Get Events';
133
96
  }
134
- class GetReceivers {
135
- static type = '[Notification] Get Receivers';
136
- }
137
- class GetProperties {
138
- isMultiLang;
139
- static type = '[Notification] Get Properties';
140
- constructor(isMultiLang) {
141
- this.isMultiLang = isMultiLang;
142
- }
143
- }
144
- // ============================================================================
145
- // Action Mode (Workflow)
146
- // ============================================================================
147
- class SetActionMode {
148
- actionId;
149
- static type = '[Notification] Set Action Mode';
150
- constructor(actionId) {
151
- this.actionId = actionId;
97
+ class GetAuthoringContext {
98
+ eventId;
99
+ static type = '[Notification] Get Authoring Context';
100
+ constructor(eventId) {
101
+ this.eventId = eventId;
152
102
  }
153
103
  }
154
- class GetActionReceivers {
155
- actionId;
156
- static type = '[Notification] Get Action Receivers';
157
- constructor(actionId) {
158
- this.actionId = actionId;
104
+ class GetDefaultRecipes {
105
+ eventId;
106
+ static type = '[Notification] Get Default Recipes';
107
+ constructor(eventId) {
108
+ this.eventId = eventId;
159
109
  }
160
110
  }
161
- class GetActionProperties {
162
- actionId;
163
- isMultiLang;
164
- static type = '[Notification] Get Action Properties';
165
- constructor(actionId, isMultiLang) {
166
- this.actionId = actionId;
167
- this.isMultiLang = isMultiLang;
111
+ class GetPublications {
112
+ templateId;
113
+ static type = '[Notification] Get Publications';
114
+ constructor(templateId) {
115
+ this.templateId = templateId;
168
116
  }
169
117
  }
170
118
  // ============================================================================
171
- // Selection Actions
119
+ // Selection
172
120
  // ============================================================================
173
121
  class SelectEvent {
174
- eventName;
122
+ eventId;
175
123
  static type = '[Notification] Select Event';
176
- constructor(eventName) {
177
- this.eventName = eventName;
124
+ constructor(eventId) {
125
+ this.eventId = eventId;
178
126
  }
179
127
  }
180
128
  class SelectTemplate {
@@ -185,58 +133,426 @@ class SelectTemplate {
185
133
  }
186
134
  }
187
135
  // ============================================================================
188
- // Receiver CRUD
136
+ // Template writes
189
137
  // ============================================================================
190
- class AddReceivers {
138
+ class CreateTemplate {
139
+ eventId;
140
+ payload;
141
+ static type = '[Notification] Create Template';
142
+ constructor(eventId, payload) {
143
+ this.eventId = eventId;
144
+ this.payload = payload;
145
+ }
146
+ }
147
+ class SaveTemplate {
191
148
  templateId;
192
149
  payload;
193
- static type = '[Notification] Add Receivers';
150
+ static type = '[Notification] Save Template';
194
151
  constructor(templateId, payload) {
195
152
  this.templateId = templateId;
196
153
  this.payload = payload;
197
154
  }
198
155
  }
156
+ class DeleteTemplate {
157
+ templateId;
158
+ expectedRevision;
159
+ static type = '[Notification] Delete Template';
160
+ constructor(templateId, expectedRevision) {
161
+ this.templateId = templateId;
162
+ this.expectedRevision = expectedRevision;
163
+ }
164
+ }
165
+ class AddReceivers {
166
+ templateId;
167
+ expectedRevision;
168
+ receivers;
169
+ static type = '[Notification] Add Receivers';
170
+ constructor(templateId, expectedRevision, receivers) {
171
+ this.templateId = templateId;
172
+ this.expectedRevision = expectedRevision;
173
+ this.receivers = receivers;
174
+ }
175
+ }
199
176
  class DeleteReceiver {
200
177
  receiverId;
178
+ expectedRevision;
201
179
  static type = '[Notification] Delete Receiver';
202
- constructor(receiverId) {
180
+ constructor(receiverId, expectedRevision) {
203
181
  this.receiverId = receiverId;
182
+ this.expectedRevision = expectedRevision;
204
183
  }
205
184
  }
206
185
  // ============================================================================
207
- // Template CRUD
186
+ // Lifecycle
208
187
  // ============================================================================
209
- class AddTemplate {
210
- eventId;
211
- payload;
212
- static type = '[Notification] Add Template';
213
- constructor(eventId, payload) {
214
- this.eventId = eventId;
215
- this.payload = payload;
188
+ class PublishTemplate {
189
+ templateId;
190
+ expectedRevision;
191
+ reviews;
192
+ static type = '[Notification] Publish Template';
193
+ constructor(templateId, expectedRevision, reviews) {
194
+ this.templateId = templateId;
195
+ this.expectedRevision = expectedRevision;
196
+ this.reviews = reviews;
216
197
  }
217
198
  }
218
- class UpdateTemplate {
199
+ class SetTemplateActivation {
219
200
  templateId;
220
- payload;
221
- static type = '[Notification] Update Template';
222
- constructor(templateId, payload) {
201
+ expectedRevision;
202
+ enabled;
203
+ reviews;
204
+ static type = '[Notification] Set Template Activation';
205
+ constructor(templateId, expectedRevision, enabled, reviews) {
223
206
  this.templateId = templateId;
224
- this.payload = payload;
207
+ this.expectedRevision = expectedRevision;
208
+ this.enabled = enabled;
209
+ this.reviews = reviews;
225
210
  }
226
211
  }
227
- class DeleteTemplate {
212
+ class RestorePublication {
228
213
  templateId;
229
- static type = '[Notification] Delete Template';
230
- constructor(templateId) {
214
+ expectedRevision;
215
+ publicationRevision;
216
+ reviews;
217
+ static type = '[Notification] Restore Publication';
218
+ constructor(templateId, expectedRevision, publicationRevision, reviews) {
231
219
  this.templateId = templateId;
220
+ this.expectedRevision = expectedRevision;
221
+ this.publicationRevision = publicationRevision;
222
+ this.reviews = reviews;
232
223
  }
233
224
  }
234
225
  // ============================================================================
235
- // Reset State
226
+ // Defaults
236
227
  // ============================================================================
237
- class ResetNotificationState {
238
- static type = '[Notification] Reset State';
228
+ class InitializeDefaults {
229
+ eventId;
230
+ static type = '[Notification] Initialize Defaults';
231
+ constructor(eventId) {
232
+ this.eventId = eventId;
233
+ }
234
+ }
235
+ class RestoreDefault {
236
+ eventId;
237
+ payload;
238
+ static type = '[Notification] Restore Default';
239
+ constructor(eventId, payload) {
240
+ this.eventId = eventId;
241
+ this.payload = payload;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Notification authoring endpoints. Every call is marked `noMessage`: the
247
+ * backend answers failures with a bare stable code (`NOTIFICATION_*`) as the
248
+ * message, so the host's message interceptor would toast that raw code. The
249
+ * editor translates the code itself (see `notification.errors.ts`).
250
+ */
251
+ class NotificationTemplateApi {
252
+ http = inject(HttpClient);
253
+ baseUrl = 'notifications';
254
+ headers = new HttpHeaders({ noMessage: 'true' });
255
+ getEvents(scopePath) {
256
+ return this.get(`${scopePath}/events`);
257
+ }
258
+ getContext(eventId) {
259
+ return this.get(`/events/${eventId}/authoring-context`);
260
+ }
261
+ getDefaults(eventId) {
262
+ return this.get(`/events/${eventId}/default-recipes`);
263
+ }
264
+ initializeDefaults(eventId) {
265
+ return this.post(`/events/${eventId}/defaults`, null);
266
+ }
267
+ restoreDefault(eventId, payload) {
268
+ return this.post(`/events/${eventId}/defaults/restore`, payload);
269
+ }
270
+ preview(eventId, request) {
271
+ return this.post(`/events/${eventId}/preview`, request);
272
+ }
273
+ createTemplate(eventId, payload) {
274
+ return this.post(`/events/${eventId}`, payload);
275
+ }
276
+ saveTemplate(templateId, payload) {
277
+ return this.http
278
+ .put(this.url(`/templates/${templateId}`), payload, { headers: this.headers })
279
+ .pipe(map((response) => response.data));
280
+ }
281
+ addReceivers(templateId, expectedRevision, receivers) {
282
+ return this.post(`/templates/${templateId}`, {
283
+ expectedRevision,
284
+ receivers,
285
+ });
286
+ }
287
+ deleteReceiver(receiverId, expectedRevision) {
288
+ return this.delete(`/receivers/${receiverId}`, expectedRevision);
289
+ }
290
+ deleteTemplate(templateId, expectedRevision) {
291
+ return this.delete(`/templates/${templateId}`, expectedRevision);
292
+ }
293
+ getPublications(templateId) {
294
+ return this.get(`/templates/${templateId}/publications`);
295
+ }
296
+ publish(templateId, expectedRevision, reviews) {
297
+ return this.post(`/templates/${templateId}/publish`, {
298
+ expectedRevision,
299
+ ...reviews,
300
+ });
301
+ }
302
+ setActivation(templateId, expectedRevision, enabled, reviews) {
303
+ // Turning off needs no review; the flags are only sent when turning on.
304
+ const body = enabled
305
+ ? { expectedRevision, enabled, ...reviews }
306
+ : { expectedRevision, enabled };
307
+ return this.http
308
+ .put(this.url(`/templates/${templateId}/activation`), body, { headers: this.headers })
309
+ .pipe(map((response) => response.data));
310
+ }
311
+ restorePublication(templateId, expectedRevision, publicationRevision, reviews) {
312
+ return this.post(`/templates/${templateId}/restore`, {
313
+ expectedRevision,
314
+ publicationRevision,
315
+ ...reviews,
316
+ });
317
+ }
318
+ get(path) {
319
+ return this.http
320
+ .get(this.url(path), { headers: this.headers })
321
+ .pipe(map((response) => response.data));
322
+ }
323
+ post(path, body) {
324
+ return this.http
325
+ .post(this.url(path), body, { headers: this.headers })
326
+ .pipe(map((response) => response.data));
327
+ }
328
+ delete(path, expectedRevision) {
329
+ return this.http
330
+ .delete(this.url(path), {
331
+ headers: this.headers,
332
+ params: { expectedRevision },
333
+ })
334
+ .pipe(map((response) => response.data));
335
+ }
336
+ url(path) {
337
+ return `${this.baseUrl}${path}`;
338
+ }
339
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationTemplateApi, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
340
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationTemplateApi, providedIn: 'root' });
341
+ }
342
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationTemplateApi, decorators: [{
343
+ type: Injectable,
344
+ args: [{ providedIn: 'root' }]
345
+ }] });
346
+
347
+ /**
348
+ * Reads the stable code out of a failed notification request.
349
+ *
350
+ * Domain failures arrive as `errors: { code, message, details }`. Command
351
+ * endpoints can wrap the domain code in `errors.details.code` under an outer
352
+ * validation code (e.g. `VAL_002`), so the nested code wins when present.
353
+ * Display text is never matched — only codes.
354
+ */
355
+ function readNotificationFailure(error) {
356
+ const status = error instanceof HttpErrorResponse ? error.status : null;
357
+ const body = error instanceof HttpErrorResponse ? error.error : error;
358
+ const errors = body?.errors;
359
+ const details = errors?.details;
360
+ const nestedCode = details && typeof details === 'object' && !Array.isArray(details)
361
+ ? details.code
362
+ : undefined;
363
+ const code = typeof nestedCode === 'string'
364
+ ? nestedCode
365
+ : typeof errors?.code === 'string'
366
+ ? errors.code
367
+ : null;
368
+ return {
369
+ code,
370
+ status,
371
+ diagnostics: Array.isArray(details) ? toDiagnostics(details) : [],
372
+ };
373
+ }
374
+ function isRevisionConflict(failure) {
375
+ return (failure.status === 409 ||
376
+ failure.code === 'NOTIFICATION_REVISION_CONFLICT' ||
377
+ failure.code === 'EMAIL_REVISION_CONFLICT');
378
+ }
379
+ function toDiagnostics(values) {
380
+ return values.filter((value) => typeof value === 'object' &&
381
+ value !== null &&
382
+ typeof value.code === 'string');
383
+ }
384
+
385
+ function recipientKey(type, id) {
386
+ return `${type ?? ''}:${id ?? ''}`;
387
+ }
388
+ /**
389
+ * Flattens the recipient catalog into select-ready groups. A node is a
390
+ * subgroup only when its `items` is an array — leaves carry `items: null` —
391
+ * and subgroup names are kept as a prefix so two "Manager" roles from
392
+ * different levels stay distinguishable.
393
+ */
394
+ function flattenRecipientCatalog(groups, lang) {
395
+ return groups
396
+ .map((group) => ({
397
+ groupName: group.groupName,
398
+ items: flattenNodes(group.items, lang),
399
+ }))
400
+ .filter((group) => group.items.length > 0);
401
+ }
402
+ function flattenNodes(nodes, lang, prefix) {
403
+ return (nodes ?? []).flatMap((node) => {
404
+ if (Array.isArray(node.items)) {
405
+ const label = node.subGroupName
406
+ ? prefix
407
+ ? `${prefix} - ${node.subGroupName}`
408
+ : node.subGroupName
409
+ : prefix;
410
+ return flattenNodes(node.items, lang, label);
411
+ }
412
+ if (!node.id || !node.type)
413
+ return [];
414
+ const name = (lang === 'ar' ? node.nameAr : node.nameEn) || node.name || node.id;
415
+ return [
416
+ {
417
+ key: recipientKey(node.type, node.id),
418
+ id: node.id,
419
+ type: node.type,
420
+ name: prefix ? `${prefix} - ${name}` : name,
421
+ },
422
+ ];
423
+ });
424
+ }
425
+ function templateStatus(template) {
426
+ if (template.publishedRevision == null)
427
+ return 'draft';
428
+ return template.isEnabled ? 'on' : 'off';
429
+ }
430
+ /** The retained publication that is live right now, if it is in the loaded history. */
431
+ function currentPublication(template, publications) {
432
+ if (template.publishedRevision == null)
433
+ return null;
434
+ return (publications.find((publication) => publication.revision === template.publishedRevision) ?? null);
435
+ }
436
+ /**
437
+ * Whether the saved draft differs from what is published. Comparing
438
+ * `draftRevision` to `publishedRevision` is wrong (they count different
439
+ * things); the contract compares the draft against the `sourceDraftRevision`
440
+ * of the current publication. Null when that cannot be known yet.
441
+ */
442
+ function hasUnpublishedChanges(template, publications) {
443
+ if (template.publishedRevision == null)
444
+ return null;
445
+ const publication = currentPublication(template, publications);
446
+ if (!publication)
447
+ return null;
448
+ return template.draftRevision !== publication.sourceDraftRevision;
449
+ }
450
+ /** The default slot that owns this template, if any (owned defaults need a language review). */
451
+ function isDefaultOwned(template, defaults) {
452
+ return (defaults?.slots ?? []).some((slot) => slot.templateId === template.id);
453
+ }
454
+ /** Channels with a recipe but no slot at all — the only ones "Add defaults" may create. */
455
+ function missingDefaultChannels(defaults) {
456
+ if (!defaults)
457
+ return [];
458
+ const slotted = new Set(defaults.slots.map((slot) => slot.channel));
459
+ return defaults.recipes
460
+ .map((recipe) => recipe.channel)
461
+ .filter((channel) => !slotted.has(channel));
462
+ }
463
+ function parseAppContent(value) {
464
+ if (!value)
465
+ return { en: '', ar: '' };
466
+ try {
467
+ const parsed = JSON.parse(value);
468
+ return {
469
+ en: typeof parsed?.en === 'string' ? parsed.en : '',
470
+ ar: typeof parsed?.ar === 'string' ? parsed.ar : '',
471
+ };
472
+ }
473
+ catch {
474
+ return { en: '', ar: '' };
475
+ }
476
+ }
477
+ /** Maps retained publication content onto the event preview request. */
478
+ function publicationPreviewRequest(content) {
479
+ return contentPreviewRequest({
480
+ channel: content.channel,
481
+ subject: content.subject,
482
+ emailTemplate: content.emailContent,
483
+ smsTemplate: content.smsContent,
484
+ appTemplate: parseAppContent(content.appContent),
485
+ });
486
+ }
487
+ function templatePreviewRequest(template) {
488
+ return contentPreviewRequest(template);
489
+ }
490
+ function contentPreviewRequest(content) {
491
+ switch (content.channel) {
492
+ case 'Email':
493
+ return {
494
+ channel: 'Email',
495
+ subject: content.subject ?? '',
496
+ emailTemplate: content.emailTemplate ?? '',
497
+ };
498
+ case 'Sms':
499
+ return { channel: 'Sms', smsTemplate: content.smsTemplate ?? '' };
500
+ case 'App':
501
+ return {
502
+ channel: 'App',
503
+ appTemplate: {
504
+ en: content.appTemplate?.en ?? '',
505
+ ar: content.appTemplate?.ar ?? '',
506
+ },
507
+ };
508
+ }
509
+ }
510
+ const PREVIEW_TOKEN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
511
+ const MAX_PREVIEW_SAMPLES = 100;
512
+ /** Sample for the record link: resolved by the server against its client base URL. */
513
+ const SAMPLE_WEB_URL = '/';
514
+ /**
515
+ * Readable sample values for a preview: every field the content uses shows as
516
+ * its own name in brackets (`[Item name]`) instead of the server's "-", and the
517
+ * record link resolves. Only keys in the event's catalog are sent — the server
518
+ * rejects any other sample key — and never more than the server's limit.
519
+ */
520
+ function previewSamples(request, properties, lang) {
521
+ const catalog = new Map(properties
522
+ .flatMap((group) => group.properties)
523
+ .map((property) => [property.key.toLowerCase(), property]));
524
+ const text = [
525
+ request.emailTemplate,
526
+ request.smsTemplate,
527
+ request.appTemplate?.en,
528
+ request.appTemplate?.ar,
529
+ ]
530
+ .filter((value) => !!value)
531
+ .join(' ');
532
+ const samples = {};
533
+ for (const match of text.matchAll(PREVIEW_TOKEN)) {
534
+ if (Object.keys(samples).length >= MAX_PREVIEW_SAMPLES)
535
+ break;
536
+ const property = catalog.get(match[1].toLowerCase());
537
+ if (!property || property.key in samples)
538
+ continue;
539
+ if (property.key.toLowerCase() === 'weburl') {
540
+ samples[property.key] = SAMPLE_WEB_URL;
541
+ continue;
542
+ }
543
+ if (property.key.toLowerCase() === 'mobileuri')
544
+ continue;
545
+ const label = (lang === 'ar' ? property.nameAr : property.nameEn) || property.name;
546
+ samples[property.key] = `[${label}]`;
547
+ }
548
+ return samples;
549
+ }
550
+ function withPreviewSamples(request, properties, lang) {
551
+ const samples = previewSamples(request, properties, lang);
552
+ return Object.keys(samples).length > 0 ? { ...request, samples } : request;
239
553
  }
554
+ /** The system principal that publishes generated App defaults. */
555
+ const DEFAULTS_PUBLISHER = 'system:notification-defaults';
240
556
 
241
557
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
242
558
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -244,59 +560,51 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
244
560
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
245
561
  return c > 3 && r && Object.defineProperty(target, key, r), r;
246
562
  };
247
- // Default State
248
563
  const DEFAULT_STATE = {
249
- // Module configuration
250
564
  moduleType: null,
251
565
  moduleId: null,
252
566
  parentModuleType: null,
253
567
  parentModuleId: null,
254
568
  parentPath: '',
255
- // Action mode
256
- isActionMode: false,
257
- selectedActionId: null,
258
- // Data collections
259
569
  events: [],
260
- templates: [], // Flattened templates
261
- actions: [],
262
- receivers: [],
263
- receiverGroups: [],
264
- propertyGroups: [],
265
- // Selection state
266
- selectedEventName: null,
570
+ templates: [],
571
+ context: null,
572
+ defaults: null,
573
+ publications: [],
574
+ publicationsTemplateId: null,
575
+ selectedEventId: null,
267
576
  selectedTemplateId: null,
268
- // Loading state (from LoadingStateShape)
269
577
  loadingActive: [],
270
578
  errors: {},
271
579
  };
272
- // State Definition
273
- let NotificationState = class NotificationState extends CrudStateBase {
274
- http = inject(HttpClient);
275
- baseUrl = 'notifications';
276
- // Selectors - Individual selectors for fine-grained reactivity
277
- static getState(state) {
278
- return state;
279
- }
580
+ let NotificationState = class NotificationState {
581
+ api = inject(NotificationTemplateApi);
582
+ // ==========================================================================
583
+ // Slice selectors
584
+ // ==========================================================================
280
585
  static getEvents(state) {
281
586
  return state.events;
282
587
  }
283
588
  static getTemplates(state) {
284
589
  return state.templates;
285
590
  }
286
- static getReceivers(state) {
287
- return state.receivers;
591
+ static getSelectedEventId(state) {
592
+ return state.selectedEventId;
288
593
  }
289
- static getReceiverGroups(state) {
290
- return state.receiverGroups;
594
+ static getSelectedTemplateId(state) {
595
+ return state.selectedTemplateId;
291
596
  }
292
- static getPropertyGroups(state) {
293
- return state.propertyGroups;
597
+ static getContext(state) {
598
+ return state.context;
294
599
  }
295
- static getSelectedEventName(state) {
296
- return state.selectedEventName;
600
+ static getDefaults(state) {
601
+ return state.defaults;
297
602
  }
298
- static getSelectedTemplateId(state) {
299
- return state.selectedTemplateId;
603
+ static getPublicationsSlice(state) {
604
+ return {
605
+ templateId: state.publicationsTemplateId,
606
+ items: state.publications,
607
+ };
300
608
  }
301
609
  static getLoadingActive(state) {
302
610
  return state.loadingActive;
@@ -304,119 +612,26 @@ let NotificationState = class NotificationState extends CrudStateBase {
304
612
  static getErrors(state) {
305
613
  return state.errors;
306
614
  }
615
+ // ==========================================================================
307
616
  // Derived selectors
308
- static getSelectedEvent(events, selectedEventName) {
309
- return events.find((e) => e.eventName === selectedEventName) ?? null;
617
+ // ==========================================================================
618
+ static getSelectedEvent(events, selectedEventId) {
619
+ return events.find((event) => event.id === selectedEventId) ?? null;
310
620
  }
311
- static getEventTemplates(templates, selectedEvent) {
312
- if (!selectedEvent)
621
+ static getEventTemplates(templates, selectedEventId) {
622
+ if (selectedEventId == null)
313
623
  return [];
314
- return templates.filter((t) => t.eventId === selectedEvent.id);
624
+ return templates.filter((template) => template.eventId === selectedEventId);
315
625
  }
316
626
  static getSelectedTemplate(templates, selectedTemplateId) {
317
- return templates.find((t) => t.id === selectedTemplateId) ?? null;
318
- }
319
- static getAvailableReceivers(receivers, selectedTemplate) {
320
- if (!selectedTemplate)
321
- return receivers;
322
- const templateReceivers = selectedTemplate.receivers ?? [];
323
- const assignedIds = new Set(templateReceivers.map((r) => r.identifier));
324
- return receivers.filter((r) => !assignedIds.has(String(r.id)));
325
- }
326
- static getAvailableReceiverGroups(receiverGroups, selectedTemplate) {
327
- if (!selectedTemplate)
328
- return receiverGroups;
329
- const assignedIds = new Set((selectedTemplate.receivers ?? []).map((r) => r.identifier));
330
- return receiverGroups
331
- .map((group) => ({
332
- ...group,
333
- items: group.items.filter((item) => !assignedIds.has(String(item.id))),
334
- }))
335
- .filter((group) => group.items.length > 0);
336
- }
337
- // Helper Methods
338
- /** API returns events with nested templates - we flatten for state */
339
- flattenEventsResponse(eventsWithTemplates) {
340
- const events = [];
341
- const templates = [];
342
- for (const event of eventsWithTemplates) {
343
- const { templates: eventTemplates, ...eventData } = event;
344
- events.push(eventData);
345
- templates.push(...eventTemplates.map((t) => ({ ...t, eventId: event.id })));
346
- }
347
- return { events, templates };
348
- }
349
- normalizeReceiverGroups(groups) {
350
- return groups
351
- .map((group) => ({
352
- groupName: group.groupName,
353
- items: this.flattenReceiverItems(group.items),
354
- }))
355
- .filter((group) => group.items.length > 0);
356
- }
357
- flattenReceiverItems(items, parentLabel) {
358
- if (!Array.isArray(items)) {
359
- return [];
360
- }
361
- return items.flatMap((item) => {
362
- const subGroupItems = item.items;
363
- if (Array.isArray(subGroupItems)) {
364
- const subGroupName = item.subGroupName;
365
- const nextLabel = subGroupName
366
- ? parentLabel
367
- ? `${parentLabel} - ${subGroupName}`
368
- : subGroupName
369
- : parentLabel;
370
- return this.flattenReceiverItems(subGroupItems, nextLabel);
371
- }
372
- const receiver = item;
373
- if (receiver.id == null || !receiver.name) {
374
- return [];
375
- }
376
- return [
377
- {
378
- ...receiver,
379
- name: parentLabel
380
- ? `${parentLabel} - ${receiver.name}`
381
- : receiver.name,
382
- },
383
- ];
384
- });
385
- }
386
- hasOwnField(value, field) {
387
- return Object.prototype.hasOwnProperty.call(value, field);
627
+ return (templates.find((template) => template.id === selectedTemplateId) ?? null);
388
628
  }
389
- mergeUpdatedTemplate(existingTemplate, updatedTemplate) {
390
- const mergedTemplate = {
391
- ...existingTemplate,
392
- name: updatedTemplate.name ?? existingTemplate.name,
393
- };
394
- if (this.hasOwnField(updatedTemplate, 'emailTemplate')) {
395
- mergedTemplate.emailTemplate = updatedTemplate.emailTemplate ?? '';
396
- }
397
- if (this.hasOwnField(updatedTemplate, 'emailContentTranslatable') &&
398
- updatedTemplate.emailContentTranslatable) {
399
- mergedTemplate.emailContentTranslatable = {
400
- ...existingTemplate.emailContentTranslatable,
401
- ...updatedTemplate.emailContentTranslatable,
402
- };
403
- }
404
- if (this.hasOwnField(updatedTemplate, 'smsTemplate')) {
405
- mergedTemplate.smsTemplate = updatedTemplate.smsTemplate ?? '';
406
- }
407
- if (this.hasOwnField(updatedTemplate, 'appTemplate') &&
408
- updatedTemplate.appTemplate) {
409
- mergedTemplate.appTemplate = {
410
- ...existingTemplate.appTemplate,
411
- ...updatedTemplate.appTemplate,
412
- };
413
- }
414
- if (this.hasOwnField(updatedTemplate, 'conditionFormula')) {
415
- mergedTemplate.conditionFormula = updatedTemplate.conditionFormula;
416
- }
417
- return mergedTemplate;
629
+ static getPublications(slice, selectedTemplateId) {
630
+ return slice.templateId === selectedTemplateId ? slice.items : [];
418
631
  }
419
- // Module Configuration
632
+ // ==========================================================================
633
+ // Scope
634
+ // ==========================================================================
420
635
  setModuleInfo(ctx, action) {
421
636
  let parentPath = '';
422
637
  if (action.parentModuleType && action.parentModuleId) {
@@ -431,218 +646,273 @@ let NotificationState = class NotificationState extends CrudStateBase {
431
646
  parentModuleType: action.parentModuleType ?? null,
432
647
  parentModuleId: action.parentModuleId ?? null,
433
648
  parentPath,
434
- isActionMode: action.moduleType === 'workflow',
435
649
  });
436
650
  }
437
651
  resetState(ctx) {
438
652
  ctx.setState(DEFAULT_STATE);
439
653
  }
440
- // Data Loading Actions (Normal Mode)
654
+ // ==========================================================================
655
+ // Loading
656
+ // ==========================================================================
441
657
  getEvents(ctx) {
442
- const { moduleType, moduleId, parentPath } = ctx.getState();
443
- const req$ = this.http.get(`${this.baseUrl}${parentPath}/${moduleType}/${moduleId}/events`);
444
- return this.load(ctx, {
658
+ const scope = scopePathOf(ctx.getState());
659
+ if (!scope)
660
+ return;
661
+ const previousEventId = ctx.getState().selectedEventId;
662
+ return handleApiRequest({
663
+ ctx,
445
664
  key: NotificationActionKey.GetEvents,
446
- request$: req$,
447
- updateState: (_state, data) => {
448
- const { events, templates } = this.flattenEventsResponse(data ?? []);
449
- const firstEvent = events[0];
450
- const firstTemplate = templates.find((t) => t.eventId === firstEvent?.id);
451
- return {
452
- events,
453
- templates,
454
- selectedEventName: firstEvent?.eventName ?? null,
455
- selectedTemplateId: firstTemplate?.id ?? null,
456
- };
665
+ request$: this.api.getEvents(scope),
666
+ onSuccess: (data, state) => {
667
+ // A late answer for a scope the host already left must not land.
668
+ if (scopePathOf(state) !== scope)
669
+ return;
670
+ const { events, templates } = flattenEvents(data ?? []);
671
+ const selectedEventId = events.some((event) => event.id === state.selectedEventId)
672
+ ? state.selectedEventId
673
+ : (events[0]?.id ?? null);
674
+ const eventTemplates = templates.filter((template) => template.eventId === selectedEventId);
675
+ const selectedTemplateId = eventTemplates.some((template) => template.id === state.selectedTemplateId)
676
+ ? state.selectedTemplateId
677
+ : (eventTemplates[0]?.id ?? null);
678
+ return { events, templates, selectedEventId, selectedTemplateId };
457
679
  },
680
+ }).pipe(tap(() => {
681
+ const state = ctx.getState();
682
+ if (scopePathOf(state) !== scope)
683
+ return;
684
+ const eventId = state.selectedEventId;
685
+ if (eventId != null &&
686
+ (eventId !== previousEventId || state.context?.eventId !== eventId)) {
687
+ ctx.patchState({ context: null, defaults: null });
688
+ ctx.dispatch([
689
+ new GetAuthoringContext(eventId),
690
+ new GetDefaultRecipes(eventId),
691
+ ]);
692
+ }
693
+ if (state.selectedTemplateId != null) {
694
+ ctx.dispatch(new GetPublications(state.selectedTemplateId));
695
+ }
696
+ }));
697
+ }
698
+ getAuthoringContext(ctx, action) {
699
+ return handleApiRequest({
700
+ ctx,
701
+ key: NotificationActionKey.GetContext,
702
+ request$: this.api.getContext(action.eventId),
703
+ onSuccess: (context, state) => state.selectedEventId === action.eventId ? { context } : undefined,
458
704
  });
459
705
  }
460
- getReceivers(ctx) {
461
- const { parentPath, moduleType, moduleId } = ctx.getState();
462
- const req$ = this.http.get(`${this.baseUrl}${parentPath}/${moduleType}/${moduleId}/receivers`);
706
+ getDefaultRecipes(ctx, action) {
463
707
  return handleApiRequest({
464
708
  ctx,
465
- key: NotificationActionKey.GetReceivers,
466
- request$: req$,
467
- onSuccess: (response) => {
468
- const receiverGroups = this.normalizeReceiverGroups(response.data ?? []);
469
- return {
470
- receiverGroups,
471
- receivers: receiverGroups.flatMap((group) => group.items),
472
- };
473
- },
709
+ key: NotificationActionKey.GetDefaults,
710
+ request$: this.api.getDefaults(action.eventId),
711
+ onSuccess: (defaults, state) => state.selectedEventId === action.eventId ? { defaults } : undefined,
474
712
  });
475
713
  }
476
- getProperties(ctx, action) {
477
- const { moduleType, moduleId, parentPath } = ctx.getState();
478
- const url = `${this.baseUrl}${parentPath}/${moduleType}/${moduleId}/properties?IsMultiLanguageEnabled=${action.isMultiLang ? 'true' : 'false'}`;
479
- const req$ = this.http.get(url);
714
+ getPublications(ctx, action) {
715
+ const template = ctx
716
+ .getState()
717
+ .templates.find((item) => item.id === action.templateId);
718
+ // A template that was never published has no history to read.
719
+ if (template?.publishedRevision == null) {
720
+ ctx.patchState({
721
+ publications: [],
722
+ publicationsTemplateId: action.templateId,
723
+ });
724
+ return;
725
+ }
480
726
  return handleApiRequest({
481
727
  ctx,
482
- key: NotificationActionKey.GetProperties,
483
- request$: req$,
484
- onSuccess: (response) => ({
485
- propertyGroups: response.data?.map((group) => ({
486
- ...group,
487
- icon: 'custom.list-cards',
488
- })) ?? [],
489
- }),
728
+ key: NotificationActionKey.GetPublications,
729
+ request$: this.api.getPublications(action.templateId),
730
+ onSuccess: (publications, state) => state.selectedTemplateId === action.templateId
731
+ ? { publications, publicationsTemplateId: action.templateId }
732
+ : undefined,
490
733
  });
491
734
  }
492
- // Action Mode (Workflow)
493
- setActionMode(ctx, action) {
735
+ // ==========================================================================
736
+ // Selection
737
+ // ==========================================================================
738
+ selectEvent(ctx, action) {
739
+ const state = ctx.getState();
740
+ if (state.selectedEventId === action.eventId)
741
+ return;
742
+ const firstTemplate = state.templates.find((template) => template.eventId === action.eventId);
494
743
  ctx.patchState({
495
- selectedActionId: action.actionId,
744
+ selectedEventId: action.eventId,
745
+ selectedTemplateId: firstTemplate?.id ?? null,
746
+ context: null,
747
+ defaults: null,
748
+ publications: [],
749
+ publicationsTemplateId: null,
496
750
  });
751
+ const loads = [
752
+ new GetAuthoringContext(action.eventId),
753
+ new GetDefaultRecipes(action.eventId),
754
+ ];
755
+ if (firstTemplate)
756
+ loads.push(new GetPublications(firstTemplate.id));
757
+ return ctx.dispatch(loads);
497
758
  }
498
- getActionReceivers(ctx, action) {
499
- const req$ = this.http.get(`${this.baseUrl}/action/${action.actionId}/receivers`);
500
- return handleApiRequest({
501
- ctx,
502
- key: NotificationActionKey.GetReceivers,
503
- request$: req$,
504
- onSuccess: (response) => {
505
- const receiverGroups = this.normalizeReceiverGroups(response.data ?? []);
506
- return {
507
- receiverGroups,
508
- receivers: receiverGroups.flatMap((group) => group.items),
509
- };
510
- },
759
+ selectTemplate(ctx, action) {
760
+ if (ctx.getState().selectedTemplateId === action.templateId)
761
+ return;
762
+ ctx.patchState({
763
+ selectedTemplateId: action.templateId,
764
+ publications: [],
765
+ publicationsTemplateId: null,
511
766
  });
767
+ return ctx.dispatch(new GetPublications(action.templateId));
512
768
  }
513
- getActionProperties(ctx, action) {
514
- const url = `${this.baseUrl}/action/${action.actionId}/properties?IsMultiLanguageEnabled=${action.isMultiLang ? 'true' : 'false'}`;
515
- const req$ = this.http.get(url);
769
+ // ==========================================================================
770
+ // Template writes — every one returns the whole template DTO, which becomes
771
+ // the new server baseline (its `revision` is the next expectedRevision).
772
+ // ==========================================================================
773
+ createTemplate(ctx, action) {
516
774
  return handleApiRequest({
517
775
  ctx,
518
- key: NotificationActionKey.GetProperties,
519
- request$: req$,
520
- onSuccess: (response) => ({
521
- propertyGroups: response.data ?? [],
776
+ key: NotificationActionKey.CreateTemplate,
777
+ request$: this.api.createTemplate(action.eventId, action.payload),
778
+ onSuccess: (template, state) => ({
779
+ templates: upsertTemplate(state.templates, template),
780
+ ...(state.selectedEventId === action.eventId
781
+ ? {
782
+ selectedTemplateId: template.id,
783
+ publications: [],
784
+ publicationsTemplateId: template.id,
785
+ }
786
+ : {}),
522
787
  }),
523
788
  });
524
789
  }
525
- // Selection Actions
526
- selectEvent(ctx, action) {
527
- const state = ctx.getState();
528
- const event = state.events.find((e) => e.eventName === action.eventName);
529
- const eventTemplates = state.templates.filter((t) => t.eventId === event?.id);
530
- ctx.patchState({
531
- selectedEventName: action.eventName,
532
- selectedTemplateId: eventTemplates[0]?.id ?? null,
790
+ saveTemplate(ctx, action) {
791
+ return handleApiRequest({
792
+ ctx,
793
+ key: NotificationActionKey.SaveTemplate,
794
+ request$: this.api.saveTemplate(action.templateId, action.payload),
795
+ onSuccess: (template, state) => ({
796
+ templates: upsertTemplate(state.templates, template),
797
+ }),
533
798
  });
534
799
  }
535
- selectTemplate(ctx, action) {
536
- ctx.patchState({ selectedTemplateId: action.templateId });
537
- }
538
- // Receiver CRUD
539
800
  addReceivers(ctx, action) {
540
- const req$ = this.http.post(`${this.baseUrl}/templates/${action.templateId}`, action.payload);
541
801
  return handleApiRequest({
542
802
  ctx,
543
803
  key: NotificationActionKey.AddReceivers,
544
- request$: req$,
545
- onSuccess: (response, state) => {
546
- const newReceivers = response.data ?? [];
547
- const templates = this.adapter.updateOne(state.templates, action.templateId, {
548
- receivers: [
549
- ...(state.templates.find((t) => t.id === action.templateId)
550
- ?.receivers ?? []),
551
- ...newReceivers,
552
- ],
553
- }, 'id');
554
- return { templates };
555
- },
804
+ request$: this.api.addReceivers(action.templateId, action.expectedRevision, action.receivers),
805
+ onSuccess: (template, state) => ({
806
+ templates: upsertTemplate(state.templates, template),
807
+ }),
556
808
  });
557
809
  }
558
810
  deleteReceiver(ctx, action) {
559
- const state = ctx.getState();
560
- const template = this.getSelectedTemplate(state);
561
- if (!template) {
562
- return;
563
- }
564
- const req$ = this.http.delete(`${this.baseUrl}/receivers/${action.receiverId}`);
565
811
  return handleApiRequest({
566
812
  ctx,
567
813
  key: NotificationActionKey.DeleteReceiver,
568
- request$: req$,
569
- onSuccess: (_, currentState) => {
570
- const templates = this.adapter.updateOne(currentState.templates, template.id, {
571
- receivers: template.receivers.filter((r) => r.id !== action.receiverId),
572
- }, 'id');
573
- return { templates };
574
- },
814
+ request$: this.api.deleteReceiver(action.receiverId, action.expectedRevision),
815
+ onSuccess: (template, state) => ({
816
+ templates: upsertTemplate(state.templates, template),
817
+ }),
575
818
  });
576
819
  }
577
- // Template CRUD
578
- addTemplate(ctx, action) {
579
- const req$ = this.http.post(`${this.baseUrl}/events/${action.eventId}`, action.payload);
820
+ deleteTemplate(ctx, action) {
821
+ const eventId = ctx
822
+ .getState()
823
+ .templates.find((template) => template.id === action.templateId)?.eventId;
580
824
  return handleApiRequest({
581
825
  ctx,
582
- key: NotificationActionKey.AddTemplate,
583
- request$: req$,
584
- onSuccess: (response, state) => {
585
- const newTemplate = response.data;
586
- if (!newTemplate)
587
- return {};
826
+ key: NotificationActionKey.DeleteTemplate,
827
+ request$: this.api.deleteTemplate(action.templateId, action.expectedRevision),
828
+ onSuccess: (_, state) => {
829
+ const templates = state.templates.filter((template) => template.id !== action.templateId);
830
+ const next = templates.find((template) => template.eventId === state.selectedEventId);
588
831
  return {
589
- templates: this.adapter.addOne(state.templates, {
590
- ...newTemplate,
591
- eventId: action.eventId,
592
- }),
593
- selectedTemplateId: newTemplate.id,
832
+ templates,
833
+ selectedTemplateId: state.selectedTemplateId === action.templateId
834
+ ? (next?.id ?? null)
835
+ : state.selectedTemplateId,
836
+ publications: [],
837
+ publicationsTemplateId: null,
594
838
  };
595
839
  },
596
- });
597
- }
598
- updateTemplate(ctx, action) {
599
- const req$ = this.http.put(`${this.baseUrl}/templates/${action.templateId}`, action.payload, {
600
- headers: new HttpHeaders({
601
- 'Content-Type': 'application/json',
840
+ }).pipe(tap(() => {
841
+ // Deleting a default leaves its slot behind as "intentionally removed".
842
+ if (eventId != null)
843
+ ctx.dispatch(new GetDefaultRecipes(eventId));
844
+ const selected = ctx.getState().selectedTemplateId;
845
+ if (selected != null)
846
+ ctx.dispatch(new GetPublications(selected));
847
+ }));
848
+ }
849
+ // ==========================================================================
850
+ // Lifecycle
851
+ // ==========================================================================
852
+ publishTemplate(ctx, action) {
853
+ return handleApiRequest({
854
+ ctx,
855
+ key: NotificationActionKey.PublishTemplate,
856
+ request$: this.api.publish(action.templateId, action.expectedRevision, action.reviews),
857
+ onSuccess: (template, state) => ({
858
+ templates: upsertTemplate(state.templates, template),
602
859
  }),
603
- });
860
+ }).pipe(tap(() => ctx.dispatch(new GetPublications(action.templateId))));
861
+ }
862
+ setTemplateActivation(ctx, action) {
604
863
  return handleApiRequest({
605
864
  ctx,
606
- key: NotificationActionKey.UpdateTemplate,
607
- request$: req$,
608
- onSuccess: (response, state) => {
609
- const updatedTemplate = response.data;
610
- if (!updatedTemplate)
611
- return {};
612
- // Preserve existing fields that API may not return or shouldn't change
613
- const existingTemplate = state.templates.find((t) => t.id === action.templateId);
614
- if (!existingTemplate)
615
- return {};
616
- // Only update name and content fields, preserve channel/eventId/receivers
617
- const templates = this.adapter.upsertOne(state.templates, this.mergeUpdatedTemplate(existingTemplate, updatedTemplate), 'id');
618
- return { templates };
619
- },
865
+ key: NotificationActionKey.SetActivation,
866
+ request$: this.api.setActivation(action.templateId, action.expectedRevision, action.enabled, action.reviews),
867
+ onSuccess: (template, state) => ({
868
+ templates: upsertTemplate(state.templates, template),
869
+ }),
620
870
  });
621
871
  }
622
- deleteTemplate(ctx, action) {
623
- const req$ = this.http.delete(`${this.baseUrl}/templates/${action.templateId}`);
872
+ restorePublication(ctx, action) {
624
873
  return handleApiRequest({
625
874
  ctx,
626
- key: NotificationActionKey.DeleteTemplate,
627
- request$: req$,
628
- onSuccess: (_, state) => {
629
- const templates = this.adapter.removeOne(state.templates, action.templateId, 'id');
630
- // Select first template of current event after deletion
631
- const currentEvent = state.events.find((e) => e.eventName === state.selectedEventName);
632
- const eventTemplates = templates.filter((t) => t.eventId === currentEvent?.id);
633
- const systemTemplate = eventTemplates.find((t) => t.isSystem);
634
- return {
635
- templates,
636
- selectedTemplateId: systemTemplate?.id ?? eventTemplates[0]?.id ?? null,
637
- };
638
- },
639
- });
875
+ key: NotificationActionKey.RestorePublication,
876
+ request$: this.api.restorePublication(action.templateId, action.expectedRevision, action.publicationRevision, action.reviews),
877
+ onSuccess: (template, state) => ({
878
+ templates: upsertTemplate(state.templates, template),
879
+ }),
880
+ }).pipe(tap(() => ctx.dispatch(new GetPublications(action.templateId))));
640
881
  }
641
- // Private Helpers
642
- getSelectedTemplate(state) {
643
- return (state.templates.find((t) => t.id === state.selectedTemplateId) ?? null);
882
+ // ==========================================================================
883
+ // Defaults
884
+ // ==========================================================================
885
+ initializeDefaults(ctx, action) {
886
+ return handleApiRequest({
887
+ ctx,
888
+ key: NotificationActionKey.InitializeDefaults,
889
+ request$: this.api.initializeDefaults(action.eventId),
890
+ onSuccess: (result, state) => state.selectedEventId === action.eventId
891
+ ? { defaults: result.defaults }
892
+ : undefined,
893
+ }).pipe(
894
+ // `createdTemplates` omits a template whose audit projection failed even
895
+ // though it was committed, so the event list is re-read instead.
896
+ tap(() => ctx.dispatch(new GetEvents())));
897
+ }
898
+ restoreDefault(ctx, action) {
899
+ return handleApiRequest({
900
+ ctx,
901
+ key: NotificationActionKey.RestoreDefault,
902
+ request$: this.api.restoreDefault(action.eventId, action.payload),
903
+ onSuccess: (template, state) => ({
904
+ templates: upsertTemplate(state.templates, template),
905
+ ...(state.selectedEventId === action.eventId
906
+ ? {
907
+ selectedTemplateId: template.id,
908
+ publications: [],
909
+ publicationsTemplateId: template.id,
910
+ }
911
+ : {}),
912
+ }),
913
+ }).pipe(tap(() => ctx.dispatch(new GetDefaultRecipes(action.eventId))));
644
914
  }
645
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
915
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
646
916
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState });
647
917
  };
648
918
  __decorate([
@@ -652,47 +922,53 @@ __decorate([
652
922
  Action(ResetNotificationState)
653
923
  ], NotificationState.prototype, "resetState", null);
654
924
  __decorate([
655
- Action(GetEvents)
925
+ Action(GetEvents, { cancelUncompleted: true })
656
926
  ], NotificationState.prototype, "getEvents", null);
657
927
  __decorate([
658
- Action(GetReceivers)
659
- ], NotificationState.prototype, "getReceivers", null);
928
+ Action(GetAuthoringContext, { cancelUncompleted: true })
929
+ ], NotificationState.prototype, "getAuthoringContext", null);
660
930
  __decorate([
661
- Action(GetProperties)
662
- ], NotificationState.prototype, "getProperties", null);
931
+ Action(GetDefaultRecipes, { cancelUncompleted: true })
932
+ ], NotificationState.prototype, "getDefaultRecipes", null);
663
933
  __decorate([
664
- Action(SetActionMode)
665
- ], NotificationState.prototype, "setActionMode", null);
666
- __decorate([
667
- Action(GetActionReceivers)
668
- ], NotificationState.prototype, "getActionReceivers", null);
669
- __decorate([
670
- Action(GetActionProperties)
671
- ], NotificationState.prototype, "getActionProperties", null);
934
+ Action(GetPublications, { cancelUncompleted: true })
935
+ ], NotificationState.prototype, "getPublications", null);
672
936
  __decorate([
673
937
  Action(SelectEvent)
674
938
  ], NotificationState.prototype, "selectEvent", null);
675
939
  __decorate([
676
940
  Action(SelectTemplate)
677
941
  ], NotificationState.prototype, "selectTemplate", null);
942
+ __decorate([
943
+ Action(CreateTemplate)
944
+ ], NotificationState.prototype, "createTemplate", null);
945
+ __decorate([
946
+ Action(SaveTemplate)
947
+ ], NotificationState.prototype, "saveTemplate", null);
678
948
  __decorate([
679
949
  Action(AddReceivers)
680
950
  ], NotificationState.prototype, "addReceivers", null);
681
951
  __decorate([
682
952
  Action(DeleteReceiver)
683
953
  ], NotificationState.prototype, "deleteReceiver", null);
684
- __decorate([
685
- Action(AddTemplate)
686
- ], NotificationState.prototype, "addTemplate", null);
687
- __decorate([
688
- Action(UpdateTemplate)
689
- ], NotificationState.prototype, "updateTemplate", null);
690
954
  __decorate([
691
955
  Action(DeleteTemplate)
692
956
  ], NotificationState.prototype, "deleteTemplate", null);
693
957
  __decorate([
694
- Selector()
695
- ], NotificationState, "getState", null);
958
+ Action(PublishTemplate)
959
+ ], NotificationState.prototype, "publishTemplate", null);
960
+ __decorate([
961
+ Action(SetTemplateActivation)
962
+ ], NotificationState.prototype, "setTemplateActivation", null);
963
+ __decorate([
964
+ Action(RestorePublication)
965
+ ], NotificationState.prototype, "restorePublication", null);
966
+ __decorate([
967
+ Action(InitializeDefaults)
968
+ ], NotificationState.prototype, "initializeDefaults", null);
969
+ __decorate([
970
+ Action(RestoreDefault)
971
+ ], NotificationState.prototype, "restoreDefault", null);
696
972
  __decorate([
697
973
  Selector()
698
974
  ], NotificationState, "getEvents", null);
@@ -701,19 +977,19 @@ __decorate([
701
977
  ], NotificationState, "getTemplates", null);
702
978
  __decorate([
703
979
  Selector()
704
- ], NotificationState, "getReceivers", null);
980
+ ], NotificationState, "getSelectedEventId", null);
705
981
  __decorate([
706
982
  Selector()
707
- ], NotificationState, "getReceiverGroups", null);
983
+ ], NotificationState, "getSelectedTemplateId", null);
708
984
  __decorate([
709
985
  Selector()
710
- ], NotificationState, "getPropertyGroups", null);
986
+ ], NotificationState, "getContext", null);
711
987
  __decorate([
712
988
  Selector()
713
- ], NotificationState, "getSelectedEventName", null);
989
+ ], NotificationState, "getDefaults", null);
714
990
  __decorate([
715
991
  Selector()
716
- ], NotificationState, "getSelectedTemplateId", null);
992
+ ], NotificationState, "getPublicationsSlice", null);
717
993
  __decorate([
718
994
  Selector()
719
995
  ], NotificationState, "getLoadingActive", null);
@@ -721,15 +997,12 @@ __decorate([
721
997
  Selector()
722
998
  ], NotificationState, "getErrors", null);
723
999
  __decorate([
724
- Selector([
725
- NotificationState.getEvents,
726
- NotificationState.getSelectedEventName,
727
- ])
1000
+ Selector([NotificationState.getEvents, NotificationState.getSelectedEventId])
728
1001
  ], NotificationState, "getSelectedEvent", null);
729
1002
  __decorate([
730
1003
  Selector([
731
1004
  NotificationState.getTemplates,
732
- NotificationState.getSelectedEvent,
1005
+ NotificationState.getSelectedEventId,
733
1006
  ])
734
1007
  ], NotificationState, "getEventTemplates", null);
735
1008
  __decorate([
@@ -740,16 +1013,10 @@ __decorate([
740
1013
  ], NotificationState, "getSelectedTemplate", null);
741
1014
  __decorate([
742
1015
  Selector([
743
- NotificationState.getReceivers,
744
- NotificationState.getSelectedTemplate,
745
- ])
746
- ], NotificationState, "getAvailableReceivers", null);
747
- __decorate([
748
- Selector([
749
- NotificationState.getReceiverGroups,
750
- NotificationState.getSelectedTemplate,
1016
+ NotificationState.getPublicationsSlice,
1017
+ NotificationState.getSelectedTemplateId,
751
1018
  ])
752
- ], NotificationState, "getAvailableReceiverGroups", null);
1019
+ ], NotificationState, "getPublications", null);
753
1020
  NotificationState = __decorate([
754
1021
  State({
755
1022
  name: 'notification',
@@ -758,48 +1025,71 @@ NotificationState = __decorate([
758
1025
  ], NotificationState);
759
1026
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState, decorators: [{
760
1027
  type: Injectable
761
- }], propDecorators: { setModuleInfo: [], resetState: [], getEvents: [], getReceivers: [], getProperties: [], setActionMode: [], getActionReceivers: [], getActionProperties: [], selectEvent: [], selectTemplate: [], addReceivers: [], deleteReceiver: [], addTemplate: [], updateTemplate: [], deleteTemplate: [] } });
1028
+ }], propDecorators: { setModuleInfo: [], resetState: [], getEvents: [], getAuthoringContext: [], getDefaultRecipes: [], getPublications: [], selectEvent: [], selectTemplate: [], createTemplate: [], saveTemplate: [], addReceivers: [], deleteReceiver: [], deleteTemplate: [], publishTemplate: [], setTemplateActivation: [], restorePublication: [], initializeDefaults: [], restoreDefault: [] } });
1029
+ function scopePathOf(state) {
1030
+ if (!state.moduleType || state.moduleId == null || state.moduleId === '') {
1031
+ return null;
1032
+ }
1033
+ return `${state.parentPath}/${state.moduleType}/${state.moduleId}`;
1034
+ }
1035
+ function flattenEvents(data) {
1036
+ const events = [];
1037
+ const templates = [];
1038
+ for (const { templates: eventTemplates, ...event } of data) {
1039
+ events.push(event);
1040
+ templates.push(...(eventTemplates ?? []).map((template) => ({
1041
+ ...template,
1042
+ eventId: event.id,
1043
+ })));
1044
+ }
1045
+ return { events, templates };
1046
+ }
1047
+ function upsertTemplate(templates, template) {
1048
+ const index = templates.findIndex((item) => item.id === template.id);
1049
+ if (index === -1)
1050
+ return [...templates, template];
1051
+ const next = [...templates];
1052
+ next[index] = template;
1053
+ return next;
1054
+ }
762
1055
 
763
1056
  class NotificationFacade {
764
1057
  store = inject(Store);
765
- // ============================================================================
766
- // Data Selectors - Memoized by NGXS (fine-grained reactivity)
767
- // ============================================================================
1058
+ api = inject(NotificationTemplateApi);
1059
+ // ==========================================================================
1060
+ // Data
1061
+ // ==========================================================================
768
1062
  events = select(NotificationState.getEvents);
1063
+ /** Templates of the selected event. */
769
1064
  templates = select(NotificationState.getEventTemplates);
770
- receivers = select(NotificationState.getReceivers);
771
- receiverGroups = select(NotificationState.getReceiverGroups);
772
- propertyGroups = select(NotificationState.getPropertyGroups);
773
- // Selection selectors
774
- selectedEventName = select(NotificationState.getSelectedEventName);
1065
+ selectedEventId = select(NotificationState.getSelectedEventId);
775
1066
  selectedTemplateId = select(NotificationState.getSelectedTemplateId);
776
1067
  selectedEvent = select(NotificationState.getSelectedEvent);
777
1068
  selectedTemplate = select(NotificationState.getSelectedTemplate);
778
- availableReceivers = select(NotificationState.getAvailableReceivers);
779
- availableReceiverGroups = select(NotificationState.getAvailableReceiverGroups);
780
- // ============================================================================
781
- // Loading/Error Slices - Memoized by NGXS
782
- // ============================================================================
1069
+ context = select(NotificationState.getContext);
1070
+ defaults = select(NotificationState.getDefaults);
1071
+ publications = select(NotificationState.getPublications);
1072
+ // ==========================================================================
1073
+ // Loading / errors
1074
+ // ==========================================================================
783
1075
  loadingActive = select(NotificationState.getLoadingActive);
784
1076
  errors = select(NotificationState.getErrors);
785
- // ============================================================================
786
- // Loading Signals - Computed from slice (minimal reactivity)
787
- // ============================================================================
788
1077
  isLoadingEvents = computed(() => this.loadingActive().includes(NotificationActionKey.GetEvents), ...(ngDevMode ? [{ debugName: "isLoadingEvents" }] : /* istanbul ignore next */ []));
789
- isLoadingReceivers = computed(() => this.loadingActive().includes(NotificationActionKey.GetReceivers), ...(ngDevMode ? [{ debugName: "isLoadingReceivers" }] : /* istanbul ignore next */ []));
790
- isLoadingProperties = computed(() => this.loadingActive().includes(NotificationActionKey.GetProperties), ...(ngDevMode ? [{ debugName: "isLoadingProperties" }] : /* istanbul ignore next */ []));
791
- isAddingReceivers = computed(() => this.loadingActive().includes(NotificationActionKey.AddReceivers), ...(ngDevMode ? [{ debugName: "isAddingReceivers" }] : /* istanbul ignore next */ []));
792
- isDeletingReceiver = computed(() => this.loadingActive().includes(NotificationActionKey.DeleteReceiver), ...(ngDevMode ? [{ debugName: "isDeletingReceiver" }] : /* istanbul ignore next */ []));
793
- isAddingTemplate = computed(() => this.loadingActive().includes(NotificationActionKey.AddTemplate), ...(ngDevMode ? [{ debugName: "isAddingTemplate" }] : /* istanbul ignore next */ []));
794
- isUpdatingTemplate = computed(() => this.loadingActive().includes(NotificationActionKey.UpdateTemplate), ...(ngDevMode ? [{ debugName: "isUpdatingTemplate" }] : /* istanbul ignore next */ []));
795
- isDeletingTemplate = computed(() => this.loadingActive().includes(NotificationActionKey.DeleteTemplate), ...(ngDevMode ? [{ debugName: "isDeletingTemplate" }] : /* istanbul ignore next */ []));
796
- // ============================================================================
797
- // Error Signals - Computed from slice (minimal reactivity)
798
- // ============================================================================
1078
+ isLoadingContext = computed(() => this.loadingActive().includes(NotificationActionKey.GetContext), ...(ngDevMode ? [{ debugName: "isLoadingContext" }] : /* istanbul ignore next */ []));
1079
+ isLoadingDefaults = computed(() => this.loadingActive().includes(NotificationActionKey.GetDefaults), ...(ngDevMode ? [{ debugName: "isLoadingDefaults" }] : /* istanbul ignore next */ []));
1080
+ isLoadingPublications = computed(() => this.loadingActive().includes(NotificationActionKey.GetPublications), ...(ngDevMode ? [{ debugName: "isLoadingPublications" }] : /* istanbul ignore next */ []));
1081
+ /** True while any write is in flight — every write control disables on it. */
1082
+ isMutating = computed(() => this.loadingActive().some((key) => NOTIFICATION_MUTATION_KEYS.includes(key)), ...(ngDevMode ? [{ debugName: "isMutating" }] : /* istanbul ignore next */ []));
799
1083
  eventsError = computed(() => this.errors()[NotificationActionKey.GetEvents] ?? null, ...(ngDevMode ? [{ debugName: "eventsError" }] : /* istanbul ignore next */ []));
800
- receiversError = computed(() => this.errors()[NotificationActionKey.GetReceivers] ?? null, ...(ngDevMode ? [{ debugName: "receiversError" }] : /* istanbul ignore next */ []));
801
- propertiesError = computed(() => this.errors()[NotificationActionKey.GetProperties] ?? null, ...(ngDevMode ? [{ debugName: "propertiesError" }] : /* istanbul ignore next */ []));
802
- // Action Dispatchers - Configuration
1084
+ contextError = computed(() => this.errors()[NotificationActionKey.GetContext] ?? null, ...(ngDevMode ? [{ debugName: "contextError" }] : /* istanbul ignore next */ []));
1085
+ defaultsError = computed(() => this.errors()[NotificationActionKey.GetDefaults] ?? null, ...(ngDevMode ? [{ debugName: "defaultsError" }] : /* istanbul ignore next */ []));
1086
+ /** Accepts the enum or its string value, so templates can name the key. */
1087
+ isLoading(key) {
1088
+ return this.loadingActive().includes(key);
1089
+ }
1090
+ // ==========================================================================
1091
+ // Scope
1092
+ // ==========================================================================
803
1093
  /**
804
1094
  * Scopes this (shared, singleton) state to a module/event context.
805
1095
  *
@@ -817,68 +1107,70 @@ class NotificationFacade {
817
1107
  resetState() {
818
1108
  return this.store.dispatch(new ResetNotificationState());
819
1109
  }
820
- // Action Dispatchers - Data Loading
1110
+ /** Loads the scope's events; the selected event's catalog and defaults follow. */
821
1111
  loadEvents() {
822
1112
  return this.store.dispatch(new GetEvents());
823
1113
  }
824
- loadReceivers() {
825
- return this.store.dispatch(new GetReceivers());
1114
+ /** Kept for existing hosts — both load the scope's events and what follows from them. */
1115
+ loadBaseData() {
1116
+ return this.loadEvents();
826
1117
  }
827
- loadProperties(isMultiLang) {
828
- return this.store.dispatch(new GetProperties(isMultiLang));
1118
+ loadAllData() {
1119
+ return this.loadEvents();
829
1120
  }
830
- loadPropertiesForCurrentContext(isMultiLang) {
831
- const state = this.store.selectSnapshot(NotificationState.getState);
832
- if (state.isActionMode && state.selectedActionId != null) {
833
- return this.store.dispatch(new GetActionProperties(state.selectedActionId, isMultiLang));
834
- }
835
- return this.store.dispatch(new GetProperties(isMultiLang));
1121
+ // ==========================================================================
1122
+ // Selection
1123
+ // ==========================================================================
1124
+ selectEvent(eventId) {
1125
+ return this.store.dispatch(new SelectEvent(eventId));
836
1126
  }
837
- // Action Dispatchers - Action Mode (Workflow)
838
- setActionMode(actionId) {
839
- return this.store.dispatch(new SetActionMode(actionId));
1127
+ selectTemplate(templateId) {
1128
+ return this.store.dispatch(new SelectTemplate(templateId));
840
1129
  }
841
- loadActionReceivers(actionId) {
842
- return this.store.dispatch(new GetActionReceivers(actionId));
1130
+ reloadDefaults(eventId) {
1131
+ return this.store.dispatch(new GetDefaultRecipes(eventId));
843
1132
  }
844
- loadActionProperties(actionId, isMultiLang) {
845
- return this.store.dispatch(new GetActionProperties(actionId, isMultiLang));
1133
+ // ==========================================================================
1134
+ // Writes
1135
+ // ==========================================================================
1136
+ createTemplate(eventId, payload) {
1137
+ return this.store.dispatch(new CreateTemplate(eventId, payload));
846
1138
  }
847
- // Action Dispatchers - Selection
848
- selectEvent(eventName) {
849
- return this.store.dispatch(new SelectEvent(eventName));
1139
+ saveTemplate(templateId, payload) {
1140
+ return this.store.dispatch(new SaveTemplate(templateId, payload));
850
1141
  }
851
- selectTemplate(templateId) {
852
- return this.store.dispatch(new SelectTemplate(templateId));
1142
+ deleteTemplate(templateId, expectedRevision) {
1143
+ return this.store.dispatch(new DeleteTemplate(templateId, expectedRevision));
853
1144
  }
854
- // Action Dispatchers - Receiver CRUD
855
- addReceivers(templateId, payload) {
856
- return this.store.dispatch(new AddReceivers(templateId, payload));
1145
+ addReceivers(templateId, expectedRevision, receivers) {
1146
+ return this.store.dispatch(new AddReceivers(templateId, expectedRevision, receivers));
857
1147
  }
858
- deleteReceiver(receiverId) {
859
- return this.store.dispatch(new DeleteReceiver(receiverId));
1148
+ deleteReceiver(receiverId, expectedRevision) {
1149
+ return this.store.dispatch(new DeleteReceiver(receiverId, expectedRevision));
860
1150
  }
861
- // Action Dispatchers - Template CRUD
862
- addTemplate(eventId, payload) {
863
- return this.store.dispatch(new AddTemplate(eventId, payload));
1151
+ publish(templateId, expectedRevision, reviews) {
1152
+ return this.store.dispatch(new PublishTemplate(templateId, expectedRevision, reviews));
864
1153
  }
865
- updateTemplate(templateId, payload) {
866
- return this.store.dispatch(new UpdateTemplate(templateId, payload));
1154
+ turnOn(templateId, expectedRevision, reviews) {
1155
+ return this.store.dispatch(new SetTemplateActivation(templateId, expectedRevision, true, reviews));
867
1156
  }
868
- deleteTemplate(templateId) {
869
- return this.store.dispatch(new DeleteTemplate(templateId));
1157
+ turnOff(templateId, expectedRevision) {
1158
+ return this.store.dispatch(new SetTemplateActivation(templateId, expectedRevision, false, null));
870
1159
  }
871
- loadBaseData() {
872
- this.loadEvents();
873
- this.loadReceivers();
1160
+ restorePublication(templateId, expectedRevision, publicationRevision, reviews) {
1161
+ return this.store.dispatch(new RestorePublication(templateId, expectedRevision, publicationRevision, reviews));
874
1162
  }
875
- /**
876
- * Load all data for normal mode (non-workflow)
877
- */
878
- loadAllData(isMultiLang = true) {
879
- this.loadEvents();
880
- this.loadReceivers();
881
- this.loadPropertiesForCurrentContext(isMultiLang);
1163
+ initializeDefaults(eventId) {
1164
+ return this.store.dispatch(new InitializeDefaults(eventId));
1165
+ }
1166
+ restoreDefault(eventId, payload) {
1167
+ return this.store.dispatch(new RestoreDefault(eventId, payload));
1168
+ }
1169
+ // ==========================================================================
1170
+ // Preview — render-only, never stored: it saves, publishes and sends nothing.
1171
+ // ==========================================================================
1172
+ preview(eventId, request) {
1173
+ return this.api.preview(eventId, request);
882
1174
  }
883
1175
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationFacade, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
884
1176
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationFacade, providedIn: 'root' });
@@ -888,405 +1180,1214 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
888
1180
  args: [{ providedIn: 'root' }]
889
1181
  }] });
890
1182
 
891
- class NotificationTemplate {
1183
+ /**
1184
+ * Turns the backend's stable codes into translated sentences. Codes are the
1185
+ * contract; display text from the server is never matched or shown raw.
1186
+ */
1187
+ class NotificationMessages {
1188
+ transloco = inject(TranslocoService);
1189
+ code(code) {
1190
+ if (code) {
1191
+ // EMAIL_* codes are shared with the Email design tab. The loaded keys
1192
+ // are checked first: a translate() miss logs a missing-key warning.
1193
+ const loaded = this.transloco.getTranslation(this.transloco.getActiveLang());
1194
+ for (const key of [
1195
+ `notification.codes.${code}`,
1196
+ `email-delivery.design.codes.${code}`,
1197
+ ]) {
1198
+ if (Object.prototype.hasOwnProperty.call(loaded, key)) {
1199
+ return this.transloco.translate(key);
1200
+ }
1201
+ }
1202
+ }
1203
+ return this.transloco.translate('notification.codes.generic', {
1204
+ code: code ?? '—',
1205
+ });
1206
+ }
1207
+ diagnostic(diagnostic) {
1208
+ const message = this.code(diagnostic.code);
1209
+ return diagnostic.path ? `${message} (${diagnostic.path})` : message;
1210
+ }
1211
+ channel(channel) {
1212
+ return this.transloco.translate(`notification.channel.${channel}`);
1213
+ }
1214
+ t(key, params) {
1215
+ return this.transloco.translate(`notification.${key}`, params);
1216
+ }
1217
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationMessages, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1218
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationMessages, providedIn: 'root' });
1219
+ }
1220
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationMessages, decorators: [{
1221
+ type: Injectable,
1222
+ args: [{ providedIn: 'root' }]
1223
+ }] });
1224
+ const CHANNEL_ICONS = {
1225
+ App: 'alert.bell-01',
1226
+ Email: 'communication.mail-01',
1227
+ Sms: 'communication.message-text-square-01',
1228
+ };
1229
+
1230
+ /** A template's channel is chosen once, here — it cannot change afterwards. */
1231
+ class AddTemplateDialog {
1232
+ modalService = inject(ModalService);
1233
+ ref = inject(ModalRef);
1234
+ transloco = inject(TranslocoService);
1235
+ /** Channels the event's authoring context allows. */
1236
+ channels = input(NOTIFICATION_CHANNELS, ...(ngDevMode ? [{ debugName: "channels" }] : /* istanbul ignore next */ []));
1237
+ form = new FormGroup({
1238
+ name: new FormControl('', {
1239
+ nonNullable: true,
1240
+ validators: [Validators.required, Validators.maxLength(250)],
1241
+ }),
1242
+ channel: new FormControl(null, {
1243
+ validators: [Validators.required],
1244
+ }),
1245
+ });
1246
+ channelOptions = computed(() => this.channels().map((channel) => ({
1247
+ id: channel,
1248
+ name: this.transloco.translate(`notification.channel.${channel}`),
1249
+ description: this.transloco.translate(`notification.channel-description.${channel}`),
1250
+ icon: CHANNEL_ICONS[channel],
1251
+ })), ...(ngDevMode ? [{ debugName: "channelOptions" }] : /* istanbul ignore next */ []));
1252
+ onSave() {
1253
+ const { name, channel } = this.form.getRawValue();
1254
+ if (this.form.invalid || !channel || !name.trim()) {
1255
+ this.form.markAllAsTouched();
1256
+ return;
1257
+ }
1258
+ const result = { name: name.trim(), channel };
1259
+ this.ref.close(result);
1260
+ }
1261
+ onCancel() {
1262
+ this.ref.close();
1263
+ }
1264
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AddTemplateDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
1265
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.8", type: AddTemplateDialog, isStandalone: true, selector: "mt-add-template-dialog", inputs: { channels: { classPropertyName: "channels", publicName: "channels", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'notification'\">\n <div\n [class]=\"modalService.contentClass\"\n class=\"flex flex-col gap-4 p-4\"\n [formGroup]=\"form\"\n >\n <mt-text-field\n formControlName=\"name\"\n [label]=\"t('name')\"\n [placeholder]=\"t('enterName')\"\n [required]=\"true\"\n />\n <mt-radio-cards-field\n formControlName=\"channel\"\n [options]=\"channelOptions()\"\n optionLabel=\"name\"\n optionValue=\"id\"\n [label]=\"t('channel.label')\"\n [hint]=\"t('channel.fixed-hint')\"\n [required]=\"true\"\n />\n </div>\n\n <div [class]=\"modalService.footerClass\">\n <mt-button\n [label]=\"t('cancel')\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"onCancel()\"\n />\n <mt-button\n [label]=\"t('create')\"\n [disabled]=\"form.invalid\"\n (onClick)=\"onSave()\"\n />\n </div>\n</ng-container>\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { 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.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "component", type: TextField, selector: "mt-text-field", inputs: ["field", "hint", "label", "placeholder", "class", "type", "readonly", "pInputs", "required", "maxLength", "icon", "iconPosition"] }, { kind: "component", type: RadioCardsField, selector: "mt-radio-cards-field", inputs: ["circle", "label", "hint", "readonly", "required", "color", "size", "optionLabel", "optionValue", "options"] }, { 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1266
+ }
1267
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AddTemplateDialog, decorators: [{
1268
+ type: Component,
1269
+ args: [{ selector: 'mt-add-template-dialog', imports: [
1270
+ ReactiveFormsModule,
1271
+ TranslocoDirective,
1272
+ TextField,
1273
+ RadioCardsField,
1274
+ Button,
1275
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'notification'\">\n <div\n [class]=\"modalService.contentClass\"\n class=\"flex flex-col gap-4 p-4\"\n [formGroup]=\"form\"\n >\n <mt-text-field\n formControlName=\"name\"\n [label]=\"t('name')\"\n [placeholder]=\"t('enterName')\"\n [required]=\"true\"\n />\n <mt-radio-cards-field\n formControlName=\"channel\"\n [options]=\"channelOptions()\"\n optionLabel=\"name\"\n optionValue=\"id\"\n [label]=\"t('channel.label')\"\n [hint]=\"t('channel.fixed-hint')\"\n [required]=\"true\"\n />\n </div>\n\n <div [class]=\"modalService.footerClass\">\n <mt-button\n [label]=\"t('cancel')\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"onCancel()\"\n />\n <mt-button\n [label]=\"t('create')\"\n [disabled]=\"form.invalid\"\n (onClick)=\"onSave()\"\n />\n </div>\n</ng-container>\n" }]
1276
+ }], propDecorators: { channels: [{ type: i0.Input, args: [{ isSignal: true, alias: "channels", required: false }] }] } });
1277
+
1278
+ /**
1279
+ * Renders a notification preview the way it will be read: Email inside the
1280
+ * shared design, App as its English and Arabic messages, SMS as plain text.
1281
+ * HTML only ever goes into the sandboxed `mt-email-html-preview` frame.
1282
+ * Images always show: the server strips every image outside
1283
+ * `approvedImageOrigins`, and the frame's CSP allows only those origins.
1284
+ */
1285
+ class NotificationContentPreview {
1286
+ messages = inject(NotificationMessages);
1287
+ preview = input(null, ...(ngDevMode ? [{ debugName: "preview" }] : /* istanbul ignore next */ []));
1288
+ status = input('idle', ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
1289
+ errorCode = input(null, ...(ngDevMode ? [{ debugName: "errorCode" }] : /* istanbul ignore next */ []));
1290
+ /** The shown render is from an older version of the content. */
1291
+ stale = input(false, ...(ngDevMode ? [{ debugName: "stale" }] : /* istanbul ignore next */ []));
1292
+ height = input('26rem', ...(ngDevMode ? [{ debugName: "height" }] : /* istanbul ignore next */ []));
1293
+ /**
1294
+ * Stretch the rendered frames to the height the host gives this preview
1295
+ * (beside the editor), with `height` as the minimum.
1296
+ */
1297
+ fill = input(false, ...(ngDevMode ? [{ debugName: "fill" }] : /* istanbul ignore next */ []));
1298
+ /** Off beside the editor, where the Subject field is in view. */
1299
+ showSubject = input(true, ...(ngDevMode ? [{ debugName: "showSubject" }] : /* istanbul ignore next */ []));
1300
+ retry = output();
1301
+ narrow = new FormControl(false, { nonNullable: true });
1302
+ narrowValue = signal(false, ...(ngDevMode ? [{ debugName: "narrowValue" }] : /* istanbul ignore next */ []));
1303
+ email = computed(() => {
1304
+ const preview = this.preview();
1305
+ return preview?.channel === 'Email' ? preview : null;
1306
+ }, ...(ngDevMode ? [{ debugName: "email" }] : /* istanbul ignore next */ []));
1307
+ app = computed(() => {
1308
+ const preview = this.preview();
1309
+ return preview?.channel === 'App' ? preview : null;
1310
+ }, ...(ngDevMode ? [{ debugName: "app" }] : /* istanbul ignore next */ []));
1311
+ sms = computed(() => {
1312
+ const preview = this.preview();
1313
+ return preview?.channel === 'Sms' ? preview : null;
1314
+ }, ...(ngDevMode ? [{ debugName: "sms" }] : /* istanbul ignore next */ []));
1315
+ diagnostics = computed(() => {
1316
+ const preview = this.preview();
1317
+ if (!preview)
1318
+ return [];
1319
+ if (preview.channel === 'App') {
1320
+ return [...preview.english.diagnostics, ...preview.arabic.diagnostics];
1321
+ }
1322
+ return preview.diagnostics;
1323
+ }, ...(ngDevMode ? [{ debugName: "diagnostics" }] : /* istanbul ignore next */ []));
1324
+ constructor() {
1325
+ this.narrow.valueChanges
1326
+ .pipe(takeUntilDestroyed())
1327
+ .subscribe((value) => this.narrowValue.set(value));
1328
+ }
1329
+ describe(diagnostic) {
1330
+ return this.messages.diagnostic(diagnostic);
1331
+ }
1332
+ describeCode(code) {
1333
+ return this.messages.code(code);
1334
+ }
1335
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationContentPreview, deps: [], target: i0.ɵɵFactoryTarget.Component });
1336
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: NotificationContentPreview, isStandalone: true, selector: "mt-notification-content-preview", inputs: { preview: { classPropertyName: "preview", publicName: "preview", isSignal: true, isRequired: false, transformFunction: null }, status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: false, transformFunction: null }, errorCode: { classPropertyName: "errorCode", publicName: "errorCode", isSignal: true, isRequired: false, transformFunction: null }, stale: { classPropertyName: "stale", publicName: "stale", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, fill: { classPropertyName: "fill", publicName: "fill", isSignal: true, isRequired: false, transformFunction: null }, showSubject: { classPropertyName: "showSubject", publicName: "showSubject", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { retry: "retry" }, host: { properties: { "class.flex-1": "fill()" }, classAttribute: "flex min-w-0 flex-col" }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'notification.preview'\">\n <div class=\"flex min-w-0 flex-1 flex-col gap-3\">\n <!-- The dimmed render already shows an update in progress; this only\n announces it. -->\n <span class=\"sr-only\" aria-live=\"polite\">{{\n status() === \"updating\" ? t(\"updating\") : \"\"\n }}</span>\n @if (email()) {\n <div class=\"flex justify-end\">\n <mt-toggle-field [formControl]=\"narrow\" [label]=\"t('narrow')\" />\n </div>\n }\n\n @if (status() === \"error\") {\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700\"\n role=\"alert\"\n >\n <span>{{ describeCode(errorCode()) }}</span>\n <mt-button\n [label]=\"t('retry')\"\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"retry.emit()\"\n />\n </div>\n }\n\n @if (preview()) {\n <div\n class=\"flex min-w-0 flex-1 flex-col gap-3 transition-opacity\"\n [class.opacity-60]=\"stale() || status() === 'updating'\"\n >\n @if (email(); as email) {\n @if (showSubject()) {\n <div\n class=\"rounded-md border border-surface bg-surface-50 px-3 py-2 text-sm\"\n >\n <span class=\"text-muted-color\">{{ t(\"subject\") }}:</span>\n <span class=\"ms-1 font-medium\">{{ email.subject || \"\u2014\" }}</span>\n </div>\n }\n <div\n class=\"mx-auto w-full overflow-hidden rounded-md border border-surface\"\n [class.max-w-[375px]]=\"narrowValue()\"\n [class.relative]=\"fill()\"\n [class.flex-1]=\"fill()\"\n [style.min-height]=\"fill() ? height() : null\"\n >\n <mt-email-html-preview\n [class.absolute]=\"fill()\"\n [class.inset-0]=\"fill()\"\n [html]=\"email.html\"\n [showImages]=\"true\"\n [approvedImageOrigins]=\"email.approvedImageOrigins\"\n [title]=\"t('email-title')\"\n [height]=\"fill() ? '100%' : height()\"\n />\n </div>\n }\n @if (app(); as app) {\n <div class=\"flex min-w-0 flex-1 flex-col gap-3\">\n <div class=\"flex min-w-0 flex-1 flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"english\")\n }}</span>\n <div\n class=\"overflow-hidden rounded-md border border-surface\"\n [class.relative]=\"fill()\"\n [class.flex-1]=\"fill()\"\n [class.min-h-48]=\"fill()\"\n >\n <mt-email-html-preview\n [class.absolute]=\"fill()\"\n [class.inset-0]=\"fill()\"\n [html]=\"app.english.html\"\n [fragment]=\"true\"\n direction=\"ltr\"\n [showImages]=\"true\"\n [approvedImageOrigins]=\"app.approvedImageOrigins\"\n [title]=\"t('english')\"\n [height]=\"fill() ? '100%' : '12rem'\"\n />\n </div>\n </div>\n <div class=\"flex min-w-0 flex-1 flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"arabic\")\n }}</span>\n <div\n class=\"overflow-hidden rounded-md border border-surface\"\n [class.relative]=\"fill()\"\n [class.flex-1]=\"fill()\"\n [class.min-h-48]=\"fill()\"\n >\n <mt-email-html-preview\n [class.absolute]=\"fill()\"\n [class.inset-0]=\"fill()\"\n [html]=\"app.arabic.html\"\n [fragment]=\"true\"\n direction=\"rtl\"\n [showImages]=\"true\"\n [approvedImageOrigins]=\"app.approvedImageOrigins\"\n [title]=\"t('arabic')\"\n [height]=\"fill() ? '100%' : '12rem'\"\n />\n </div>\n </div>\n </div>\n }\n @if (sms(); as sms) {\n <div\n class=\"whitespace-pre-wrap break-words rounded-md border border-surface bg-surface-50 px-4 py-3 text-sm\"\n dir=\"auto\"\n >\n {{ sms.text }}\n </div>\n <span class=\"text-xs text-muted-color\">{{\n t(\"sms-length\", { count: sms.text.length })\n }}</span>\n }\n\n @if (diagnostics().length > 0) {\n <ul class=\"list-disc ps-5 text-sm text-amber-700\">\n @for (diagnostic of diagnostics(); track $index) {\n <li>{{ describe(diagnostic) }}</li>\n }\n </ul>\n }\n </div>\n } @else if (status() !== \"error\") {\n <p-skeleton width=\"100%\" [height]=\"height()\" borderRadius=\"0.375rem\" />\n }\n </div>\n</ng-container>\n", dependencies: [{ kind: "directive", type: TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { 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: ToggleField, selector: "mt-toggle-field", inputs: ["label", "inputId", "labelPosition", "placeholder", "readonly", "pInputs", "required", "toggleShape", "size", "icon", "descriptionCard"], outputs: ["onChange"] }, { kind: "component", type: EmailHtmlPreview, selector: "mt-email-html-preview", inputs: ["html", "showImages", "approvedImageOrigins", "fragment", "direction", "title", "height"] }, { kind: "component", type: Skeleton, selector: "p-skeleton", inputs: ["styleClass", "shape", "animation", "borderRadius", "size", "width", "height"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1337
+ }
1338
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationContentPreview, decorators: [{
1339
+ type: Component,
1340
+ args: [{ selector: 'mt-notification-content-preview', imports: [
1341
+ TranslocoDirective,
1342
+ ReactiveFormsModule,
1343
+ Button,
1344
+ ToggleField,
1345
+ EmailHtmlPreview,
1346
+ Skeleton,
1347
+ ], host: { class: 'flex min-w-0 flex-col', '[class.flex-1]': 'fill()' }, changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'notification.preview'\">\n <div class=\"flex min-w-0 flex-1 flex-col gap-3\">\n <!-- The dimmed render already shows an update in progress; this only\n announces it. -->\n <span class=\"sr-only\" aria-live=\"polite\">{{\n status() === \"updating\" ? t(\"updating\") : \"\"\n }}</span>\n @if (email()) {\n <div class=\"flex justify-end\">\n <mt-toggle-field [formControl]=\"narrow\" [label]=\"t('narrow')\" />\n </div>\n }\n\n @if (status() === \"error\") {\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700\"\n role=\"alert\"\n >\n <span>{{ describeCode(errorCode()) }}</span>\n <mt-button\n [label]=\"t('retry')\"\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"retry.emit()\"\n />\n </div>\n }\n\n @if (preview()) {\n <div\n class=\"flex min-w-0 flex-1 flex-col gap-3 transition-opacity\"\n [class.opacity-60]=\"stale() || status() === 'updating'\"\n >\n @if (email(); as email) {\n @if (showSubject()) {\n <div\n class=\"rounded-md border border-surface bg-surface-50 px-3 py-2 text-sm\"\n >\n <span class=\"text-muted-color\">{{ t(\"subject\") }}:</span>\n <span class=\"ms-1 font-medium\">{{ email.subject || \"\u2014\" }}</span>\n </div>\n }\n <div\n class=\"mx-auto w-full overflow-hidden rounded-md border border-surface\"\n [class.max-w-[375px]]=\"narrowValue()\"\n [class.relative]=\"fill()\"\n [class.flex-1]=\"fill()\"\n [style.min-height]=\"fill() ? height() : null\"\n >\n <mt-email-html-preview\n [class.absolute]=\"fill()\"\n [class.inset-0]=\"fill()\"\n [html]=\"email.html\"\n [showImages]=\"true\"\n [approvedImageOrigins]=\"email.approvedImageOrigins\"\n [title]=\"t('email-title')\"\n [height]=\"fill() ? '100%' : height()\"\n />\n </div>\n }\n @if (app(); as app) {\n <div class=\"flex min-w-0 flex-1 flex-col gap-3\">\n <div class=\"flex min-w-0 flex-1 flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"english\")\n }}</span>\n <div\n class=\"overflow-hidden rounded-md border border-surface\"\n [class.relative]=\"fill()\"\n [class.flex-1]=\"fill()\"\n [class.min-h-48]=\"fill()\"\n >\n <mt-email-html-preview\n [class.absolute]=\"fill()\"\n [class.inset-0]=\"fill()\"\n [html]=\"app.english.html\"\n [fragment]=\"true\"\n direction=\"ltr\"\n [showImages]=\"true\"\n [approvedImageOrigins]=\"app.approvedImageOrigins\"\n [title]=\"t('english')\"\n [height]=\"fill() ? '100%' : '12rem'\"\n />\n </div>\n </div>\n <div class=\"flex min-w-0 flex-1 flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"arabic\")\n }}</span>\n <div\n class=\"overflow-hidden rounded-md border border-surface\"\n [class.relative]=\"fill()\"\n [class.flex-1]=\"fill()\"\n [class.min-h-48]=\"fill()\"\n >\n <mt-email-html-preview\n [class.absolute]=\"fill()\"\n [class.inset-0]=\"fill()\"\n [html]=\"app.arabic.html\"\n [fragment]=\"true\"\n direction=\"rtl\"\n [showImages]=\"true\"\n [approvedImageOrigins]=\"app.approvedImageOrigins\"\n [title]=\"t('arabic')\"\n [height]=\"fill() ? '100%' : '12rem'\"\n />\n </div>\n </div>\n </div>\n }\n @if (sms(); as sms) {\n <div\n class=\"whitespace-pre-wrap break-words rounded-md border border-surface bg-surface-50 px-4 py-3 text-sm\"\n dir=\"auto\"\n >\n {{ sms.text }}\n </div>\n <span class=\"text-xs text-muted-color\">{{\n t(\"sms-length\", { count: sms.text.length })\n }}</span>\n }\n\n @if (diagnostics().length > 0) {\n <ul class=\"list-disc ps-5 text-sm text-amber-700\">\n @for (diagnostic of diagnostics(); track $index) {\n <li>{{ describe(diagnostic) }}</li>\n }\n </ul>\n }\n </div>\n } @else if (status() !== \"error\") {\n <p-skeleton width=\"100%\" [height]=\"height()\" borderRadius=\"0.375rem\" />\n }\n </div>\n</ng-container>\n" }]
1348
+ }], ctorParameters: () => [], propDecorators: { preview: [{ type: i0.Input, args: [{ isSignal: true, alias: "preview", required: false }] }], status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: false }] }], errorCode: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorCode", required: false }] }], stale: [{ type: i0.Input, args: [{ isSignal: true, alias: "stale", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], fill: [{ type: i0.Input, args: [{ isSignal: true, alias: "fill", required: false }] }], showSubject: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSubject", required: false }] }], retry: [{ type: i0.Output, args: ["retry"] }] } });
1349
+
1350
+ /**
1351
+ * One preview pipeline per editor. A new request cancels the one in flight,
1352
+ * a late answer for an older draft is dropped (`switchMap`), and Retry
1353
+ * re-renders the exact request that failed — so retrying a history preview
1354
+ * keeps the selected publication instead of falling back to the draft.
1355
+ * Previews never save, publish, enable or send anything.
1356
+ */
1357
+ class NotificationPreviewRunner {
1358
+ status = signal('idle', ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
1359
+ /** Last successful render — kept on screen while the next one runs. */
1360
+ result = signal(null, ...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
1361
+ errorCode = signal(null, ...(ngDevMode ? [{ debugName: "errorCode" }] : /* istanbul ignore next */ []));
1362
+ /** Identity of the request `result` was rendered from. */
1363
+ resultKey = signal(null, ...(ngDevMode ? [{ debugName: "resultKey" }] : /* istanbul ignore next */ []));
1364
+ immediate$ = new Subject();
1365
+ debounced$ = new Subject();
1366
+ retry$ = new Subject();
1367
+ lastJob = null;
1368
+ /** Bumped by every request, so a pending debounced one can tell it was overtaken. */
1369
+ seq = 0;
1370
+ constructor(facade, destroyRef, debounceMs = 600) {
1371
+ merge(this.immediate$, this.debounced$.pipe(debounceTime(debounceMs), filter((job) => job.seq === this.seq)), this.retry$.pipe(map(() => this.lastJob)))
1372
+ .pipe(switchMap((job) => {
1373
+ this.lastJob = job;
1374
+ if (!job) {
1375
+ return of({ status: 'idle' });
1376
+ }
1377
+ return facade.preview(job.eventId, job.request).pipe(map((preview) => ({ status: 'ready', preview, job })), catchError((error) => of({
1378
+ status: 'error',
1379
+ code: readNotificationFailure(error).code,
1380
+ })), startWith({ status: 'updating' }));
1381
+ }), takeUntilDestroyed(destroyRef))
1382
+ .subscribe((state) => {
1383
+ this.status.set(state.status);
1384
+ if (state.status === 'ready') {
1385
+ this.result.set(state.preview);
1386
+ this.resultKey.set(state.job.key);
1387
+ this.errorCode.set(null);
1388
+ }
1389
+ else if (state.status === 'error') {
1390
+ this.errorCode.set(state.code);
1391
+ }
1392
+ else if (state.status === 'idle') {
1393
+ this.result.set(null);
1394
+ this.resultKey.set(null);
1395
+ this.errorCode.set(null);
1396
+ }
1397
+ });
1398
+ }
1399
+ /** Render now (opening a review, selecting a history entry). */
1400
+ run(eventId, request) {
1401
+ this.seq++;
1402
+ this.immediate$.next(toJob(eventId, request));
1403
+ }
1404
+ /** Render after a typing pause. */
1405
+ schedule(eventId, request) {
1406
+ this.debounced$.next({ ...toJob(eventId, request), seq: ++this.seq });
1407
+ }
1408
+ retry() {
1409
+ this.retry$.next();
1410
+ }
1411
+ clear() {
1412
+ this.seq++;
1413
+ this.immediate$.next(null);
1414
+ }
1415
+ isCurrent(eventId, request) {
1416
+ return (this.status() === 'ready' &&
1417
+ this.resultKey() === toJob(eventId, request).key);
1418
+ }
1419
+ }
1420
+ function toJob(eventId, request) {
1421
+ return { eventId, request, key: `${eventId}:${JSON.stringify(request)}` };
1422
+ }
1423
+
1424
+ /**
1425
+ * Retained publications of one template, newest first. Selecting a version
1426
+ * previews that version's own content and recipients — never the draft's.
1427
+ * "Restore" hands the chosen version back to the editor, which runs the
1428
+ * review before anything is written.
1429
+ */
1430
+ class TemplateHistoryDrawer {
1431
+ modalService = inject(ModalService);
1432
+ ref = inject(ModalRef);
892
1433
  facade = inject(NotificationFacade);
1434
+ destroyRef = inject(DestroyRef);
893
1435
  transloco = inject(TranslocoService);
894
- confirmationService = inject(ConfirmationService);
1436
+ eventId = input.required(...(ngDevMode ? [{ debugName: "eventId" }] : /* istanbul ignore next */ []));
1437
+ /** Restoring is blocked while the editor holds unsaved edits. */
1438
+ draftDirty = input(false, ...(ngDevMode ? [{ debugName: "draftDirty" }] : /* istanbul ignore next */ []));
1439
+ historyLimit = input(null, ...(ngDevMode ? [{ debugName: "historyLimit" }] : /* istanbul ignore next */ []));
1440
+ runner = new NotificationPreviewRunner(this.facade, this.destroyRef);
1441
+ publications = this.facade.publications;
1442
+ template = this.facade.selectedTemplate;
1443
+ selectedRevision = signal(null, ...(ngDevMode ? [{ debugName: "selectedRevision" }] : /* istanbul ignore next */ []));
1444
+ selected = computed(() => this.publications().find((publication) => publication.revision === this.selectedRevision()) ?? null, ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
1445
+ defaultsPublisher = DEFAULTS_PUBLISHER;
1446
+ receiverLabels = computed(() => new Map(flattenRecipientCatalog(this.facade.context()?.recipients ?? [], this.transloco.getActiveLang())
1447
+ .flatMap((group) => group.items)
1448
+ .map((item) => [item.key.toLowerCase(), item.name])), ...(ngDevMode ? [{ debugName: "receiverLabels" }] : /* istanbul ignore next */ []));
1449
+ constructor() {
1450
+ // Open on the live version once the history arrives.
1451
+ effect(() => {
1452
+ const publications = this.publications();
1453
+ const live = this.template()?.publishedRevision ?? null;
1454
+ untracked(() => {
1455
+ if (this.selectedRevision() !== null || publications.length === 0) {
1456
+ return;
1457
+ }
1458
+ this.select(publications.find((item) => item.revision === live) ??
1459
+ publications[0]);
1460
+ });
1461
+ });
1462
+ }
1463
+ select(publication) {
1464
+ this.selectedRevision.set(publication.revision);
1465
+ this.runner.run(this.eventId(), withPreviewSamples(publicationPreviewRequest(publication.content), this.facade.context()?.properties ?? [], this.transloco.getActiveLang()));
1466
+ }
1467
+ /** The catalog's (localized) name; the snapshot keeps whatever was saved. */
1468
+ recipientName(recipient) {
1469
+ return (this.receiverLabels().get(recipientKey(recipient.type, recipient.identifier).toLowerCase()) ??
1470
+ (recipient.name || recipient.identifier));
1471
+ }
1472
+ restore() {
1473
+ const publication = this.selected();
1474
+ if (!publication || this.draftDirty())
1475
+ return;
1476
+ const result = { publication };
1477
+ this.ref.close(result);
1478
+ }
1479
+ close() {
1480
+ this.ref.close();
1481
+ }
1482
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TemplateHistoryDrawer, deps: [], target: i0.ɵɵFactoryTarget.Component });
1483
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: TemplateHistoryDrawer, isStandalone: true, selector: "mt-notification-template-history-drawer", inputs: { eventId: { classPropertyName: "eventId", publicName: "eventId", isSignal: true, isRequired: true, transformFunction: null }, draftDirty: { classPropertyName: "draftDirty", publicName: "draftDirty", isSignal: true, isRequired: false, transformFunction: null }, historyLimit: { classPropertyName: "historyLimit", publicName: "historyLimit", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'notification.history'\">\n <div\n [class]=\"modalService.contentClass\"\n class=\"flex min-h-0 flex-col gap-4 p-4\"\n >\n @if (facade.isLoadingPublications() && publications().length === 0) {\n <div class=\"flex flex-col gap-2\">\n <p-skeleton width=\"100%\" height=\"3rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"3rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"20rem\" borderRadius=\"0.375rem\" />\n </div>\n } @else if (publications().length === 0) {\n <p class=\"text-sm text-muted-color\">{{ t(\"empty\") }}</p>\n } @else {\n @if (historyLimit(); as limit) {\n <p class=\"text-xs text-muted-color\">\n {{ t(\"limit\", { limit: limit }) }}\n </p>\n }\n <ul\n class=\"flex flex-col gap-2\"\n role=\"listbox\"\n [attr.aria-label]=\"t('title')\"\n >\n @for (publication of publications(); track publication.revision) {\n <li>\n <button\n type=\"button\"\n role=\"option\"\n [attr.aria-selected]=\"publication.revision === selectedRevision()\"\n class=\"flex w-full flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-start text-sm transition-colors\"\n [class]=\"\n publication.revision === selectedRevision()\n ? 'border-primary bg-primary-50'\n : 'border-surface bg-content hover:bg-surface-50'\n \"\n (click)=\"select(publication)\"\n >\n <span class=\"flex items-center gap-2\">\n <span class=\"font-medium\">{{\n t(\"version\", { revision: publication.revision })\n }}</span>\n @if (publication.revision === template()?.publishedRevision) {\n <span\n class=\"rounded-md border border-green-200 bg-green-50 px-1.5 py-0.5 text-xs text-green-700\"\n >{{ t(\"live\") }}</span\n >\n }\n </span>\n <span class=\"text-xs text-muted-color\">\n {{ publication.publishedAtUtc | date: \"medium\" }}\n \u00B7\n {{\n publication.publishedBy === defaultsPublisher\n ? t(\"by-system\")\n : t(\"by-user\")\n }}\n </span>\n </button>\n </li>\n }\n </ul>\n\n @if (selected(); as selected) {\n <section class=\"flex flex-col gap-3 border-t border-surface pt-4\">\n <h4 class=\"text-sm font-semibold\">\n {{ t(\"version\", { revision: selected.revision }) }} \u2014\n {{ selected.content.name }}\n </h4>\n <mt-notification-content-preview\n [preview]=\"runner.result()\"\n [status]=\"runner.status()\"\n [errorCode]=\"runner.errorCode()\"\n height=\"22rem\"\n (retry)=\"runner.retry()\"\n />\n <div class=\"flex flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"recipients\")\n }}</span>\n @if (selected.content.receivers.length > 0) {\n <ul class=\"flex flex-wrap gap-2\">\n @for (recipient of selected.content.receivers; track $index) {\n <li\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-1 text-sm\"\n >\n {{ recipientName(recipient) }}\n </li>\n }\n </ul>\n } @else {\n <span class=\"text-sm text-muted-color\">{{\n t(\"no-recipients\")\n }}</span>\n }\n </div>\n @if (selected.content.conditionFormula; as condition) {\n <div class=\"flex flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"condition\")\n }}</span>\n <code\n class=\"block break-all rounded-md border border-surface bg-surface-50 px-3 py-2 text-xs\"\n dir=\"ltr\"\n >{{ condition }}</code\n >\n </div>\n }\n </section>\n }\n }\n </div>\n\n <div [class]=\"modalService.footerClass\">\n <mt-button\n [label]=\"t('close')\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"close()\"\n />\n <mt-button\n icon=\"time.clock-rewind\"\n [label]=\"t('restore')\"\n [tooltip]=\"draftDirty() ? t('restore-blocked') : ''\"\n [disabled]=\"!selected() || draftDirty()\"\n (onClick)=\"restore()\"\n />\n </div>\n</ng-container>\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: Skeleton, selector: "p-skeleton", inputs: ["styleClass", "shape", "animation", "borderRadius", "size", "width", "height"] }, { kind: "component", type: NotificationContentPreview, selector: "mt-notification-content-preview", inputs: ["preview", "status", "errorCode", "stale", "height", "fill", "showSubject"], outputs: ["retry"] }, { kind: "pipe", type: DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1484
+ }
1485
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TemplateHistoryDrawer, decorators: [{
1486
+ type: Component,
1487
+ args: [{ selector: 'mt-notification-template-history-drawer', imports: [
1488
+ TranslocoDirective,
1489
+ DatePipe,
1490
+ Button,
1491
+ Skeleton,
1492
+ NotificationContentPreview,
1493
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'notification.history'\">\n <div\n [class]=\"modalService.contentClass\"\n class=\"flex min-h-0 flex-col gap-4 p-4\"\n >\n @if (facade.isLoadingPublications() && publications().length === 0) {\n <div class=\"flex flex-col gap-2\">\n <p-skeleton width=\"100%\" height=\"3rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"3rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"20rem\" borderRadius=\"0.375rem\" />\n </div>\n } @else if (publications().length === 0) {\n <p class=\"text-sm text-muted-color\">{{ t(\"empty\") }}</p>\n } @else {\n @if (historyLimit(); as limit) {\n <p class=\"text-xs text-muted-color\">\n {{ t(\"limit\", { limit: limit }) }}\n </p>\n }\n <ul\n class=\"flex flex-col gap-2\"\n role=\"listbox\"\n [attr.aria-label]=\"t('title')\"\n >\n @for (publication of publications(); track publication.revision) {\n <li>\n <button\n type=\"button\"\n role=\"option\"\n [attr.aria-selected]=\"publication.revision === selectedRevision()\"\n class=\"flex w-full flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-start text-sm transition-colors\"\n [class]=\"\n publication.revision === selectedRevision()\n ? 'border-primary bg-primary-50'\n : 'border-surface bg-content hover:bg-surface-50'\n \"\n (click)=\"select(publication)\"\n >\n <span class=\"flex items-center gap-2\">\n <span class=\"font-medium\">{{\n t(\"version\", { revision: publication.revision })\n }}</span>\n @if (publication.revision === template()?.publishedRevision) {\n <span\n class=\"rounded-md border border-green-200 bg-green-50 px-1.5 py-0.5 text-xs text-green-700\"\n >{{ t(\"live\") }}</span\n >\n }\n </span>\n <span class=\"text-xs text-muted-color\">\n {{ publication.publishedAtUtc | date: \"medium\" }}\n \u00B7\n {{\n publication.publishedBy === defaultsPublisher\n ? t(\"by-system\")\n : t(\"by-user\")\n }}\n </span>\n </button>\n </li>\n }\n </ul>\n\n @if (selected(); as selected) {\n <section class=\"flex flex-col gap-3 border-t border-surface pt-4\">\n <h4 class=\"text-sm font-semibold\">\n {{ t(\"version\", { revision: selected.revision }) }} \u2014\n {{ selected.content.name }}\n </h4>\n <mt-notification-content-preview\n [preview]=\"runner.result()\"\n [status]=\"runner.status()\"\n [errorCode]=\"runner.errorCode()\"\n height=\"22rem\"\n (retry)=\"runner.retry()\"\n />\n <div class=\"flex flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"recipients\")\n }}</span>\n @if (selected.content.receivers.length > 0) {\n <ul class=\"flex flex-wrap gap-2\">\n @for (recipient of selected.content.receivers; track $index) {\n <li\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-1 text-sm\"\n >\n {{ recipientName(recipient) }}\n </li>\n }\n </ul>\n } @else {\n <span class=\"text-sm text-muted-color\">{{\n t(\"no-recipients\")\n }}</span>\n }\n </div>\n @if (selected.content.conditionFormula; as condition) {\n <div class=\"flex flex-col gap-1\">\n <span class=\"text-xs font-medium text-muted-color\">{{\n t(\"condition\")\n }}</span>\n <code\n class=\"block break-all rounded-md border border-surface bg-surface-50 px-3 py-2 text-xs\"\n dir=\"ltr\"\n >{{ condition }}</code\n >\n </div>\n }\n </section>\n }\n }\n </div>\n\n <div [class]=\"modalService.footerClass\">\n <mt-button\n [label]=\"t('close')\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"close()\"\n />\n <mt-button\n icon=\"time.clock-rewind\"\n [label]=\"t('restore')\"\n [tooltip]=\"draftDirty() ? t('restore-blocked') : ''\"\n [disabled]=\"!selected() || draftDirty()\"\n (onClick)=\"restore()\"\n />\n </div>\n</ng-container>\n" }]
1494
+ }], ctorParameters: () => [], propDecorators: { eventId: [{ type: i0.Input, args: [{ isSignal: true, alias: "eventId", required: true }] }], draftDirty: [{ type: i0.Input, args: [{ isSignal: true, alias: "draftDirty", required: false }] }], historyLimit: [{ type: i0.Input, args: [{ isSignal: true, alias: "historyLimit", required: false }] }] } });
1495
+
1496
+ /**
1497
+ * The explicit review before a write changes what is sent: the actual content
1498
+ * (rendered by the server) and the actual receivers of the version being
1499
+ * published / turned on / restored — never the working draft's.
1500
+ *
1501
+ * The review flags are not separate tick boxes (product decision, 2026-10-27):
1502
+ * confirming this dialog, which only enables once the server preview has
1503
+ * rendered, is the acknowledgement. They are sent only from that confirmation,
1504
+ * never as defaults elsewhere.
1505
+ */
1506
+ class TemplateReviewDialog {
895
1507
  modalService = inject(ModalService);
1508
+ ref = inject(ModalRef);
1509
+ facade = inject(NotificationFacade);
1510
+ destroyRef = inject(DestroyRef);
1511
+ mode = input.required(...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
1512
+ eventId = input.required(...(ngDevMode ? [{ debugName: "eventId" }] : /* istanbul ignore next */ []));
1513
+ request = input.required(...(ngDevMode ? [{ debugName: "request" }] : /* istanbul ignore next */ []));
1514
+ recipients = input([], ...(ngDevMode ? [{ debugName: "recipients" }] : /* istanbul ignore next */ []));
1515
+ condition = input(null, ...(ngDevMode ? [{ debugName: "condition" }] : /* istanbul ignore next */ []));
1516
+ /** Templates a ready-made slot owns also need `languageReviewed`. */
1517
+ requireLanguageReview = input(false, ...(ngDevMode ? [{ debugName: "requireLanguageReview" }] : /* istanbul ignore next */ []));
1518
+ /** Publishing an enabled template changes live sending at once. */
1519
+ isEnabled = input(false, ...(ngDevMode ? [{ debugName: "isEnabled" }] : /* istanbul ignore next */ []));
1520
+ runner = new NotificationPreviewRunner(this.facade, this.destroyRef);
1521
+ previewReady = computed(() => this.runner.status() === 'ready', ...(ngDevMode ? [{ debugName: "previewReady" }] : /* istanbul ignore next */ []));
1522
+ ngOnInit() {
1523
+ this.runner.run(this.eventId(), this.request());
1524
+ }
1525
+ confirm() {
1526
+ if (!this.previewReady())
1527
+ return;
1528
+ const reviewed = this.mode() !== 'restoreDefault';
1529
+ const result = {
1530
+ recipientsReviewed: reviewed,
1531
+ languageReviewed: reviewed && this.requireLanguageReview(),
1532
+ };
1533
+ this.ref.close(result);
1534
+ }
1535
+ cancel() {
1536
+ this.ref.close();
1537
+ }
1538
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TemplateReviewDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
1539
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: TemplateReviewDialog, isStandalone: true, selector: "mt-notification-template-review-dialog", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: true, transformFunction: null }, eventId: { classPropertyName: "eventId", publicName: "eventId", isSignal: true, isRequired: true, transformFunction: null }, request: { classPropertyName: "request", publicName: "request", isSignal: true, isRequired: true, transformFunction: null }, recipients: { classPropertyName: "recipients", publicName: "recipients", isSignal: true, isRequired: false, transformFunction: null }, condition: { classPropertyName: "condition", publicName: "condition", isSignal: true, isRequired: false, transformFunction: null }, requireLanguageReview: { classPropertyName: "requireLanguageReview", publicName: "requireLanguageReview", isSignal: true, isRequired: false, transformFunction: null }, isEnabled: { classPropertyName: "isEnabled", publicName: "isEnabled", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'notification.review'\">\n <div [class]=\"modalService.contentClass\" class=\"flex flex-col gap-4 p-4\">\n <div\n class=\"rounded-md border px-4 py-3 text-sm\"\n [class]=\"\n mode() === 'publish' && isEnabled()\n ? 'border-amber-200 bg-amber-50 text-amber-700'\n : 'border-surface bg-surface-50'\n \"\n >\n {{\n t(\n \"intro.\" +\n mode() +\n (mode() === \"publish\" && isEnabled() ? \"-live\" : \"\")\n )\n }}\n </div>\n\n <section class=\"flex flex-col gap-2\">\n <h4 class=\"text-sm font-semibold\">{{ t(\"recipients\") }}</h4>\n @if (recipients().length > 0) {\n <ul class=\"flex flex-wrap gap-2\">\n @for (recipient of recipients(); track $index) {\n <li\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-1 text-sm\"\n >\n <span class=\"font-medium\">{{ recipient.name }}</span>\n <span class=\"ms-1 text-xs text-muted-color\">{{\n t(\"recipient-type.\" + recipient.type)\n }}</span>\n </li>\n }\n </ul>\n } @else {\n <p class=\"text-sm text-amber-700\">{{ t(\"no-recipients\") }}</p>\n }\n </section>\n\n <section class=\"flex flex-col gap-2\">\n <h4 class=\"text-sm font-semibold\">{{ t(\"condition\") }}</h4>\n @if (condition(); as condition) {\n <code\n class=\"block break-all rounded-md border border-surface bg-surface-50 px-3 py-2 text-xs\"\n dir=\"ltr\"\n >{{ condition }}</code\n >\n } @else {\n <p class=\"text-sm text-muted-color\">{{ t(\"always\") }}</p>\n }\n </section>\n\n <section class=\"flex flex-col gap-2\">\n <h4 class=\"text-sm font-semibold\">{{ t(\"content\") }}</h4>\n <mt-notification-content-preview\n [preview]=\"runner.result()\"\n [status]=\"runner.status()\"\n [errorCode]=\"runner.errorCode()\"\n height=\"20rem\"\n (retry)=\"runner.retry()\"\n />\n </section>\n </div>\n\n <div [class]=\"modalService.footerClass\">\n <div class=\"flex w-full justify-end gap-2\">\n <mt-button\n [label]=\"t('cancel')\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"cancel()\"\n />\n <mt-button\n [label]=\"t('confirm.' + mode())\"\n [tooltip]=\"previewReady() ? '' : t('waiting-preview')\"\n [disabled]=\"!previewReady()\"\n (onClick)=\"confirm()\"\n />\n </div>\n </div>\n</ng-container>\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: NotificationContentPreview, selector: "mt-notification-content-preview", inputs: ["preview", "status", "errorCode", "stale", "height", "fill", "showSubject"], outputs: ["retry"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1540
+ }
1541
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TemplateReviewDialog, decorators: [{
1542
+ type: Component,
1543
+ args: [{ selector: 'mt-notification-template-review-dialog', imports: [TranslocoDirective, Button, NotificationContentPreview], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'notification.review'\">\n <div [class]=\"modalService.contentClass\" class=\"flex flex-col gap-4 p-4\">\n <div\n class=\"rounded-md border px-4 py-3 text-sm\"\n [class]=\"\n mode() === 'publish' && isEnabled()\n ? 'border-amber-200 bg-amber-50 text-amber-700'\n : 'border-surface bg-surface-50'\n \"\n >\n {{\n t(\n \"intro.\" +\n mode() +\n (mode() === \"publish\" && isEnabled() ? \"-live\" : \"\")\n )\n }}\n </div>\n\n <section class=\"flex flex-col gap-2\">\n <h4 class=\"text-sm font-semibold\">{{ t(\"recipients\") }}</h4>\n @if (recipients().length > 0) {\n <ul class=\"flex flex-wrap gap-2\">\n @for (recipient of recipients(); track $index) {\n <li\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-1 text-sm\"\n >\n <span class=\"font-medium\">{{ recipient.name }}</span>\n <span class=\"ms-1 text-xs text-muted-color\">{{\n t(\"recipient-type.\" + recipient.type)\n }}</span>\n </li>\n }\n </ul>\n } @else {\n <p class=\"text-sm text-amber-700\">{{ t(\"no-recipients\") }}</p>\n }\n </section>\n\n <section class=\"flex flex-col gap-2\">\n <h4 class=\"text-sm font-semibold\">{{ t(\"condition\") }}</h4>\n @if (condition(); as condition) {\n <code\n class=\"block break-all rounded-md border border-surface bg-surface-50 px-3 py-2 text-xs\"\n dir=\"ltr\"\n >{{ condition }}</code\n >\n } @else {\n <p class=\"text-sm text-muted-color\">{{ t(\"always\") }}</p>\n }\n </section>\n\n <section class=\"flex flex-col gap-2\">\n <h4 class=\"text-sm font-semibold\">{{ t(\"content\") }}</h4>\n <mt-notification-content-preview\n [preview]=\"runner.result()\"\n [status]=\"runner.status()\"\n [errorCode]=\"runner.errorCode()\"\n height=\"20rem\"\n (retry)=\"runner.retry()\"\n />\n </section>\n </div>\n\n <div [class]=\"modalService.footerClass\">\n <div class=\"flex w-full justify-end gap-2\">\n <mt-button\n [label]=\"t('cancel')\"\n severity=\"secondary\"\n variant=\"outlined\"\n (onClick)=\"cancel()\"\n />\n <mt-button\n [label]=\"t('confirm.' + mode())\"\n [tooltip]=\"previewReady() ? '' : t('waiting-preview')\"\n [disabled]=\"!previewReady()\"\n (onClick)=\"confirm()\"\n />\n </div>\n </div>\n</ng-container>\n" }]
1544
+ }], propDecorators: { mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: true }] }], eventId: [{ type: i0.Input, args: [{ isSignal: true, alias: "eventId", required: true }] }], request: [{ type: i0.Input, args: [{ isSignal: true, alias: "request", required: true }] }], recipients: [{ type: i0.Input, args: [{ isSignal: true, alias: "recipients", required: false }] }], condition: [{ type: i0.Input, args: [{ isSignal: true, alias: "condition", required: false }] }], requireLanguageReview: [{ type: i0.Input, args: [{ isSignal: true, alias: "requireLanguageReview", required: false }] }], isEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "isEnabled", required: false }] }] } });
1545
+
1546
+ const WIDE_LAYOUT_MIN_WIDTH = 960;
1547
+ const DRAWER_STYLE_CLASS = '!shadow-none !w-full !max-w-full md:!w-[70%] lg:!w-[50%]';
1548
+ /**
1549
+ * Control Panel notification templates for one scope (Level, Module,
1550
+ * LevelModule, Request/Step, EscalationRequest/Step, Delegation).
1551
+ *
1552
+ * Saving and previewing a draft never changes what is sent: only Publish
1553
+ * (snapshot the saved draft) and Turn on/off do, and both go through an
1554
+ * explicit review of the actual content and recipients first. Every write
1555
+ * carries the last revision read; a conflict keeps the user's edits.
1556
+ */
1557
+ class NotificationTemplate {
1558
+ facade = inject(NotificationFacade);
1559
+ messages = inject(NotificationMessages);
1560
+ transloco = inject(TranslocoService);
1561
+ confirmation = inject(ConfirmationService);
1562
+ modal = inject(ModalService);
896
1563
  toast = inject(ToastService);
897
- // ============================================================================
898
- // Data Signals from Facade
899
- // ============================================================================
1564
+ destroyRef = inject(DestroyRef);
1565
+ lang = toSignal(this.transloco.langChanges$, {
1566
+ initialValue: this.transloco.getActiveLang(),
1567
+ });
1568
+ // ==========================================================================
1569
+ // Store
1570
+ // ==========================================================================
900
1571
  events = this.facade.events;
901
1572
  templates = this.facade.templates;
902
- receivers = this.facade.receivers;
903
- propertyGroups = this.facade.propertyGroups;
904
- // Selection Signals
905
- selectedEventName = this.facade.selectedEventName;
906
- selectedTemplateId = this.facade.selectedTemplateId;
907
1573
  selectedEvent = this.facade.selectedEvent;
908
- selectedTemplate = this.facade.selectedTemplate;
909
- availableReceivers = this.facade.availableReceivers;
910
- availableReceiverGroups = this.facade.availableReceiverGroups;
911
- // ============================================================================
912
- // Loading States from Facade
913
- // ============================================================================
914
- isLoadingEvents = this.facade.isLoadingEvents;
915
- isLoadingReceivers = this.facade.isLoadingReceivers;
916
- isLoadingProperties = this.facade.isLoadingProperties;
917
- isUpdatingTemplate = this.facade.isUpdatingTemplate;
918
- isDeletingTemplate = this.facade.isDeletingTemplate;
919
- isAddingTemplate = this.facade.isAddingTemplate;
920
- // ============================================================================
921
- // Error States from Facade
922
- // ============================================================================
923
- eventsError = this.facade.eventsError;
924
- propertiesError = this.facade.propertiesError;
925
- // ============================================================================
926
- // Local State
927
- // ============================================================================
928
- deletingReceiverIds = signal([], ...(ngDevMode ? [{ debugName: "deletingReceiverIds" }] : /* istanbul ignore next */ []));
929
- addingReceiverIds = signal([], ...(ngDevMode ? [{ debugName: "addingReceiverIds" }] : /* istanbul ignore next */ []));
930
- // ============================================================================
931
- // Form - Dynamic controls based on CHANNEL_CONFIGS
932
- // ============================================================================
933
- form = this.buildForm();
934
- propertyPickerControl = new FormControl(null);
935
- receiverPickerControl = new FormControl(null);
936
- channelControl = this.form.controls['channel'];
937
- channelSignal = toSignal(this.channelControl.valueChanges, {
938
- initialValue: this.channelControl.value,
1574
+ selectedEventId = this.facade.selectedEventId;
1575
+ selectedTemplateId = this.facade.selectedTemplateId;
1576
+ template = this.facade.selectedTemplate;
1577
+ context = this.facade.context;
1578
+ defaults = this.facade.defaults;
1579
+ publications = this.facade.publications;
1580
+ isMutating = this.facade.isMutating;
1581
+ // ==========================================================================
1582
+ // Controls
1583
+ // ==========================================================================
1584
+ eventControl = new FormControl(null);
1585
+ placeholderControl = new FormControl(null);
1586
+ recipientControl = new FormControl(null);
1587
+ form = new FormGroup({
1588
+ name: new FormControl('', {
1589
+ nonNullable: true,
1590
+ validators: [Validators.required, Validators.maxLength(250)],
1591
+ }),
1592
+ subject: new FormControl('', {
1593
+ nonNullable: true,
1594
+ validators: [Validators.maxLength(250)],
1595
+ }),
1596
+ emailTemplate: new FormControl('', { nonNullable: true }),
1597
+ smsTemplate: new FormControl('', {
1598
+ nonNullable: true,
1599
+ validators: [Validators.maxLength(4000)],
1600
+ }),
1601
+ appEn: new FormControl('', { nonNullable: true }),
1602
+ appAr: new FormControl('', { nonNullable: true }),
1603
+ conditionFormula: new FormControl('', { nonNullable: true }),
939
1604
  });
940
- buildForm() {
941
- const fc = (v = '') => new FormControl(v, { nonNullable: true });
942
- const controls = {
943
- event: new FormControl(null),
944
- name: fc(),
945
- channel: new FormControl('email', {
946
- nonNullable: true,
947
- }),
948
- };
949
- for (const ch of CHANNEL_CONFIGS) {
950
- if (ch.templateKey)
951
- controls[ch.templateKey] = fc();
952
- if (ch.translatableKey) {
953
- // Nested FormGroup to match payload structure { en: '', ar: '' }
954
- controls[ch.translatableKey] = new FormGroup({
955
- en: fc(),
956
- ar: fc(),
957
- });
958
- }
959
- if (ch.bilingualOptional) {
960
- controls[`${ch.key}Bilingual`] = new FormControl(false, {
961
- nonNullable: true,
962
- });
963
- }
964
- }
965
- return new FormGroup(controls);
966
- }
967
- // ============================================================================
968
- // Computed
969
- // ============================================================================
970
- channelConfigs = CHANNEL_CONFIGS;
971
- /** Check if channel should show bilingual fields - reads from form */
972
- isBilingual(ch) {
973
- if (!ch.supportsBilingual)
1605
+ formValue = signal(this.form.getRawValue(), ...(ngDevMode ? [{ debugName: "formValue" }] : /* istanbul ignore next */ []));
1606
+ /** Mirrors the tabs' own `active` model so a cancelled switch can snap back. */
1607
+ activeTab = signal(null, ...(ngDevMode ? [{ debugName: "activeTab" }] : /* istanbul ignore next */ []));
1608
+ activeField = signal('emailTemplate', ...(ngDevMode ? [{ debugName: "activeField" }] : /* istanbul ignore next */ []));
1609
+ channelIcons = CHANNEL_ICONS;
1610
+ host = inject(ElementRef);
1611
+ /** Editor and preview side by side; see the ResizeObserver in the constructor. */
1612
+ wide = signal(false, ...(ngDevMode ? [{ debugName: "wide" }] : /* istanbul ignore next */ []));
1613
+ conflict = signal(false, ...(ngDevMode ? [{ debugName: "conflict" }] : /* istanbul ignore next */ []));
1614
+ writeDiagnostics = signal([], ...(ngDevMode ? [{ debugName: "writeDiagnostics" }] : /* istanbul ignore next */ []));
1615
+ preview = new NotificationPreviewRunner(this.facade, this.destroyRef);
1616
+ /** A newly opened template renders at once instead of after the typing pause. */
1617
+ renderPreviewNow = false;
1618
+ emailEditor = viewChild('emailEditor', { ...(ngDevMode ? { debugName: "emailEditor" } : /* istanbul ignore next */ {}), read: EmailVisualEditor });
1619
+ appEnEditor = viewChild('appEnEditor', { ...(ngDevMode ? { debugName: "appEnEditor" } : /* istanbul ignore next */ {}), read: EmailVisualEditor });
1620
+ appArEditor = viewChild('appArEditor', { ...(ngDevMode ? { debugName: "appArEditor" } : /* istanbul ignore next */ {}), read: EmailVisualEditor });
1621
+ smsSource = viewChild('smsSource', ...(ngDevMode ? [{ debugName: "smsSource" }] : /* istanbul ignore next */ []));
1622
+ // ==========================================================================
1623
+ // Derived state
1624
+ // ==========================================================================
1625
+ channel = computed(() => this.template()?.channel ?? null, ...(ngDevMode ? [{ debugName: "channel" }] : /* istanbul ignore next */ []));
1626
+ status = computed(() => {
1627
+ const template = this.template();
1628
+ return template ? templateStatus(template) : null;
1629
+ }, ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
1630
+ unpublishedChanges = computed(() => {
1631
+ const template = this.template();
1632
+ return template
1633
+ ? hasUnpublishedChanges(template, this.publications())
1634
+ : null;
1635
+ }, ...(ngDevMode ? [{ debugName: "unpublishedChanges" }] : /* istanbul ignore next */ []));
1636
+ defaultOwned = computed(() => {
1637
+ const template = this.template();
1638
+ return !!template && isDefaultOwned(template, this.defaults());
1639
+ }, ...(ngDevMode ? [{ debugName: "defaultOwned" }] : /* istanbul ignore next */ []));
1640
+ /** Unsaved edits, measured against the server baseline rather than control flags. */
1641
+ dirty = computed(() => {
1642
+ const template = this.template();
1643
+ if (!template)
974
1644
  return false;
975
- if (!ch.bilingualOptional)
976
- return true; // forced bilingual (app)
977
- return this.form.controls[`${ch.key}Bilingual`]?.value ?? false;
978
- }
979
- channelOptions = computed(() => CHANNEL_CONFIGS.map((c) => ({
980
- ...c,
981
- name: this.transloco.translate(c.labelKey),
982
- })), ...(ngDevMode ? [{ debugName: "channelOptions" }] : /* istanbul ignore next */ []));
983
- /** Templates of the selected event, rendered as tabs. */
984
- templateTabs = computed(() => this.templates().map((t) => ({
985
- label: t.name,
986
- value: t.id,
987
- isSystem: t.isSystem,
1645
+ const value = this.formValue();
1646
+ const same = (current, saved) => current === (saved ?? '');
1647
+ if (!same(value.name, template.name))
1648
+ return true;
1649
+ if (value.conditionFormula.trim() !== (template.conditionFormula ?? '').trim()) {
1650
+ return true;
1651
+ }
1652
+ switch (template.channel) {
1653
+ case 'Email':
1654
+ return (!same(value.subject, template.subject) ||
1655
+ !same(value.emailTemplate, template.emailTemplate));
1656
+ case 'Sms':
1657
+ return !same(value.smsTemplate, template.smsTemplate);
1658
+ case 'App':
1659
+ return (!same(value.appEn, template.appTemplate?.en) ||
1660
+ !same(value.appAr, template.appTemplate?.ar));
1661
+ }
1662
+ }, ...(ngDevMode ? [{ debugName: "dirty" }] : /* istanbul ignore next */ []));
1663
+ nameValid = computed(() => this.formValue().name.trim().length > 0 &&
1664
+ this.formValue().name.length <= 250, ...(ngDevMode ? [{ debugName: "nameValid" }] : /* istanbul ignore next */ []));
1665
+ canSave = computed(() => this.dirty() && this.nameValid() && !this.isMutating(), ...(ngDevMode ? [{ debugName: "canSave" }] : /* istanbul ignore next */ []));
1666
+ canPublish = computed(() => !!this.template() && !this.dirty() && !this.isMutating(), ...(ngDevMode ? [{ debugName: "canPublish" }] : /* istanbul ignore next */ []));
1667
+ canTurnOn = computed(() => {
1668
+ const template = this.template();
1669
+ return (!!template &&
1670
+ template.publishedRevision != null &&
1671
+ !!currentPublication(template, this.publications()) &&
1672
+ !this.isMutating());
1673
+ }, ...(ngDevMode ? [{ debugName: "canTurnOn" }] : /* istanbul ignore next */ []));
1674
+ templateTabs = computed(() => this.templates().map((template) => ({
1675
+ label: template.name || this.messages.t('untitled'),
1676
+ value: template.id,
1677
+ icon: CHANNEL_ICONS[template.channel],
988
1678
  })), ...(ngDevMode ? [{ debugName: "templateTabs" }] : /* istanbul ignore next */ []));
989
- activeChannel = computed(() => this.selectedTemplate()?.channel ?? this.channelSignal(), ...(ngDevMode ? [{ debugName: "activeChannel" }] : /* istanbul ignore next */ []));
990
- shouldUseMultilingualProperties = computed(() => this.activeChannel() !== 'app', ...(ngDevMode ? [{ debugName: "shouldUseMultilingualProperties" }] : /* istanbul ignore next */ []));
991
- isFormInvalid = computed(() => !this.form.controls['name'].value?.trim(), ...(ngDevMode ? [{ debugName: "isFormInvalid" }] : /* istanbul ignore next */ []));
992
- /**
993
- * Snapshot of the form's default shape, captured before any context ever
994
- * patches it. Used by {@link resetTemplateFields} to clear the form when the
995
- * selected template resolves to nothing, so a component switching context
996
- * never keeps displaying a previous context's values (see
997
- * `ResetNotificationState` for the store-level counterpart of this reset).
998
- */
999
- emptyFormValue = this.form.getRawValue();
1679
+ placeholderGroups = computed(() => {
1680
+ const ar = this.lang() === 'ar';
1681
+ return (this.context()?.properties ?? []).map((group) => ({
1682
+ groupName: group.groupName,
1683
+ properties: group.properties.map((property) => ({
1684
+ key: property.key,
1685
+ label: (ar ? property.nameAr : property.nameEn) || property.name,
1686
+ })),
1687
+ }));
1688
+ }, ...(ngDevMode ? [{ debugName: "placeholderGroups" }] : /* istanbul ignore next */ []));
1689
+ /** Image hosts the server accepts, as reported by the latest email preview. */
1690
+ emailImageOrigins = computed(() => {
1691
+ const result = this.preview.result();
1692
+ return result?.channel === 'Email' ? result.approvedImageOrigins : [];
1693
+ }, ...(ngDevMode ? [{ debugName: "emailImageOrigins" }] : /* istanbul ignore next */ []));
1694
+ /** Image hosts the server accepts in App messages, from the latest App preview. */
1695
+ appImageOrigins = computed(() => {
1696
+ const result = this.preview.result();
1697
+ return result?.channel === 'App' ? result.approvedImageOrigins : [];
1698
+ }, ...(ngDevMode ? [{ debugName: "appImageOrigins" }] : /* istanbul ignore next */ []));
1699
+ /** Catalog names by `type:id`, so saved receivers read as the catalog does. */
1700
+ receiverLabels = computed(() => new Map(flattenRecipientCatalog(this.context()?.recipients ?? [], this.lang())
1701
+ .flatMap((group) => group.items)
1702
+ .map((item) => [item.key.toLowerCase(), item.name])), ...(ngDevMode ? [{ debugName: "receiverLabels" }] : /* istanbul ignore next */ []));
1703
+ /** Field labels for the email editor's placeholder chips. */
1704
+ placeholderOptions = computed(() => this.placeholderGroups().flatMap((group) => group.properties), ...(ngDevMode ? [{ debugName: "placeholderOptions" }] : /* istanbul ignore next */ []));
1705
+ recipientGroups = computed(() => {
1706
+ const assigned = new Set((this.template()?.receivers ?? []).map((receiver) => recipientKey(receiver.type, receiver.identifier)));
1707
+ return flattenRecipientCatalog(this.context()?.recipients ?? [], this.lang())
1708
+ .map((group) => ({
1709
+ ...group,
1710
+ items: group.items.filter((item) => !assigned.has(item.key)),
1711
+ }))
1712
+ .filter((group) => group.items.length > 0);
1713
+ }, ...(ngDevMode ? [{ debugName: "recipientGroups" }] : /* istanbul ignore next */ []));
1714
+ conditionPropertiesByPath = computed(() => {
1715
+ const ar = this.lang() === 'ar';
1716
+ return {
1717
+ Current: (this.context()?.conditionProperties ?? []).map((property) => ({
1718
+ key: property.key,
1719
+ name: (ar ? property.nameAr : property.nameEn) || property.name,
1720
+ })),
1721
+ };
1722
+ }, ...(ngDevMode ? [{ debugName: "conditionPropertiesByPath" }] : /* istanbul ignore next */ []));
1723
+ conditionKeys = computed(() => (this.context()?.conditionProperties ?? []).map((property) => property.key), ...(ngDevMode ? [{ debugName: "conditionKeys" }] : /* istanbul ignore next */ []));
1724
+ missingDefaults = computed(() => missingDefaultChannels(this.defaults()), ...(ngDevMode ? [{ debugName: "missingDefaults" }] : /* istanbul ignore next */ []));
1725
+ removedDefaults = computed(() => {
1726
+ const defaults = this.defaults();
1727
+ if (!defaults)
1728
+ return [];
1729
+ return defaults.slots
1730
+ .filter((slot) => slot.templateId == null)
1731
+ .map((slot) => ({
1732
+ slot,
1733
+ recipe: defaults.recipes.find((recipe) => recipe.channel === slot.channel) ??
1734
+ null,
1735
+ }))
1736
+ .filter((item) => item.recipe !== null);
1737
+ }, ...(ngDevMode ? [{ debugName: "removedDefaults" }] : /* istanbul ignore next */ []));
1738
+ /** What the live preview renders: the editor's current, unsaved values. */
1739
+ previewRequest = computed(() => {
1740
+ const template = this.template();
1741
+ if (!template)
1742
+ return null;
1743
+ const value = this.formValue();
1744
+ return this.withSamples(templatePreviewRequest({
1745
+ channel: template.channel,
1746
+ subject: value.subject,
1747
+ emailTemplate: value.emailTemplate,
1748
+ smsTemplate: value.smsTemplate,
1749
+ appTemplate: { en: value.appEn, ar: value.appAr },
1750
+ }));
1751
+ }, ...(ngDevMode ? [{ debugName: "previewRequest" }] : /* istanbul ignore next */ []));
1752
+ previewStale = computed(() => {
1753
+ const eventId = this.selectedEventId();
1754
+ const request = this.previewRequest();
1755
+ return (eventId == null ||
1756
+ request == null ||
1757
+ !this.preview.isCurrent(eventId, request));
1758
+ }, ...(ngDevMode ? [{ debugName: "previewStale" }] : /* istanbul ignore next */ []));
1000
1759
  constructor() {
1760
+ this.form.valueChanges
1761
+ .pipe(takeUntilDestroyed())
1762
+ .subscribe(() => this.formValue.set(this.form.getRawValue()));
1001
1763
  effect(() => {
1002
- const eventName = this.selectedEventName();
1003
- if (eventName) {
1004
- if (this.form.controls['event'].value !== eventName) {
1005
- this.form.controls['event'].setValue(eventName, {
1006
- emitEvent: false,
1007
- });
1764
+ const eventId = this.selectedEventId();
1765
+ untracked(() => {
1766
+ if (this.eventControl.value !== eventId) {
1767
+ this.eventControl.setValue(eventId, { emitEvent: false });
1008
1768
  }
1009
- }
1010
- else {
1011
- this.form.controls['event'].setValue(null, { emitEvent: false });
1012
- }
1769
+ });
1013
1770
  });
1771
+ // A newly selected template replaces the editor; a write on the same
1772
+ // template (recipients, publish, activation) never touches unsaved edits.
1014
1773
  effect(() => {
1015
- this.selectedTemplateId(); // depend on selected template id
1016
- const template = untracked(this.selectedTemplate);
1017
- if (template) {
1018
- this.patchForm(template);
1019
- }
1020
- else {
1021
- this.resetTemplateFields();
1022
- }
1774
+ const templateId = this.selectedTemplateId();
1775
+ this.activeTab.set(templateId);
1776
+ untracked(() => {
1777
+ this.conflict.set(false);
1778
+ this.writeDiagnostics.set([]);
1779
+ // Never leave the previous template's render (possibly another
1780
+ // channel) on screen under the new one.
1781
+ this.preview.clear();
1782
+ this.renderPreviewNow = true;
1783
+ const template = this.template();
1784
+ if (template) {
1785
+ this.patchFromTemplate(template);
1786
+ this.activeField.set(defaultField(template.channel));
1787
+ }
1788
+ else {
1789
+ this.form.reset();
1790
+ }
1791
+ });
1023
1792
  });
1024
1793
  effect(() => {
1025
- const template = this.selectedTemplate();
1026
- if (!template) {
1794
+ const eventId = this.selectedEventId();
1795
+ const request = this.previewRequest();
1796
+ // The field catalog supplies the preview's sample values; rendering
1797
+ // before it arrives shows "-" and a warning per field for a moment. A
1798
+ // failed catalog load still previews, just without samples. (Not keyed
1799
+ // on the loading flag: a reload cancels and restarts the catalog read.)
1800
+ if (eventId != null &&
1801
+ this.context()?.eventId !== eventId &&
1802
+ this.facade.contextError() == null) {
1027
1803
  return;
1028
1804
  }
1029
- this.facade.loadPropertiesForCurrentContext(this.shouldUseMultilingualProperties());
1805
+ untracked(() => {
1806
+ if (eventId == null || request == null) {
1807
+ this.preview.clear();
1808
+ }
1809
+ else if (this.renderPreviewNow) {
1810
+ this.renderPreviewNow = false;
1811
+ this.preview.run(eventId, request);
1812
+ }
1813
+ else {
1814
+ this.preview.schedule(eventId, request);
1815
+ }
1816
+ });
1030
1817
  });
1818
+ // Two columns only when the editor itself is wide enough. Hosts range from
1819
+ // a full page to a builder drawer, so the viewport says little; a
1820
+ // ResizeObserver is used rather than container queries, which re-parent
1821
+ // PrimeNG's in-place overlays.
1822
+ if (typeof ResizeObserver !== 'undefined') {
1823
+ const observer = new ResizeObserver(([entry]) => this.wide.set(entry.contentRect.width >= WIDE_LAYOUT_MIN_WIDTH));
1824
+ observer.observe(this.host.nativeElement);
1825
+ this.destroyRef.onDestroy(() => observer.disconnect());
1826
+ }
1031
1827
  }
1032
- // ============================================================================
1033
- // Event Handlers
1034
- // ============================================================================
1035
- onEventChange(eventName) {
1036
- if (eventName)
1037
- this.facade.selectEvent(eventName);
1828
+ onBeforeUnload(event) {
1829
+ if (this.dirty() || this.isMutating())
1830
+ event.preventDefault();
1831
+ }
1832
+ // ==========================================================================
1833
+ // Selection
1834
+ // ==========================================================================
1835
+ onEventChange(eventId) {
1836
+ if (eventId == null) {
1837
+ // Clearing the picker is not a selection; keep showing the current event.
1838
+ this.eventControl.setValue(this.selectedEventId(), { emitEvent: false });
1839
+ return;
1840
+ }
1841
+ if (eventId === this.selectedEventId())
1842
+ return;
1843
+ this.confirmDiscard().subscribe((discard) => {
1844
+ if (discard) {
1845
+ this.facade.selectEvent(eventId);
1846
+ }
1847
+ else {
1848
+ this.eventControl.setValue(this.selectedEventId(), {
1849
+ emitEvent: false,
1850
+ });
1851
+ }
1852
+ });
1038
1853
  }
1039
1854
  onTemplateTabChange(templateId) {
1040
- this.facade.selectTemplate(templateId);
1041
- }
1042
- // Channel is fixed per template - set on creation only
1043
- onAddProperty(propertyKey) {
1044
- if (!propertyKey)
1855
+ const current = this.selectedTemplateId();
1856
+ if (templateId === current)
1045
1857
  return;
1046
- const tag = `{{${propertyKey}}}`;
1047
- const ch = getChannelConfig(this.form.controls['channel'].value);
1048
- if (this.isBilingual(ch) && ch.translatableKey) {
1049
- const group = this.form.get(ch.translatableKey);
1050
- if (group) {
1051
- const enCtrl = group.get('en');
1052
- const arCtrl = group.get('ar');
1053
- enCtrl?.setValue(`${enCtrl.value ?? ''} ${tag}`);
1054
- arCtrl?.setValue(`${arCtrl.value ?? ''} ${tag}`);
1858
+ this.activeTab.set(templateId);
1859
+ this.confirmDiscard().subscribe((discard) => {
1860
+ if (discard) {
1861
+ this.facade.selectTemplate(templateId);
1862
+ }
1863
+ else {
1864
+ this.activeTab.set(current);
1055
1865
  }
1866
+ });
1867
+ }
1868
+ retryLoad() {
1869
+ const eventId = this.selectedEventId();
1870
+ if (this.facade.eventsError() || eventId == null) {
1871
+ this.facade.loadEvents();
1872
+ return;
1056
1873
  }
1057
- else if (ch.templateKey) {
1058
- this.appendToControl(ch.templateKey, tag);
1874
+ this.facade.reloadDefaults(eventId);
1875
+ this.facade.loadEvents();
1876
+ }
1877
+ // ==========================================================================
1878
+ // Content editing
1879
+ // ==========================================================================
1880
+ setActiveField(field) {
1881
+ this.activeField.set(field);
1882
+ }
1883
+ onInsertPlaceholder(key) {
1884
+ if (!key)
1885
+ return;
1886
+ // After the select has finished applying its own pick, or the reset is overwritten.
1887
+ setTimeout(() => this.placeholderControl.reset());
1888
+ const field = this.activeField();
1889
+ const label = this.placeholderOptions().find((item) => item.key === key)?.label;
1890
+ const editor = field === 'emailTemplate'
1891
+ ? this.emailEditor()
1892
+ : field === 'appEn'
1893
+ ? this.appEnEditor()
1894
+ : field === 'appAr'
1895
+ ? this.appArEditor()
1896
+ : undefined;
1897
+ if (editor) {
1898
+ editor.insertToken(key, label);
1899
+ return;
1059
1900
  }
1901
+ // SMS: plain text at the caret.
1902
+ const token = `{{${key}}}`;
1903
+ const control = this.form.controls.smsTemplate;
1904
+ const textarea = this.smsSource()?.nativeElement.querySelector('textarea');
1905
+ const start = textarea?.selectionStart ?? control.value.length;
1906
+ const end = textarea?.selectionEnd ?? start;
1907
+ control.setValue(control.value.slice(0, start) + token + control.value.slice(end));
1908
+ const caret = start + token.length;
1909
+ // ngModel writes the new value into the textarea asynchronously, which
1910
+ // moves the caret to the end; restore it after that write.
1060
1911
  setTimeout(() => {
1061
- this.propertyPickerControl.reset();
1062
- }, 0);
1912
+ textarea?.focus();
1913
+ textarea?.setSelectionRange(caret, caret);
1914
+ });
1063
1915
  }
1064
- onAddReceiver(receiverId) {
1065
- const template = this.selectedTemplate();
1066
- if (!receiverId || !template) {
1067
- setTimeout(() => this.receiverPickerControl.reset(), 0);
1916
+ onSave() {
1917
+ const template = this.template();
1918
+ if (!template || !this.canSave()) {
1919
+ this.form.markAllAsTouched();
1068
1920
  return;
1069
1921
  }
1070
- if (this.addingReceiverIds().includes(receiverId)) {
1071
- setTimeout(() => this.receiverPickerControl.reset(), 0);
1922
+ this.facade
1923
+ .saveTemplate(template.id, this.buildSavePayload(template))
1924
+ .subscribe({
1925
+ next: () => {
1926
+ const saved = this.template();
1927
+ // Show exactly what the server stored (it sanitizes on save).
1928
+ if (saved)
1929
+ this.patchFromTemplate(saved);
1930
+ this.afterWrite('saved');
1931
+ },
1932
+ error: (error) => this.onWriteError(error),
1933
+ });
1934
+ }
1935
+ onDiscard() {
1936
+ // Discarded edits cannot be brought back, so ask first (as switching does).
1937
+ this.confirmDiscard().subscribe((discard) => {
1938
+ const template = this.template();
1939
+ if (discard && template)
1940
+ this.patchFromTemplate(template);
1941
+ });
1942
+ }
1943
+ // ==========================================================================
1944
+ // Recipients
1945
+ // ==========================================================================
1946
+ onAddRecipient(key) {
1947
+ const template = this.template();
1948
+ setTimeout(() => this.recipientControl.reset());
1949
+ if (!key || !template || this.isMutating())
1072
1950
  return;
1073
- }
1074
- const selectedReceiver = this.availableReceivers().find((r) => r.id === receiverId);
1075
- if (!selectedReceiver) {
1076
- setTimeout(() => this.receiverPickerControl.reset(), 0);
1951
+ const option = this.recipientGroups()
1952
+ .flatMap((group) => group.items)
1953
+ .find((item) => item.key === key);
1954
+ if (!option)
1077
1955
  return;
1078
- }
1079
- this.addingReceiverIds.update((ids) => [...ids, receiverId]);
1080
1956
  this.facade
1081
- .addReceivers(template.id, {
1082
- receivers: [
1083
- {
1084
- id: selectedReceiver.id,
1085
- type: selectedReceiver.type,
1086
- },
1087
- ],
1088
- })
1957
+ .addReceivers(template.id, template.revision, [
1958
+ { type: option.type, id: option.id },
1959
+ ])
1089
1960
  .subscribe({
1090
- complete: () => this.addingReceiverIds.update((ids) => ids.filter((id) => id !== receiverId)),
1091
- error: () => this.addingReceiverIds.update((ids) => ids.filter((id) => id !== receiverId)),
1961
+ next: () => this.afterWrite(null),
1962
+ error: (error) => this.onWriteError(error),
1092
1963
  });
1093
- setTimeout(() => this.receiverPickerControl.reset(), 0);
1094
1964
  }
1095
- onDeleteReceiver(receiver) {
1096
- this.confirmationService.confirmDelete({
1965
+ onRemoveRecipient(receiver) {
1966
+ const template = this.template();
1967
+ if (!template || receiver.isSystem || this.isMutating())
1968
+ return;
1969
+ this.confirmation.confirmDelete({
1097
1970
  type: 'dialog',
1098
- accept: () => {
1099
- this.deletingReceiverIds.update((ids) => [...ids, receiver.id]);
1100
- this.facade.deleteReceiver(receiver.id).subscribe({
1101
- complete: () => {
1102
- this.deletingReceiverIds.update((ids) => ids.filter((id) => id !== receiver.id));
1103
- },
1104
- error: () => {
1105
- this.deletingReceiverIds.update((ids) => ids.filter((id) => id !== receiver.id));
1106
- },
1107
- });
1971
+ message: this.messages.t('recipients.remove-message', {
1972
+ name: this.receiverName(receiver),
1973
+ }),
1974
+ accept: () => this.facade.deleteReceiver(receiver.id, template.revision).subscribe({
1975
+ next: () => this.afterWrite(null),
1976
+ error: (error) => this.onWriteError(error),
1977
+ }),
1978
+ });
1979
+ }
1980
+ /**
1981
+ * The catalog's name first: ready-made templates come back with the raw
1982
+ * identifier as their saved name (`creator`), while the catalog has the
1983
+ * localized label.
1984
+ */
1985
+ receiverName(receiver) {
1986
+ const fromCatalog = this.receiverLabels().get(recipientKey(receiver.type, receiver.identifier).toLowerCase());
1987
+ if (fromCatalog)
1988
+ return fromCatalog;
1989
+ const name = receiver.name;
1990
+ if (typeof name === 'string')
1991
+ return name || receiver.identifier || '';
1992
+ const ar = this.lang() === 'ar';
1993
+ return ((ar ? name?.ar : name?.en) || name?.display || receiver.identifier || '');
1994
+ }
1995
+ // ==========================================================================
1996
+ // Lifecycle
1997
+ // ==========================================================================
1998
+ onPublish() {
1999
+ const template = this.template();
2000
+ if (!template || !this.canPublish())
2001
+ return;
2002
+ const revision = template.revision;
2003
+ this.review('publish', {
2004
+ request: templatePreviewRequest(template),
2005
+ recipients: template.receivers.map((receiver) => ({
2006
+ name: this.receiverName(receiver),
2007
+ type: receiver.type,
2008
+ })),
2009
+ condition: template.conditionFormula,
2010
+ isEnabled: template.isEnabled,
2011
+ }).subscribe((reviews) => this.facade.publish(template.id, revision, reviews).subscribe({
2012
+ next: () => this.afterWrite('published'),
2013
+ error: (error) => this.onWriteError(error),
2014
+ }));
2015
+ }
2016
+ onTurnOn() {
2017
+ const template = this.template();
2018
+ if (!template || !this.canTurnOn())
2019
+ return;
2020
+ const publication = currentPublication(template, this.publications());
2021
+ if (!publication)
2022
+ return;
2023
+ const revision = template.revision;
2024
+ // Turning on sends the *published* version — review that, not the draft.
2025
+ this.review('enable', publicationReview(publication, (receiver) => this.receiverName(receiver))).subscribe((reviews) => this.facade.turnOn(template.id, revision, reviews).subscribe({
2026
+ next: () => this.afterWrite('turned-on'),
2027
+ error: (error) => this.onWriteError(error),
2028
+ }));
2029
+ }
2030
+ onTurnOff() {
2031
+ const template = this.template();
2032
+ if (!template || this.isMutating())
2033
+ return;
2034
+ const revision = template.revision;
2035
+ this.confirmation.confirm({
2036
+ type: 'dialog',
2037
+ header: this.messages.t('turn-off.header'),
2038
+ message: this.messages.t('turn-off.message'),
2039
+ icon: 'general.toggle-01-left',
2040
+ acceptLabel: this.messages.t('actions.turn-off'),
2041
+ acceptButton: { severity: 'danger' },
2042
+ accept: () => this.facade.turnOff(template.id, revision).subscribe({
2043
+ next: () => this.afterWrite('turned-off'),
2044
+ error: (error) => this.onWriteError(error),
2045
+ }),
2046
+ });
2047
+ }
2048
+ onOpenHistory() {
2049
+ const eventId = this.selectedEventId();
2050
+ if (eventId == null)
2051
+ return;
2052
+ const ref = this.modal.openModal(TemplateHistoryDrawer, 'drawer', {
2053
+ header: this.messages.t('history.title'),
2054
+ styleClass: DRAWER_STYLE_CLASS,
2055
+ position: 'end',
2056
+ dismissableMask: true,
2057
+ dismissible: true,
2058
+ inputValues: {
2059
+ eventId,
2060
+ draftDirty: this.dirty(),
2061
+ historyLimit: this.context()?.publicationHistoryLimit ?? null,
1108
2062
  },
1109
2063
  });
2064
+ ref.onClose.subscribe((result) => {
2065
+ if (result?.publication)
2066
+ this.restoreVersion(result.publication);
2067
+ });
1110
2068
  }
1111
- isReceiverDeleting(receiverId) {
1112
- return this.deletingReceiverIds().includes(receiverId);
2069
+ restoreVersion(publication) {
2070
+ const template = this.template();
2071
+ if (!template || this.dirty() || this.isMutating())
2072
+ return;
2073
+ const revision = template.revision;
2074
+ this.review('restore', {
2075
+ ...publicationReview(publication, (receiver) => this.receiverName(receiver)),
2076
+ isEnabled: template.isEnabled,
2077
+ }).subscribe((reviews) => this.facade
2078
+ .restorePublication(template.id, revision, publication.revision, reviews)
2079
+ .subscribe({
2080
+ next: () => {
2081
+ const restored = this.template();
2082
+ if (restored)
2083
+ this.patchFromTemplate(restored);
2084
+ this.afterWrite('restored');
2085
+ },
2086
+ error: (error) => this.onWriteError(error),
2087
+ }));
1113
2088
  }
1114
- onSaveTemplate() {
1115
- const template = this.selectedTemplate();
1116
- if (!template || this.isFormInvalid()) {
1117
- this.form.markAllAsTouched();
2089
+ onDelete() {
2090
+ const template = this.template();
2091
+ if (!template || template.isSystem || this.isMutating())
1118
2092
  return;
1119
- }
1120
- // Build payload dynamically from all channel controls
1121
- const payload = this.buildUpdatePayload(template);
1122
- this.facade.updateTemplate(template.id, payload).subscribe({
1123
- next: () => this.form.markAsPristine(),
2093
+ this.confirmation.confirmDelete({
2094
+ type: 'dialog',
2095
+ message: this.messages.t(this.defaultOwned() ? 'delete.default-message' : 'delete.message'),
2096
+ accept: () => this.facade.deleteTemplate(template.id, template.revision).subscribe({
2097
+ next: () => this.toast.success(this.messages.t('toast.deleted')),
2098
+ error: (error) => this.onWriteError(error),
2099
+ }),
2100
+ });
2101
+ }
2102
+ onAddTemplate() {
2103
+ const event = this.selectedEvent();
2104
+ if (!event || this.isMutating())
2105
+ return;
2106
+ this.confirmDiscard().subscribe((discard) => {
2107
+ if (!discard)
2108
+ return;
2109
+ const ref = this.modal.openModal(AddTemplateDialog, 'dialog', {
2110
+ header: this.messages.t('addTemplate'),
2111
+ styleClass: '!w-[34rem] max-w-[96vw]',
2112
+ dismissible: true,
2113
+ inputValues: {
2114
+ channels: this.context()?.channels ?? NOTIFICATION_CHANNELS,
2115
+ },
2116
+ });
2117
+ ref.onClose.subscribe((result) => {
2118
+ if (!result)
2119
+ return;
2120
+ this.facade
2121
+ .createTemplate(event.id, {
2122
+ channel: result.channel,
2123
+ name: result.name,
2124
+ subject: null,
2125
+ emailTemplate: null,
2126
+ smsTemplate: null,
2127
+ appTemplate: null,
2128
+ conditionFormula: null,
2129
+ receivers: [],
2130
+ })
2131
+ .subscribe({
2132
+ next: () => this.afterWrite('created'),
2133
+ error: (error) => this.onWriteError(error),
2134
+ });
2135
+ });
1124
2136
  });
1125
2137
  }
1126
2138
  onTestEmail() {
1127
2139
  const event = this.selectedEvent();
1128
- const template = this.selectedTemplate();
1129
- if (!event || !template || this.form.controls['channel'].value !== 'email')
2140
+ const template = this.template();
2141
+ if (!event || !template || template.channel !== 'Email')
1130
2142
  return;
1131
- if (this.form.dirty) {
1132
- this.toast.warn(this.transloco.translate('notification.saveBeforeEmailTest'));
2143
+ if (this.dirty()) {
2144
+ this.toast.warn(this.messages.t('saveBeforeEmailTest'));
1133
2145
  return;
1134
2146
  }
1135
- const eventId = toNumericId(event.id);
1136
- const templateId = toNumericId(template.id);
1137
- if (eventId === null || templateId === null)
1138
- return;
1139
- this.modalService.openModal(EmailUserTestDialog, 'dialog', {
1140
- header: this.transloco.translate('notification.testEmail'),
2147
+ // Sends the saved draft — never the editor's unsaved values.
2148
+ this.modal.openModal(EmailUserTestDialog, 'dialog', {
2149
+ header: this.messages.t('testEmail'),
1141
2150
  styleClass: '!w-[96vw] sm:!w-[38rem]',
1142
2151
  inputValues: {
1143
2152
  kind: 'template',
1144
- eventId,
1145
- templateId,
1146
- sourceLabel: `${event.name} — ${template.name}`,
1147
- placeholderFields: buildEmailTemplatePlaceholderFields(collectEmailTemplatePlaceholders(template.emailTemplate, template.emailContentTranslatable?.en, template.emailContentTranslatable?.ar), this.propertyGroups().flatMap((group) => group.properties), this.transloco.getActiveLang()),
2153
+ eventId: event.id,
2154
+ templateId: template.id,
2155
+ sourceLabel: `${event.name} — ${template.name ?? ''}`,
2156
+ // The same readable values as the preview, instead of a field per
2157
+ // placeholder.
2158
+ sampleValues: previewSamples({
2159
+ channel: 'Email',
2160
+ subject: template.subject,
2161
+ emailTemplate: template.emailTemplate,
2162
+ }, this.context()?.properties ?? [], this.transloco.getActiveLang()),
1148
2163
  },
1149
2164
  });
1150
2165
  }
1151
- onDeleteTemplate() {
1152
- const template = this.selectedTemplate();
1153
- if (!template || template.isSystem)
2166
+ // ==========================================================================
2167
+ // Defaults
2168
+ // ==========================================================================
2169
+ onInitializeDefaults() {
2170
+ const eventId = this.selectedEventId();
2171
+ if (eventId == null || this.isMutating())
1154
2172
  return;
1155
- this.confirmationService.confirm({
1156
- type: 'dialog',
1157
- message: this.transloco.translate('notification.deleteTemplateMessage'),
1158
- icon: 'general.trash-01',
1159
- acceptButton: { severity: 'danger' },
1160
- accept: () => this.facade.deleteTemplate(template.id),
2173
+ const autoApp = (this.defaults()?.recipes ?? []).some((recipe) => recipe.autoPublishApp &&
2174
+ this.missingDefaults().includes(recipe.channel));
2175
+ this.facade.initializeDefaults(eventId).subscribe({
2176
+ next: () => this.toast.success(this.messages.t(autoApp ? 'defaults.added-app-on' : 'defaults.added')),
2177
+ error: (error) => this.onWriteError(error),
1161
2178
  });
1162
2179
  }
1163
- onAddTemplateClick() {
1164
- const event = this.selectedEvent();
1165
- if (!event)
2180
+ onRestoreDefault(channel) {
2181
+ const eventId = this.selectedEventId();
2182
+ if (eventId == null || this.isMutating())
1166
2183
  return;
1167
- const ref = this.modalService.openModal(AddTemplateDialog, 'dialog', {
1168
- header: this.transloco.translate('notification.addTemplate'),
1169
- styleClass: '!w-[25rem]',
1170
- });
1171
- ref.onClose.subscribe((result) => {
1172
- if (result?.name) {
1173
- this.facade.addTemplate(event.id, {
1174
- name: result.name,
1175
- channel: this.form.controls['channel'].value,
1176
- });
1177
- }
2184
+ // Review the catalog as it is now, not as it was when the page loaded.
2185
+ this.facade.reloadDefaults(eventId).subscribe(() => {
2186
+ const defaults = this.defaults();
2187
+ const slot = defaults?.slots.find((item) => item.channel === channel && item.templateId == null);
2188
+ const recipe = defaults?.recipes.find((item) => item.channel === channel);
2189
+ if (!slot || !recipe)
2190
+ return;
2191
+ this.review('restoreDefault', {
2192
+ request: templatePreviewRequest(recipe.draft),
2193
+ recipients: recipe.draft.receivers.map((receiver) => ({
2194
+ name: this.receiverName(receiver),
2195
+ type: receiver.type,
2196
+ })),
2197
+ condition: recipe.draft.conditionFormula,
2198
+ }).subscribe(() => this.facade
2199
+ .restoreDefault(eventId, {
2200
+ channel,
2201
+ expectedSlotRevision: slot.revision,
2202
+ recipeHash: recipe.recipeHash,
2203
+ })
2204
+ .subscribe({
2205
+ next: () => this.afterWrite('default-restored'),
2206
+ error: (error) => this.onWriteError(error),
2207
+ }));
1178
2208
  });
1179
2209
  }
1180
- // ============================================================================
1181
- // Private Methods
1182
- // ============================================================================
2210
+ // ==========================================================================
2211
+ // Conflicts and errors
2212
+ // ==========================================================================
1183
2213
  /**
1184
- * Clears every template-owned control back to the captured empty shape.
1185
- *
1186
- * `event` is deliberately excluded, so `patchValue` leaves whatever the event
1187
- * effect (or the user's own pick) put in that control. An event with no
1188
- * templates yet — a brand-new one, or one whose last template was just
1189
- * deleted — resolves `selectedTemplate` to null, and this effect runs after
1190
- * the event effect in the same flush: resetting the whole group there would
1191
- * blank the event picker the user just used, while `selectedEvent()` stays
1192
- * truthy and keeps the tabs/add-template row visible. Leaving the control
1193
- * alone also keeps the two effects ordering-independent — on a full
1194
- * `ResetNotificationState` the event effect has already nulled it, and that
1195
- * null is carried through.
1196
- *
1197
- * `channel` is restored through {@link channelControl} with events enabled
1198
- * because `channelSignal` is a `toSignal` of its `valueChanges` and is the
1199
- * only consumer of them on this form — a silent reset would leave
1200
- * `activeChannel()` reporting the previous template's channel.
2214
+ * After a revision conflict: re-read the latest version, then either keep
2215
+ * the user's edits on top of it (the next Save applies them deliberately)
2216
+ * or replace them with what is saved.
1201
2217
  */
1202
- resetTemplateFields() {
1203
- const { event: _event, channel, ...templateFields } = this.emptyFormValue;
1204
- this.form.patchValue(templateFields, { emitEvent: false });
1205
- this.channelControl.setValue(channel);
1206
- this.form.markAsPristine();
1207
- this.form.markAsUntouched();
1208
- }
1209
- appendToControl(key, value) {
1210
- const ctrl = this.form.controls[key];
1211
- ctrl?.setValue(`${ctrl.value ?? ''} ${value}`);
1212
- }
1213
- patchForm(template) {
1214
- const channel = template.channel || 'email';
1215
- this.form.patchValue({
1216
- name: template.name,
1217
- channel: channel,
1218
- });
1219
- // Patch all channel controls dynamically
1220
- const t = template;
1221
- for (const ch of CHANNEL_CONFIGS) {
1222
- if (ch.templateKey) {
1223
- this.form
1224
- .get(ch.templateKey)
1225
- ?.setValue(t[ch.templateKey] ?? '');
1226
- }
1227
- if (ch.translatableKey) {
1228
- const trans = t[ch.translatableKey] ?? {};
1229
- this.form.get(ch.translatableKey)?.patchValue({
1230
- en: trans.en ?? '',
1231
- ar: trans.ar ?? '',
1232
- });
2218
+ resolveConflict(keepMine) {
2219
+ this.facade.loadEvents().subscribe(() => {
2220
+ if (!keepMine) {
2221
+ const template = this.template();
2222
+ if (template)
2223
+ this.patchFromTemplate(template);
1233
2224
  }
2225
+ this.conflict.set(false);
2226
+ });
2227
+ }
2228
+ describeDiagnostic(diagnostic) {
2229
+ return this.messages.diagnostic(diagnostic);
2230
+ }
2231
+ channelLabel(channel) {
2232
+ return this.messages.channel(channel);
2233
+ }
2234
+ afterWrite(toastKey) {
2235
+ this.writeDiagnostics.set([]);
2236
+ const template = this.template();
2237
+ // The write committed even when its audit record did not; never repeat it.
2238
+ if (template?.auditState === 'Failed') {
2239
+ this.toast.warn(this.messages.t('toast.audit-warning'));
2240
+ return;
1234
2241
  }
2242
+ if (toastKey)
2243
+ this.toast.success(this.messages.t(`toast.${toastKey}`));
1235
2244
  }
1236
- buildUpdatePayload(template) {
1237
- const title = this.form.controls['name'].value?.trim() || template.name;
1238
- // The backend treats this PUT as a full replace: any channel template
1239
- // omitted from the payload is nulled out. Always send email/sms/app
1240
- // together — patchForm() populates every channel control from the loaded
1241
- // template, so channels not edited this session keep their values.
1242
- const payload = { title };
1243
- const target = payload;
1244
- for (const ch of CHANNEL_CONFIGS) {
1245
- if (ch.templateKey) {
1246
- target[ch.templateKey] = this.getTemplateControlValue(ch.templateKey);
1247
- }
1248
- if (ch.translatableKey) {
1249
- target[ch.translatableKey] = this.getTranslatableControlValue(ch.translatableKey);
1250
- }
2245
+ onWriteError(error) {
2246
+ const failure = readNotificationFailure(error);
2247
+ if (isRevisionConflict(failure)) {
2248
+ this.conflict.set(true);
2249
+ return;
2250
+ }
2251
+ this.writeDiagnostics.set(failure.diagnostics);
2252
+ this.toast.error(this.messages.code(failure.code));
2253
+ const eventId = this.selectedEventId();
2254
+ if (eventId != null &&
2255
+ (failure.code === 'NOTIFICATION_RECIPE_CHANGED' ||
2256
+ failure.code === 'NOTIFICATION_DEFAULT_ALREADY_PRESENT' ||
2257
+ failure.code === 'NOTIFICATION_DEFAULT_ADOPTION_AMBIGUOUS')) {
2258
+ this.facade.reloadDefaults(eventId);
2259
+ }
2260
+ else if (failure.code?.endsWith('_NOT_FOUND')) {
2261
+ this.facade.loadEvents();
1251
2262
  }
1252
- return payload;
1253
2263
  }
1254
- getTemplateControlValue(controlName) {
1255
- const control = this.form.get(controlName);
1256
- return control?.value ?? '';
2264
+ // ==========================================================================
2265
+ // Helpers
2266
+ // ==========================================================================
2267
+ review(mode, content) {
2268
+ const eventId = this.selectedEventId();
2269
+ if (eventId == null)
2270
+ return of();
2271
+ const ref = this.modal.openModal(TemplateReviewDialog, 'dialog', {
2272
+ header: this.messages.t(`review.title.${mode}`),
2273
+ styleClass: '!w-[96vw] lg:!w-[52rem]',
2274
+ dismissible: true,
2275
+ inputValues: {
2276
+ mode,
2277
+ eventId,
2278
+ request: this.withSamples(content.request),
2279
+ recipients: content.recipients,
2280
+ condition: content.condition,
2281
+ requireLanguageReview: mode !== 'restoreDefault' && this.defaultOwned(),
2282
+ isEnabled: content.isEnabled ?? false,
2283
+ },
2284
+ });
2285
+ return ref.onClose.pipe(filter((result) => !!result), map((result) => result));
2286
+ }
2287
+ /** Readable sample values for every field the previewed content uses. */
2288
+ withSamples(request) {
2289
+ return withPreviewSamples(request, this.context()?.properties ?? [], this.lang());
2290
+ }
2291
+ confirmDiscard() {
2292
+ if (!this.dirty())
2293
+ return of(true);
2294
+ return new Observable((subscriber) => {
2295
+ this.confirmation.confirm({
2296
+ type: 'dialog',
2297
+ header: this.messages.t('unsaved.header'),
2298
+ message: this.messages.t('unsaved.message'),
2299
+ acceptLabel: this.messages.t('unsaved.discard'),
2300
+ rejectLabel: this.messages.t('unsaved.stay'),
2301
+ acceptButton: { severity: 'danger' },
2302
+ accept: () => {
2303
+ subscriber.next(true);
2304
+ subscriber.complete();
2305
+ },
2306
+ reject: () => {
2307
+ subscriber.next(false);
2308
+ subscriber.complete();
2309
+ },
2310
+ });
2311
+ });
1257
2312
  }
1258
- getTranslatableControlValue(controlName) {
1259
- const group = this.form.get(controlName);
1260
- const value = group?.getRawValue();
2313
+ patchFromTemplate(template) {
2314
+ this.form.reset({
2315
+ name: template.name ?? '',
2316
+ subject: template.subject ?? '',
2317
+ emailTemplate: template.emailTemplate ?? '',
2318
+ smsTemplate: template.smsTemplate ?? '',
2319
+ appEn: template.appTemplate?.en ?? '',
2320
+ appAr: template.appTemplate?.ar ?? '',
2321
+ conditionFormula: template.conditionFormula ?? '',
2322
+ });
2323
+ this.formValue.set(this.form.getRawValue());
2324
+ }
2325
+ /** Replacement semantics: only the template's own channel field is sent. */
2326
+ buildSavePayload(template) {
2327
+ const value = this.form.getRawValue();
2328
+ const condition = value.conditionFormula.trim();
1261
2329
  return {
1262
- en: value?.en ?? '',
1263
- ar: value?.ar ?? '',
2330
+ expectedRevision: template.revision,
2331
+ name: value.name.trim(),
2332
+ subject: template.channel === 'Email' ? value.subject.trim() || null : null,
2333
+ emailTemplate: template.channel === 'Email' ? value.emailTemplate : null,
2334
+ smsTemplate: template.channel === 'Sms' ? value.smsTemplate : null,
2335
+ appTemplate: template.channel === 'App'
2336
+ ? { en: value.appEn, ar: value.appAr }
2337
+ : null,
2338
+ conditionFormula: condition || null,
1264
2339
  };
1265
2340
  }
1266
2341
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationTemplate, deps: [], target: i0.ɵɵFactoryTarget.Component });
1267
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: NotificationTemplate, isStandalone: true, selector: "mt-notification-template", ngImport: i0, template: "<div class=\"flex flex-col gap-4 p-4\" [formGroup]=\"form\">\r\n <!-- Loading State -->\r\n @if (isLoadingEvents()) {\r\n <div class=\"flex flex-col gap-4\">\r\n <div class=\"w-1/2\">\r\n <p-skeleton width=\"4rem\" height=\"1rem\" styleClass=\"mb-2\" />\r\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n <div class=\"flex items-center justify-between\">\r\n <div class=\"flex gap-2\">\r\n <p-skeleton width=\"5rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"5rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"5rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n <p-skeleton width=\"8rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"100%\" height=\"8rem\" borderRadius=\"0.5rem\" />\r\n <div class=\"flex justify-end gap-2 pt-4\">\r\n <p-skeleton width=\"6rem\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"6rem\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n </div>\r\n } @else if (events().length === 0) {\r\n <!-- Empty State -->\r\n <mt-card>\r\n <div\r\n class=\"flex flex-col items-center justify-center py-12 text-slate-500\"\r\n >\r\n <mt-icon icon=\"alert.alert-circle\" class=\"mb-2 h-12 w-12\" />\r\n <p>{{ \"notification.noEventsFound\" | transloco }}</p>\r\n </div>\r\n </mt-card>\r\n } @else {\r\n <!-- Event Selector -->\r\n <div class=\"w-1/2\">\r\n <mt-select-field\r\n formControlName=\"event\"\r\n [options]=\"events()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'eventName'\"\r\n [label]=\"'notification.events' | transloco\"\r\n [placeholder]=\"'notification.selectEvent' | transloco\"\r\n [required]=\"true\"\r\n [filter]=\"false\"\r\n (onChange)=\"onEventChange($event)\"\r\n />\r\n </div>\r\n\r\n @if (selectedEvent()) {\r\n <!-- Template Tabs + Add notification -->\r\n <div class=\"flex items-center justify-between gap-2\">\r\n <mt-tabs\r\n [active]=\"selectedTemplateId()\"\r\n [options]=\"templateTabs()\"\r\n (activeChange)=\"onTemplateTabChange($event)\"\r\n />\r\n <mt-button\r\n icon=\"general.plus\"\r\n [label]=\"'notification.addNotification' | transloco\"\r\n size=\"small\"\r\n [loading]=\"isAddingTemplate()\"\r\n (onClick)=\"onAddTemplateClick()\"\r\n />\r\n </div>\r\n\r\n <!-- Template Content -->\r\n @if (selectedTemplate(); as template) {\r\n <div class=\"flex flex-col gap-4\">\r\n <!-- Name -->\r\n <div class=\"w-full\">\r\n <mt-text-field\r\n formControlName=\"name\"\r\n [label]=\"'notification.name' | transloco\"\r\n [placeholder]=\"'notification.enterName' | transloco\"\r\n [required]=\"true\"\r\n />\r\n </div>\r\n <!-- Channel & Properties Picker -->\r\n <div class=\"flex flex-col gap-4\">\r\n <mt-select-field\r\n formControlName=\"channel\"\r\n [options]=\"channelOptions()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'key'\"\r\n [label]=\"'notification.channel.label' | transloco\"\r\n [placeholder]=\"'notification.selectChannel' | transloco\"\r\n [required]=\"true\"\r\n [filter]=\"false\"\r\n [disabled]=\"true\"\r\n />\r\n <mt-select-field\r\n [options]=\"propertyGroups()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'key'\"\r\n [group]=\"true\"\r\n [optionGroupLabel]=\"'groupName'\"\r\n [optionGroupChildren]=\"'properties'\"\r\n [label]=\"'notification.propertiesList' | transloco\"\r\n [placeholder]=\"'notification.selectProperty' | transloco\"\r\n [hasPlaceholderPrefix]=\"false\"\r\n [filter]=\"true\"\r\n [filterBy]=\"'name'\"\r\n [loading]=\"isLoadingProperties()\"\r\n [formControl]=\"propertyPickerControl\"\r\n (onChange)=\"onAddProperty($event)\"\r\n />\r\n </div>\r\n <!-- Channel Content Editors -->\r\n @for (ch of channelConfigs; track ch.key) {\r\n @if (form.controls[\"channel\"].value === ch.key) {\r\n <!-- Bilingual Content Fields -->\r\n @if (isBilingual(ch) && ch.translatableKey) {\r\n <ng-container [formGroupName]=\"ch.translatableKey\">\r\n @if (ch.useRichEditor) {\r\n <mt-editor-field\r\n formControlName=\"en\"\r\n [label]=\"'notification.english' | transloco\"\r\n dir=\"ltr\"\r\n />\r\n <mt-editor-field\r\n formControlName=\"ar\"\r\n [label]=\"'notification.arabic' | transloco\"\r\n dir=\"rtl\"\r\n />\r\n } @else {\r\n <mt-textarea-field\r\n formControlName=\"en\"\r\n [label]=\"'notification.english' | transloco\"\r\n [rows]=\"'6'\"\r\n dir=\"ltr\"\r\n />\r\n <mt-textarea-field\r\n formControlName=\"ar\"\r\n [label]=\"'notification.arabic' | transloco\"\r\n [rows]=\"'6'\"\r\n dir=\"rtl\"\r\n />\r\n }\r\n </ng-container>\r\n } @else if (ch.templateKey) {\r\n <!-- Non-Bilingual Content Field -->\r\n @if (ch.useRichEditor) {\r\n <mt-editor-field\r\n [formControlName]=\"ch.templateKey\"\r\n [label]=\"'notification.notificationBody' | transloco\"\r\n />\r\n } @else {\r\n <mt-textarea-field\r\n [formControlName]=\"ch.templateKey\"\r\n [label]=\"'notification.notificationBody' | transloco\"\r\n [rows]=\"'6'\"\r\n />\r\n }\r\n }\r\n <!-- Bilingual Toggle -->\r\n @if (ch.bilingualOptional) {\r\n <mt-toggle-field\r\n [formControlName]=\"ch.key + 'Bilingual'\"\r\n [label]=\"'notification.bilingual' | transloco\"\r\n />\r\n }\r\n }\r\n }\r\n <!-- Receivers -->\r\n <div class=\"w-full\">\r\n <mt-select-field\r\n [options]=\"availableReceiverGroups()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'id'\"\r\n [group]=\"true\"\r\n [optionGroupLabel]=\"'groupName'\"\r\n [optionGroupChildren]=\"'items'\"\r\n [label]=\"'notification.receivers' | transloco\"\r\n [placeholder]=\"'notification.selectReceiver' | transloco\"\r\n [filter]=\"true\"\r\n [filterBy]=\"'name'\"\r\n [loading]=\"isLoadingReceivers()\"\r\n [formControl]=\"receiverPickerControl\"\r\n (onChange)=\"onAddReceiver($event)\"\r\n />\r\n </div>\r\n @if ((template.receivers ?? []).length > 0) {\r\n <div class=\"flex w-full flex-wrap gap-2\">\r\n @for (receiver of template.receivers; track receiver.id) {\r\n <div\r\n class=\"flex items-center gap-2 rounded-full border border-surface bg-surface-50 py-1 pe-2 ps-3 text-sm transition-colors hover:bg-surface-100\"\r\n >\r\n <mt-icon\r\n icon=\"user.user-01\"\r\n class=\"h-4 w-4 shrink-0 text-slate-500\"\r\n />\r\n <span class=\"font-medium\">{{ receiver.name?.display }}</span>\r\n @if (!receiver.isSystem) {\r\n <mt-button\r\n icon=\"general.trash-01\"\r\n severity=\"danger\"\r\n size=\"small\"\r\n [text]=\"true\"\r\n [loading]=\"isReceiverDeleting(receiver.id)\"\r\n (onClick)=\"onDeleteReceiver(receiver)\"\r\n />\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n <!-- Actions -->\r\n <div class=\"flex justify-end gap-2 pt-4\">\r\n @if (form.controls[\"channel\"].value === \"email\") {\r\n <mt-button\r\n icon=\"communication.send-01\"\r\n [label]=\"'notification.testEmail' | transloco\"\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [disabled]=\"\r\n form.dirty || isUpdatingTemplate() || isDeletingTemplate()\r\n \"\r\n (onClick)=\"onTestEmail()\"\r\n />\r\n }\r\n @if (!template.isSystem) {\r\n <mt-button\r\n icon=\"general.trash-01\"\r\n [label]=\"'notification.delete' | transloco\"\r\n severity=\"danger\"\r\n [loading]=\"isDeletingTemplate()\"\r\n [disabled]=\"isUpdatingTemplate()\"\r\n (onClick)=\"onDeleteTemplate()\"\r\n />\r\n }\r\n <mt-button\r\n icon=\"general.save-01\"\r\n [label]=\"'notification.save' | transloco\"\r\n [loading]=\"isUpdatingTemplate()\"\r\n [disabled]=\"isDeletingTemplate() || isFormInvalid()\"\r\n (onClick)=\"onSaveTemplate()\"\r\n />\r\n </div>\r\n </div>\r\n } @else {\r\n <!-- No template selected -->\r\n <div\r\n class=\"flex flex-col items-center justify-center py-12 text-slate-500\"\r\n >\r\n <mt-icon icon=\"alert.bell-01\" class=\"mb-2 h-12 w-12\" />\r\n <p>{{ \"notification.selectTemplatePrompt\" | transloco }}</p>\r\n </div>\r\n }\r\n }\r\n }\r\n</div>\r\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep mt-textarea-field>.flex.items-center,:host ::ng-deep mt-editor-field>.flex.items-center{direction:ltr}:host-context([dir=rtl]) ::ng-deep mt-textarea-field>.flex.items-center,:host-context([dir=rtl]) ::ng-deep mt-editor-field>.flex.items-center{direction:rtl}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { 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: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "directive", type: i1.FormGroupName, selector: "[formGroupName]", inputs: ["formGroupName"] }, { kind: "ngmodule", type: TranslocoModule }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { 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: SelectField, selector: "mt-select-field", inputs: ["field", "hint", "label", "placeholder", "hasPlaceholderPrefix", "class", "readonly", "pInputs", "options", "optionValue", "optionLabel", "filter", "filterBy", "dataKey", "showClear", "clearAfterSelect", "required", "group", "size", "optionGroupLabel", "optionGroupChildren", "loading", "optionIcon", "optionIconColor", "optionIconShape", "optionAvatarShape", "optionGroupIcon", "optionGroupIconColor", "optionGroupIconShape", "optionGroupAvatarShape", "markCurrentUser"], outputs: ["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: TextareaField, selector: "mt-textarea-field", inputs: ["field", "hint", "label", "placeholder", "class", "readonly", "noErrorStyle", "pInputs", "rows", "required", "maxLength"] }, { kind: "component", type: EditorField, selector: "mt-editor-field", inputs: ["label", "hint", "placeholder", "readonly", "theme", "height", "dir", "modules", "required"] }, { kind: "component", type: ToggleField, selector: "mt-toggle-field", inputs: ["label", "inputId", "labelPosition", "placeholder", "readonly", "pInputs", "required", "toggleShape", "size", "icon", "descriptionCard"], outputs: ["onChange"] }, { 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: Icon, selector: "mt-icon", inputs: ["icon"] }, { kind: "component", type: Skeleton, selector: "p-skeleton", inputs: ["styleClass", "shape", "animation", "borderRadius", "size", "width", "height"] }, { kind: "pipe", type: i2.TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2342
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: NotificationTemplate, isStandalone: true, selector: "mt-notification-template", host: { listeners: { "window:beforeunload": "onBeforeUnload($event)" } }, viewQueries: [{ propertyName: "emailEditor", first: true, predicate: ["emailEditor"], descendants: true, read: EmailVisualEditor, isSignal: true }, { propertyName: "appEnEditor", first: true, predicate: ["appEnEditor"], descendants: true, read: EmailVisualEditor, isSignal: true }, { propertyName: "appArEditor", first: true, predicate: ["appArEditor"], descendants: true, read: EmailVisualEditor, isSignal: true }, { propertyName: "smsSource", first: true, predicate: ["smsSource"], descendants: true, isSignal: true }], ngImport: i0, template: "<ng-container *transloco=\"let t; prefix: 'notification'\">\n <div class=\"flex min-w-0 flex-col gap-4 p-4\">\n @if (facade.isLoadingEvents() && events().length === 0) {\n <div class=\"flex flex-col gap-4\" aria-live=\"polite\">\n <p-skeleton width=\"50%\" height=\"2.5rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"16rem\" borderRadius=\"0.375rem\" />\n </div>\n } @else if (facade.eventsError() && events().length === 0) {\n <mt-empty-state\n illustration=\"none\"\n [title]=\"t('load-error')\"\n [actionLabel]=\"t('actions.retry')\"\n actionIcon=\"arrow.refresh-cw-01\"\n (actionClick)=\"retryLoad()\"\n />\n } @else if (events().length === 0) {\n <mt-empty-state illustration=\"none\" [title]=\"t('noEventsFound')\" />\n } @else {\n <!-- Event -->\n <div class=\"w-full md:w-1/2\">\n <mt-select-field\n [formControl]=\"eventControl\"\n [options]=\"events()\"\n optionLabel=\"name\"\n optionValue=\"id\"\n [label]=\"t('events')\"\n [placeholder]=\"t('selectEvent')\"\n [filter]=\"events().length > 8\"\n filterBy=\"name\"\n [showClear]=\"false\"\n (onChange)=\"onEventChange($event)\"\n />\n </div>\n\n @if (selectedEvent(); as event) {\n <!-- Ready-made defaults -->\n @if (missingDefaults().length > 0 || removedDefaults().length > 0) {\n <div\n class=\"flex flex-col gap-2 rounded-md border border-surface bg-surface-50 px-4 py-3 text-sm\"\n >\n @if (missingDefaults().length > 0) {\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n <span class=\"flex items-center gap-2\">\n <mt-icon\n icon=\"general.zap\"\n class=\"size-4 shrink-0 text-primary\"\n />\n {{ t(\"defaults.missing\") }}\n </span>\n <mt-button\n size=\"small\"\n [label]=\"t('defaults.add')\"\n [loading]=\"facade.isLoading('initializeDefaults')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onInitializeDefaults()\"\n />\n </div>\n }\n @for (item of removedDefaults(); track item.slot.channel) {\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n <span>{{\n t(\"defaults.removed\", {\n channel: channelLabel(item.slot.channel),\n })\n }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('defaults.restore')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onRestoreDefault(item.slot.channel)\"\n />\n </div>\n }\n </div>\n }\n\n <!-- Templates of the event -->\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n @if (templates().length > 0) {\n <mt-tabs\n class=\"min-w-0 flex-1\"\n mode=\"underline\"\n [active]=\"activeTab()\"\n [options]=\"templateTabs()\"\n (activeChange)=\"onTemplateTabChange($event)\"\n />\n } @else {\n <span class=\"text-sm text-muted-color\">{{\n t(\"no-templates\")\n }}</span>\n }\n <mt-button\n icon=\"general.plus\"\n [label]=\"t('addNotification')\"\n size=\"small\"\n [disabled]=\"isMutating() || !context()\"\n (onClick)=\"onAddTemplate()\"\n />\n </div>\n\n @if (facade.contextError() && !context()) {\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700\"\n role=\"alert\"\n >\n <span>{{ t(\"context-error\") }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.retry')\"\n (onClick)=\"retryLoad()\"\n />\n </div>\n }\n\n @if (template(); as template) {\n @if (!context()) {\n <div class=\"flex flex-col gap-3\">\n <p-skeleton\n width=\"100%\"\n height=\"3.5rem\"\n borderRadius=\"0.375rem\"\n />\n <p-skeleton width=\"100%\" height=\"20rem\" borderRadius=\"0.375rem\" />\n </div>\n } @else {\n <!-- Status and actions -->\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-surface bg-content px-4 py-3\"\n >\n <div class=\"flex min-w-0 flex-wrap items-center gap-2 text-sm\">\n <span\n class=\"inline-flex items-center gap-1 rounded-md border border-surface bg-surface-50 px-2 py-0.5\"\n >\n <mt-icon\n [icon]=\"channelIcons[template.channel]\"\n class=\"size-4\"\n />\n {{ channelLabel(template.channel) }}\n </span>\n @switch (status()) {\n @case (\"on\") {\n <span\n class=\"rounded-md border border-green-200 bg-green-50 px-2 py-0.5 font-medium text-green-700\"\n [mtTooltip]=\"t('status.on-hint')\"\n >{{ t(\"status.on\") }}</span\n >\n }\n @case (\"off\") {\n <span\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-0.5 font-medium text-muted-color\"\n [mtTooltip]=\"t('status.off-hint')\"\n >{{ t(\"status.off\") }}</span\n >\n }\n @case (\"draft\") {\n <span\n class=\"rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 font-medium text-amber-700\"\n [mtTooltip]=\"t('status.draft-hint')\"\n >{{ t(\"status.draft\") }}</span\n >\n }\n }\n @if (unpublishedChanges()) {\n <span\n class=\"rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-700\"\n [mtTooltip]=\"t('status.unpublished-hint')\"\n >{{ t(\"status.unpublished\") }}</span\n >\n }\n @if (defaultOwned()) {\n <span\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-0.5 text-muted-color\"\n >{{ t(\"status.default\") }}</span\n >\n }\n @if (dirty()) {\n <span\n class=\"rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-700\"\n >{{ t(\"status.unsaved\") }}</span\n >\n }\n </div>\n\n <div class=\"flex flex-wrap items-center gap-2\">\n <mt-button\n icon=\"time.clock-rewind\"\n severity=\"secondary\"\n variant=\"text\"\n [tooltip]=\"t('actions.history')\"\n [ariaLabel]=\"t('actions.history')\"\n [disabled]=\"template.publishedRevision === null\"\n (onClick)=\"onOpenHistory()\"\n />\n @if (template.channel === \"Email\") {\n <mt-button\n icon=\"communication.send-01\"\n severity=\"secondary\"\n variant=\"text\"\n [tooltip]=\"\n dirty() ? t('saveBeforeEmailTest') : t('testEmail')\n \"\n [ariaLabel]=\"t('testEmail')\"\n [disabled]=\"dirty() || isMutating()\"\n (onClick)=\"onTestEmail()\"\n />\n }\n @if (!template.isSystem) {\n <mt-button\n icon=\"general.trash-01\"\n severity=\"danger\"\n variant=\"text\"\n [tooltip]=\"t('actions.delete')\"\n [ariaLabel]=\"t('actions.delete')\"\n [loading]=\"facade.isLoading('deleteTemplate')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onDelete()\"\n />\n }\n @if (status() === \"on\") {\n <mt-button\n icon=\"general.toggle-01-left\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.turn-off')\"\n [loading]=\"facade.isLoading('setActivation')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onTurnOff()\"\n />\n } @else {\n <mt-button\n icon=\"general.toggle-01-right\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.turn-on')\"\n [tooltip]=\"\n template.publishedRevision === null\n ? t('actions.turn-on-blocked')\n : ''\n \"\n [loading]=\"facade.isLoading('setActivation')\"\n [disabled]=\"!canTurnOn()\"\n (onClick)=\"onTurnOn()\"\n />\n }\n <mt-button\n icon=\"general.check\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.publish')\"\n [tooltip]=\"dirty() ? t('actions.save-first') : ''\"\n [loading]=\"facade.isLoading('publishTemplate')\"\n [disabled]=\"!canPublish()\"\n (onClick)=\"onPublish()\"\n />\n @if (dirty()) {\n <mt-button\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('actions.discard')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onDiscard()\"\n />\n }\n <mt-button\n icon=\"general.save-01\"\n [label]=\"t('save')\"\n [loading]=\"facade.isLoading('saveTemplate')\"\n [disabled]=\"!canSave()\"\n (onClick)=\"onSave()\"\n />\n </div>\n </div>\n\n @if (conflict()) {\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700\"\n role=\"alert\"\n >\n <span>{{ t(\"conflict.message\") }}</span>\n <div class=\"flex flex-wrap gap-2\">\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('conflict.keep-mine')\"\n (onClick)=\"resolveConflict(true)\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n [label]=\"t('conflict.load-latest')\"\n (onClick)=\"resolveConflict(false)\"\n />\n </div>\n </div>\n }\n\n @if (writeDiagnostics().length > 0) {\n <ul\n class=\"list-disc rounded-md border border-red-200 bg-red-50 py-3 pe-4 ps-8 text-sm text-red-700\"\n role=\"alert\"\n >\n @for (diagnostic of writeDiagnostics(); track $index) {\n <li>{{ describeDiagnostic(diagnostic) }}</li>\n }\n </ul>\n }\n\n <div\n class=\"grid min-w-0 gap-4\"\n [class]=\"wide() ? 'grid-cols-2' : 'grid-cols-1'\"\n [formGroup]=\"form\"\n >\n <!-- Content -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.content\") }}\n </header>\n <div class=\"flex min-w-0 flex-col gap-4 p-4\">\n <mt-text-field\n formControlName=\"name\"\n [label]=\"t('name')\"\n [placeholder]=\"t('enterName')\"\n [required]=\"true\"\n />\n @if (template.channel === \"Email\") {\n <mt-text-field\n formControlName=\"subject\"\n [label]=\"t('subject')\"\n [hint]=\"t('subject-hint')\"\n />\n }\n <mt-select-field\n [formControl]=\"placeholderControl\"\n [options]=\"placeholderGroups()\"\n optionLabel=\"label\"\n optionValue=\"key\"\n [group]=\"true\"\n optionGroupLabel=\"groupName\"\n optionGroupChildren=\"properties\"\n [label]=\"t('placeholders.label')\"\n [hint]=\"t('placeholders.hint')\"\n [placeholder]=\"t('placeholders.placeholder')\"\n [hasPlaceholderPrefix]=\"false\"\n [filter]=\"true\"\n filterBy=\"label\"\n (onChange)=\"onInsertPlaceholder($event)\"\n />\n @switch (template.channel) {\n @case (\"Email\") {\n <mt-email-visual-editor\n #emailEditor\n formControlName=\"emailTemplate\"\n [label]=\"t('email-body')\"\n [hint]=\"t('email-body-hint')\"\n [placeholders]=\"placeholderOptions()\"\n [approvedImageOrigins]=\"emailImageOrigins()\"\n height=\"26rem\"\n />\n }\n @case (\"Sms\") {\n <div #smsSource (focusin)=\"setActiveField('smsTemplate')\">\n <mt-textarea-field\n dir=\"auto\"\n formControlName=\"smsTemplate\"\n [label]=\"t('sms-body')\"\n [hint]=\"t('sms-body-hint')\"\n rows=\"6\"\n />\n </div>\n }\n @case (\"App\") {\n <mt-email-visual-editor\n #appEnEditor\n formControlName=\"appEn\"\n [label]=\"t('english')\"\n direction=\"ltr\"\n [starter]=\"false\"\n [placeholders]=\"placeholderOptions()\"\n [approvedImageOrigins]=\"appImageOrigins()\"\n height=\"12rem\"\n (focused)=\"setActiveField('appEn')\"\n />\n <mt-email-visual-editor\n #appArEditor\n formControlName=\"appAr\"\n [label]=\"t('arabic')\"\n direction=\"rtl\"\n [starter]=\"false\"\n [placeholders]=\"placeholderOptions()\"\n [approvedImageOrigins]=\"appImageOrigins()\"\n height=\"12rem\"\n (focused)=\"setActiveField('appAr')\"\n />\n }\n }\n </div>\n </section>\n\n <!-- Live preview -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.preview\") }}\n </header>\n <div class=\"flex flex-1 flex-col p-4\">\n <mt-notification-content-preview\n [preview]=\"preview.result()\"\n [status]=\"preview.status()\"\n [errorCode]=\"preview.errorCode()\"\n [stale]=\"previewStale()\"\n [fill]=\"true\"\n [showSubject]=\"false\"\n (retry)=\"preview.retry()\"\n />\n </div>\n </section>\n\n <!-- Recipients -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.recipients\") }}\n </header>\n <div class=\"flex min-w-0 flex-col gap-3 p-4\">\n <mt-select-field\n [formControl]=\"recipientControl\"\n [options]=\"recipientGroups()\"\n optionLabel=\"name\"\n optionValue=\"key\"\n [group]=\"true\"\n optionGroupLabel=\"groupName\"\n optionGroupChildren=\"items\"\n [label]=\"t('recipients.add')\"\n [hint]=\"t('recipients.hint')\"\n [placeholder]=\"t('recipients.placeholder')\"\n [filter]=\"true\"\n filterBy=\"name\"\n [loading]=\"facade.isLoading('addReceivers')\"\n (onChange)=\"onAddRecipient($event)\"\n />\n @if (template.receivers.length > 0) {\n <ul class=\"flex flex-wrap gap-2\">\n @for (receiver of template.receivers; track receiver.id) {\n <li\n class=\"flex items-center gap-2 rounded-md border border-surface bg-surface-50 py-1 pe-1 ps-3 text-sm\"\n >\n <span class=\"font-medium\">{{\n receiverName(receiver)\n }}</span>\n <span class=\"text-xs text-muted-color\">{{\n t(\"recipient-type.\" + receiver.type)\n }}</span>\n @if (!receiver.isSystem) {\n <mt-button\n icon=\"general.x-close\"\n severity=\"secondary\"\n size=\"small\"\n [text]=\"true\"\n [ariaLabel]=\"t('recipients.remove')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onRemoveRecipient(receiver)\"\n />\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"text-sm text-amber-700\">\n {{ t(\"recipients.empty\") }}\n </p>\n }\n </div>\n </section>\n\n <!-- Send only when -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.condition\") }}\n </header>\n <div class=\"flex min-w-0 flex-col gap-2 p-4\">\n <p class=\"text-sm text-muted-color\">\n {{ t(\"condition.hint\") }}\n </p>\n <mt-formula-builder\n formControlName=\"conditionFormula\"\n valueMode=\"expression\"\n [isProcessBuilder]=\"true\"\n [propertiesByPath]=\"conditionPropertiesByPath()\"\n [knownProperties]=\"conditionKeys()\"\n [placeholder]=\"t('condition.placeholder')\"\n />\n </div>\n </section>\n </div>\n }\n } @else if (templates().length > 0) {\n <p class=\"py-8 text-center text-sm text-muted-color\">\n {{ t(\"selectTemplatePrompt\") }}\n </p>\n }\n }\n }\n </div>\n</ng-container>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep mt-textarea-field>.flex.items-center,:host ::ng-deep mt-editor-field>.flex.items-center{direction:ltr}:host-context([dir=rtl]) ::ng-deep mt-textarea-field>.flex.items-center,:host-context([dir=rtl]) ::ng-deep mt-editor-field>.flex.items-center{direction:rtl}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { 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: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { 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: EmptyState, selector: "mt-empty-state", inputs: ["variant", "illustration", "title", "description", "actionLabel", "actionIcon"], outputs: ["actionClick"] }, { kind: "component", type: SelectField, selector: "mt-select-field", inputs: ["field", "hint", "label", "placeholder", "hasPlaceholderPrefix", "class", "readonly", "pInputs", "options", "optionValue", "optionLabel", "filter", "filterBy", "dataKey", "showClear", "clearAfterSelect", "required", "group", "size", "optionGroupLabel", "optionGroupChildren", "loading", "optionIcon", "optionIconColor", "optionIconShape", "optionAvatarShape", "optionGroupIcon", "optionGroupIconColor", "optionGroupIconShape", "optionGroupAvatarShape", "markCurrentUser"], outputs: ["onChange"] }, { 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: TextareaField, selector: "mt-textarea-field", inputs: ["field", "hint", "label", "placeholder", "class", "readonly", "noErrorStyle", "pInputs", "rows", "required", "maxLength"] }, { kind: "directive", type: Tooltip, selector: "[mtTooltip]" }, { kind: "component", type: FormulaBuilder, selector: "mt-formula-builder", inputs: ["propertiesByPath", "levelSchemaId", "moduleId", "contextKey", "contextEntityTypeKey", "templateId", "placeholder", "hideToolbar", "hideStatusBar", "invalidWhenEmpty", "toolbarTabs", "functionCategories", "operators", "codeOnly", "builderOnly", "valueMode", "isProcessBuilder", "knownProperties"], outputs: ["validationChange", "tokensChange"] }, { kind: "component", type: EmailVisualEditor, selector: "mt-email-visual-editor", inputs: ["label", "hint", "placeholders", "layout", "height", "direction", "starter", "approvedImageOrigins"], outputs: ["focused"] }, { kind: "component", type: Icon, selector: "mt-icon", inputs: ["icon"] }, { kind: "component", type: Skeleton, selector: "p-skeleton", inputs: ["styleClass", "shape", "animation", "borderRadius", "size", "width", "height"] }, { kind: "component", type: NotificationContentPreview, selector: "mt-notification-content-preview", inputs: ["preview", "status", "errorCode", "stale", "height", "fill", "showSubject"], outputs: ["retry"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1268
2343
  }
1269
2344
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationTemplate, decorators: [{
1270
2345
  type: Component,
1271
- args: [{ selector: 'mt-notification-template', standalone: true, imports: [
1272
- CommonModule,
2346
+ args: [{ selector: 'mt-notification-template', imports: [
1273
2347
  ReactiveFormsModule,
1274
- TranslocoModule,
1275
- Card,
2348
+ TranslocoDirective,
1276
2349
  Button,
2350
+ EmptyState,
1277
2351
  SelectField,
2352
+ Tabs,
1278
2353
  TextField,
1279
2354
  TextareaField,
1280
- EditorField,
1281
- ToggleField,
1282
- Tabs,
2355
+ Tooltip,
2356
+ FormulaBuilder,
2357
+ EmailVisualEditor,
1283
2358
  Icon,
1284
2359
  Skeleton,
1285
- ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"flex flex-col gap-4 p-4\" [formGroup]=\"form\">\r\n <!-- Loading State -->\r\n @if (isLoadingEvents()) {\r\n <div class=\"flex flex-col gap-4\">\r\n <div class=\"w-1/2\">\r\n <p-skeleton width=\"4rem\" height=\"1rem\" styleClass=\"mb-2\" />\r\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n <div class=\"flex items-center justify-between\">\r\n <div class=\"flex gap-2\">\r\n <p-skeleton width=\"5rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"5rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"5rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n <p-skeleton width=\"8rem\" height=\"2.25rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"100%\" height=\"8rem\" borderRadius=\"0.5rem\" />\r\n <div class=\"flex justify-end gap-2 pt-4\">\r\n <p-skeleton width=\"6rem\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n <p-skeleton width=\"6rem\" height=\"2.5rem\" borderRadius=\"0.5rem\" />\r\n </div>\r\n </div>\r\n } @else if (events().length === 0) {\r\n <!-- Empty State -->\r\n <mt-card>\r\n <div\r\n class=\"flex flex-col items-center justify-center py-12 text-slate-500\"\r\n >\r\n <mt-icon icon=\"alert.alert-circle\" class=\"mb-2 h-12 w-12\" />\r\n <p>{{ \"notification.noEventsFound\" | transloco }}</p>\r\n </div>\r\n </mt-card>\r\n } @else {\r\n <!-- Event Selector -->\r\n <div class=\"w-1/2\">\r\n <mt-select-field\r\n formControlName=\"event\"\r\n [options]=\"events()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'eventName'\"\r\n [label]=\"'notification.events' | transloco\"\r\n [placeholder]=\"'notification.selectEvent' | transloco\"\r\n [required]=\"true\"\r\n [filter]=\"false\"\r\n (onChange)=\"onEventChange($event)\"\r\n />\r\n </div>\r\n\r\n @if (selectedEvent()) {\r\n <!-- Template Tabs + Add notification -->\r\n <div class=\"flex items-center justify-between gap-2\">\r\n <mt-tabs\r\n [active]=\"selectedTemplateId()\"\r\n [options]=\"templateTabs()\"\r\n (activeChange)=\"onTemplateTabChange($event)\"\r\n />\r\n <mt-button\r\n icon=\"general.plus\"\r\n [label]=\"'notification.addNotification' | transloco\"\r\n size=\"small\"\r\n [loading]=\"isAddingTemplate()\"\r\n (onClick)=\"onAddTemplateClick()\"\r\n />\r\n </div>\r\n\r\n <!-- Template Content -->\r\n @if (selectedTemplate(); as template) {\r\n <div class=\"flex flex-col gap-4\">\r\n <!-- Name -->\r\n <div class=\"w-full\">\r\n <mt-text-field\r\n formControlName=\"name\"\r\n [label]=\"'notification.name' | transloco\"\r\n [placeholder]=\"'notification.enterName' | transloco\"\r\n [required]=\"true\"\r\n />\r\n </div>\r\n <!-- Channel & Properties Picker -->\r\n <div class=\"flex flex-col gap-4\">\r\n <mt-select-field\r\n formControlName=\"channel\"\r\n [options]=\"channelOptions()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'key'\"\r\n [label]=\"'notification.channel.label' | transloco\"\r\n [placeholder]=\"'notification.selectChannel' | transloco\"\r\n [required]=\"true\"\r\n [filter]=\"false\"\r\n [disabled]=\"true\"\r\n />\r\n <mt-select-field\r\n [options]=\"propertyGroups()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'key'\"\r\n [group]=\"true\"\r\n [optionGroupLabel]=\"'groupName'\"\r\n [optionGroupChildren]=\"'properties'\"\r\n [label]=\"'notification.propertiesList' | transloco\"\r\n [placeholder]=\"'notification.selectProperty' | transloco\"\r\n [hasPlaceholderPrefix]=\"false\"\r\n [filter]=\"true\"\r\n [filterBy]=\"'name'\"\r\n [loading]=\"isLoadingProperties()\"\r\n [formControl]=\"propertyPickerControl\"\r\n (onChange)=\"onAddProperty($event)\"\r\n />\r\n </div>\r\n <!-- Channel Content Editors -->\r\n @for (ch of channelConfigs; track ch.key) {\r\n @if (form.controls[\"channel\"].value === ch.key) {\r\n <!-- Bilingual Content Fields -->\r\n @if (isBilingual(ch) && ch.translatableKey) {\r\n <ng-container [formGroupName]=\"ch.translatableKey\">\r\n @if (ch.useRichEditor) {\r\n <mt-editor-field\r\n formControlName=\"en\"\r\n [label]=\"'notification.english' | transloco\"\r\n dir=\"ltr\"\r\n />\r\n <mt-editor-field\r\n formControlName=\"ar\"\r\n [label]=\"'notification.arabic' | transloco\"\r\n dir=\"rtl\"\r\n />\r\n } @else {\r\n <mt-textarea-field\r\n formControlName=\"en\"\r\n [label]=\"'notification.english' | transloco\"\r\n [rows]=\"'6'\"\r\n dir=\"ltr\"\r\n />\r\n <mt-textarea-field\r\n formControlName=\"ar\"\r\n [label]=\"'notification.arabic' | transloco\"\r\n [rows]=\"'6'\"\r\n dir=\"rtl\"\r\n />\r\n }\r\n </ng-container>\r\n } @else if (ch.templateKey) {\r\n <!-- Non-Bilingual Content Field -->\r\n @if (ch.useRichEditor) {\r\n <mt-editor-field\r\n [formControlName]=\"ch.templateKey\"\r\n [label]=\"'notification.notificationBody' | transloco\"\r\n />\r\n } @else {\r\n <mt-textarea-field\r\n [formControlName]=\"ch.templateKey\"\r\n [label]=\"'notification.notificationBody' | transloco\"\r\n [rows]=\"'6'\"\r\n />\r\n }\r\n }\r\n <!-- Bilingual Toggle -->\r\n @if (ch.bilingualOptional) {\r\n <mt-toggle-field\r\n [formControlName]=\"ch.key + 'Bilingual'\"\r\n [label]=\"'notification.bilingual' | transloco\"\r\n />\r\n }\r\n }\r\n }\r\n <!-- Receivers -->\r\n <div class=\"w-full\">\r\n <mt-select-field\r\n [options]=\"availableReceiverGroups()\"\r\n [optionLabel]=\"'name'\"\r\n [optionValue]=\"'id'\"\r\n [group]=\"true\"\r\n [optionGroupLabel]=\"'groupName'\"\r\n [optionGroupChildren]=\"'items'\"\r\n [label]=\"'notification.receivers' | transloco\"\r\n [placeholder]=\"'notification.selectReceiver' | transloco\"\r\n [filter]=\"true\"\r\n [filterBy]=\"'name'\"\r\n [loading]=\"isLoadingReceivers()\"\r\n [formControl]=\"receiverPickerControl\"\r\n (onChange)=\"onAddReceiver($event)\"\r\n />\r\n </div>\r\n @if ((template.receivers ?? []).length > 0) {\r\n <div class=\"flex w-full flex-wrap gap-2\">\r\n @for (receiver of template.receivers; track receiver.id) {\r\n <div\r\n class=\"flex items-center gap-2 rounded-full border border-surface bg-surface-50 py-1 pe-2 ps-3 text-sm transition-colors hover:bg-surface-100\"\r\n >\r\n <mt-icon\r\n icon=\"user.user-01\"\r\n class=\"h-4 w-4 shrink-0 text-slate-500\"\r\n />\r\n <span class=\"font-medium\">{{ receiver.name?.display }}</span>\r\n @if (!receiver.isSystem) {\r\n <mt-button\r\n icon=\"general.trash-01\"\r\n severity=\"danger\"\r\n size=\"small\"\r\n [text]=\"true\"\r\n [loading]=\"isReceiverDeleting(receiver.id)\"\r\n (onClick)=\"onDeleteReceiver(receiver)\"\r\n />\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n <!-- Actions -->\r\n <div class=\"flex justify-end gap-2 pt-4\">\r\n @if (form.controls[\"channel\"].value === \"email\") {\r\n <mt-button\r\n icon=\"communication.send-01\"\r\n [label]=\"'notification.testEmail' | transloco\"\r\n severity=\"secondary\"\r\n variant=\"outlined\"\r\n [disabled]=\"\r\n form.dirty || isUpdatingTemplate() || isDeletingTemplate()\r\n \"\r\n (onClick)=\"onTestEmail()\"\r\n />\r\n }\r\n @if (!template.isSystem) {\r\n <mt-button\r\n icon=\"general.trash-01\"\r\n [label]=\"'notification.delete' | transloco\"\r\n severity=\"danger\"\r\n [loading]=\"isDeletingTemplate()\"\r\n [disabled]=\"isUpdatingTemplate()\"\r\n (onClick)=\"onDeleteTemplate()\"\r\n />\r\n }\r\n <mt-button\r\n icon=\"general.save-01\"\r\n [label]=\"'notification.save' | transloco\"\r\n [loading]=\"isUpdatingTemplate()\"\r\n [disabled]=\"isDeletingTemplate() || isFormInvalid()\"\r\n (onClick)=\"onSaveTemplate()\"\r\n />\r\n </div>\r\n </div>\r\n } @else {\r\n <!-- No template selected -->\r\n <div\r\n class=\"flex flex-col items-center justify-center py-12 text-slate-500\"\r\n >\r\n <mt-icon icon=\"alert.bell-01\" class=\"mb-2 h-12 w-12\" />\r\n <p>{{ \"notification.selectTemplatePrompt\" | transloco }}</p>\r\n </div>\r\n }\r\n }\r\n }\r\n</div>\r\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep mt-textarea-field>.flex.items-center,:host ::ng-deep mt-editor-field>.flex.items-center{direction:ltr}:host-context([dir=rtl]) ::ng-deep mt-textarea-field>.flex.items-center,:host-context([dir=rtl]) ::ng-deep mt-editor-field>.flex.items-center{direction:rtl}\n"] }]
1286
- }], ctorParameters: () => [] });
1287
- function toNumericId(value) {
1288
- const parsed = Number(value);
1289
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
2360
+ NotificationContentPreview,
2361
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<ng-container *transloco=\"let t; prefix: 'notification'\">\n <div class=\"flex min-w-0 flex-col gap-4 p-4\">\n @if (facade.isLoadingEvents() && events().length === 0) {\n <div class=\"flex flex-col gap-4\" aria-live=\"polite\">\n <p-skeleton width=\"50%\" height=\"2.5rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"2.5rem\" borderRadius=\"0.375rem\" />\n <p-skeleton width=\"100%\" height=\"16rem\" borderRadius=\"0.375rem\" />\n </div>\n } @else if (facade.eventsError() && events().length === 0) {\n <mt-empty-state\n illustration=\"none\"\n [title]=\"t('load-error')\"\n [actionLabel]=\"t('actions.retry')\"\n actionIcon=\"arrow.refresh-cw-01\"\n (actionClick)=\"retryLoad()\"\n />\n } @else if (events().length === 0) {\n <mt-empty-state illustration=\"none\" [title]=\"t('noEventsFound')\" />\n } @else {\n <!-- Event -->\n <div class=\"w-full md:w-1/2\">\n <mt-select-field\n [formControl]=\"eventControl\"\n [options]=\"events()\"\n optionLabel=\"name\"\n optionValue=\"id\"\n [label]=\"t('events')\"\n [placeholder]=\"t('selectEvent')\"\n [filter]=\"events().length > 8\"\n filterBy=\"name\"\n [showClear]=\"false\"\n (onChange)=\"onEventChange($event)\"\n />\n </div>\n\n @if (selectedEvent(); as event) {\n <!-- Ready-made defaults -->\n @if (missingDefaults().length > 0 || removedDefaults().length > 0) {\n <div\n class=\"flex flex-col gap-2 rounded-md border border-surface bg-surface-50 px-4 py-3 text-sm\"\n >\n @if (missingDefaults().length > 0) {\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n <span class=\"flex items-center gap-2\">\n <mt-icon\n icon=\"general.zap\"\n class=\"size-4 shrink-0 text-primary\"\n />\n {{ t(\"defaults.missing\") }}\n </span>\n <mt-button\n size=\"small\"\n [label]=\"t('defaults.add')\"\n [loading]=\"facade.isLoading('initializeDefaults')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onInitializeDefaults()\"\n />\n </div>\n }\n @for (item of removedDefaults(); track item.slot.channel) {\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n <span>{{\n t(\"defaults.removed\", {\n channel: channelLabel(item.slot.channel),\n })\n }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('defaults.restore')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onRestoreDefault(item.slot.channel)\"\n />\n </div>\n }\n </div>\n }\n\n <!-- Templates of the event -->\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n @if (templates().length > 0) {\n <mt-tabs\n class=\"min-w-0 flex-1\"\n mode=\"underline\"\n [active]=\"activeTab()\"\n [options]=\"templateTabs()\"\n (activeChange)=\"onTemplateTabChange($event)\"\n />\n } @else {\n <span class=\"text-sm text-muted-color\">{{\n t(\"no-templates\")\n }}</span>\n }\n <mt-button\n icon=\"general.plus\"\n [label]=\"t('addNotification')\"\n size=\"small\"\n [disabled]=\"isMutating() || !context()\"\n (onClick)=\"onAddTemplate()\"\n />\n </div>\n\n @if (facade.contextError() && !context()) {\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700\"\n role=\"alert\"\n >\n <span>{{ t(\"context-error\") }}</span>\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.retry')\"\n (onClick)=\"retryLoad()\"\n />\n </div>\n }\n\n @if (template(); as template) {\n @if (!context()) {\n <div class=\"flex flex-col gap-3\">\n <p-skeleton\n width=\"100%\"\n height=\"3.5rem\"\n borderRadius=\"0.375rem\"\n />\n <p-skeleton width=\"100%\" height=\"20rem\" borderRadius=\"0.375rem\" />\n </div>\n } @else {\n <!-- Status and actions -->\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-surface bg-content px-4 py-3\"\n >\n <div class=\"flex min-w-0 flex-wrap items-center gap-2 text-sm\">\n <span\n class=\"inline-flex items-center gap-1 rounded-md border border-surface bg-surface-50 px-2 py-0.5\"\n >\n <mt-icon\n [icon]=\"channelIcons[template.channel]\"\n class=\"size-4\"\n />\n {{ channelLabel(template.channel) }}\n </span>\n @switch (status()) {\n @case (\"on\") {\n <span\n class=\"rounded-md border border-green-200 bg-green-50 px-2 py-0.5 font-medium text-green-700\"\n [mtTooltip]=\"t('status.on-hint')\"\n >{{ t(\"status.on\") }}</span\n >\n }\n @case (\"off\") {\n <span\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-0.5 font-medium text-muted-color\"\n [mtTooltip]=\"t('status.off-hint')\"\n >{{ t(\"status.off\") }}</span\n >\n }\n @case (\"draft\") {\n <span\n class=\"rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 font-medium text-amber-700\"\n [mtTooltip]=\"t('status.draft-hint')\"\n >{{ t(\"status.draft\") }}</span\n >\n }\n }\n @if (unpublishedChanges()) {\n <span\n class=\"rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-700\"\n [mtTooltip]=\"t('status.unpublished-hint')\"\n >{{ t(\"status.unpublished\") }}</span\n >\n }\n @if (defaultOwned()) {\n <span\n class=\"rounded-md border border-surface bg-surface-50 px-2 py-0.5 text-muted-color\"\n >{{ t(\"status.default\") }}</span\n >\n }\n @if (dirty()) {\n <span\n class=\"rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-700\"\n >{{ t(\"status.unsaved\") }}</span\n >\n }\n </div>\n\n <div class=\"flex flex-wrap items-center gap-2\">\n <mt-button\n icon=\"time.clock-rewind\"\n severity=\"secondary\"\n variant=\"text\"\n [tooltip]=\"t('actions.history')\"\n [ariaLabel]=\"t('actions.history')\"\n [disabled]=\"template.publishedRevision === null\"\n (onClick)=\"onOpenHistory()\"\n />\n @if (template.channel === \"Email\") {\n <mt-button\n icon=\"communication.send-01\"\n severity=\"secondary\"\n variant=\"text\"\n [tooltip]=\"\n dirty() ? t('saveBeforeEmailTest') : t('testEmail')\n \"\n [ariaLabel]=\"t('testEmail')\"\n [disabled]=\"dirty() || isMutating()\"\n (onClick)=\"onTestEmail()\"\n />\n }\n @if (!template.isSystem) {\n <mt-button\n icon=\"general.trash-01\"\n severity=\"danger\"\n variant=\"text\"\n [tooltip]=\"t('actions.delete')\"\n [ariaLabel]=\"t('actions.delete')\"\n [loading]=\"facade.isLoading('deleteTemplate')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onDelete()\"\n />\n }\n @if (status() === \"on\") {\n <mt-button\n icon=\"general.toggle-01-left\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.turn-off')\"\n [loading]=\"facade.isLoading('setActivation')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onTurnOff()\"\n />\n } @else {\n <mt-button\n icon=\"general.toggle-01-right\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.turn-on')\"\n [tooltip]=\"\n template.publishedRevision === null\n ? t('actions.turn-on-blocked')\n : ''\n \"\n [loading]=\"facade.isLoading('setActivation')\"\n [disabled]=\"!canTurnOn()\"\n (onClick)=\"onTurnOn()\"\n />\n }\n <mt-button\n icon=\"general.check\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('actions.publish')\"\n [tooltip]=\"dirty() ? t('actions.save-first') : ''\"\n [loading]=\"facade.isLoading('publishTemplate')\"\n [disabled]=\"!canPublish()\"\n (onClick)=\"onPublish()\"\n />\n @if (dirty()) {\n <mt-button\n severity=\"secondary\"\n variant=\"text\"\n [label]=\"t('actions.discard')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onDiscard()\"\n />\n }\n <mt-button\n icon=\"general.save-01\"\n [label]=\"t('save')\"\n [loading]=\"facade.isLoading('saveTemplate')\"\n [disabled]=\"!canSave()\"\n (onClick)=\"onSave()\"\n />\n </div>\n </div>\n\n @if (conflict()) {\n <div\n class=\"flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700\"\n role=\"alert\"\n >\n <span>{{ t(\"conflict.message\") }}</span>\n <div class=\"flex flex-wrap gap-2\">\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n variant=\"outlined\"\n [label]=\"t('conflict.keep-mine')\"\n (onClick)=\"resolveConflict(true)\"\n />\n <mt-button\n size=\"small\"\n severity=\"secondary\"\n [label]=\"t('conflict.load-latest')\"\n (onClick)=\"resolveConflict(false)\"\n />\n </div>\n </div>\n }\n\n @if (writeDiagnostics().length > 0) {\n <ul\n class=\"list-disc rounded-md border border-red-200 bg-red-50 py-3 pe-4 ps-8 text-sm text-red-700\"\n role=\"alert\"\n >\n @for (diagnostic of writeDiagnostics(); track $index) {\n <li>{{ describeDiagnostic(diagnostic) }}</li>\n }\n </ul>\n }\n\n <div\n class=\"grid min-w-0 gap-4\"\n [class]=\"wide() ? 'grid-cols-2' : 'grid-cols-1'\"\n [formGroup]=\"form\"\n >\n <!-- Content -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.content\") }}\n </header>\n <div class=\"flex min-w-0 flex-col gap-4 p-4\">\n <mt-text-field\n formControlName=\"name\"\n [label]=\"t('name')\"\n [placeholder]=\"t('enterName')\"\n [required]=\"true\"\n />\n @if (template.channel === \"Email\") {\n <mt-text-field\n formControlName=\"subject\"\n [label]=\"t('subject')\"\n [hint]=\"t('subject-hint')\"\n />\n }\n <mt-select-field\n [formControl]=\"placeholderControl\"\n [options]=\"placeholderGroups()\"\n optionLabel=\"label\"\n optionValue=\"key\"\n [group]=\"true\"\n optionGroupLabel=\"groupName\"\n optionGroupChildren=\"properties\"\n [label]=\"t('placeholders.label')\"\n [hint]=\"t('placeholders.hint')\"\n [placeholder]=\"t('placeholders.placeholder')\"\n [hasPlaceholderPrefix]=\"false\"\n [filter]=\"true\"\n filterBy=\"label\"\n (onChange)=\"onInsertPlaceholder($event)\"\n />\n @switch (template.channel) {\n @case (\"Email\") {\n <mt-email-visual-editor\n #emailEditor\n formControlName=\"emailTemplate\"\n [label]=\"t('email-body')\"\n [hint]=\"t('email-body-hint')\"\n [placeholders]=\"placeholderOptions()\"\n [approvedImageOrigins]=\"emailImageOrigins()\"\n height=\"26rem\"\n />\n }\n @case (\"Sms\") {\n <div #smsSource (focusin)=\"setActiveField('smsTemplate')\">\n <mt-textarea-field\n dir=\"auto\"\n formControlName=\"smsTemplate\"\n [label]=\"t('sms-body')\"\n [hint]=\"t('sms-body-hint')\"\n rows=\"6\"\n />\n </div>\n }\n @case (\"App\") {\n <mt-email-visual-editor\n #appEnEditor\n formControlName=\"appEn\"\n [label]=\"t('english')\"\n direction=\"ltr\"\n [starter]=\"false\"\n [placeholders]=\"placeholderOptions()\"\n [approvedImageOrigins]=\"appImageOrigins()\"\n height=\"12rem\"\n (focused)=\"setActiveField('appEn')\"\n />\n <mt-email-visual-editor\n #appArEditor\n formControlName=\"appAr\"\n [label]=\"t('arabic')\"\n direction=\"rtl\"\n [starter]=\"false\"\n [placeholders]=\"placeholderOptions()\"\n [approvedImageOrigins]=\"appImageOrigins()\"\n height=\"12rem\"\n (focused)=\"setActiveField('appAr')\"\n />\n }\n }\n </div>\n </section>\n\n <!-- Live preview -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.preview\") }}\n </header>\n <div class=\"flex flex-1 flex-col p-4\">\n <mt-notification-content-preview\n [preview]=\"preview.result()\"\n [status]=\"preview.status()\"\n [errorCode]=\"preview.errorCode()\"\n [stale]=\"previewStale()\"\n [fill]=\"true\"\n [showSubject]=\"false\"\n (retry)=\"preview.retry()\"\n />\n </div>\n </section>\n\n <!-- Recipients -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.recipients\") }}\n </header>\n <div class=\"flex min-w-0 flex-col gap-3 p-4\">\n <mt-select-field\n [formControl]=\"recipientControl\"\n [options]=\"recipientGroups()\"\n optionLabel=\"name\"\n optionValue=\"key\"\n [group]=\"true\"\n optionGroupLabel=\"groupName\"\n optionGroupChildren=\"items\"\n [label]=\"t('recipients.add')\"\n [hint]=\"t('recipients.hint')\"\n [placeholder]=\"t('recipients.placeholder')\"\n [filter]=\"true\"\n filterBy=\"name\"\n [loading]=\"facade.isLoading('addReceivers')\"\n (onChange)=\"onAddRecipient($event)\"\n />\n @if (template.receivers.length > 0) {\n <ul class=\"flex flex-wrap gap-2\">\n @for (receiver of template.receivers; track receiver.id) {\n <li\n class=\"flex items-center gap-2 rounded-md border border-surface bg-surface-50 py-1 pe-1 ps-3 text-sm\"\n >\n <span class=\"font-medium\">{{\n receiverName(receiver)\n }}</span>\n <span class=\"text-xs text-muted-color\">{{\n t(\"recipient-type.\" + receiver.type)\n }}</span>\n @if (!receiver.isSystem) {\n <mt-button\n icon=\"general.x-close\"\n severity=\"secondary\"\n size=\"small\"\n [text]=\"true\"\n [ariaLabel]=\"t('recipients.remove')\"\n [disabled]=\"isMutating()\"\n (onClick)=\"onRemoveRecipient(receiver)\"\n />\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"text-sm text-amber-700\">\n {{ t(\"recipients.empty\") }}\n </p>\n }\n </div>\n </section>\n\n <!-- Send only when -->\n <section\n class=\"flex min-w-0 flex-col overflow-hidden rounded-md border border-surface bg-content\"\n >\n <header\n class=\"border-b border-surface bg-surface-50 px-4 py-2.5 text-sm font-semibold\"\n >\n {{ t(\"sections.condition\") }}\n </header>\n <div class=\"flex min-w-0 flex-col gap-2 p-4\">\n <p class=\"text-sm text-muted-color\">\n {{ t(\"condition.hint\") }}\n </p>\n <mt-formula-builder\n formControlName=\"conditionFormula\"\n valueMode=\"expression\"\n [isProcessBuilder]=\"true\"\n [propertiesByPath]=\"conditionPropertiesByPath()\"\n [knownProperties]=\"conditionKeys()\"\n [placeholder]=\"t('condition.placeholder')\"\n />\n </div>\n </section>\n </div>\n }\n } @else if (templates().length > 0) {\n <p class=\"py-8 text-center text-sm text-muted-color\">\n {{ t(\"selectTemplatePrompt\") }}\n </p>\n }\n }\n }\n </div>\n</ng-container>\n", styles: [":host{display:block;width:100%;height:100%}:host ::ng-deep mt-textarea-field>.flex.items-center,:host ::ng-deep mt-editor-field>.flex.items-center{direction:ltr}:host-context([dir=rtl]) ::ng-deep mt-textarea-field>.flex.items-center,:host-context([dir=rtl]) ::ng-deep mt-editor-field>.flex.items-center{direction:rtl}\n"] }]
2362
+ }], ctorParameters: () => [], propDecorators: { emailEditor: [{ type: i0.ViewChild, args: ['emailEditor', { ...{
2363
+ read: EmailVisualEditor,
2364
+ }, isSignal: true }] }], appEnEditor: [{ type: i0.ViewChild, args: ['appEnEditor', { ...{
2365
+ read: EmailVisualEditor,
2366
+ }, isSignal: true }] }], appArEditor: [{ type: i0.ViewChild, args: ['appArEditor', { ...{
2367
+ read: EmailVisualEditor,
2368
+ }, isSignal: true }] }], smsSource: [{ type: i0.ViewChild, args: ['smsSource', { isSignal: true }] }], onBeforeUnload: [{
2369
+ type: HostListener,
2370
+ args: ['window:beforeunload', ['$event']]
2371
+ }] } });
2372
+ function defaultField(channel) {
2373
+ switch (channel) {
2374
+ case 'Email':
2375
+ return 'emailTemplate';
2376
+ case 'Sms':
2377
+ return 'smsTemplate';
2378
+ case 'App':
2379
+ return 'appEn';
2380
+ }
2381
+ }
2382
+ function publicationReview(publication, nameOf) {
2383
+ return {
2384
+ request: publicationPreviewRequest(publication.content),
2385
+ recipients: publication.content.receivers.map((receiver) => ({
2386
+ name: nameOf(receiver),
2387
+ type: receiver.type,
2388
+ })),
2389
+ condition: publication.content.conditionFormula,
2390
+ };
1290
2391
  }
1291
2392
 
1292
2393
  const APP_STATES = [NotificationState];
@@ -1295,5 +2396,5 @@ const APP_STATES = [NotificationState];
1295
2396
  * Generated bundle index. Do not edit.
1296
2397
  */
1297
2398
 
1298
- export { APP_STATES, AddReceivers, AddTemplate, AddTemplateDialog, CHANNEL_CONFIGS, DeleteReceiver, DeleteTemplate, GetActionProperties, GetActionReceivers, GetEvents, GetProperties, GetReceivers, NotificationActionKey, NotificationFacade, NotificationState, NotificationTemplate, ResetNotificationState, SelectEvent, SelectTemplate, SetActionMode, SetModuleInfo, UpdateTemplate, getChannelConfig, getFormControlName };
2399
+ export { APP_STATES, AddReceivers, AddTemplateDialog, CreateTemplate, DEFAULTS_PUBLISHER, DeleteReceiver, DeleteTemplate, GetAuthoringContext, GetDefaultRecipes, GetEvents, GetPublications, InitializeDefaults, NOTIFICATION_CHANNELS, NOTIFICATION_MUTATION_KEYS, NotificationActionKey, NotificationContentPreview, NotificationFacade, NotificationState, NotificationTemplate, NotificationTemplateApi, PublishTemplate, ResetNotificationState, RestoreDefault, RestorePublication, SaveTemplate, SelectEvent, SelectTemplate, SetModuleInfo, SetTemplateActivation, TemplateHistoryDrawer, TemplateReviewDialog, currentPublication, flattenRecipientCatalog, hasUnpublishedChanges, isDefaultOwned, isRevisionConflict, missingDefaultChannels, parseAppContent, previewSamples, publicationPreviewRequest, readNotificationFailure, recipientKey, templatePreviewRequest, templateStatus, withPreviewSamples };
1299
2400
  //# sourceMappingURL=masterteam-notification.mjs.map