@masterteam/notification 0.0.27 → 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,112 +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 { ModalRef } from '@masterteam/components/dialog';
22
- 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';
23
23
  import { Action, Selector, State, Store, select } from '@ngxs/store';
24
- import { CrudStateBase, handleApiRequest } from '@masterteam/components';
25
-
26
- class AddTemplateDialog {
27
- modalService = inject(ModalService);
28
- ref = inject(ModalRef);
29
- data = input(null, ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
30
- templateNameControl = new FormControl('', [Validators.required]);
31
- onSave() {
32
- if (this.templateNameControl.valid) {
33
- this.ref.close({ name: this.templateNameControl.value?.trim() });
34
- }
35
- }
36
- onCancel() {
37
- this.ref.close();
38
- }
39
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AddTemplateDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
40
- 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" }] });
41
- }
42
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AddTemplateDialog, decorators: [{
43
- type: Component,
44
- args: [{ selector: 'mt-add-template-dialog', standalone: true, imports: [
45
- CommonModule,
46
- ReactiveFormsModule,
47
- TranslocoModule,
48
- TextField,
49
- Button,
50
- ], 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" }]
51
- }], 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';
52
29
 
53
30
  // ============================================================================
54
- // Action Keys Enum
31
+ // Action Keys
55
32
  // ============================================================================
56
33
  var NotificationActionKey;
57
34
  (function (NotificationActionKey) {
58
35
  NotificationActionKey["GetEvents"] = "getEvents";
59
- NotificationActionKey["GetReceivers"] = "getReceivers";
60
- NotificationActionKey["GetProperties"] = "getProperties";
36
+ NotificationActionKey["GetContext"] = "getContext";
37
+ NotificationActionKey["GetDefaults"] = "getDefaults";
38
+ NotificationActionKey["GetPublications"] = "getPublications";
39
+ NotificationActionKey["CreateTemplate"] = "createTemplate";
40
+ NotificationActionKey["SaveTemplate"] = "saveTemplate";
61
41
  NotificationActionKey["AddReceivers"] = "addReceivers";
62
42
  NotificationActionKey["DeleteReceiver"] = "deleteReceiver";
63
- NotificationActionKey["AddTemplate"] = "addTemplate";
64
- NotificationActionKey["UpdateTemplate"] = "updateTemplate";
65
43
  NotificationActionKey["DeleteTemplate"] = "deleteTemplate";
44
+ NotificationActionKey["PublishTemplate"] = "publishTemplate";
45
+ NotificationActionKey["SetActivation"] = "setActivation";
46
+ NotificationActionKey["RestorePublication"] = "restorePublication";
47
+ NotificationActionKey["InitializeDefaults"] = "initializeDefaults";
48
+ NotificationActionKey["RestoreDefault"] = "restoreDefault";
66
49
  })(NotificationActionKey || (NotificationActionKey = {}));
67
- const CHANNEL_CONFIGS = [
68
- {
69
- key: 'email',
70
- labelKey: 'notification.channel.email',
71
- supportsBilingual: false,
72
- bilingualOptional: false,
73
- useRichEditor: true,
74
- templateKey: 'emailTemplate',
75
- },
76
- {
77
- key: 'sms',
78
- labelKey: 'notification.channel.sms',
79
- supportsBilingual: false,
80
- bilingualOptional: false,
81
- useRichEditor: false,
82
- templateKey: 'smsTemplate',
83
- },
84
- {
85
- key: 'app',
86
- labelKey: 'notification.channel.app',
87
- supportsBilingual: true,
88
- bilingualOptional: false,
89
- useRichEditor: true,
90
- translatableKey: 'appTemplate',
91
- },
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',
92
67
  ];
93
- /** Helper to get channel config by key */
94
- function getChannelConfig(key) {
95
- return CHANNEL_CONFIGS.find((c) => c.key === key) ?? CHANNEL_CONFIGS[0];
96
- }
97
- /** Build form control name for a channel field */
98
- function getFormControlName(ch, type) {
99
- if (type === 'single')
100
- return ch.templateKey ?? null;
101
- if (!ch.translatableKey)
102
- return null;
103
- return type === 'en'
104
- ? `${ch.translatableKey}.en`
105
- : `${ch.translatableKey}.ar`;
106
- }
107
68
 
108
69
  // ============================================================================
109
- // Module Configuration
70
+ // Scope
110
71
  // ============================================================================
111
72
  class SetModuleInfo {
112
73
  moduleType;
@@ -123,56 +84,45 @@ class SetModuleInfo {
123
84
  this.parentPath = parentPath;
124
85
  }
125
86
  }
87
+ class ResetNotificationState {
88
+ static type = '[Notification] Reset State';
89
+ }
126
90
  // ============================================================================
127
- // Data Loading Actions
91
+ // Loading
128
92
  // ============================================================================
93
+ /** Loads (or re-reads) the scope's events, keeping the current selection when it still exists. */
129
94
  class GetEvents {
130
95
  static type = '[Notification] Get Events';
131
96
  }
132
- class GetReceivers {
133
- static type = '[Notification] Get Receivers';
134
- }
135
- class GetProperties {
136
- isMultiLang;
137
- static type = '[Notification] Get Properties';
138
- constructor(isMultiLang) {
139
- this.isMultiLang = isMultiLang;
140
- }
141
- }
142
- // ============================================================================
143
- // Action Mode (Workflow)
144
- // ============================================================================
145
- class SetActionMode {
146
- actionId;
147
- static type = '[Notification] Set Action Mode';
148
- constructor(actionId) {
149
- this.actionId = actionId;
97
+ class GetAuthoringContext {
98
+ eventId;
99
+ static type = '[Notification] Get Authoring Context';
100
+ constructor(eventId) {
101
+ this.eventId = eventId;
150
102
  }
151
103
  }
152
- class GetActionReceivers {
153
- actionId;
154
- static type = '[Notification] Get Action Receivers';
155
- constructor(actionId) {
156
- this.actionId = actionId;
104
+ class GetDefaultRecipes {
105
+ eventId;
106
+ static type = '[Notification] Get Default Recipes';
107
+ constructor(eventId) {
108
+ this.eventId = eventId;
157
109
  }
158
110
  }
159
- class GetActionProperties {
160
- actionId;
161
- isMultiLang;
162
- static type = '[Notification] Get Action Properties';
163
- constructor(actionId, isMultiLang) {
164
- this.actionId = actionId;
165
- this.isMultiLang = isMultiLang;
111
+ class GetPublications {
112
+ templateId;
113
+ static type = '[Notification] Get Publications';
114
+ constructor(templateId) {
115
+ this.templateId = templateId;
166
116
  }
167
117
  }
168
118
  // ============================================================================
169
- // Selection Actions
119
+ // Selection
170
120
  // ============================================================================
171
121
  class SelectEvent {
172
- eventName;
122
+ eventId;
173
123
  static type = '[Notification] Select Event';
174
- constructor(eventName) {
175
- this.eventName = eventName;
124
+ constructor(eventId) {
125
+ this.eventId = eventId;
176
126
  }
177
127
  }
178
128
  class SelectTemplate {
@@ -183,58 +133,426 @@ class SelectTemplate {
183
133
  }
184
134
  }
185
135
  // ============================================================================
186
- // Receiver CRUD
136
+ // Template writes
187
137
  // ============================================================================
188
- 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 {
189
148
  templateId;
190
149
  payload;
191
- static type = '[Notification] Add Receivers';
150
+ static type = '[Notification] Save Template';
192
151
  constructor(templateId, payload) {
193
152
  this.templateId = templateId;
194
153
  this.payload = payload;
195
154
  }
196
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
+ }
197
176
  class DeleteReceiver {
198
177
  receiverId;
178
+ expectedRevision;
199
179
  static type = '[Notification] Delete Receiver';
200
- constructor(receiverId) {
180
+ constructor(receiverId, expectedRevision) {
201
181
  this.receiverId = receiverId;
182
+ this.expectedRevision = expectedRevision;
202
183
  }
203
184
  }
204
185
  // ============================================================================
205
- // Template CRUD
186
+ // Lifecycle
206
187
  // ============================================================================
207
- class AddTemplate {
208
- eventId;
209
- payload;
210
- static type = '[Notification] Add Template';
211
- constructor(eventId, payload) {
212
- this.eventId = eventId;
213
- 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;
214
197
  }
215
198
  }
216
- class UpdateTemplate {
199
+ class SetTemplateActivation {
217
200
  templateId;
218
- payload;
219
- static type = '[Notification] Update Template';
220
- constructor(templateId, payload) {
201
+ expectedRevision;
202
+ enabled;
203
+ reviews;
204
+ static type = '[Notification] Set Template Activation';
205
+ constructor(templateId, expectedRevision, enabled, reviews) {
221
206
  this.templateId = templateId;
222
- this.payload = payload;
207
+ this.expectedRevision = expectedRevision;
208
+ this.enabled = enabled;
209
+ this.reviews = reviews;
223
210
  }
224
211
  }
225
- class DeleteTemplate {
212
+ class RestorePublication {
226
213
  templateId;
227
- static type = '[Notification] Delete Template';
228
- constructor(templateId) {
214
+ expectedRevision;
215
+ publicationRevision;
216
+ reviews;
217
+ static type = '[Notification] Restore Publication';
218
+ constructor(templateId, expectedRevision, publicationRevision, reviews) {
229
219
  this.templateId = templateId;
220
+ this.expectedRevision = expectedRevision;
221
+ this.publicationRevision = publicationRevision;
222
+ this.reviews = reviews;
230
223
  }
231
224
  }
232
225
  // ============================================================================
233
- // Reset State
226
+ // Defaults
234
227
  // ============================================================================
235
- class ResetNotificationState {
236
- 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');
237
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;
553
+ }
554
+ /** The system principal that publishes generated App defaults. */
555
+ const DEFAULTS_PUBLISHER = 'system:notification-defaults';
238
556
 
239
557
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
240
558
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -242,59 +560,51 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
242
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;
243
561
  return c > 3 && r && Object.defineProperty(target, key, r), r;
244
562
  };
245
- // Default State
246
563
  const DEFAULT_STATE = {
247
- // Module configuration
248
564
  moduleType: null,
249
565
  moduleId: null,
250
566
  parentModuleType: null,
251
567
  parentModuleId: null,
252
568
  parentPath: '',
253
- // Action mode
254
- isActionMode: false,
255
- selectedActionId: null,
256
- // Data collections
257
569
  events: [],
258
- templates: [], // Flattened templates
259
- actions: [],
260
- receivers: [],
261
- receiverGroups: [],
262
- propertyGroups: [],
263
- // Selection state
264
- selectedEventName: null,
570
+ templates: [],
571
+ context: null,
572
+ defaults: null,
573
+ publications: [],
574
+ publicationsTemplateId: null,
575
+ selectedEventId: null,
265
576
  selectedTemplateId: null,
266
- // Loading state (from LoadingStateShape)
267
577
  loadingActive: [],
268
578
  errors: {},
269
579
  };
270
- // State Definition
271
- let NotificationState = class NotificationState extends CrudStateBase {
272
- http = inject(HttpClient);
273
- baseUrl = 'notifications';
274
- // Selectors - Individual selectors for fine-grained reactivity
275
- static getState(state) {
276
- return state;
277
- }
580
+ let NotificationState = class NotificationState {
581
+ api = inject(NotificationTemplateApi);
582
+ // ==========================================================================
583
+ // Slice selectors
584
+ // ==========================================================================
278
585
  static getEvents(state) {
279
586
  return state.events;
280
587
  }
281
588
  static getTemplates(state) {
282
589
  return state.templates;
283
590
  }
284
- static getReceivers(state) {
285
- return state.receivers;
591
+ static getSelectedEventId(state) {
592
+ return state.selectedEventId;
286
593
  }
287
- static getReceiverGroups(state) {
288
- return state.receiverGroups;
594
+ static getSelectedTemplateId(state) {
595
+ return state.selectedTemplateId;
289
596
  }
290
- static getPropertyGroups(state) {
291
- return state.propertyGroups;
597
+ static getContext(state) {
598
+ return state.context;
292
599
  }
293
- static getSelectedEventName(state) {
294
- return state.selectedEventName;
600
+ static getDefaults(state) {
601
+ return state.defaults;
295
602
  }
296
- static getSelectedTemplateId(state) {
297
- return state.selectedTemplateId;
603
+ static getPublicationsSlice(state) {
604
+ return {
605
+ templateId: state.publicationsTemplateId,
606
+ items: state.publications,
607
+ };
298
608
  }
299
609
  static getLoadingActive(state) {
300
610
  return state.loadingActive;
@@ -302,119 +612,26 @@ let NotificationState = class NotificationState extends CrudStateBase {
302
612
  static getErrors(state) {
303
613
  return state.errors;
304
614
  }
615
+ // ==========================================================================
305
616
  // Derived selectors
306
- static getSelectedEvent(events, selectedEventName) {
307
- return events.find((e) => e.eventName === selectedEventName) ?? null;
617
+ // ==========================================================================
618
+ static getSelectedEvent(events, selectedEventId) {
619
+ return events.find((event) => event.id === selectedEventId) ?? null;
308
620
  }
309
- static getEventTemplates(templates, selectedEvent) {
310
- if (!selectedEvent)
621
+ static getEventTemplates(templates, selectedEventId) {
622
+ if (selectedEventId == null)
311
623
  return [];
312
- return templates.filter((t) => t.eventId === selectedEvent.id);
624
+ return templates.filter((template) => template.eventId === selectedEventId);
313
625
  }
314
626
  static getSelectedTemplate(templates, selectedTemplateId) {
315
- return templates.find((t) => t.id === selectedTemplateId) ?? null;
316
- }
317
- static getAvailableReceivers(receivers, selectedTemplate) {
318
- if (!selectedTemplate)
319
- return receivers;
320
- const templateReceivers = selectedTemplate.receivers ?? [];
321
- const assignedIds = new Set(templateReceivers.map((r) => r.identifier));
322
- return receivers.filter((r) => !assignedIds.has(String(r.id)));
323
- }
324
- static getAvailableReceiverGroups(receiverGroups, selectedTemplate) {
325
- if (!selectedTemplate)
326
- return receiverGroups;
327
- const assignedIds = new Set((selectedTemplate.receivers ?? []).map((r) => r.identifier));
328
- return receiverGroups
329
- .map((group) => ({
330
- ...group,
331
- items: group.items.filter((item) => !assignedIds.has(String(item.id))),
332
- }))
333
- .filter((group) => group.items.length > 0);
334
- }
335
- // Helper Methods
336
- /** API returns events with nested templates - we flatten for state */
337
- flattenEventsResponse(eventsWithTemplates) {
338
- const events = [];
339
- const templates = [];
340
- for (const event of eventsWithTemplates) {
341
- const { templates: eventTemplates, ...eventData } = event;
342
- events.push(eventData);
343
- templates.push(...eventTemplates.map((t) => ({ ...t, eventId: event.id })));
344
- }
345
- return { events, templates };
346
- }
347
- normalizeReceiverGroups(groups) {
348
- return groups
349
- .map((group) => ({
350
- groupName: group.groupName,
351
- items: this.flattenReceiverItems(group.items),
352
- }))
353
- .filter((group) => group.items.length > 0);
354
- }
355
- flattenReceiverItems(items, parentLabel) {
356
- if (!Array.isArray(items)) {
357
- return [];
358
- }
359
- return items.flatMap((item) => {
360
- const subGroupItems = item.items;
361
- if (Array.isArray(subGroupItems)) {
362
- const subGroupName = item.subGroupName;
363
- const nextLabel = subGroupName
364
- ? parentLabel
365
- ? `${parentLabel} - ${subGroupName}`
366
- : subGroupName
367
- : parentLabel;
368
- return this.flattenReceiverItems(subGroupItems, nextLabel);
369
- }
370
- const receiver = item;
371
- if (receiver.id == null || !receiver.name) {
372
- return [];
373
- }
374
- return [
375
- {
376
- ...receiver,
377
- name: parentLabel
378
- ? `${parentLabel} - ${receiver.name}`
379
- : receiver.name,
380
- },
381
- ];
382
- });
383
- }
384
- hasOwnField(value, field) {
385
- return Object.prototype.hasOwnProperty.call(value, field);
627
+ return (templates.find((template) => template.id === selectedTemplateId) ?? null);
386
628
  }
387
- mergeUpdatedTemplate(existingTemplate, updatedTemplate) {
388
- const mergedTemplate = {
389
- ...existingTemplate,
390
- name: updatedTemplate.name ?? existingTemplate.name,
391
- };
392
- if (this.hasOwnField(updatedTemplate, 'emailTemplate')) {
393
- mergedTemplate.emailTemplate = updatedTemplate.emailTemplate ?? '';
394
- }
395
- if (this.hasOwnField(updatedTemplate, 'emailContentTranslatable') &&
396
- updatedTemplate.emailContentTranslatable) {
397
- mergedTemplate.emailContentTranslatable = {
398
- ...existingTemplate.emailContentTranslatable,
399
- ...updatedTemplate.emailContentTranslatable,
400
- };
401
- }
402
- if (this.hasOwnField(updatedTemplate, 'smsTemplate')) {
403
- mergedTemplate.smsTemplate = updatedTemplate.smsTemplate ?? '';
404
- }
405
- if (this.hasOwnField(updatedTemplate, 'appTemplate') &&
406
- updatedTemplate.appTemplate) {
407
- mergedTemplate.appTemplate = {
408
- ...existingTemplate.appTemplate,
409
- ...updatedTemplate.appTemplate,
410
- };
411
- }
412
- if (this.hasOwnField(updatedTemplate, 'conditionFormula')) {
413
- mergedTemplate.conditionFormula = updatedTemplate.conditionFormula;
414
- }
415
- return mergedTemplate;
629
+ static getPublications(slice, selectedTemplateId) {
630
+ return slice.templateId === selectedTemplateId ? slice.items : [];
416
631
  }
417
- // Module Configuration
632
+ // ==========================================================================
633
+ // Scope
634
+ // ==========================================================================
418
635
  setModuleInfo(ctx, action) {
419
636
  let parentPath = '';
420
637
  if (action.parentModuleType && action.parentModuleId) {
@@ -429,218 +646,273 @@ let NotificationState = class NotificationState extends CrudStateBase {
429
646
  parentModuleType: action.parentModuleType ?? null,
430
647
  parentModuleId: action.parentModuleId ?? null,
431
648
  parentPath,
432
- isActionMode: action.moduleType === 'workflow',
433
649
  });
434
650
  }
435
651
  resetState(ctx) {
436
652
  ctx.setState(DEFAULT_STATE);
437
653
  }
438
- // Data Loading Actions (Normal Mode)
654
+ // ==========================================================================
655
+ // Loading
656
+ // ==========================================================================
439
657
  getEvents(ctx) {
440
- const { moduleType, moduleId, parentPath } = ctx.getState();
441
- const req$ = this.http.get(`${this.baseUrl}${parentPath}/${moduleType}/${moduleId}/events`);
442
- return this.load(ctx, {
443
- key: NotificationActionKey.GetEvents,
444
- request$: req$,
445
- updateState: (_state, data) => {
446
- const { events, templates } = this.flattenEventsResponse(data ?? []);
447
- const firstEvent = events[0];
448
- const firstTemplate = templates.find((t) => t.eventId === firstEvent?.id);
449
- return {
450
- events,
451
- templates,
452
- selectedEventName: firstEvent?.eventName ?? null,
453
- selectedTemplateId: firstTemplate?.id ?? null,
454
- };
455
- },
456
- });
457
- }
458
- getReceivers(ctx) {
459
- const { parentPath, moduleType, moduleId } = ctx.getState();
460
- const req$ = this.http.get(`${this.baseUrl}${parentPath}/${moduleType}/${moduleId}/receivers`);
658
+ const scope = scopePathOf(ctx.getState());
659
+ if (!scope)
660
+ return;
661
+ const previousEventId = ctx.getState().selectedEventId;
461
662
  return handleApiRequest({
462
663
  ctx,
463
- key: NotificationActionKey.GetReceivers,
464
- request$: req$,
465
- onSuccess: (response) => {
466
- const receiverGroups = this.normalizeReceiverGroups(response.data ?? []);
467
- return {
468
- receiverGroups,
469
- receivers: receiverGroups.flatMap((group) => group.items),
470
- };
664
+ key: NotificationActionKey.GetEvents,
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 };
471
679
  },
472
- });
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
+ }));
473
697
  }
474
- getProperties(ctx, action) {
475
- const { moduleType, moduleId, parentPath } = ctx.getState();
476
- const url = `${this.baseUrl}${parentPath}/${moduleType}/${moduleId}/properties?IsMultiLanguageEnabled=${action.isMultiLang ? 'true' : 'false'}`;
477
- const req$ = this.http.get(url);
698
+ getAuthoringContext(ctx, action) {
478
699
  return handleApiRequest({
479
700
  ctx,
480
- key: NotificationActionKey.GetProperties,
481
- request$: req$,
482
- onSuccess: (response) => ({
483
- propertyGroups: response.data?.map((group) => ({
484
- ...group,
485
- icon: 'custom.list-cards',
486
- })) ?? [],
487
- }),
701
+ key: NotificationActionKey.GetContext,
702
+ request$: this.api.getContext(action.eventId),
703
+ onSuccess: (context, state) => state.selectedEventId === action.eventId ? { context } : undefined,
488
704
  });
489
705
  }
490
- // Action Mode (Workflow)
491
- setActionMode(ctx, action) {
492
- ctx.patchState({
493
- selectedActionId: action.actionId,
494
- });
495
- }
496
- getActionReceivers(ctx, action) {
497
- const req$ = this.http.get(`${this.baseUrl}/action/${action.actionId}/receivers`);
706
+ getDefaultRecipes(ctx, action) {
498
707
  return handleApiRequest({
499
708
  ctx,
500
- key: NotificationActionKey.GetReceivers,
501
- request$: req$,
502
- onSuccess: (response) => {
503
- const receiverGroups = this.normalizeReceiverGroups(response.data ?? []);
504
- return {
505
- receiverGroups,
506
- receivers: receiverGroups.flatMap((group) => group.items),
507
- };
508
- },
709
+ key: NotificationActionKey.GetDefaults,
710
+ request$: this.api.getDefaults(action.eventId),
711
+ onSuccess: (defaults, state) => state.selectedEventId === action.eventId ? { defaults } : undefined,
509
712
  });
510
713
  }
511
- getActionProperties(ctx, action) {
512
- const url = `${this.baseUrl}/action/${action.actionId}/properties?IsMultiLanguageEnabled=${action.isMultiLang ? 'true' : 'false'}`;
513
- 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
+ }
514
726
  return handleApiRequest({
515
727
  ctx,
516
- key: NotificationActionKey.GetProperties,
517
- request$: req$,
518
- onSuccess: (response) => ({
519
- propertyGroups: response.data ?? [],
520
- }),
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,
521
733
  });
522
734
  }
523
- // Selection Actions
735
+ // ==========================================================================
736
+ // Selection
737
+ // ==========================================================================
524
738
  selectEvent(ctx, action) {
525
739
  const state = ctx.getState();
526
- const event = state.events.find((e) => e.eventName === action.eventName);
527
- const eventTemplates = state.templates.filter((t) => t.eventId === event?.id);
740
+ if (state.selectedEventId === action.eventId)
741
+ return;
742
+ const firstTemplate = state.templates.find((template) => template.eventId === action.eventId);
528
743
  ctx.patchState({
529
- selectedEventName: action.eventName,
530
- selectedTemplateId: eventTemplates[0]?.id ?? null,
744
+ selectedEventId: action.eventId,
745
+ selectedTemplateId: firstTemplate?.id ?? null,
746
+ context: null,
747
+ defaults: null,
748
+ publications: [],
749
+ publicationsTemplateId: null,
531
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);
532
758
  }
533
759
  selectTemplate(ctx, action) {
534
- ctx.patchState({ selectedTemplateId: action.templateId });
760
+ if (ctx.getState().selectedTemplateId === action.templateId)
761
+ return;
762
+ ctx.patchState({
763
+ selectedTemplateId: action.templateId,
764
+ publications: [],
765
+ publicationsTemplateId: null,
766
+ });
767
+ return ctx.dispatch(new GetPublications(action.templateId));
535
768
  }
536
- // Receiver CRUD
537
- addReceivers(ctx, action) {
538
- const req$ = this.http.post(`${this.baseUrl}/templates/${action.templateId}`, action.payload);
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) {
539
774
  return handleApiRequest({
540
775
  ctx,
541
- key: NotificationActionKey.AddReceivers,
542
- request$: req$,
543
- onSuccess: (response, state) => {
544
- const newReceivers = response.data ?? [];
545
- const templates = this.adapter.updateOne(state.templates, action.templateId, {
546
- receivers: [
547
- ...(state.templates.find((t) => t.id === action.templateId)
548
- ?.receivers ?? []),
549
- ...newReceivers,
550
- ],
551
- }, 'id');
552
- return { templates };
553
- },
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
+ : {}),
787
+ }),
554
788
  });
555
789
  }
556
- deleteReceiver(ctx, action) {
557
- const state = ctx.getState();
558
- const template = this.getSelectedTemplate(state);
559
- if (!template) {
560
- return;
561
- }
562
- const req$ = this.http.delete(`${this.baseUrl}/receivers/${action.receiverId}`);
790
+ saveTemplate(ctx, action) {
563
791
  return handleApiRequest({
564
792
  ctx,
565
- key: NotificationActionKey.DeleteReceiver,
566
- request$: req$,
567
- onSuccess: (_, currentState) => {
568
- const templates = this.adapter.updateOne(currentState.templates, template.id, {
569
- receivers: template.receivers.filter((r) => r.id !== action.receiverId),
570
- }, 'id');
571
- return { templates };
572
- },
793
+ key: NotificationActionKey.SaveTemplate,
794
+ request$: this.api.saveTemplate(action.templateId, action.payload),
795
+ onSuccess: (template, state) => ({
796
+ templates: upsertTemplate(state.templates, template),
797
+ }),
573
798
  });
574
799
  }
575
- // Template CRUD
576
- addTemplate(ctx, action) {
577
- const req$ = this.http.post(`${this.baseUrl}/events/${action.eventId}`, action.payload);
800
+ addReceivers(ctx, action) {
578
801
  return handleApiRequest({
579
802
  ctx,
580
- key: NotificationActionKey.AddTemplate,
581
- request$: req$,
582
- onSuccess: (response, state) => {
583
- const newTemplate = response.data;
584
- if (!newTemplate)
585
- return {};
586
- return {
587
- templates: this.adapter.addOne(state.templates, {
588
- ...newTemplate,
589
- eventId: action.eventId,
590
- }),
591
- selectedTemplateId: newTemplate.id,
592
- };
593
- },
594
- });
595
- }
596
- updateTemplate(ctx, action) {
597
- const req$ = this.http.put(`${this.baseUrl}/templates/${action.templateId}`, action.payload, {
598
- headers: new HttpHeaders({
599
- 'Content-Type': 'application/json',
803
+ key: NotificationActionKey.AddReceivers,
804
+ request$: this.api.addReceivers(action.templateId, action.expectedRevision, action.receivers),
805
+ onSuccess: (template, state) => ({
806
+ templates: upsertTemplate(state.templates, template),
600
807
  }),
601
808
  });
809
+ }
810
+ deleteReceiver(ctx, action) {
602
811
  return handleApiRequest({
603
812
  ctx,
604
- key: NotificationActionKey.UpdateTemplate,
605
- request$: req$,
606
- onSuccess: (response, state) => {
607
- const updatedTemplate = response.data;
608
- if (!updatedTemplate)
609
- return {};
610
- // Preserve existing fields that API may not return or shouldn't change
611
- const existingTemplate = state.templates.find((t) => t.id === action.templateId);
612
- if (!existingTemplate)
613
- return {};
614
- // Only update name and content fields, preserve channel/eventId/receivers
615
- const templates = this.adapter.upsertOne(state.templates, this.mergeUpdatedTemplate(existingTemplate, updatedTemplate), 'id');
616
- return { templates };
617
- },
813
+ key: NotificationActionKey.DeleteReceiver,
814
+ request$: this.api.deleteReceiver(action.receiverId, action.expectedRevision),
815
+ onSuccess: (template, state) => ({
816
+ templates: upsertTemplate(state.templates, template),
817
+ }),
618
818
  });
619
819
  }
620
820
  deleteTemplate(ctx, action) {
621
- const req$ = this.http.delete(`${this.baseUrl}/templates/${action.templateId}`);
821
+ const eventId = ctx
822
+ .getState()
823
+ .templates.find((template) => template.id === action.templateId)?.eventId;
622
824
  return handleApiRequest({
623
825
  ctx,
624
826
  key: NotificationActionKey.DeleteTemplate,
625
- request$: req$,
827
+ request$: this.api.deleteTemplate(action.templateId, action.expectedRevision),
626
828
  onSuccess: (_, state) => {
627
- const templates = this.adapter.removeOne(state.templates, action.templateId, 'id');
628
- // Select first template of current event after deletion
629
- const currentEvent = state.events.find((e) => e.eventName === state.selectedEventName);
630
- const eventTemplates = templates.filter((t) => t.eventId === currentEvent?.id);
631
- const systemTemplate = eventTemplates.find((t) => t.isSystem);
829
+ const templates = state.templates.filter((template) => template.id !== action.templateId);
830
+ const next = templates.find((template) => template.eventId === state.selectedEventId);
632
831
  return {
633
832
  templates,
634
- selectedTemplateId: systemTemplate?.id ?? eventTemplates[0]?.id ?? null,
833
+ selectedTemplateId: state.selectedTemplateId === action.templateId
834
+ ? (next?.id ?? null)
835
+ : state.selectedTemplateId,
836
+ publications: [],
837
+ publicationsTemplateId: null,
635
838
  };
636
839
  },
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),
859
+ }),
860
+ }).pipe(tap(() => ctx.dispatch(new GetPublications(action.templateId))));
861
+ }
862
+ setTemplateActivation(ctx, action) {
863
+ return handleApiRequest({
864
+ ctx,
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
+ }),
637
870
  });
638
871
  }
639
- // Private Helpers
640
- getSelectedTemplate(state) {
641
- return (state.templates.find((t) => t.id === state.selectedTemplateId) ?? null);
872
+ restorePublication(ctx, action) {
873
+ return handleApiRequest({
874
+ ctx,
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))));
642
881
  }
643
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
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))));
914
+ }
915
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
644
916
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState });
645
917
  };
646
918
  __decorate([
@@ -650,47 +922,53 @@ __decorate([
650
922
  Action(ResetNotificationState)
651
923
  ], NotificationState.prototype, "resetState", null);
652
924
  __decorate([
653
- Action(GetEvents)
925
+ Action(GetEvents, { cancelUncompleted: true })
654
926
  ], NotificationState.prototype, "getEvents", null);
655
927
  __decorate([
656
- Action(GetReceivers)
657
- ], NotificationState.prototype, "getReceivers", null);
658
- __decorate([
659
- Action(GetProperties)
660
- ], NotificationState.prototype, "getProperties", null);
928
+ Action(GetAuthoringContext, { cancelUncompleted: true })
929
+ ], NotificationState.prototype, "getAuthoringContext", null);
661
930
  __decorate([
662
- Action(SetActionMode)
663
- ], NotificationState.prototype, "setActionMode", null);
931
+ Action(GetDefaultRecipes, { cancelUncompleted: true })
932
+ ], NotificationState.prototype, "getDefaultRecipes", null);
664
933
  __decorate([
665
- Action(GetActionReceivers)
666
- ], NotificationState.prototype, "getActionReceivers", null);
667
- __decorate([
668
- Action(GetActionProperties)
669
- ], NotificationState.prototype, "getActionProperties", null);
934
+ Action(GetPublications, { cancelUncompleted: true })
935
+ ], NotificationState.prototype, "getPublications", null);
670
936
  __decorate([
671
937
  Action(SelectEvent)
672
938
  ], NotificationState.prototype, "selectEvent", null);
673
939
  __decorate([
674
940
  Action(SelectTemplate)
675
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);
676
948
  __decorate([
677
949
  Action(AddReceivers)
678
950
  ], NotificationState.prototype, "addReceivers", null);
679
951
  __decorate([
680
952
  Action(DeleteReceiver)
681
953
  ], NotificationState.prototype, "deleteReceiver", null);
682
- __decorate([
683
- Action(AddTemplate)
684
- ], NotificationState.prototype, "addTemplate", null);
685
- __decorate([
686
- Action(UpdateTemplate)
687
- ], NotificationState.prototype, "updateTemplate", null);
688
954
  __decorate([
689
955
  Action(DeleteTemplate)
690
956
  ], NotificationState.prototype, "deleteTemplate", null);
691
957
  __decorate([
692
- Selector()
693
- ], 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);
694
972
  __decorate([
695
973
  Selector()
696
974
  ], NotificationState, "getEvents", null);
@@ -699,19 +977,19 @@ __decorate([
699
977
  ], NotificationState, "getTemplates", null);
700
978
  __decorate([
701
979
  Selector()
702
- ], NotificationState, "getReceivers", null);
980
+ ], NotificationState, "getSelectedEventId", null);
703
981
  __decorate([
704
982
  Selector()
705
- ], NotificationState, "getReceiverGroups", null);
983
+ ], NotificationState, "getSelectedTemplateId", null);
706
984
  __decorate([
707
985
  Selector()
708
- ], NotificationState, "getPropertyGroups", null);
986
+ ], NotificationState, "getContext", null);
709
987
  __decorate([
710
988
  Selector()
711
- ], NotificationState, "getSelectedEventName", null);
989
+ ], NotificationState, "getDefaults", null);
712
990
  __decorate([
713
991
  Selector()
714
- ], NotificationState, "getSelectedTemplateId", null);
992
+ ], NotificationState, "getPublicationsSlice", null);
715
993
  __decorate([
716
994
  Selector()
717
995
  ], NotificationState, "getLoadingActive", null);
@@ -719,15 +997,12 @@ __decorate([
719
997
  Selector()
720
998
  ], NotificationState, "getErrors", null);
721
999
  __decorate([
722
- Selector([
723
- NotificationState.getEvents,
724
- NotificationState.getSelectedEventName,
725
- ])
1000
+ Selector([NotificationState.getEvents, NotificationState.getSelectedEventId])
726
1001
  ], NotificationState, "getSelectedEvent", null);
727
1002
  __decorate([
728
1003
  Selector([
729
1004
  NotificationState.getTemplates,
730
- NotificationState.getSelectedEvent,
1005
+ NotificationState.getSelectedEventId,
731
1006
  ])
732
1007
  ], NotificationState, "getEventTemplates", null);
733
1008
  __decorate([
@@ -738,16 +1013,10 @@ __decorate([
738
1013
  ], NotificationState, "getSelectedTemplate", null);
739
1014
  __decorate([
740
1015
  Selector([
741
- NotificationState.getReceivers,
742
- NotificationState.getSelectedTemplate,
743
- ])
744
- ], NotificationState, "getAvailableReceivers", null);
745
- __decorate([
746
- Selector([
747
- NotificationState.getReceiverGroups,
748
- NotificationState.getSelectedTemplate,
1016
+ NotificationState.getPublicationsSlice,
1017
+ NotificationState.getSelectedTemplateId,
749
1018
  ])
750
- ], NotificationState, "getAvailableReceiverGroups", null);
1019
+ ], NotificationState, "getPublications", null);
751
1020
  NotificationState = __decorate([
752
1021
  State({
753
1022
  name: 'notification',
@@ -756,48 +1025,71 @@ NotificationState = __decorate([
756
1025
  ], NotificationState);
757
1026
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationState, decorators: [{
758
1027
  type: Injectable
759
- }], 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
+ }
760
1055
 
761
1056
  class NotificationFacade {
762
1057
  store = inject(Store);
763
- // ============================================================================
764
- // Data Selectors - Memoized by NGXS (fine-grained reactivity)
765
- // ============================================================================
1058
+ api = inject(NotificationTemplateApi);
1059
+ // ==========================================================================
1060
+ // Data
1061
+ // ==========================================================================
766
1062
  events = select(NotificationState.getEvents);
1063
+ /** Templates of the selected event. */
767
1064
  templates = select(NotificationState.getEventTemplates);
768
- receivers = select(NotificationState.getReceivers);
769
- receiverGroups = select(NotificationState.getReceiverGroups);
770
- propertyGroups = select(NotificationState.getPropertyGroups);
771
- // Selection selectors
772
- selectedEventName = select(NotificationState.getSelectedEventName);
1065
+ selectedEventId = select(NotificationState.getSelectedEventId);
773
1066
  selectedTemplateId = select(NotificationState.getSelectedTemplateId);
774
1067
  selectedEvent = select(NotificationState.getSelectedEvent);
775
1068
  selectedTemplate = select(NotificationState.getSelectedTemplate);
776
- availableReceivers = select(NotificationState.getAvailableReceivers);
777
- availableReceiverGroups = select(NotificationState.getAvailableReceiverGroups);
778
- // ============================================================================
779
- // Loading/Error Slices - Memoized by NGXS
780
- // ============================================================================
1069
+ context = select(NotificationState.getContext);
1070
+ defaults = select(NotificationState.getDefaults);
1071
+ publications = select(NotificationState.getPublications);
1072
+ // ==========================================================================
1073
+ // Loading / errors
1074
+ // ==========================================================================
781
1075
  loadingActive = select(NotificationState.getLoadingActive);
782
1076
  errors = select(NotificationState.getErrors);
783
- // ============================================================================
784
- // Loading Signals - Computed from slice (minimal reactivity)
785
- // ============================================================================
786
1077
  isLoadingEvents = computed(() => this.loadingActive().includes(NotificationActionKey.GetEvents), ...(ngDevMode ? [{ debugName: "isLoadingEvents" }] : /* istanbul ignore next */ []));
787
- isLoadingReceivers = computed(() => this.loadingActive().includes(NotificationActionKey.GetReceivers), ...(ngDevMode ? [{ debugName: "isLoadingReceivers" }] : /* istanbul ignore next */ []));
788
- isLoadingProperties = computed(() => this.loadingActive().includes(NotificationActionKey.GetProperties), ...(ngDevMode ? [{ debugName: "isLoadingProperties" }] : /* istanbul ignore next */ []));
789
- isAddingReceivers = computed(() => this.loadingActive().includes(NotificationActionKey.AddReceivers), ...(ngDevMode ? [{ debugName: "isAddingReceivers" }] : /* istanbul ignore next */ []));
790
- isDeletingReceiver = computed(() => this.loadingActive().includes(NotificationActionKey.DeleteReceiver), ...(ngDevMode ? [{ debugName: "isDeletingReceiver" }] : /* istanbul ignore next */ []));
791
- isAddingTemplate = computed(() => this.loadingActive().includes(NotificationActionKey.AddTemplate), ...(ngDevMode ? [{ debugName: "isAddingTemplate" }] : /* istanbul ignore next */ []));
792
- isUpdatingTemplate = computed(() => this.loadingActive().includes(NotificationActionKey.UpdateTemplate), ...(ngDevMode ? [{ debugName: "isUpdatingTemplate" }] : /* istanbul ignore next */ []));
793
- isDeletingTemplate = computed(() => this.loadingActive().includes(NotificationActionKey.DeleteTemplate), ...(ngDevMode ? [{ debugName: "isDeletingTemplate" }] : /* istanbul ignore next */ []));
794
- // ============================================================================
795
- // Error Signals - Computed from slice (minimal reactivity)
796
- // ============================================================================
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 */ []));
797
1083
  eventsError = computed(() => this.errors()[NotificationActionKey.GetEvents] ?? null, ...(ngDevMode ? [{ debugName: "eventsError" }] : /* istanbul ignore next */ []));
798
- receiversError = computed(() => this.errors()[NotificationActionKey.GetReceivers] ?? null, ...(ngDevMode ? [{ debugName: "receiversError" }] : /* istanbul ignore next */ []));
799
- propertiesError = computed(() => this.errors()[NotificationActionKey.GetProperties] ?? null, ...(ngDevMode ? [{ debugName: "propertiesError" }] : /* istanbul ignore next */ []));
800
- // 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
+ // ==========================================================================
801
1093
  /**
802
1094
  * Scopes this (shared, singleton) state to a module/event context.
803
1095
  *
@@ -815,68 +1107,70 @@ class NotificationFacade {
815
1107
  resetState() {
816
1108
  return this.store.dispatch(new ResetNotificationState());
817
1109
  }
818
- // Action Dispatchers - Data Loading
1110
+ /** Loads the scope's events; the selected event's catalog and defaults follow. */
819
1111
  loadEvents() {
820
1112
  return this.store.dispatch(new GetEvents());
821
1113
  }
822
- loadReceivers() {
823
- 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();
824
1117
  }
825
- loadProperties(isMultiLang) {
826
- return this.store.dispatch(new GetProperties(isMultiLang));
1118
+ loadAllData() {
1119
+ return this.loadEvents();
827
1120
  }
828
- loadPropertiesForCurrentContext(isMultiLang) {
829
- const state = this.store.selectSnapshot(NotificationState.getState);
830
- if (state.isActionMode && state.selectedActionId != null) {
831
- return this.store.dispatch(new GetActionProperties(state.selectedActionId, isMultiLang));
832
- }
833
- return this.store.dispatch(new GetProperties(isMultiLang));
1121
+ // ==========================================================================
1122
+ // Selection
1123
+ // ==========================================================================
1124
+ selectEvent(eventId) {
1125
+ return this.store.dispatch(new SelectEvent(eventId));
834
1126
  }
835
- // Action Dispatchers - Action Mode (Workflow)
836
- setActionMode(actionId) {
837
- return this.store.dispatch(new SetActionMode(actionId));
1127
+ selectTemplate(templateId) {
1128
+ return this.store.dispatch(new SelectTemplate(templateId));
838
1129
  }
839
- loadActionReceivers(actionId) {
840
- return this.store.dispatch(new GetActionReceivers(actionId));
1130
+ reloadDefaults(eventId) {
1131
+ return this.store.dispatch(new GetDefaultRecipes(eventId));
841
1132
  }
842
- loadActionProperties(actionId, isMultiLang) {
843
- 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));
844
1138
  }
845
- // Action Dispatchers - Selection
846
- selectEvent(eventName) {
847
- return this.store.dispatch(new SelectEvent(eventName));
1139
+ saveTemplate(templateId, payload) {
1140
+ return this.store.dispatch(new SaveTemplate(templateId, payload));
848
1141
  }
849
- selectTemplate(templateId) {
850
- return this.store.dispatch(new SelectTemplate(templateId));
1142
+ deleteTemplate(templateId, expectedRevision) {
1143
+ return this.store.dispatch(new DeleteTemplate(templateId, expectedRevision));
851
1144
  }
852
- // Action Dispatchers - Receiver CRUD
853
- addReceivers(templateId, payload) {
854
- return this.store.dispatch(new AddReceivers(templateId, payload));
1145
+ addReceivers(templateId, expectedRevision, receivers) {
1146
+ return this.store.dispatch(new AddReceivers(templateId, expectedRevision, receivers));
855
1147
  }
856
- deleteReceiver(receiverId) {
857
- return this.store.dispatch(new DeleteReceiver(receiverId));
1148
+ deleteReceiver(receiverId, expectedRevision) {
1149
+ return this.store.dispatch(new DeleteReceiver(receiverId, expectedRevision));
858
1150
  }
859
- // Action Dispatchers - Template CRUD
860
- addTemplate(eventId, payload) {
861
- return this.store.dispatch(new AddTemplate(eventId, payload));
1151
+ publish(templateId, expectedRevision, reviews) {
1152
+ return this.store.dispatch(new PublishTemplate(templateId, expectedRevision, reviews));
862
1153
  }
863
- updateTemplate(templateId, payload) {
864
- return this.store.dispatch(new UpdateTemplate(templateId, payload));
1154
+ turnOn(templateId, expectedRevision, reviews) {
1155
+ return this.store.dispatch(new SetTemplateActivation(templateId, expectedRevision, true, reviews));
865
1156
  }
866
- deleteTemplate(templateId) {
867
- return this.store.dispatch(new DeleteTemplate(templateId));
1157
+ turnOff(templateId, expectedRevision) {
1158
+ return this.store.dispatch(new SetTemplateActivation(templateId, expectedRevision, false, null));
868
1159
  }
869
- loadBaseData() {
870
- this.loadEvents();
871
- this.loadReceivers();
1160
+ restorePublication(templateId, expectedRevision, publicationRevision, reviews) {
1161
+ return this.store.dispatch(new RestorePublication(templateId, expectedRevision, publicationRevision, reviews));
872
1162
  }
873
- /**
874
- * Load all data for normal mode (non-workflow)
875
- */
876
- loadAllData(isMultiLang = true) {
877
- this.loadEvents();
878
- this.loadReceivers();
879
- 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);
880
1174
  }
881
1175
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationFacade, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
882
1176
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationFacade, providedIn: 'root' });
@@ -886,374 +1180,1215 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
886
1180
  args: [{ providedIn: 'root' }]
887
1181
  }] });
888
1182
 
889
- 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);
890
1433
  facade = inject(NotificationFacade);
1434
+ destroyRef = inject(DestroyRef);
891
1435
  transloco = inject(TranslocoService);
892
- 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 {
893
1507
  modalService = inject(ModalService);
894
- // ============================================================================
895
- // Data Signals from Facade
896
- // ============================================================================
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);
1563
+ toast = inject(ToastService);
1564
+ destroyRef = inject(DestroyRef);
1565
+ lang = toSignal(this.transloco.langChanges$, {
1566
+ initialValue: this.transloco.getActiveLang(),
1567
+ });
1568
+ // ==========================================================================
1569
+ // Store
1570
+ // ==========================================================================
897
1571
  events = this.facade.events;
898
1572
  templates = this.facade.templates;
899
- receivers = this.facade.receivers;
900
- propertyGroups = this.facade.propertyGroups;
901
- // Selection Signals
902
- selectedEventName = this.facade.selectedEventName;
903
- selectedTemplateId = this.facade.selectedTemplateId;
904
1573
  selectedEvent = this.facade.selectedEvent;
905
- selectedTemplate = this.facade.selectedTemplate;
906
- availableReceivers = this.facade.availableReceivers;
907
- availableReceiverGroups = this.facade.availableReceiverGroups;
908
- // ============================================================================
909
- // Loading States from Facade
910
- // ============================================================================
911
- isLoadingEvents = this.facade.isLoadingEvents;
912
- isLoadingReceivers = this.facade.isLoadingReceivers;
913
- isLoadingProperties = this.facade.isLoadingProperties;
914
- isUpdatingTemplate = this.facade.isUpdatingTemplate;
915
- isDeletingTemplate = this.facade.isDeletingTemplate;
916
- isAddingTemplate = this.facade.isAddingTemplate;
917
- // ============================================================================
918
- // Error States from Facade
919
- // ============================================================================
920
- eventsError = this.facade.eventsError;
921
- propertiesError = this.facade.propertiesError;
922
- // ============================================================================
923
- // Local State
924
- // ============================================================================
925
- deletingReceiverIds = signal([], ...(ngDevMode ? [{ debugName: "deletingReceiverIds" }] : /* istanbul ignore next */ []));
926
- addingReceiverIds = signal([], ...(ngDevMode ? [{ debugName: "addingReceiverIds" }] : /* istanbul ignore next */ []));
927
- // ============================================================================
928
- // Form - Dynamic controls based on CHANNEL_CONFIGS
929
- // ============================================================================
930
- form = this.buildForm();
931
- propertyPickerControl = new FormControl(null);
932
- receiverPickerControl = new FormControl(null);
933
- channelControl = this.form.controls['channel'];
934
- channelSignal = toSignal(this.channelControl.valueChanges, {
935
- 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 }),
936
1604
  });
937
- buildForm() {
938
- const fc = (v = '') => new FormControl(v, { nonNullable: true });
939
- const controls = {
940
- event: new FormControl(null),
941
- name: fc(),
942
- channel: new FormControl('email', {
943
- nonNullable: true,
944
- }),
945
- };
946
- for (const ch of CHANNEL_CONFIGS) {
947
- if (ch.templateKey)
948
- controls[ch.templateKey] = fc();
949
- if (ch.translatableKey) {
950
- // Nested FormGroup to match payload structure { en: '', ar: '' }
951
- controls[ch.translatableKey] = new FormGroup({
952
- en: fc(),
953
- ar: fc(),
954
- });
955
- }
956
- if (ch.bilingualOptional) {
957
- controls[`${ch.key}Bilingual`] = new FormControl(false, {
958
- nonNullable: true,
959
- });
960
- }
961
- }
962
- return new FormGroup(controls);
963
- }
964
- // ============================================================================
965
- // Computed
966
- // ============================================================================
967
- channelConfigs = CHANNEL_CONFIGS;
968
- /** Check if channel should show bilingual fields - reads from form */
969
- isBilingual(ch) {
970
- 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)
971
1644
  return false;
972
- if (!ch.bilingualOptional)
973
- return true; // forced bilingual (app)
974
- return this.form.controls[`${ch.key}Bilingual`]?.value ?? false;
975
- }
976
- channelOptions = computed(() => CHANNEL_CONFIGS.map((c) => ({
977
- ...c,
978
- name: this.transloco.translate(c.labelKey),
979
- })), ...(ngDevMode ? [{ debugName: "channelOptions" }] : /* istanbul ignore next */ []));
980
- /** Templates of the selected event, rendered as tabs. */
981
- templateTabs = computed(() => this.templates().map((t) => ({
982
- label: t.name,
983
- value: t.id,
984
- 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],
985
1678
  })), ...(ngDevMode ? [{ debugName: "templateTabs" }] : /* istanbul ignore next */ []));
986
- activeChannel = computed(() => this.selectedTemplate()?.channel ?? this.channelSignal(), ...(ngDevMode ? [{ debugName: "activeChannel" }] : /* istanbul ignore next */ []));
987
- shouldUseMultilingualProperties = computed(() => this.activeChannel() !== 'app', ...(ngDevMode ? [{ debugName: "shouldUseMultilingualProperties" }] : /* istanbul ignore next */ []));
988
- isFormInvalid = computed(() => !this.form.controls['name'].value?.trim(), ...(ngDevMode ? [{ debugName: "isFormInvalid" }] : /* istanbul ignore next */ []));
989
- /**
990
- * Snapshot of the form's default shape, captured before any context ever
991
- * patches it. Used by {@link resetTemplateFields} to clear the form when the
992
- * selected template resolves to nothing, so a component switching context
993
- * never keeps displaying a previous context's values (see
994
- * `ResetNotificationState` for the store-level counterpart of this reset).
995
- */
996
- 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 */ []));
997
1759
  constructor() {
1760
+ this.form.valueChanges
1761
+ .pipe(takeUntilDestroyed())
1762
+ .subscribe(() => this.formValue.set(this.form.getRawValue()));
998
1763
  effect(() => {
999
- const eventName = this.selectedEventName();
1000
- if (eventName) {
1001
- if (this.form.controls['event'].value !== eventName) {
1002
- this.form.controls['event'].setValue(eventName, {
1003
- emitEvent: false,
1004
- });
1764
+ const eventId = this.selectedEventId();
1765
+ untracked(() => {
1766
+ if (this.eventControl.value !== eventId) {
1767
+ this.eventControl.setValue(eventId, { emitEvent: false });
1005
1768
  }
1006
- }
1007
- else {
1008
- this.form.controls['event'].setValue(null, { emitEvent: false });
1009
- }
1769
+ });
1010
1770
  });
1771
+ // A newly selected template replaces the editor; a write on the same
1772
+ // template (recipients, publish, activation) never touches unsaved edits.
1011
1773
  effect(() => {
1012
- this.selectedTemplateId(); // depend on selected template id
1013
- const template = untracked(this.selectedTemplate);
1014
- if (template) {
1015
- this.patchForm(template);
1016
- }
1017
- else {
1018
- this.resetTemplateFields();
1019
- }
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
+ });
1020
1792
  });
1021
1793
  effect(() => {
1022
- const template = this.selectedTemplate();
1023
- 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) {
1024
1803
  return;
1025
1804
  }
1026
- 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
+ });
1027
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
+ }
1028
1827
  }
1029
- // ============================================================================
1030
- // Event Handlers
1031
- // ============================================================================
1032
- onEventChange(eventName) {
1033
- if (eventName)
1034
- 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
+ });
1035
1853
  }
1036
1854
  onTemplateTabChange(templateId) {
1037
- this.facade.selectTemplate(templateId);
1038
- }
1039
- // Channel is fixed per template - set on creation only
1040
- onAddProperty(propertyKey) {
1041
- if (!propertyKey)
1855
+ const current = this.selectedTemplateId();
1856
+ if (templateId === current)
1042
1857
  return;
1043
- const tag = `{{${propertyKey}}}`;
1044
- const ch = getChannelConfig(this.form.controls['channel'].value);
1045
- if (this.isBilingual(ch) && ch.translatableKey) {
1046
- const group = this.form.get(ch.translatableKey);
1047
- if (group) {
1048
- const enCtrl = group.get('en');
1049
- const arCtrl = group.get('ar');
1050
- enCtrl?.setValue(`${enCtrl.value ?? ''} ${tag}`);
1051
- arCtrl?.setValue(`${arCtrl.value ?? ''} ${tag}`);
1858
+ this.activeTab.set(templateId);
1859
+ this.confirmDiscard().subscribe((discard) => {
1860
+ if (discard) {
1861
+ this.facade.selectTemplate(templateId);
1052
1862
  }
1053
- }
1054
- else if (ch.templateKey) {
1055
- this.appendToControl(ch.templateKey, tag);
1056
- }
1057
- setTimeout(() => {
1058
- this.propertyPickerControl.reset();
1059
- }, 0);
1863
+ else {
1864
+ this.activeTab.set(current);
1865
+ }
1866
+ });
1060
1867
  }
1061
- onAddReceiver(receiverId) {
1062
- const template = this.selectedTemplate();
1063
- if (!receiverId || !template) {
1064
- setTimeout(() => this.receiverPickerControl.reset(), 0);
1868
+ retryLoad() {
1869
+ const eventId = this.selectedEventId();
1870
+ if (this.facade.eventsError() || eventId == null) {
1871
+ this.facade.loadEvents();
1065
1872
  return;
1066
1873
  }
1067
- if (this.addingReceiverIds().includes(receiverId)) {
1068
- setTimeout(() => this.receiverPickerControl.reset(), 0);
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);
1069
1899
  return;
1070
1900
  }
1071
- const selectedReceiver = this.availableReceivers().find((r) => r.id === receiverId);
1072
- if (!selectedReceiver) {
1073
- setTimeout(() => this.receiverPickerControl.reset(), 0);
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.
1911
+ setTimeout(() => {
1912
+ textarea?.focus();
1913
+ textarea?.setSelectionRange(caret, caret);
1914
+ });
1915
+ }
1916
+ onSave() {
1917
+ const template = this.template();
1918
+ if (!template || !this.canSave()) {
1919
+ this.form.markAllAsTouched();
1074
1920
  return;
1075
1921
  }
1076
- this.addingReceiverIds.update((ids) => [...ids, receiverId]);
1077
1922
  this.facade
1078
- .addReceivers(template.id, {
1079
- receivers: [
1080
- {
1081
- id: selectedReceiver.id,
1082
- type: selectedReceiver.type,
1083
- },
1084
- ],
1085
- })
1923
+ .saveTemplate(template.id, this.buildSavePayload(template))
1086
1924
  .subscribe({
1087
- complete: () => this.addingReceiverIds.update((ids) => ids.filter((id) => id !== receiverId)),
1088
- error: () => this.addingReceiverIds.update((ids) => ids.filter((id) => id !== receiverId)),
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),
1089
1933
  });
1090
- setTimeout(() => this.receiverPickerControl.reset(), 0);
1091
1934
  }
1092
- onDeleteReceiver(receiver) {
1093
- this.confirmationService.confirmDelete({
1094
- type: 'dialog',
1095
- accept: () => {
1096
- this.deletingReceiverIds.update((ids) => [...ids, receiver.id]);
1097
- this.facade.deleteReceiver(receiver.id).subscribe({
1098
- complete: () => {
1099
- this.deletingReceiverIds.update((ids) => ids.filter((id) => id !== receiver.id));
1100
- },
1101
- error: () => {
1102
- this.deletingReceiverIds.update((ids) => ids.filter((id) => id !== receiver.id));
1103
- },
1104
- });
1105
- },
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);
1106
1941
  });
1107
1942
  }
1108
- isReceiverDeleting(receiverId) {
1109
- return this.deletingReceiverIds().includes(receiverId);
1943
+ // ==========================================================================
1944
+ // Recipients
1945
+ // ==========================================================================
1946
+ onAddRecipient(key) {
1947
+ const template = this.template();
1948
+ setTimeout(() => this.recipientControl.reset());
1949
+ if (!key || !template || this.isMutating())
1950
+ return;
1951
+ const option = this.recipientGroups()
1952
+ .flatMap((group) => group.items)
1953
+ .find((item) => item.key === key);
1954
+ if (!option)
1955
+ return;
1956
+ this.facade
1957
+ .addReceivers(template.id, template.revision, [
1958
+ { type: option.type, id: option.id },
1959
+ ])
1960
+ .subscribe({
1961
+ next: () => this.afterWrite(null),
1962
+ error: (error) => this.onWriteError(error),
1963
+ });
1110
1964
  }
1111
- onSaveTemplate() {
1112
- const template = this.selectedTemplate();
1113
- if (!template || this.isFormInvalid()) {
1114
- this.form.markAllAsTouched();
1965
+ onRemoveRecipient(receiver) {
1966
+ const template = this.template();
1967
+ if (!template || receiver.isSystem || this.isMutating())
1115
1968
  return;
1116
- }
1117
- // Build payload dynamically from all channel controls
1118
- const payload = this.buildUpdatePayload(template);
1119
- this.facade.updateTemplate(template.id, payload);
1969
+ this.confirmation.confirmDelete({
1970
+ type: 'dialog',
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
+ });
1120
1979
  }
1121
- onDeleteTemplate() {
1122
- const template = this.selectedTemplate();
1123
- if (!template || template.isSystem)
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())
1124
2033
  return;
1125
- this.confirmationService.confirm({
2034
+ const revision = template.revision;
2035
+ this.confirmation.confirm({
1126
2036
  type: 'dialog',
1127
- message: this.transloco.translate('notification.deleteTemplateMessage'),
1128
- icon: 'general.trash-01',
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'),
1129
2041
  acceptButton: { severity: 'danger' },
1130
- accept: () => this.facade.deleteTemplate(template.id),
2042
+ accept: () => this.facade.turnOff(template.id, revision).subscribe({
2043
+ next: () => this.afterWrite('turned-off'),
2044
+ error: (error) => this.onWriteError(error),
2045
+ }),
1131
2046
  });
1132
2047
  }
1133
- onAddTemplateClick() {
1134
- const event = this.selectedEvent();
1135
- if (!event)
2048
+ onOpenHistory() {
2049
+ const eventId = this.selectedEventId();
2050
+ if (eventId == null)
1136
2051
  return;
1137
- const ref = this.modalService.openModal(AddTemplateDialog, 'dialog', {
1138
- header: this.transloco.translate('notification.addTemplate'),
1139
- styleClass: '!w-[25rem]',
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,
2062
+ },
1140
2063
  });
1141
2064
  ref.onClose.subscribe((result) => {
1142
- if (result?.name) {
1143
- this.facade.addTemplate(event.id, {
2065
+ if (result?.publication)
2066
+ this.restoreVersion(result.publication);
2067
+ });
2068
+ }
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
+ }));
2088
+ }
2089
+ onDelete() {
2090
+ const template = this.template();
2091
+ if (!template || template.isSystem || this.isMutating())
2092
+ return;
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,
1144
2123
  name: result.name,
1145
- channel: this.form.controls['channel'].value,
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),
1146
2134
  });
1147
- }
2135
+ });
2136
+ });
2137
+ }
2138
+ onTestEmail() {
2139
+ const event = this.selectedEvent();
2140
+ const template = this.template();
2141
+ if (!event || !template || template.channel !== 'Email')
2142
+ return;
2143
+ if (this.dirty()) {
2144
+ this.toast.warn(this.messages.t('saveBeforeEmailTest'));
2145
+ return;
2146
+ }
2147
+ // Sends the saved draft — never the editor's unsaved values.
2148
+ this.modal.openModal(EmailUserTestDialog, 'dialog', {
2149
+ header: this.messages.t('testEmail'),
2150
+ styleClass: '!w-[96vw] sm:!w-[38rem]',
2151
+ inputValues: {
2152
+ kind: 'template',
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()),
2163
+ },
2164
+ });
2165
+ }
2166
+ // ==========================================================================
2167
+ // Defaults
2168
+ // ==========================================================================
2169
+ onInitializeDefaults() {
2170
+ const eventId = this.selectedEventId();
2171
+ if (eventId == null || this.isMutating())
2172
+ return;
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),
2178
+ });
2179
+ }
2180
+ onRestoreDefault(channel) {
2181
+ const eventId = this.selectedEventId();
2182
+ if (eventId == null || this.isMutating())
2183
+ return;
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
+ }));
1148
2208
  });
1149
2209
  }
1150
- // ============================================================================
1151
- // Private Methods
1152
- // ============================================================================
2210
+ // ==========================================================================
2211
+ // Conflicts and errors
2212
+ // ==========================================================================
1153
2213
  /**
1154
- * Clears every template-owned control back to the captured empty shape.
1155
- *
1156
- * `event` is deliberately excluded, so `patchValue` leaves whatever the event
1157
- * effect (or the user's own pick) put in that control. An event with no
1158
- * templates yet — a brand-new one, or one whose last template was just
1159
- * deleted — resolves `selectedTemplate` to null, and this effect runs after
1160
- * the event effect in the same flush: resetting the whole group there would
1161
- * blank the event picker the user just used, while `selectedEvent()` stays
1162
- * truthy and keeps the tabs/add-template row visible. Leaving the control
1163
- * alone also keeps the two effects ordering-independent — on a full
1164
- * `ResetNotificationState` the event effect has already nulled it, and that
1165
- * null is carried through.
1166
- *
1167
- * `channel` is restored through {@link channelControl} with events enabled
1168
- * because `channelSignal` is a `toSignal` of its `valueChanges` and is the
1169
- * only consumer of them on this form — a silent reset would leave
1170
- * `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.
1171
2217
  */
1172
- resetTemplateFields() {
1173
- const { event: _event, channel, ...templateFields } = this.emptyFormValue;
1174
- this.form.patchValue(templateFields, { emitEvent: false });
1175
- this.channelControl.setValue(channel);
1176
- this.form.markAsPristine();
1177
- this.form.markAsUntouched();
1178
- }
1179
- appendToControl(key, value) {
1180
- const ctrl = this.form.controls[key];
1181
- ctrl?.setValue(`${ctrl.value ?? ''} ${value}`);
1182
- }
1183
- patchForm(template) {
1184
- const channel = template.channel || 'email';
1185
- this.form.patchValue({
1186
- name: template.name,
1187
- channel: channel,
1188
- });
1189
- // Patch all channel controls dynamically
1190
- const t = template;
1191
- for (const ch of CHANNEL_CONFIGS) {
1192
- if (ch.templateKey) {
1193
- this.form
1194
- .get(ch.templateKey)
1195
- ?.setValue(t[ch.templateKey] ?? '');
1196
- }
1197
- if (ch.translatableKey) {
1198
- const trans = t[ch.translatableKey] ?? {};
1199
- this.form.get(ch.translatableKey)?.patchValue({
1200
- en: trans.en ?? '',
1201
- ar: trans.ar ?? '',
1202
- });
2218
+ resolveConflict(keepMine) {
2219
+ this.facade.loadEvents().subscribe(() => {
2220
+ if (!keepMine) {
2221
+ const template = this.template();
2222
+ if (template)
2223
+ this.patchFromTemplate(template);
1203
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;
1204
2241
  }
2242
+ if (toastKey)
2243
+ this.toast.success(this.messages.t(`toast.${toastKey}`));
1205
2244
  }
1206
- buildUpdatePayload(template) {
1207
- const title = this.form.controls['name'].value?.trim() || template.name;
1208
- // The backend treats this PUT as a full replace: any channel template
1209
- // omitted from the payload is nulled out. Always send email/sms/app
1210
- // together — patchForm() populates every channel control from the loaded
1211
- // template, so channels not edited this session keep their values.
1212
- const payload = { title };
1213
- const target = payload;
1214
- for (const ch of CHANNEL_CONFIGS) {
1215
- if (ch.templateKey) {
1216
- target[ch.templateKey] = this.getTemplateControlValue(ch.templateKey);
1217
- }
1218
- if (ch.translatableKey) {
1219
- target[ch.translatableKey] = this.getTranslatableControlValue(ch.translatableKey);
1220
- }
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();
1221
2262
  }
1222
- return payload;
1223
2263
  }
1224
- getTemplateControlValue(controlName) {
1225
- const control = this.form.get(controlName);
1226
- 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
+ });
2312
+ }
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());
1227
2324
  }
1228
- getTranslatableControlValue(controlName) {
1229
- const group = this.form.get(controlName);
1230
- const value = group?.getRawValue();
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();
1231
2329
  return {
1232
- en: value?.en ?? '',
1233
- 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,
1234
2339
  };
1235
2340
  }
1236
2341
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationTemplate, deps: [], target: i0.ɵɵFactoryTarget.Component });
1237
- 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 (!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 });
1238
2343
  }
1239
2344
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: NotificationTemplate, decorators: [{
1240
2345
  type: Component,
1241
- args: [{ selector: 'mt-notification-template', standalone: true, imports: [
1242
- CommonModule,
2346
+ args: [{ selector: 'mt-notification-template', imports: [
1243
2347
  ReactiveFormsModule,
1244
- TranslocoModule,
1245
- Card,
2348
+ TranslocoDirective,
1246
2349
  Button,
2350
+ EmptyState,
1247
2351
  SelectField,
2352
+ Tabs,
1248
2353
  TextField,
1249
2354
  TextareaField,
1250
- EditorField,
1251
- ToggleField,
1252
- Tabs,
2355
+ Tooltip,
2356
+ FormulaBuilder,
2357
+ EmailVisualEditor,
1253
2358
  Icon,
1254
2359
  Skeleton,
1255
- ], 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 (!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"] }]
1256
- }], ctorParameters: () => [] });
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
+ };
2391
+ }
1257
2392
 
1258
2393
  const APP_STATES = [NotificationState];
1259
2394
 
@@ -1261,5 +2396,5 @@ const APP_STATES = [NotificationState];
1261
2396
  * Generated bundle index. Do not edit.
1262
2397
  */
1263
2398
 
1264
- 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 };
1265
2400
  //# sourceMappingURL=masterteam-notification.mjs.map