ngx-dynamic-entity 0.0.1

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.
Files changed (47) hide show
  1. package/esm2022/lib/field-types/checkbox-field.component.mjs +73 -0
  2. package/esm2022/lib/field-types/date-field.component.mjs +82 -0
  3. package/esm2022/lib/field-types/dropdown-field.component.mjs +83 -0
  4. package/esm2022/lib/field-types/entity-ref-field.component.mjs +117 -0
  5. package/esm2022/lib/field-types/multi-select-field.component.mjs +91 -0
  6. package/esm2022/lib/field-types/number-field.component.mjs +73 -0
  7. package/esm2022/lib/field-types/text-field.component.mjs +74 -0
  8. package/esm2022/lib/field-types/textarea-field.component.mjs +73 -0
  9. package/esm2022/lib/form/dynamic-field/dynamic-field.component.mjs +86 -0
  10. package/esm2022/lib/form/dynamic-form.component.mjs +167 -0
  11. package/esm2022/lib/providers/provide-ngx-dynamic-entity.mjs +23 -0
  12. package/esm2022/lib/services/config.service.mjs +58 -0
  13. package/esm2022/lib/services/entity-ref-registry.service.mjs +31 -0
  14. package/esm2022/lib/services/field-registry.service.mjs +50 -0
  15. package/esm2022/lib/services/hook-registry.service.mjs +26 -0
  16. package/esm2022/lib/services/rbac.service.mjs +52 -0
  17. package/esm2022/lib/services/validator-registry.service.mjs +49 -0
  18. package/esm2022/lib/services/version.service.mjs +40 -0
  19. package/esm2022/lib/table/dynamic-table.component.mjs +160 -0
  20. package/esm2022/lib/tokens/injection-tokens.mjs +16 -0
  21. package/esm2022/ngx-dynamic-entity.mjs +5 -0
  22. package/esm2022/public-api.mjs +26 -0
  23. package/fesm2022/ngx-dynamic-entity.mjs +1352 -0
  24. package/fesm2022/ngx-dynamic-entity.mjs.map +1 -0
  25. package/index.d.ts +5 -0
  26. package/lib/field-types/checkbox-field.component.d.ts +12 -0
  27. package/lib/field-types/date-field.component.d.ts +14 -0
  28. package/lib/field-types/dropdown-field.component.d.ts +13 -0
  29. package/lib/field-types/entity-ref-field.component.d.ts +26 -0
  30. package/lib/field-types/multi-select-field.component.d.ts +13 -0
  31. package/lib/field-types/number-field.component.d.ts +12 -0
  32. package/lib/field-types/text-field.component.d.ts +13 -0
  33. package/lib/field-types/textarea-field.component.d.ts +12 -0
  34. package/lib/form/dynamic-field/dynamic-field.component.d.ts +30 -0
  35. package/lib/form/dynamic-form.component.d.ts +52 -0
  36. package/lib/providers/provide-ngx-dynamic-entity.d.ts +29 -0
  37. package/lib/services/config.service.d.ts +25 -0
  38. package/lib/services/entity-ref-registry.service.d.ts +20 -0
  39. package/lib/services/field-registry.service.d.ts +22 -0
  40. package/lib/services/hook-registry.service.d.ts +12 -0
  41. package/lib/services/rbac.service.d.ts +32 -0
  42. package/lib/services/validator-registry.service.d.ts +19 -0
  43. package/lib/services/version.service.d.ts +26 -0
  44. package/lib/table/dynamic-table.component.d.ts +56 -0
  45. package/lib/tokens/injection-tokens.d.ts +16 -0
  46. package/package.json +37 -0
  47. package/public-api.d.ts +21 -0
@@ -0,0 +1,1352 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, Injectable, Input, Component, signal, ViewContainerRef, ViewChild, EventEmitter, Output, computed } from '@angular/core';
3
+ import { HttpClient } from '@angular/common/http';
4
+ import { map, shareReplay } from 'rxjs/operators';
5
+ import * as i1 from '@angular/forms';
6
+ import { ReactiveFormsModule, Validators, FormBuilder } from '@angular/forms';
7
+
8
+ /** Base URL for the dynamic-entity-server API */
9
+ const DYNAMIC_ENTITY_API_URL = new InjectionToken('DYNAMIC_ENTITY_API_URL');
10
+ /** Roles that see XXXXXXXXX for masked fields (ADR-003) */
11
+ const MASKED_ROLES = new InjectionToken('MASKED_ROLES');
12
+ /** Migration strategy for the frontend — 'strict' | 'graceful' only (ADR-005, never 'auto') */
13
+ const MIGRATION_STRATEGY = new InjectionToken('MIGRATION_STRATEGY');
14
+ /** Registry: fieldType string → Angular component class */
15
+ const FIELD_TYPE_REGISTRY = new InjectionToken('FIELD_TYPE_REGISTRY');
16
+ /** Registry: entity key → async () => options[] loader function */
17
+ const ENTITY_REF_REGISTRY = new InjectionToken('ENTITY_REF_REGISTRY');
18
+ /** Registry: validator key → ValidatorFn */
19
+ const VALIDATOR_REGISTRY = new InjectionToken('VALIDATOR_REGISTRY');
20
+ /** Registry: hook key → async (data, context) => data */
21
+ const HOOK_REGISTRY = new InjectionToken('HOOK_REGISTRY');
22
+
23
+ /**
24
+ * Provider function for ngx-dynamic-entity.
25
+ * All options are optional — consumers provide only what they use (ISP).
26
+ *
27
+ * Usage in app.config.ts:
28
+ * provideNgxDynamicEntity({
29
+ * apiUrl: 'http://localhost:3000/api/entities',
30
+ * maskedRoles: ['IT_SUPPORT'],
31
+ * entityRefs: { clients: () => clientService.getOptions() },
32
+ * })
33
+ */
34
+ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
35
+ { provide: DYNAMIC_ENTITY_API_URL, useValue: config.apiUrl ?? '' },
36
+ { provide: MASKED_ROLES, useValue: config.maskedRoles ?? [] },
37
+ { provide: MIGRATION_STRATEGY, useValue: config.migrationStrategy ?? 'graceful' },
38
+ { provide: FIELD_TYPE_REGISTRY, useValue: new Map(Object.entries(config.fieldTypes ?? {})) },
39
+ { provide: ENTITY_REF_REGISTRY, useValue: new Map(Object.entries(config.entityRefs ?? {})) },
40
+ { provide: VALIDATOR_REGISTRY, useValue: new Map(Object.entries(config.validators ?? {})) },
41
+ { provide: HOOK_REGISTRY, useValue: new Map(Object.entries(config.hooks ?? {})) },
42
+ ]);
43
+
44
+ /**
45
+ * ConfigService — fetches entity configs from the dynamic-entity-server API.
46
+ * Uses shareReplay(1) to cache per config key — avoids duplicate HTTP requests.
47
+ * Following ONEHERMES smart service pattern: Observable$ + shareReplay.
48
+ */
49
+ class ConfigService {
50
+ http = inject(HttpClient);
51
+ apiUrl = inject(DYNAMIC_ENTITY_API_URL, { optional: true }) ?? '';
52
+ cache = new Map();
53
+ /** Get a config by entity key. Cached via shareReplay(1). */
54
+ getConfig(entity) {
55
+ if (!this.cache.has(entity)) {
56
+ const config$ = this.http
57
+ .get(`${this.apiUrl}/config/${entity}`)
58
+ .pipe(map(res => res.data), // ADR-004: Success guarantees data
59
+ shareReplay(1));
60
+ this.cache.set(entity, config$);
61
+ }
62
+ return this.cache.get(entity);
63
+ }
64
+ /** List all configs */
65
+ listConfigs() {
66
+ return this.http
67
+ .get(`${this.apiUrl}/config`)
68
+ .pipe(map(res => res.data)); // ADR-004: Success guarantees data
69
+ }
70
+ /** Create a new config */
71
+ saveConfig(config) {
72
+ return this.http
73
+ .post(`${this.apiUrl}/config`, config)
74
+ .pipe(map(res => res.data));
75
+ }
76
+ /** Update an existing config */
77
+ updateConfig(entity, updates) {
78
+ return this.http
79
+ .put(`${this.apiUrl}/config/${entity}`, updates)
80
+ .pipe(map(res => {
81
+ this.invalidate(entity);
82
+ return res.data;
83
+ }));
84
+ }
85
+ /** Invalidate cache for an entity (call after config update) */
86
+ invalidate(entity) {
87
+ this.cache.delete(entity);
88
+ }
89
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
90
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConfigService, providedIn: 'root' });
91
+ }
92
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConfigService, decorators: [{
93
+ type: Injectable,
94
+ args: [{ providedIn: 'root' }]
95
+ }] });
96
+
97
+ /** ADR-008: Shared field component contract — all 8 built-in types implement this. */
98
+ class TextFieldComponent {
99
+ field;
100
+ control;
101
+ language = 'en';
102
+ readonly = false;
103
+ masked = false;
104
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
105
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TextFieldComponent, isStandalone: true, selector: "ngx-text-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
106
+ <div class="ngx-field ngx-field--text" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
107
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
108
+ @if (masked) {
109
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
110
+ } @else if (readonly) {
111
+ <span class="ngx-field__value">{{ control.value }}</span>
112
+ } @else {
113
+ <input
114
+ class="ngx-field__input"
115
+ type="text"
116
+ [formControl]="$any(control)"
117
+ [placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
118
+ [attr.disabled]="field.disabled ? true : null"
119
+ />
120
+ @if (control.invalid && control.touched) {
121
+ <span class="ngx-field__error">This field has an error</span>
122
+ }
123
+ }
124
+ </div>
125
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
126
+ }
127
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, decorators: [{
128
+ type: Component,
129
+ args: [{
130
+ selector: 'ngx-text-field',
131
+ standalone: true,
132
+ imports: [ReactiveFormsModule],
133
+ template: `
134
+ <div class="ngx-field ngx-field--text" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
135
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
136
+ @if (masked) {
137
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
138
+ } @else if (readonly) {
139
+ <span class="ngx-field__value">{{ control.value }}</span>
140
+ } @else {
141
+ <input
142
+ class="ngx-field__input"
143
+ type="text"
144
+ [formControl]="$any(control)"
145
+ [placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
146
+ [attr.disabled]="field.disabled ? true : null"
147
+ />
148
+ @if (control.invalid && control.touched) {
149
+ <span class="ngx-field__error">This field has an error</span>
150
+ }
151
+ }
152
+ </div>
153
+ `,
154
+ }]
155
+ }], propDecorators: { field: [{
156
+ type: Input
157
+ }], control: [{
158
+ type: Input
159
+ }], language: [{
160
+ type: Input
161
+ }], readonly: [{
162
+ type: Input
163
+ }], masked: [{
164
+ type: Input
165
+ }] } });
166
+
167
+ class TextareaFieldComponent {
168
+ field;
169
+ control;
170
+ language = 'en';
171
+ readonly = false;
172
+ masked = false;
173
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextareaFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
174
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TextareaFieldComponent, isStandalone: true, selector: "ngx-textarea-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
175
+ <div class="ngx-field ngx-field--textarea" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
176
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
177
+ @if (masked) {
178
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
179
+ } @else if (readonly) {
180
+ <span class="ngx-field__value">{{ control.value }}</span>
181
+ } @else {
182
+ <textarea
183
+ class="ngx-field__input"
184
+ [formControl]="$any(control)"
185
+ [placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
186
+ [attr.disabled]="field.disabled ? true : null"
187
+ rows="3"
188
+ ></textarea>
189
+ @if (control.invalid && control.touched) {
190
+ <span class="ngx-field__error">This field has an error</span>
191
+ }
192
+ }
193
+ </div>
194
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
195
+ }
196
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextareaFieldComponent, decorators: [{
197
+ type: Component,
198
+ args: [{
199
+ selector: 'ngx-textarea-field',
200
+ standalone: true,
201
+ imports: [ReactiveFormsModule],
202
+ template: `
203
+ <div class="ngx-field ngx-field--textarea" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
204
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
205
+ @if (masked) {
206
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
207
+ } @else if (readonly) {
208
+ <span class="ngx-field__value">{{ control.value }}</span>
209
+ } @else {
210
+ <textarea
211
+ class="ngx-field__input"
212
+ [formControl]="$any(control)"
213
+ [placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
214
+ [attr.disabled]="field.disabled ? true : null"
215
+ rows="3"
216
+ ></textarea>
217
+ @if (control.invalid && control.touched) {
218
+ <span class="ngx-field__error">This field has an error</span>
219
+ }
220
+ }
221
+ </div>
222
+ `,
223
+ }]
224
+ }], propDecorators: { field: [{
225
+ type: Input
226
+ }], control: [{
227
+ type: Input
228
+ }], language: [{
229
+ type: Input
230
+ }], readonly: [{
231
+ type: Input
232
+ }], masked: [{
233
+ type: Input
234
+ }] } });
235
+
236
+ class NumberFieldComponent {
237
+ field;
238
+ control;
239
+ language = 'en';
240
+ readonly = false;
241
+ masked = false;
242
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
243
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: NumberFieldComponent, isStandalone: true, selector: "ngx-number-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
244
+ <div class="ngx-field ngx-field--number" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
245
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
246
+ @if (masked) {
247
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
248
+ } @else if (readonly) {
249
+ <span class="ngx-field__value">{{ control.value }}</span>
250
+ } @else {
251
+ <input
252
+ class="ngx-field__input"
253
+ type="number"
254
+ [formControl]="$any(control)"
255
+ [placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
256
+ [attr.disabled]="field.disabled ? true : null"
257
+ />
258
+ @if (control.invalid && control.touched) {
259
+ <span class="ngx-field__error">This field has an error</span>
260
+ }
261
+ }
262
+ </div>
263
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
264
+ }
265
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, decorators: [{
266
+ type: Component,
267
+ args: [{
268
+ selector: 'ngx-number-field',
269
+ standalone: true,
270
+ imports: [ReactiveFormsModule],
271
+ template: `
272
+ <div class="ngx-field ngx-field--number" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
273
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
274
+ @if (masked) {
275
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
276
+ } @else if (readonly) {
277
+ <span class="ngx-field__value">{{ control.value }}</span>
278
+ } @else {
279
+ <input
280
+ class="ngx-field__input"
281
+ type="number"
282
+ [formControl]="$any(control)"
283
+ [placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
284
+ [attr.disabled]="field.disabled ? true : null"
285
+ />
286
+ @if (control.invalid && control.touched) {
287
+ <span class="ngx-field__error">This field has an error</span>
288
+ }
289
+ }
290
+ </div>
291
+ `,
292
+ }]
293
+ }], propDecorators: { field: [{
294
+ type: Input
295
+ }], control: [{
296
+ type: Input
297
+ }], language: [{
298
+ type: Input
299
+ }], readonly: [{
300
+ type: Input
301
+ }], masked: [{
302
+ type: Input
303
+ }] } });
304
+
305
+ class CheckboxFieldComponent {
306
+ field;
307
+ control;
308
+ language = 'en';
309
+ readonly = false;
310
+ masked = false;
311
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CheckboxFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
312
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: CheckboxFieldComponent, isStandalone: true, selector: "ngx-checkbox-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
313
+ <div class="ngx-field ngx-field--checkbox" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
314
+ @if (masked) {
315
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
316
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
317
+ } @else if (readonly) {
318
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
319
+ <span class="ngx-field__value">{{ control.value ? 'Yes' : 'No' }}</span>
320
+ } @else {
321
+ <label class="ngx-field__label">
322
+ <input
323
+ class="ngx-field__input"
324
+ type="checkbox"
325
+ [formControl]="$any(control)"
326
+ [attr.disabled]="field.disabled ? true : null"
327
+ />
328
+ {{ field.label[language] || field.label['en'] }}
329
+ </label>
330
+ }
331
+ </div>
332
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
333
+ }
334
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CheckboxFieldComponent, decorators: [{
335
+ type: Component,
336
+ args: [{
337
+ selector: 'ngx-checkbox-field',
338
+ standalone: true,
339
+ imports: [ReactiveFormsModule],
340
+ template: `
341
+ <div class="ngx-field ngx-field--checkbox" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
342
+ @if (masked) {
343
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
344
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
345
+ } @else if (readonly) {
346
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
347
+ <span class="ngx-field__value">{{ control.value ? 'Yes' : 'No' }}</span>
348
+ } @else {
349
+ <label class="ngx-field__label">
350
+ <input
351
+ class="ngx-field__input"
352
+ type="checkbox"
353
+ [formControl]="$any(control)"
354
+ [attr.disabled]="field.disabled ? true : null"
355
+ />
356
+ {{ field.label[language] || field.label['en'] }}
357
+ </label>
358
+ }
359
+ </div>
360
+ `,
361
+ }]
362
+ }], propDecorators: { field: [{
363
+ type: Input
364
+ }], control: [{
365
+ type: Input
366
+ }], language: [{
367
+ type: Input
368
+ }], readonly: [{
369
+ type: Input
370
+ }], masked: [{
371
+ type: Input
372
+ }] } });
373
+
374
+ class DateFieldComponent {
375
+ field;
376
+ control;
377
+ language = 'en';
378
+ readonly = false;
379
+ masked = false;
380
+ /** ISO 8601 date → locale display string (ONEHERMES convention: always UTC, display in user locale) */
381
+ formatDate(value) {
382
+ if (!value)
383
+ return '—';
384
+ try {
385
+ return new Date(value).toLocaleDateString();
386
+ }
387
+ catch {
388
+ return value;
389
+ }
390
+ }
391
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
392
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DateFieldComponent, isStandalone: true, selector: "ngx-date-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
393
+ <div class="ngx-field ngx-field--date" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
394
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
395
+ @if (masked) {
396
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
397
+ } @else if (readonly) {
398
+ <span class="ngx-field__value">{{ formatDate(control.value) }}</span>
399
+ } @else {
400
+ <input
401
+ class="ngx-field__input"
402
+ type="date"
403
+ [formControl]="$any(control)"
404
+ [attr.disabled]="field.disabled ? true : null"
405
+ />
406
+ @if (control.invalid && control.touched) {
407
+ <span class="ngx-field__error">This field has an error</span>
408
+ }
409
+ }
410
+ </div>
411
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
412
+ }
413
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateFieldComponent, decorators: [{
414
+ type: Component,
415
+ args: [{
416
+ selector: 'ngx-date-field',
417
+ standalone: true,
418
+ imports: [ReactiveFormsModule],
419
+ template: `
420
+ <div class="ngx-field ngx-field--date" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
421
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
422
+ @if (masked) {
423
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
424
+ } @else if (readonly) {
425
+ <span class="ngx-field__value">{{ formatDate(control.value) }}</span>
426
+ } @else {
427
+ <input
428
+ class="ngx-field__input"
429
+ type="date"
430
+ [formControl]="$any(control)"
431
+ [attr.disabled]="field.disabled ? true : null"
432
+ />
433
+ @if (control.invalid && control.touched) {
434
+ <span class="ngx-field__error">This field has an error</span>
435
+ }
436
+ }
437
+ </div>
438
+ `,
439
+ }]
440
+ }], propDecorators: { field: [{
441
+ type: Input
442
+ }], control: [{
443
+ type: Input
444
+ }], language: [{
445
+ type: Input
446
+ }], readonly: [{
447
+ type: Input
448
+ }], masked: [{
449
+ type: Input
450
+ }] } });
451
+
452
+ class DropdownFieldComponent {
453
+ field;
454
+ control;
455
+ language = 'en';
456
+ readonly = false;
457
+ masked = false;
458
+ getLabel(value) {
459
+ const option = (this.field.options || []).find(o => o.value === value);
460
+ return option ? (option.label[this.language] || option.label['en'] || String(value)) : (value ?? '—');
461
+ }
462
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
463
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DropdownFieldComponent, isStandalone: true, selector: "ngx-dropdown-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
464
+ <div class="ngx-field ngx-field--dropdown" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
465
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
466
+ @if (masked) {
467
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
468
+ } @else if (readonly) {
469
+ <span class="ngx-field__value">{{ getLabel(control.value) }}</span>
470
+ } @else {
471
+ <select
472
+ class="ngx-field__input"
473
+ [formControl]="$any(control)"
474
+ [attr.disabled]="field.disabled ? true : null"
475
+ >
476
+ <option value="">{{ field.placeholder?.[language] || field.placeholder?.['en'] || 'Select...' }}</option>
477
+ @for (option of field.options || []; track option.value) {
478
+ <option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
479
+ }
480
+ </select>
481
+ @if (control.invalid && control.touched) {
482
+ <span class="ngx-field__error">This field has an error</span>
483
+ }
484
+ }
485
+ </div>
486
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
487
+ }
488
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, decorators: [{
489
+ type: Component,
490
+ args: [{
491
+ selector: 'ngx-dropdown-field',
492
+ standalone: true,
493
+ imports: [ReactiveFormsModule],
494
+ template: `
495
+ <div class="ngx-field ngx-field--dropdown" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
496
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
497
+ @if (masked) {
498
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
499
+ } @else if (readonly) {
500
+ <span class="ngx-field__value">{{ getLabel(control.value) }}</span>
501
+ } @else {
502
+ <select
503
+ class="ngx-field__input"
504
+ [formControl]="$any(control)"
505
+ [attr.disabled]="field.disabled ? true : null"
506
+ >
507
+ <option value="">{{ field.placeholder?.[language] || field.placeholder?.['en'] || 'Select...' }}</option>
508
+ @for (option of field.options || []; track option.value) {
509
+ <option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
510
+ }
511
+ </select>
512
+ @if (control.invalid && control.touched) {
513
+ <span class="ngx-field__error">This field has an error</span>
514
+ }
515
+ }
516
+ </div>
517
+ `,
518
+ }]
519
+ }], propDecorators: { field: [{
520
+ type: Input
521
+ }], control: [{
522
+ type: Input
523
+ }], language: [{
524
+ type: Input
525
+ }], readonly: [{
526
+ type: Input
527
+ }], masked: [{
528
+ type: Input
529
+ }] } });
530
+
531
+ class MultiSelectFieldComponent {
532
+ field;
533
+ control;
534
+ language = 'en';
535
+ readonly = false;
536
+ masked = false;
537
+ getLabels(values) {
538
+ if (!Array.isArray(values) || !values.length)
539
+ return '—';
540
+ return values
541
+ .map(v => {
542
+ const opt = (this.field.options || []).find(o => o.value === v);
543
+ return opt ? (opt.label[this.language] || opt.label['en']) : String(v);
544
+ })
545
+ .join(', ');
546
+ }
547
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MultiSelectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
548
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: MultiSelectFieldComponent, isStandalone: true, selector: "ngx-multi-select-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
549
+ <div class="ngx-field ngx-field--multiSelect" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
550
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
551
+ @if (masked) {
552
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
553
+ } @else if (readonly) {
554
+ <span class="ngx-field__value">{{ getLabels(control.value) }}</span>
555
+ } @else {
556
+ <select
557
+ class="ngx-field__input"
558
+ [formControl]="$any(control)"
559
+ [attr.disabled]="field.disabled ? true : null"
560
+ multiple
561
+ size="4"
562
+ >
563
+ @for (option of field.options || []; track option.value) {
564
+ <option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
565
+ }
566
+ </select>
567
+ @if (control.invalid && control.touched) {
568
+ <span class="ngx-field__error">This field has an error</span>
569
+ }
570
+ }
571
+ </div>
572
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
573
+ }
574
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MultiSelectFieldComponent, decorators: [{
575
+ type: Component,
576
+ args: [{
577
+ selector: 'ngx-multi-select-field',
578
+ standalone: true,
579
+ imports: [ReactiveFormsModule],
580
+ template: `
581
+ <div class="ngx-field ngx-field--multiSelect" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
582
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
583
+ @if (masked) {
584
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
585
+ } @else if (readonly) {
586
+ <span class="ngx-field__value">{{ getLabels(control.value) }}</span>
587
+ } @else {
588
+ <select
589
+ class="ngx-field__input"
590
+ [formControl]="$any(control)"
591
+ [attr.disabled]="field.disabled ? true : null"
592
+ multiple
593
+ size="4"
594
+ >
595
+ @for (option of field.options || []; track option.value) {
596
+ <option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
597
+ }
598
+ </select>
599
+ @if (control.invalid && control.touched) {
600
+ <span class="ngx-field__error">This field has an error</span>
601
+ }
602
+ }
603
+ </div>
604
+ `,
605
+ }]
606
+ }], propDecorators: { field: [{
607
+ type: Input
608
+ }], control: [{
609
+ type: Input
610
+ }], language: [{
611
+ type: Input
612
+ }], readonly: [{
613
+ type: Input
614
+ }], masked: [{
615
+ type: Input
616
+ }] } });
617
+
618
+ /**
619
+ * EntityRefRegistryService — resolves entity-ref option loader functions.
620
+ * ADR-006: Entity-ref options come ONLY from this registry.
621
+ * Never pass loaders via @Input() on field components.
622
+ *
623
+ * Consumers register loaders via provideNgxDynamicEntity({ entityRefs: { clients: () => [...] } }).
624
+ * EntityRefFieldComponent injects this service and calls resolve(field.component ?? field.id).
625
+ */
626
+ class EntityRefRegistryService {
627
+ registry = inject(ENTITY_REF_REGISTRY, { optional: true }) ?? new Map();
628
+ /**
629
+ * Get the option loader function for an entity key.
630
+ * @returns Async function that returns options array, or null if not registered.
631
+ */
632
+ resolve(entityKey) {
633
+ return this.registry.get(entityKey) ?? null;
634
+ }
635
+ has(entityKey) {
636
+ return this.registry.has(entityKey);
637
+ }
638
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
639
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefRegistryService, providedIn: 'root' });
640
+ }
641
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefRegistryService, decorators: [{
642
+ type: Injectable,
643
+ args: [{ providedIn: 'root' }]
644
+ }] });
645
+
646
+ /**
647
+ * EntityRefFieldComponent — renders a dropdown populated by a loader from EntityRefRegistryService.
648
+ * ADR-006: Options come ONLY from the registry. Never via @Input() loader.
649
+ * Uses field.component (if set) or field.id as the registry lookup key.
650
+ */
651
+ class EntityRefFieldComponent {
652
+ field;
653
+ control;
654
+ language = 'en';
655
+ readonly = false;
656
+ masked = false;
657
+ entityRefRegistry = inject(EntityRefRegistryService);
658
+ options = signal([]);
659
+ loading = signal(false);
660
+ async ngOnInit() {
661
+ if (this.masked)
662
+ return; // No need to load options if masked
663
+ const entityKey = this.field.component ?? this.field.id;
664
+ const loader = this.entityRefRegistry.resolve(entityKey);
665
+ if (!loader)
666
+ return;
667
+ this.loading.set(true);
668
+ try {
669
+ const raw = await loader();
670
+ this.options.set(raw);
671
+ }
672
+ finally {
673
+ this.loading.set(false);
674
+ }
675
+ }
676
+ getLabel(value) {
677
+ const option = this.options().find(o => o.value === value);
678
+ return option?.label ?? (value ?? '—');
679
+ }
680
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
681
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: EntityRefFieldComponent, isStandalone: true, selector: "ngx-entity-ref-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
682
+ <div class="ngx-field ngx-field--entity-ref" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
683
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
684
+ @if (masked) {
685
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
686
+ } @else if (readonly) {
687
+ <span class="ngx-field__value">{{ getLabel(control.value) }}</span>
688
+ } @else {
689
+ @if (loading()) {
690
+ <span class="ngx-field__value">Loading...</span>
691
+ } @else {
692
+ <select
693
+ class="ngx-field__input"
694
+ [formControl]="$any(control)"
695
+ [attr.disabled]="field.disabled ? true : null"
696
+ >
697
+ <option value="">{{ field.placeholder?.[language] || 'Select...' }}</option>
698
+ @for (option of options(); track option.value) {
699
+ <option [value]="option.value">{{ option.label }}</option>
700
+ }
701
+ </select>
702
+ }
703
+ @if (control.invalid && control.touched) {
704
+ <span class="ngx-field__error">This field has an error</span>
705
+ }
706
+ }
707
+ </div>
708
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
709
+ }
710
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefFieldComponent, decorators: [{
711
+ type: Component,
712
+ args: [{
713
+ selector: 'ngx-entity-ref-field',
714
+ standalone: true,
715
+ imports: [ReactiveFormsModule],
716
+ template: `
717
+ <div class="ngx-field ngx-field--entity-ref" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
718
+ <label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
719
+ @if (masked) {
720
+ <span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
721
+ } @else if (readonly) {
722
+ <span class="ngx-field__value">{{ getLabel(control.value) }}</span>
723
+ } @else {
724
+ @if (loading()) {
725
+ <span class="ngx-field__value">Loading...</span>
726
+ } @else {
727
+ <select
728
+ class="ngx-field__input"
729
+ [formControl]="$any(control)"
730
+ [attr.disabled]="field.disabled ? true : null"
731
+ >
732
+ <option value="">{{ field.placeholder?.[language] || 'Select...' }}</option>
733
+ @for (option of options(); track option.value) {
734
+ <option [value]="option.value">{{ option.label }}</option>
735
+ }
736
+ </select>
737
+ }
738
+ @if (control.invalid && control.touched) {
739
+ <span class="ngx-field__error">This field has an error</span>
740
+ }
741
+ }
742
+ </div>
743
+ `,
744
+ }]
745
+ }], propDecorators: { field: [{
746
+ type: Input
747
+ }], control: [{
748
+ type: Input
749
+ }], language: [{
750
+ type: Input
751
+ }], readonly: [{
752
+ type: Input
753
+ }], masked: [{
754
+ type: Input
755
+ }] } });
756
+
757
+ /**
758
+ * FieldRegistryService — resolves field type strings to Angular component classes.
759
+ * Open/Closed: open for extension via consumer fieldTypes config, closed for modification.
760
+ * DynamicFieldComponent uses this to mount the right component via createComponent().
761
+ */
762
+ class FieldRegistryService {
763
+ consumerRegistry = inject(FIELD_TYPE_REGISTRY, { optional: true }) ?? new Map();
764
+ /** Built-in field type → component map. Never remove an entry. */
765
+ builtInRegistry = new Map([
766
+ ['text', TextFieldComponent],
767
+ ['textarea', TextareaFieldComponent],
768
+ ['number', NumberFieldComponent],
769
+ ['checkbox', CheckboxFieldComponent],
770
+ ['date', DateFieldComponent],
771
+ ['dropdown', DropdownFieldComponent],
772
+ ['multiSelect', MultiSelectFieldComponent],
773
+ ['entity-ref', EntityRefFieldComponent],
774
+ ]);
775
+ /**
776
+ * Resolve a field type string to a component class.
777
+ * Consumer registry takes precedence over built-ins (open for extension).
778
+ * Returns null if type is unknown — caller must handle gracefully.
779
+ */
780
+ resolve(fieldType) {
781
+ return this.consumerRegistry.get(fieldType) ?? this.builtInRegistry.get(fieldType) ?? null;
782
+ }
783
+ /** Check if a field type has a registered component */
784
+ has(fieldType) {
785
+ return this.consumerRegistry.has(fieldType) || this.builtInRegistry.has(fieldType);
786
+ }
787
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
788
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRegistryService, providedIn: 'root' });
789
+ }
790
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRegistryService, decorators: [{
791
+ type: Injectable,
792
+ args: [{ providedIn: 'root' }]
793
+ }] });
794
+
795
+ /**
796
+ * HookRegistryService — stores and runs Angular-side pre/post hooks.
797
+ * Consumers register hooks via provideNgxDynamicEntity({ hooks: { 'clients:beforeSave': fn } }).
798
+ */
799
+ class HookRegistryService {
800
+ registry = inject(HOOK_REGISTRY, { optional: true }) ?? new Map();
801
+ has(key) {
802
+ return this.registry.has(key);
803
+ }
804
+ async run(key, data, context = {}) {
805
+ const hook = this.registry.get(key);
806
+ if (!hook)
807
+ return data;
808
+ return hook(data, context);
809
+ }
810
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
811
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookRegistryService, providedIn: 'root' });
812
+ }
813
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookRegistryService, decorators: [{
814
+ type: Injectable,
815
+ args: [{ providedIn: 'root' }]
816
+ }] });
817
+
818
+ /**
819
+ * ValidatorRegistryService — resolves validator key strings to Angular ValidatorFns.
820
+ * Built-in validators: 'required', 'email', 'min:N', 'max:N', 'minLength:N', 'maxLength:N'.
821
+ * Consumers can register custom validators via provideNgxDynamicEntity({ validators: {...} }).
822
+ */
823
+ class ValidatorRegistryService {
824
+ consumerRegistry = inject(VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
825
+ /**
826
+ * Resolve a validator key to a ValidatorFn.
827
+ * Supports parameterized built-ins like 'min:0', 'maxLength:255'.
828
+ */
829
+ resolve(validatorKey) {
830
+ // Consumer validators take precedence
831
+ if (this.consumerRegistry.has(validatorKey)) {
832
+ return this.consumerRegistry.get(validatorKey);
833
+ }
834
+ // Built-in validators
835
+ if (validatorKey === 'required')
836
+ return Validators.required;
837
+ if (validatorKey === 'email')
838
+ return Validators.email;
839
+ const [name, param] = validatorKey.split(':');
840
+ const value = parseFloat(param);
841
+ if (name === 'min' && !isNaN(value))
842
+ return Validators.min(value);
843
+ if (name === 'max' && !isNaN(value))
844
+ return Validators.max(value);
845
+ if (name === 'minLength' && !isNaN(value))
846
+ return Validators.minLength(value);
847
+ if (name === 'maxLength' && !isNaN(value))
848
+ return Validators.maxLength(value);
849
+ return null;
850
+ }
851
+ /** Resolve multiple validator keys into a ValidatorFn[] */
852
+ resolveAll(validatorKeys = []) {
853
+ return validatorKeys.map(k => this.resolve(k)).filter((v) => v !== null);
854
+ }
855
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
856
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
857
+ }
858
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, decorators: [{
859
+ type: Injectable,
860
+ args: [{ providedIn: 'root' }]
861
+ }] });
862
+
863
+ /**
864
+ * VersionService — checks config version staleness and migration strategy.
865
+ * ADR-005: Frontend strategy is 'strict' | 'graceful' only. Never 'auto'.
866
+ */
867
+ class VersionService {
868
+ strategy = inject(MIGRATION_STRATEGY, { optional: true }) ?? 'graceful';
869
+ /**
870
+ * Check if a record is stale compared to the current config version.
871
+ * Returns true if the record needs migration.
872
+ */
873
+ needsMigration(record, config) {
874
+ if (!record._configVersion)
875
+ return false;
876
+ return record._configVersion < config.version || !!record._needsMigration;
877
+ }
878
+ /**
879
+ * Get the effective migration strategy for frontend use.
880
+ * Only 'strict' or 'graceful' — never 'auto' (ADR-005).
881
+ */
882
+ getStrategy() {
883
+ return this.strategy === 'strict' ? 'strict' : 'graceful';
884
+ }
885
+ /**
886
+ * Determine if form submission should be blocked for a stale record.
887
+ * 'strict' → block; 'graceful' → allow with warning.
888
+ */
889
+ shouldBlockSubmit(record, config) {
890
+ return this.getStrategy() === 'strict' && this.needsMigration(record, config);
891
+ }
892
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VersionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
893
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VersionService, providedIn: 'root' });
894
+ }
895
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VersionService, decorators: [{
896
+ type: Injectable,
897
+ args: [{ providedIn: 'root' }]
898
+ }] });
899
+
900
+ /**
901
+ * RbacService — ALL permission checks and masking logic live here.
902
+ * CRITICAL SYNC: resolveEffectiveMask() must be identical to rbac.utils.js in dynamic-entity-server.
903
+ * Changing one without the other is a critical production bug (ADR-003).
904
+ */
905
+ class RbacService {
906
+ maskedRoles = inject(MASKED_ROLES, { optional: true }) ?? [];
907
+ /**
908
+ * Check if any of the user's roles satisfy the required roles.
909
+ * hasPermission(roles, undefined) → true (no restriction).
910
+ */
911
+ hasPermission(userRoles, requiredRoles) {
912
+ if (!requiredRoles?.length)
913
+ return true;
914
+ return userRoles.some(r => requiredRoles.includes(r));
915
+ }
916
+ /**
917
+ * ADR-003: 3-level OR resolution.
918
+ * MUST match rbac.utils.js resolveEffectiveMask() exactly.
919
+ */
920
+ resolveEffectiveMask(formMask, tabMask, fieldMask) {
921
+ return !!(formMask || tabMask || fieldMask);
922
+ }
923
+ /** Check if the user's roles include any masked role */
924
+ isUserMaskedRole(userRoles) {
925
+ return this.maskedRoles.some(r => userRoles.includes(r));
926
+ }
927
+ /** Determine if a specific field should be masked for this user */
928
+ shouldMaskField(field, tab, config, userRoles) {
929
+ if (!this.isUserMaskedRole(userRoles))
930
+ return false;
931
+ return this.resolveEffectiveMask(config.maskData, tab?.maskData, field.maskData);
932
+ }
933
+ /** Get view/edit/delete permissions for a config */
934
+ getPermissions(config, userRoles) {
935
+ return {
936
+ canView: this.hasPermission(userRoles, config.permissions?.view),
937
+ canEdit: this.hasPermission(userRoles, config.permissions?.edit),
938
+ canDelete: this.hasPermission(userRoles, config.permissions?.delete),
939
+ };
940
+ }
941
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RbacService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
942
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RbacService, providedIn: 'root' });
943
+ }
944
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RbacService, decorators: [{
945
+ type: Injectable,
946
+ args: [{ providedIn: 'root' }]
947
+ }] });
948
+
949
+ /**
950
+ * DynamicFieldComponent — mounts the correct field component for a given FieldConfig.
951
+ * Uses ViewContainerRef.createComponent() and passes inputs via setInput() (ADR-008).
952
+ * Never add field-type-specific logic here — this component must remain generic.
953
+ *
954
+ * The 5 inputs (field, control, language, readonly, masked) are passed to every mounted
955
+ * component uniformly — no special-casing per type.
956
+ */
957
+ class DynamicFieldComponent {
958
+ field;
959
+ control;
960
+ config;
961
+ language = 'en';
962
+ readonly = false;
963
+ userRoles = [];
964
+ fieldHost;
965
+ fieldRegistry = inject(FieldRegistryService);
966
+ rbacService = inject(RbacService);
967
+ mountedComponent = null;
968
+ ngOnChanges(_changes) {
969
+ this.mountField();
970
+ }
971
+ mountField() {
972
+ if (!this.field || !this.control)
973
+ return;
974
+ const ComponentClass = this.fieldRegistry.resolve(this.field.type);
975
+ if (!ComponentClass)
976
+ return; // Unknown field type — render nothing
977
+ // Determine masking for this specific field
978
+ const tab = (this.config?.tabs || []).find(t => t.id === this.field.tab);
979
+ const masked = this.rbacService.shouldMaskField(this.field, tab, this.config, this.userRoles);
980
+ // Re-use existing component if same type, otherwise recreate
981
+ if (this.mountedComponent && this.mountedComponent instanceof ComponentClass) {
982
+ this.setInputs(this.mountedComponent, masked);
983
+ return;
984
+ }
985
+ this.fieldHost.clear();
986
+ const ref = this.fieldHost.createComponent(ComponentClass);
987
+ this.mountedComponent = ref.instance;
988
+ this.setInputs(ref.instance, masked);
989
+ ref.changeDetectorRef.markForCheck();
990
+ }
991
+ /** Pass all 5 contract inputs via setInput() — uniform for all types (ADR-008) */
992
+ setInputs(instance, masked) {
993
+ if ('field' in instance)
994
+ instance.field = this.field;
995
+ if ('control' in instance)
996
+ instance.control = this.control;
997
+ if ('language' in instance)
998
+ instance.language = this.language;
999
+ if ('readonly' in instance)
1000
+ instance.readonly = this.readonly || !!this.field.readonly;
1001
+ if ('masked' in instance)
1002
+ instance.masked = masked;
1003
+ }
1004
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1005
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true });
1006
+ }
1007
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, decorators: [{
1008
+ type: Component,
1009
+ args: [{
1010
+ selector: 'ngx-dynamic-field',
1011
+ standalone: true,
1012
+ template: `<ng-container #fieldHost></ng-container>`,
1013
+ }]
1014
+ }], propDecorators: { field: [{
1015
+ type: Input
1016
+ }], control: [{
1017
+ type: Input
1018
+ }], config: [{
1019
+ type: Input
1020
+ }], language: [{
1021
+ type: Input
1022
+ }], readonly: [{
1023
+ type: Input
1024
+ }], userRoles: [{
1025
+ type: Input
1026
+ }], fieldHost: [{
1027
+ type: ViewChild,
1028
+ args: ['fieldHost', { read: ViewContainerRef, static: true }]
1029
+ }] } });
1030
+
1031
+ /**
1032
+ * DynamicFormComponent — the main form component.
1033
+ * Renders a reactive form from EntityConfig with tab support, dependencies,
1034
+ * migration banner, and RBAC-gated submission.
1035
+ *
1036
+ * ONEHERMES pattern: isSaving state lock prevents subscription cyclones (ADR-002).
1037
+ * ONEHERMES bug fix: visibility rules re-evaluated after initialData loads (form-fixes doc).
1038
+ * ONEHERMES bug fix: use @for + @if separately, not both on same element (form-fixes doc).
1039
+ */
1040
+ class DynamicFormComponent {
1041
+ // ─── Inputs ───────────────────────────────────────────────────────────────
1042
+ config;
1043
+ initialData;
1044
+ userRoles = [];
1045
+ language = 'en';
1046
+ readonly = false;
1047
+ loading = false;
1048
+ error = null;
1049
+ // ─── Outputs ──────────────────────────────────────────────────────────────
1050
+ formSubmit = new EventEmitter();
1051
+ formChange = new EventEmitter();
1052
+ formReset = new EventEmitter();
1053
+ // ─── Services ─────────────────────────────────────────────────────────────
1054
+ fb = inject(FormBuilder);
1055
+ validatorRegistry = inject(ValidatorRegistryService);
1056
+ hookRegistry = inject(HookRegistryService);
1057
+ versionService = inject(VersionService);
1058
+ rbacService = inject(RbacService);
1059
+ // ─── Signals (local reactive state) ───────────────────────────────────────
1060
+ activeTab = signal('');
1061
+ isSaving = signal(false); // ADR-002: isSaving lock prevents API spam
1062
+ // ─── Form ─────────────────────────────────────────────────────────────────
1063
+ form;
1064
+ // ─── Computed ─────────────────────────────────────────────────────────────
1065
+ get tabs() {
1066
+ return (this.config?.tabs || []).sort((a, b) => a.order - b.order);
1067
+ }
1068
+ get fieldsForActiveTab() {
1069
+ const tabId = this.activeTab();
1070
+ return (this.config?.fields || []).filter(f => {
1071
+ if (!this.tabs.length)
1072
+ return true; // No tabs — show all
1073
+ return f.tab === tabId || (!f.tab && tabId === this.tabs[0]?.id);
1074
+ });
1075
+ }
1076
+ get permissions() {
1077
+ return this.rbacService.getPermissions(this.config, this.userRoles);
1078
+ }
1079
+ get needsMigration() {
1080
+ return this.versionService.needsMigration(this.initialData || {}, this.config);
1081
+ }
1082
+ get canSubmit() {
1083
+ return (this.permissions.canEdit &&
1084
+ !this.readonly &&
1085
+ !this.isSaving() &&
1086
+ !this.versionService.shouldBlockSubmit(this.initialData || {}, this.config));
1087
+ }
1088
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
1089
+ ngOnChanges(changes) {
1090
+ if (changes['config'] && this.config) {
1091
+ this.buildForm();
1092
+ }
1093
+ // ONEHERMES fix: re-apply data after initialData loads (form-fixes doc)
1094
+ if (changes['initialData'] && this.form && this.initialData) {
1095
+ this.patchForm(this.initialData);
1096
+ }
1097
+ if (changes['config'] && this.tabs.length) {
1098
+ this.activeTab.set(this.tabs[0]?.id || '');
1099
+ }
1100
+ }
1101
+ buildForm() {
1102
+ const controls = {};
1103
+ for (const field of this.config.fields || []) {
1104
+ if (field.isSystem)
1105
+ continue; // System fields not in form controls
1106
+ const validators = this.validatorRegistry.resolveAll(field.validators);
1107
+ controls[field.id] = [field.defaultValue ?? null, validators];
1108
+ }
1109
+ this.form = this.fb.group(controls);
1110
+ // Emit changes via formChange output
1111
+ this.form.valueChanges.subscribe(value => this.formChange.emit(value));
1112
+ // Apply initial data after form builds
1113
+ if (this.initialData) {
1114
+ this.patchForm(this.initialData);
1115
+ }
1116
+ if (this.tabs.length && !this.activeTab()) {
1117
+ this.activeTab.set(this.tabs[0].id);
1118
+ }
1119
+ }
1120
+ patchForm(data) {
1121
+ // Filter empty objects (ONEHERMES array field empty row bug fix)
1122
+ const cleanData = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
1123
+ this.form.patchValue(cleanData, { emitEvent: false });
1124
+ }
1125
+ // ─── Actions ──────────────────────────────────────────────────────────────
1126
+ setActiveTab(tabId) {
1127
+ this.activeTab.set(tabId);
1128
+ }
1129
+ async submit() {
1130
+ if (!this.canSubmit || this.form.invalid) {
1131
+ this.form.markAllAsTouched();
1132
+ return;
1133
+ }
1134
+ // ADR-002: isSaving lock — prevents subscription cyclones / duplicate API calls
1135
+ if (this.isSaving())
1136
+ return;
1137
+ this.isSaving.set(true);
1138
+ try {
1139
+ let data = { ...this.form.value };
1140
+ // Run pre-submit hook (Angular-side)
1141
+ data = await this.hookRegistry.run(`${this.config.entity}:beforeSave`, data, {
1142
+ config: this.config,
1143
+ userRoles: this.userRoles,
1144
+ });
1145
+ this.formSubmit.emit(data);
1146
+ }
1147
+ finally {
1148
+ // Reset saving lock in the consumer's success/error handler — here we just unblock
1149
+ this.isSaving.set(false);
1150
+ }
1151
+ }
1152
+ reset() {
1153
+ this.form.reset();
1154
+ if (this.initialData)
1155
+ this.patchForm(this.initialData);
1156
+ this.formReset.emit();
1157
+ }
1158
+ getControl(fieldId) {
1159
+ return this.form?.get(fieldId);
1160
+ }
1161
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1162
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset" }, usesOnChanges: true, ngImport: i0, template: "<form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Migration banner \u2014 visible in graceful mode when record is stale -->\n @if (needsMigration) {\n <div class=\"ngx-form__migration-banner\" role=\"alert\">\n \u26A0\uFE0F This record was created with an older configuration version.\n @if (versionService.shouldBlockSubmit(initialData || {}, config)) {\n Please run migration before editing.\n } @else {\n It will be updated to the latest version on save.\n }\n </div>\n }\n\n <!-- Error message -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Tab bar \u2014 only if tabs defined -->\n @if (tabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\">\n <!-- ONEHERMES fix: @for and @if on separate elements, not same (form-fixes doc) -->\n @for (tab of tabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ tab.label[language] || tab.label['en'] }}\n </button>\n }\n </div>\n }\n\n <!-- Fields for active tab -->\n <div class=\"ngx-form__panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n @if (field.visible !== false) {\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"readonly || !!field.readonly\"\n [userRoles]=\"userRoles\"\n />\n }\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n [disabled]=\"!canSubmit || form.invalid\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n</form>\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "language", "readonly", "userRoles"] }] });
1163
+ }
1164
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, decorators: [{
1165
+ type: Component,
1166
+ args: [{ selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent], template: "<form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Migration banner \u2014 visible in graceful mode when record is stale -->\n @if (needsMigration) {\n <div class=\"ngx-form__migration-banner\" role=\"alert\">\n \u26A0\uFE0F This record was created with an older configuration version.\n @if (versionService.shouldBlockSubmit(initialData || {}, config)) {\n Please run migration before editing.\n } @else {\n It will be updated to the latest version on save.\n }\n </div>\n }\n\n <!-- Error message -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Tab bar \u2014 only if tabs defined -->\n @if (tabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\">\n <!-- ONEHERMES fix: @for and @if on separate elements, not same (form-fixes doc) -->\n @for (tab of tabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ tab.label[language] || tab.label['en'] }}\n </button>\n }\n </div>\n }\n\n <!-- Fields for active tab -->\n <div class=\"ngx-form__panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n @if (field.visible !== false) {\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"readonly || !!field.readonly\"\n [userRoles]=\"userRoles\"\n />\n }\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n [disabled]=\"!canSubmit || form.invalid\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n</form>\n" }]
1167
+ }], propDecorators: { config: [{
1168
+ type: Input
1169
+ }], initialData: [{
1170
+ type: Input
1171
+ }], userRoles: [{
1172
+ type: Input
1173
+ }], language: [{
1174
+ type: Input
1175
+ }], readonly: [{
1176
+ type: Input
1177
+ }], loading: [{
1178
+ type: Input
1179
+ }], error: [{
1180
+ type: Input
1181
+ }], formSubmit: [{
1182
+ type: Output
1183
+ }], formChange: [{
1184
+ type: Output
1185
+ }], formReset: [{
1186
+ type: Output
1187
+ }] } });
1188
+
1189
+ /**
1190
+ * DynamicTableComponent — renders entity records as a searchable, sortable, paginated table.
1191
+ * Columns determined by FieldConfig.tableColumn === true.
1192
+ * Cell masking applied via RbacService (defense-in-depth).
1193
+ * Pagination, sort, and search emit events — parent controls data fetching (smart/dumb pattern).
1194
+ */
1195
+ class DynamicTableComponent {
1196
+ // ─── Inputs ───────────────────────────────────────────────────────────────
1197
+ config;
1198
+ data = [];
1199
+ userRoles = [];
1200
+ language = 'en';
1201
+ loading = false;
1202
+ error = null;
1203
+ totalRecords = 0;
1204
+ pageSize = 20;
1205
+ currentPage = 1;
1206
+ // ─── Outputs ──────────────────────────────────────────────────────────────
1207
+ rowClick = new EventEmitter();
1208
+ rowEdit = new EventEmitter();
1209
+ rowDelete = new EventEmitter();
1210
+ pageChange = new EventEmitter();
1211
+ pageSizeChange = new EventEmitter();
1212
+ sortChange = new EventEmitter();
1213
+ searchChange = new EventEmitter();
1214
+ // ─── Services ─────────────────────────────────────────────────────────────
1215
+ rbacService = inject(RbacService);
1216
+ versionService = inject(VersionService);
1217
+ // ─── Signals ──────────────────────────────────────────────────────────────
1218
+ sortField = signal('');
1219
+ sortDir = signal('desc');
1220
+ searchTerm = signal('');
1221
+ // ─── Computed ─────────────────────────────────────────────────────────────
1222
+ tableColumns = computed(() => (this.config?.fields || []).filter(f => f.tableColumn === true));
1223
+ get permissions() {
1224
+ return this.rbacService.getPermissions(this.config, this.userRoles);
1225
+ }
1226
+ get totalPages() {
1227
+ return Math.ceil(this.totalRecords / this.pageSize) || 0;
1228
+ }
1229
+ ngOnChanges(_changes) {
1230
+ // Pre-resolve masking per column once (ONEHERMES hardening: pre-resolved at extract time)
1231
+ this._resolveColumnMasks();
1232
+ }
1233
+ columnMaskCache = new Map();
1234
+ _resolveColumnMasks() {
1235
+ if (!this.config)
1236
+ return;
1237
+ this.columnMaskCache.clear();
1238
+ for (const field of this.tableColumns()) {
1239
+ const tab = (this.config.tabs || []).find(t => t.id === field.tab);
1240
+ const masked = this.rbacService.shouldMaskField(field, tab, this.config, this.userRoles);
1241
+ this.columnMaskCache.set(field.id, masked);
1242
+ }
1243
+ }
1244
+ // ─── Helpers ──────────────────────────────────────────────────────────────
1245
+ isCellMasked(fieldId) {
1246
+ return this.columnMaskCache.get(fieldId) ?? false;
1247
+ }
1248
+ getCellValue(record, fieldId) {
1249
+ if (this.isCellMasked(fieldId))
1250
+ return 'XXXXXXXXX';
1251
+ const value = record[fieldId];
1252
+ if (value === null || value === undefined)
1253
+ return '—';
1254
+ if (typeof value === 'boolean')
1255
+ return value ? 'Yes' : 'No';
1256
+ if (value instanceof Date || (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/))) {
1257
+ try {
1258
+ return new Date(value).toLocaleDateString();
1259
+ }
1260
+ catch {
1261
+ return String(value);
1262
+ }
1263
+ }
1264
+ if (Array.isArray(value))
1265
+ return value.join(', ');
1266
+ return String(value);
1267
+ }
1268
+ needsMigration(record) {
1269
+ return this.versionService.needsMigration(record, this.config);
1270
+ }
1271
+ getColumnLabel(fieldId) {
1272
+ const field = (this.config?.fields || []).find(f => f.id === fieldId);
1273
+ return field?.label?.[this.language] || field?.label?.['en'] || fieldId;
1274
+ }
1275
+ // ─── Sort / Search / Pagination ───────────────────────────────────────────
1276
+ onSort(fieldId) {
1277
+ if (this.sortField() === fieldId) {
1278
+ const next = this.sortDir() === 'asc' ? 'desc' : 'asc';
1279
+ this.sortDir.set(next);
1280
+ }
1281
+ else {
1282
+ this.sortField.set(fieldId);
1283
+ this.sortDir.set('asc');
1284
+ }
1285
+ this.sortChange.emit({ field: this.sortField(), dir: this.sortDir() });
1286
+ }
1287
+ onSearch(event) {
1288
+ const value = event.target.value;
1289
+ this.searchTerm.set(value);
1290
+ this.searchChange.emit(value);
1291
+ }
1292
+ onPageChange(page) {
1293
+ if (page < 1 || page > this.totalPages)
1294
+ return;
1295
+ this.pageChange.emit(page);
1296
+ }
1297
+ onEdit(event, record) {
1298
+ event.stopPropagation();
1299
+ this.rowEdit.emit(record);
1300
+ }
1301
+ onDelete(event, record) {
1302
+ event.stopPropagation();
1303
+ this.rowDelete.emit(record);
1304
+ }
1305
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicTableComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1306
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicTableComponent, isStandalone: true, selector: "ngx-dynamic-table", inputs: { config: "config", data: "data", userRoles: "userRoles", language: "language", loading: "loading", error: "error", totalRecords: "totalRecords", pageSize: "pageSize", currentPage: "currentPage" }, outputs: { rowClick: "rowClick", rowEdit: "rowEdit", rowDelete: "rowDelete", pageChange: "pageChange", pageSizeChange: "pageSizeChange", sortChange: "sortChange", searchChange: "searchChange" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-table\">\n\n <!-- Search bar -->\n <div class=\"ngx-table__toolbar\">\n <input\n class=\"ngx-table__search\"\n type=\"search\"\n placeholder=\"Search...\"\n [value]=\"searchTerm()\"\n (input)=\"onSearch($event)\"\n aria-label=\"Search records\"\n />\n </div>\n\n <!-- Error -->\n @if (error) {\n <div class=\"ngx-table__error\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Table -->\n <table class=\"ngx-table__table\">\n <thead>\n <tr>\n @for (col of tableColumns(); track col.id) {\n <th\n class=\"ngx-table__th\"\n [style.width]=\"col.columnWidth || 'auto'\"\n (click)=\"onSort(col.id)\"\n [attr.aria-sort]=\"sortField() === col.id ? sortDir() : 'none'\"\n role=\"columnheader\"\n >\n {{ getColumnLabel(col.id) }}\n @if (sortField() === col.id) {\n <span class=\"ngx-table__sort-icon\">{{ sortDir() === 'asc' ? '\u2191' : '\u2193' }}</span>\n }\n </th>\n }\n <!-- Actions column -->\n @if (permissions.canEdit || permissions.canDelete) {\n <th class=\"ngx-table__th ngx-table__th--actions\">Actions</th>\n }\n </tr>\n </thead>\n <tbody>\n @if (loading) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__loading\">Loading\u2026</td>\n </tr>\n } @else if (!data || !data.length) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__empty\">No records found</td>\n </tr>\n } @else {\n @for (record of data; track record['_id']) {\n <tr class=\"ngx-table__row\" (click)=\"rowClick.emit(record)\">\n @for (col of tableColumns(); track col.id) {\n <td\n class=\"ngx-table__td\"\n [class.ngx-table__td--masked]=\"isCellMasked(col.id)\"\n >\n {{ getCellValue(record, col.id) }}\n <!-- Migration badge \u2014 shows when record is stale -->\n @if (tableColumns().length > 0 && col.id === tableColumns()[0].id && needsMigration(record)) {\n <span class=\"ngx-table__migration-badge\" title=\"Needs migration\">\u26A0</span>\n }\n </td>\n }\n <!-- Action buttons \u2014 gated by RBAC permissions -->\n @if (permissions.canEdit || permissions.canDelete) {\n <td class=\"ngx-table__td ngx-table__actions\">\n @if (permissions.canEdit) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--edit\"\n (click)=\"onEdit($event, record)\"\n title=\"Edit\"\n >\u270F</button>\n }\n @if (permissions.canDelete) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--delete\"\n (click)=\"onDelete($event, record)\"\n title=\"Delete\"\n >\uD83D\uDDD1</button>\n }\n </td>\n }\n </tr>\n }\n }\n </tbody>\n </table>\n\n <!-- Pagination -->\n @if (totalPages > 1) {\n <div class=\"ngx-table__pagination\">\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage <= 1\"\n (click)=\"onPageChange(currentPage - 1)\"\n aria-label=\"Previous page\"\n >\u2190</button>\n\n <span class=\"ngx-table__page-info\">\n Page {{ currentPage }} of {{ totalPages }} ({{ totalRecords }} records)\n </span>\n\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage >= totalPages\"\n (click)=\"onPageChange(currentPage + 1)\"\n aria-label=\"Next page\"\n >\u2192</button>\n </div>\n }\n\n</div>\n" });
1307
+ }
1308
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicTableComponent, decorators: [{
1309
+ type: Component,
1310
+ args: [{ selector: 'ngx-dynamic-table', standalone: true, imports: [], template: "<div class=\"ngx-table\">\n\n <!-- Search bar -->\n <div class=\"ngx-table__toolbar\">\n <input\n class=\"ngx-table__search\"\n type=\"search\"\n placeholder=\"Search...\"\n [value]=\"searchTerm()\"\n (input)=\"onSearch($event)\"\n aria-label=\"Search records\"\n />\n </div>\n\n <!-- Error -->\n @if (error) {\n <div class=\"ngx-table__error\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Table -->\n <table class=\"ngx-table__table\">\n <thead>\n <tr>\n @for (col of tableColumns(); track col.id) {\n <th\n class=\"ngx-table__th\"\n [style.width]=\"col.columnWidth || 'auto'\"\n (click)=\"onSort(col.id)\"\n [attr.aria-sort]=\"sortField() === col.id ? sortDir() : 'none'\"\n role=\"columnheader\"\n >\n {{ getColumnLabel(col.id) }}\n @if (sortField() === col.id) {\n <span class=\"ngx-table__sort-icon\">{{ sortDir() === 'asc' ? '\u2191' : '\u2193' }}</span>\n }\n </th>\n }\n <!-- Actions column -->\n @if (permissions.canEdit || permissions.canDelete) {\n <th class=\"ngx-table__th ngx-table__th--actions\">Actions</th>\n }\n </tr>\n </thead>\n <tbody>\n @if (loading) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__loading\">Loading\u2026</td>\n </tr>\n } @else if (!data || !data.length) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__empty\">No records found</td>\n </tr>\n } @else {\n @for (record of data; track record['_id']) {\n <tr class=\"ngx-table__row\" (click)=\"rowClick.emit(record)\">\n @for (col of tableColumns(); track col.id) {\n <td\n class=\"ngx-table__td\"\n [class.ngx-table__td--masked]=\"isCellMasked(col.id)\"\n >\n {{ getCellValue(record, col.id) }}\n <!-- Migration badge \u2014 shows when record is stale -->\n @if (tableColumns().length > 0 && col.id === tableColumns()[0].id && needsMigration(record)) {\n <span class=\"ngx-table__migration-badge\" title=\"Needs migration\">\u26A0</span>\n }\n </td>\n }\n <!-- Action buttons \u2014 gated by RBAC permissions -->\n @if (permissions.canEdit || permissions.canDelete) {\n <td class=\"ngx-table__td ngx-table__actions\">\n @if (permissions.canEdit) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--edit\"\n (click)=\"onEdit($event, record)\"\n title=\"Edit\"\n >\u270F</button>\n }\n @if (permissions.canDelete) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--delete\"\n (click)=\"onDelete($event, record)\"\n title=\"Delete\"\n >\uD83D\uDDD1</button>\n }\n </td>\n }\n </tr>\n }\n }\n </tbody>\n </table>\n\n <!-- Pagination -->\n @if (totalPages > 1) {\n <div class=\"ngx-table__pagination\">\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage <= 1\"\n (click)=\"onPageChange(currentPage - 1)\"\n aria-label=\"Previous page\"\n >\u2190</button>\n\n <span class=\"ngx-table__page-info\">\n Page {{ currentPage }} of {{ totalPages }} ({{ totalRecords }} records)\n </span>\n\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage >= totalPages\"\n (click)=\"onPageChange(currentPage + 1)\"\n aria-label=\"Next page\"\n >\u2192</button>\n </div>\n }\n\n</div>\n" }]
1311
+ }], propDecorators: { config: [{
1312
+ type: Input
1313
+ }], data: [{
1314
+ type: Input
1315
+ }], userRoles: [{
1316
+ type: Input
1317
+ }], language: [{
1318
+ type: Input
1319
+ }], loading: [{
1320
+ type: Input
1321
+ }], error: [{
1322
+ type: Input
1323
+ }], totalRecords: [{
1324
+ type: Input
1325
+ }], pageSize: [{
1326
+ type: Input
1327
+ }], currentPage: [{
1328
+ type: Input
1329
+ }], rowClick: [{
1330
+ type: Output
1331
+ }], rowEdit: [{
1332
+ type: Output
1333
+ }], rowDelete: [{
1334
+ type: Output
1335
+ }], pageChange: [{
1336
+ type: Output
1337
+ }], pageSizeChange: [{
1338
+ type: Output
1339
+ }], sortChange: [{
1340
+ type: Output
1341
+ }], searchChange: [{
1342
+ type: Output
1343
+ }] } });
1344
+
1345
+ // Tokens
1346
+
1347
+ /**
1348
+ * Generated bundle index. Do not edit.
1349
+ */
1350
+
1351
+ export { CheckboxFieldComponent, ConfigService, DYNAMIC_ENTITY_API_URL, DateFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicTableComponent, ENTITY_REF_REGISTRY, EntityRefFieldComponent, EntityRefRegistryService, FIELD_TYPE_REGISTRY, FieldRegistryService, HOOK_REGISTRY, HookRegistryService, MASKED_ROLES, MIGRATION_STRATEGY, MultiSelectFieldComponent, NumberFieldComponent, RbacService, TextFieldComponent, TextareaFieldComponent, VALIDATOR_REGISTRY, ValidatorRegistryService, VersionService, provideNgxDynamicEntity };
1352
+ //# sourceMappingURL=ngx-dynamic-entity.mjs.map