ngx-dynamic-entity 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -1
- package/fesm2022/ngx-dynamic-entity.mjs +1727 -397
- package/fesm2022/ngx-dynamic-entity.mjs.map +1 -1
- package/package.json +2 -2
- package/types/ngx-dynamic-entity.d.ts +538 -6
|
@@ -112,13 +112,18 @@ const HOOK_REGISTRY = new InjectionToken('HOOK_REGISTRY');
|
|
|
112
112
|
* The library ships no built-in implementations. Consumers provide their own list
|
|
113
113
|
* so the builder's module-picker and the renderer's tab switcher know what's available.
|
|
114
114
|
*
|
|
115
|
+
* `component` must be the component **class**: it is mounted through `ngComponentOutlet`,
|
|
116
|
+
* which takes a type, not a selector. An entry naming a selector string renders nothing and
|
|
117
|
+
* warns — `COMMON_MODULES` in core is a catalogue of names for the builder's picker, and
|
|
118
|
+
* offering a module the renderer cannot mount is the one way to get this wrong.
|
|
119
|
+
*
|
|
115
120
|
* @example
|
|
116
121
|
* providers: [
|
|
117
122
|
* {
|
|
118
123
|
* provide: COMMON_MODULES_REGISTRY,
|
|
119
124
|
* useValue: [
|
|
120
|
-
* { id: 'documents', label: { en: 'Documents' }, component:
|
|
121
|
-
* { id: 'audit', label: { en: 'Audit Log' }, component:
|
|
125
|
+
* { id: 'documents', label: { en: 'Documents' }, component: DocumentsViewComponent },
|
|
126
|
+
* { id: 'audit', label: { en: 'Audit Log' }, component: AuditLogComponent },
|
|
122
127
|
* ] satisfies CommonModuleEntry[]
|
|
123
128
|
* }
|
|
124
129
|
* ]
|
|
@@ -141,7 +146,10 @@ const UPLOAD_HANDLER = new InjectionToken('UPLOAD_HANDLER');
|
|
|
141
146
|
* @example
|
|
142
147
|
* { provide: ENTITY_REF_CACHE_STORE, useClass: SessionStorageRefCache }
|
|
143
148
|
*/
|
|
144
|
-
const ENTITY_REF_CACHE_STORE = new InjectionToken('ENTITY_REF_CACHE_STORE', {
|
|
149
|
+
const ENTITY_REF_CACHE_STORE = new InjectionToken('ENTITY_REF_CACHE_STORE', {
|
|
150
|
+
providedIn: 'root',
|
|
151
|
+
factory: () => new InMemoryEntityRefCacheStore(),
|
|
152
|
+
});
|
|
145
153
|
/**
|
|
146
154
|
* Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
|
|
147
155
|
* Defaults to allowing edits if not provided.
|
|
@@ -171,6 +179,54 @@ const CONFIG_SOURCE = new InjectionToken('CONFIG_SOURCE');
|
|
|
171
179
|
* { provide: MARKDOWN_RENDERER, useValue: (src: string) => marked.parse(src) as string }
|
|
172
180
|
*/
|
|
173
181
|
const MARKDOWN_RENDERER = new InjectionToken('MARKDOWN_RENDERER');
|
|
182
|
+
/**
|
|
183
|
+
* The text a masked field shows in place of its value. Defaults to `XXXXXXXXX`.
|
|
184
|
+
*
|
|
185
|
+
* Masking is presentational — see SECURITY.md — and what it prints is a product decision:
|
|
186
|
+
* `••••••••` reads as a redaction, "Hidden" reads as a permission, and a localized string
|
|
187
|
+
* reads as neither in English. It was a literal repeated across every field component, so
|
|
188
|
+
* there was no way to choose.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* { provide: MASKED_PLACEHOLDER, useValue: '••••••••' }
|
|
192
|
+
*/
|
|
193
|
+
const MASKED_PLACEHOLDER = new InjectionToken('MASKED_PLACEHOLDER');
|
|
194
|
+
/**
|
|
195
|
+
* Overrides for the library's own chrome — Save, Reset, "No rows yet.", and the rest.
|
|
196
|
+
*
|
|
197
|
+
* Field labels and options come from the config as `LocalizedText` and already follow the
|
|
198
|
+
* form's `language`. This is the text *around* them, which was English literals in the
|
|
199
|
+
* templates: an application in German rendered German labels around English buttons.
|
|
200
|
+
*
|
|
201
|
+
* The library does no translating — it publishes the keys it renders and resolves whatever
|
|
202
|
+
* a host hands back. `DEFAULT_UI_TEXT` is the full list of keys with their English source
|
|
203
|
+
* strings, so a translation file can be generated from it rather than transcribed. Anything
|
|
204
|
+
* left out keeps its English default.
|
|
205
|
+
*
|
|
206
|
+
* @example The config's own shape — `LocalizedText` per key, resolved against `language`
|
|
207
|
+
* {
|
|
208
|
+
* provide: UI_TEXT,
|
|
209
|
+
* useValue: {
|
|
210
|
+
* save: { en: 'Save', de: 'Speichern' },
|
|
211
|
+
* reset: { en: 'Reset', de: 'Zurücksetzen' },
|
|
212
|
+
* },
|
|
213
|
+
* }
|
|
214
|
+
*
|
|
215
|
+
* @example One language, or a host that re-provides on switch
|
|
216
|
+
* { provide: UI_TEXT, useValue: { save: 'Speichern', reset: 'Zurücksetzen' } }
|
|
217
|
+
*
|
|
218
|
+
* @example An existing i18n layer (ngx-translate, Transloco, $localize)
|
|
219
|
+
* {
|
|
220
|
+
* provide: UI_TEXT,
|
|
221
|
+
* useFactory: () => {
|
|
222
|
+
* const translate = inject(TranslateService);
|
|
223
|
+
* return (key, defaultText) => translate.instant(`dynamicEntity.${key}`) || defaultText;
|
|
224
|
+
* },
|
|
225
|
+
* }
|
|
226
|
+
*
|
|
227
|
+
* A resolver is read during change detection, so it must be synchronous and cheap.
|
|
228
|
+
*/
|
|
229
|
+
const UI_TEXT = new InjectionToken('UI_TEXT');
|
|
174
230
|
|
|
175
231
|
/**
|
|
176
232
|
* Provider function for ngx-dynamic-entity.
|
|
@@ -195,6 +251,7 @@ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
|
|
|
195
251
|
{ provide: HOOK_REGISTRY, useValue: new Map(Object.entries(config.hooks ?? {})) },
|
|
196
252
|
{ provide: RECORD_MIGRATIONS, useValue: config.migrations ?? [] },
|
|
197
253
|
{ provide: VALIDATION_MESSAGES, useValue: config.validationMessages ?? {} },
|
|
254
|
+
{ provide: UI_TEXT, useValue: config.uiText ?? {} },
|
|
198
255
|
]);
|
|
199
256
|
|
|
200
257
|
/** The built-in English messages. Every key here can be overridden individually. */
|
|
@@ -250,7 +307,53 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
250
307
|
args: [{ providedIn: 'root' }]
|
|
251
308
|
}] });
|
|
252
309
|
|
|
310
|
+
/**
|
|
311
|
+
* DOM ids that survive being rendered more than once.
|
|
312
|
+
*
|
|
313
|
+
* A field component used to derive its control's id straight from `field.id`. That is unique
|
|
314
|
+
* in a config and *not* unique in a document: an `array` field renders the same child fields
|
|
315
|
+
* once per row, so a two-row Contacts array put two `#name` inputs on the page. `<label for>`
|
|
316
|
+
* resolves to the first match in document order, so clicking the second row's label focused
|
|
317
|
+
* the first row's input, and a screen reader read the same association twice. Duplicate ids on
|
|
318
|
+
* focusable elements are also a WCAG failure in their own right.
|
|
319
|
+
*
|
|
320
|
+
* The fix is a counter, which is what Angular Material does for the same reason: the id stays
|
|
321
|
+
* readable — `email-de7` — and is unique for the lifetime of the page. It is deliberately not
|
|
322
|
+
* derived from the row index, because a field component is given exactly five inputs and none
|
|
323
|
+
* of them says where it is; adding a sixth would change the contract every custom field type
|
|
324
|
+
* implements.
|
|
325
|
+
*
|
|
326
|
+
* Ids are not a public contract. Address a control in a test through its `data-testid`, which
|
|
327
|
+
* is stable, or through its label, which is what a user sees.
|
|
328
|
+
*/
|
|
329
|
+
let sequence = 0;
|
|
330
|
+
/** A token unique to one component instance, for the lifetime of the page. */
|
|
331
|
+
function nextFieldInstanceId() {
|
|
332
|
+
sequence += 1;
|
|
333
|
+
return `de${sequence}`;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* `{fieldId}-{instance}{suffix}` — the field first, so the id still reads as the field it
|
|
337
|
+
* belongs to when someone is looking at the DOM.
|
|
338
|
+
*
|
|
339
|
+
* `field` is undefined until the renderer assigns it, and a template can be checked once
|
|
340
|
+
* before that happens.
|
|
341
|
+
*/
|
|
342
|
+
function fieldDomId(field, instance, suffix = '') {
|
|
343
|
+
return `${field?.id ?? 'field'}-${instance}${suffix}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
253
346
|
class TextFieldComponent {
|
|
347
|
+
/**
|
|
348
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
349
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
350
|
+
*/
|
|
351
|
+
instanceId = nextFieldInstanceId();
|
|
352
|
+
domId(suffix = '') {
|
|
353
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
354
|
+
}
|
|
355
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
356
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
254
357
|
messages = inject(ValidationMessagesService);
|
|
255
358
|
displaySub;
|
|
256
359
|
field;
|
|
@@ -281,35 +384,56 @@ class TextFieldComponent {
|
|
|
281
384
|
get errorMessage() {
|
|
282
385
|
if (!this.control || !this.control.errors || !this.control.touched)
|
|
283
386
|
return '';
|
|
284
|
-
return this.messages.resolve(this.control.errors, this.language, [
|
|
387
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
388
|
+
'required',
|
|
389
|
+
'email',
|
|
390
|
+
'minlength',
|
|
391
|
+
'maxlength',
|
|
392
|
+
'pattern',
|
|
393
|
+
]);
|
|
285
394
|
}
|
|
286
395
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
287
396
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: TextFieldComponent, isStandalone: true, selector: "ngx-text-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
288
397
|
<div
|
|
289
398
|
class="ngx-field ngx-field--text"
|
|
290
|
-
[attr.data-testid]="'field-' + field.id"
|
|
399
|
+
[attr.data-testid]="'field-' + field.id"
|
|
400
|
+
[attr.data-field-type]="field.type"
|
|
291
401
|
[class.ngx-field--readonly]="readonly"
|
|
292
402
|
[class.ngx-field--masked]="masked"
|
|
293
403
|
[class.ngx-field--invalid]="control && control.invalid && control.touched"
|
|
294
404
|
>
|
|
295
|
-
<label class="ngx-field__label" [attr.for]="
|
|
405
|
+
<label class="ngx-field__label" [attr.for]="domId()"
|
|
406
|
+
>{{ label }}
|
|
407
|
+
@if (field.validators?.required) {
|
|
408
|
+
<span class="ngx-field__req">*</span>
|
|
409
|
+
}
|
|
410
|
+
</label>
|
|
296
411
|
@if (masked) {
|
|
297
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
412
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
413
|
+
maskedText
|
|
414
|
+
}}</span>
|
|
298
415
|
} @else if (readonly) {
|
|
299
416
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
|
|
300
417
|
} @else {
|
|
301
418
|
<input
|
|
302
|
-
[id]="
|
|
419
|
+
[id]="domId()"
|
|
303
420
|
type="text"
|
|
304
|
-
class="ngx-field__input"
|
|
421
|
+
class="ngx-field__input"
|
|
422
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
305
423
|
[formControl]="$any(control)"
|
|
306
424
|
[placeholder]="placeholder"
|
|
307
425
|
[attr.disabled]="field.disabled ? true : null"
|
|
308
426
|
[attr.aria-invalid]="control.invalid && control.touched"
|
|
309
|
-
[attr.aria-describedby]="errorMessage ?
|
|
427
|
+
[attr.aria-describedby]="errorMessage ? domId('-error') : null"
|
|
310
428
|
/>
|
|
311
429
|
@if (errorMessage) {
|
|
312
|
-
<span
|
|
430
|
+
<span
|
|
431
|
+
class="ngx-field__error"
|
|
432
|
+
[attr.data-testid]="'field-' + field.id + '-error'"
|
|
433
|
+
[id]="domId('-error')"
|
|
434
|
+
role="alert"
|
|
435
|
+
>{{ errorMessage }}</span
|
|
436
|
+
>
|
|
313
437
|
}
|
|
314
438
|
}
|
|
315
439
|
</div>
|
|
@@ -325,29 +449,44 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
325
449
|
template: `
|
|
326
450
|
<div
|
|
327
451
|
class="ngx-field ngx-field--text"
|
|
328
|
-
[attr.data-testid]="'field-' + field.id"
|
|
452
|
+
[attr.data-testid]="'field-' + field.id"
|
|
453
|
+
[attr.data-field-type]="field.type"
|
|
329
454
|
[class.ngx-field--readonly]="readonly"
|
|
330
455
|
[class.ngx-field--masked]="masked"
|
|
331
456
|
[class.ngx-field--invalid]="control && control.invalid && control.touched"
|
|
332
457
|
>
|
|
333
|
-
<label class="ngx-field__label" [attr.for]="
|
|
458
|
+
<label class="ngx-field__label" [attr.for]="domId()"
|
|
459
|
+
>{{ label }}
|
|
460
|
+
@if (field.validators?.required) {
|
|
461
|
+
<span class="ngx-field__req">*</span>
|
|
462
|
+
}
|
|
463
|
+
</label>
|
|
334
464
|
@if (masked) {
|
|
335
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
465
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
466
|
+
maskedText
|
|
467
|
+
}}</span>
|
|
336
468
|
} @else if (readonly) {
|
|
337
469
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
|
|
338
470
|
} @else {
|
|
339
471
|
<input
|
|
340
|
-
[id]="
|
|
472
|
+
[id]="domId()"
|
|
341
473
|
type="text"
|
|
342
|
-
class="ngx-field__input"
|
|
474
|
+
class="ngx-field__input"
|
|
475
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
343
476
|
[formControl]="$any(control)"
|
|
344
477
|
[placeholder]="placeholder"
|
|
345
478
|
[attr.disabled]="field.disabled ? true : null"
|
|
346
479
|
[attr.aria-invalid]="control.invalid && control.touched"
|
|
347
|
-
[attr.aria-describedby]="errorMessage ?
|
|
480
|
+
[attr.aria-describedby]="errorMessage ? domId('-error') : null"
|
|
348
481
|
/>
|
|
349
482
|
@if (errorMessage) {
|
|
350
|
-
<span
|
|
483
|
+
<span
|
|
484
|
+
class="ngx-field__error"
|
|
485
|
+
[attr.data-testid]="'field-' + field.id + '-error'"
|
|
486
|
+
[id]="domId('-error')"
|
|
487
|
+
role="alert"
|
|
488
|
+
>{{ errorMessage }}</span
|
|
489
|
+
>
|
|
351
490
|
}
|
|
352
491
|
}
|
|
353
492
|
</div>
|
|
@@ -366,6 +505,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
366
505
|
}] } });
|
|
367
506
|
|
|
368
507
|
class TextareaFieldComponent {
|
|
508
|
+
/**
|
|
509
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
510
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
511
|
+
*/
|
|
512
|
+
instanceId = nextFieldInstanceId();
|
|
513
|
+
domId(suffix = '') {
|
|
514
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
515
|
+
}
|
|
516
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
517
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
518
|
+
messages = inject(ValidationMessagesService);
|
|
369
519
|
field;
|
|
370
520
|
control;
|
|
371
521
|
language = 'en';
|
|
@@ -377,26 +527,53 @@ class TextareaFieldComponent {
|
|
|
377
527
|
get placeholder() {
|
|
378
528
|
return resolveLabel(this.field?.placeholder, this.language);
|
|
379
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
532
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
533
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
534
|
+
* of fifteen field types.
|
|
535
|
+
*/
|
|
536
|
+
get errorMessage() {
|
|
537
|
+
if (!this.control?.errors || !this.control.touched)
|
|
538
|
+
return '';
|
|
539
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
540
|
+
'required',
|
|
541
|
+
'email',
|
|
542
|
+
'min',
|
|
543
|
+
'max',
|
|
544
|
+
'minlength',
|
|
545
|
+
'maxlength',
|
|
546
|
+
'pattern',
|
|
547
|
+
]);
|
|
548
|
+
}
|
|
380
549
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TextareaFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
381
550
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: TextareaFieldComponent, isStandalone: true, selector: "ngx-textarea-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
382
|
-
<div
|
|
383
|
-
|
|
384
|
-
|
|
551
|
+
<div
|
|
552
|
+
class="ngx-field ngx-field--textarea"
|
|
553
|
+
[attr.data-testid]="'field-' + field.id"
|
|
554
|
+
[attr.data-field-type]="field.type"
|
|
555
|
+
[class.ngx-field--readonly]="readonly"
|
|
556
|
+
[class.ngx-field--masked]="masked"
|
|
557
|
+
>
|
|
558
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
385
559
|
@if (masked) {
|
|
386
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
560
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
561
|
+
maskedText
|
|
562
|
+
}}</span>
|
|
387
563
|
} @else if (readonly) {
|
|
388
564
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
|
|
389
565
|
} @else {
|
|
390
566
|
<textarea
|
|
391
|
-
[id]="
|
|
392
|
-
class="ngx-field__input"
|
|
567
|
+
[id]="domId()"
|
|
568
|
+
class="ngx-field__input"
|
|
569
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
393
570
|
[formControl]="$any(control)"
|
|
394
571
|
[placeholder]="placeholder"
|
|
395
572
|
[attr.disabled]="field.disabled ? true : null"
|
|
396
573
|
rows="3"
|
|
397
574
|
></textarea>
|
|
398
|
-
@if (
|
|
399
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
575
|
+
@if (errorMessage) {
|
|
576
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
400
577
|
}
|
|
401
578
|
}
|
|
402
579
|
</div>
|
|
@@ -410,24 +587,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
410
587
|
standalone: true,
|
|
411
588
|
imports: [ReactiveFormsModule],
|
|
412
589
|
template: `
|
|
413
|
-
<div
|
|
414
|
-
|
|
415
|
-
|
|
590
|
+
<div
|
|
591
|
+
class="ngx-field ngx-field--textarea"
|
|
592
|
+
[attr.data-testid]="'field-' + field.id"
|
|
593
|
+
[attr.data-field-type]="field.type"
|
|
594
|
+
[class.ngx-field--readonly]="readonly"
|
|
595
|
+
[class.ngx-field--masked]="masked"
|
|
596
|
+
>
|
|
597
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
416
598
|
@if (masked) {
|
|
417
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
599
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
600
|
+
maskedText
|
|
601
|
+
}}</span>
|
|
418
602
|
} @else if (readonly) {
|
|
419
603
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
|
|
420
604
|
} @else {
|
|
421
605
|
<textarea
|
|
422
|
-
[id]="
|
|
423
|
-
class="ngx-field__input"
|
|
606
|
+
[id]="domId()"
|
|
607
|
+
class="ngx-field__input"
|
|
608
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
424
609
|
[formControl]="$any(control)"
|
|
425
610
|
[placeholder]="placeholder"
|
|
426
611
|
[attr.disabled]="field.disabled ? true : null"
|
|
427
612
|
rows="3"
|
|
428
613
|
></textarea>
|
|
429
|
-
@if (
|
|
430
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
614
|
+
@if (errorMessage) {
|
|
615
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
431
616
|
}
|
|
432
617
|
}
|
|
433
618
|
</div>
|
|
@@ -445,6 +630,180 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
445
630
|
type: Input
|
|
446
631
|
}] } });
|
|
447
632
|
|
|
633
|
+
/** `{name}` — the slot a `UiTextParams` entry fills. */
|
|
634
|
+
const PLACEHOLDER = new RegExp('[{]([a-zA-Z0-9_]+)[}]', 'g');
|
|
635
|
+
/**
|
|
636
|
+
* Every word this library puts on screen that does not come from a config.
|
|
637
|
+
*
|
|
638
|
+
* Field labels, placeholders and options are `LocalizedText` and already follow the form's
|
|
639
|
+
* `language`. The library's *own* chrome — Save, Reset, "No rows yet." — was English
|
|
640
|
+
* literals in twenty-odd templates, so an application in German rendered German labels
|
|
641
|
+
* around English buttons. There was no way to change them short of forking a component.
|
|
642
|
+
*
|
|
643
|
+
* This map is the whole surface: the keys are what a host translates, and the values are
|
|
644
|
+
* the English source strings to translate *from*. It is exported so a translation file can
|
|
645
|
+
* be generated from it rather than transcribed by hand.
|
|
646
|
+
*
|
|
647
|
+
* Keys are grouped by where they appear rather than alphabetically, because a translator
|
|
648
|
+
* works screen by screen and a list sorted by name hides which strings sit together.
|
|
649
|
+
*/
|
|
650
|
+
const DEFAULT_UI_TEXT = {
|
|
651
|
+
// Form actions
|
|
652
|
+
save: 'Save',
|
|
653
|
+
saving: 'Saving…',
|
|
654
|
+
reset: 'Reset',
|
|
655
|
+
saveShortcutHint: 'Shortcut: Ctrl+S',
|
|
656
|
+
loading: 'Loading…',
|
|
657
|
+
// Record view — the per-tab edit flow
|
|
658
|
+
editSection: 'Edit section',
|
|
659
|
+
saveSection: 'Save section',
|
|
660
|
+
editingSection: 'Editing this section',
|
|
661
|
+
cancel: 'Cancel',
|
|
662
|
+
edit: 'Edit',
|
|
663
|
+
delete: 'Delete',
|
|
664
|
+
// Array rows
|
|
665
|
+
addRow: '+ Row',
|
|
666
|
+
addItem: '+ Add Item',
|
|
667
|
+
itemNumber: 'Item #{number}',
|
|
668
|
+
addRowTitle: 'Add {field} row',
|
|
669
|
+
editRowTitle: 'Edit {field} row',
|
|
670
|
+
saveRow: 'Save row',
|
|
671
|
+
remove: 'Remove',
|
|
672
|
+
noRows: 'No rows yet.',
|
|
673
|
+
noItems: 'No items added yet.',
|
|
674
|
+
// Choice fields
|
|
675
|
+
selectPlaceholder: 'Select...',
|
|
676
|
+
selectParentFirst: 'Select {field} first.',
|
|
677
|
+
// Boolean and checkbox display
|
|
678
|
+
yes: 'Yes',
|
|
679
|
+
no: 'No',
|
|
680
|
+
// Files and images
|
|
681
|
+
uploading: 'Uploading…',
|
|
682
|
+
chooseFile: 'Choose file',
|
|
683
|
+
replaceFile: 'Replace file',
|
|
684
|
+
removeFile: 'Remove file',
|
|
685
|
+
downloadFile: 'Download file',
|
|
686
|
+
noImage: 'No image',
|
|
687
|
+
uploadImage: 'Upload',
|
|
688
|
+
changeImage: 'Change',
|
|
689
|
+
// Month / year pickers
|
|
690
|
+
month: 'Month',
|
|
691
|
+
year: 'Year',
|
|
692
|
+
// Markdown editor
|
|
693
|
+
write: 'Write',
|
|
694
|
+
preview: 'Preview',
|
|
695
|
+
markdownEditorMode: '{label} editor mode',
|
|
696
|
+
// Accessible names for icon-only controls
|
|
697
|
+
showPassword: 'Show password',
|
|
698
|
+
hidePassword: 'Hide password',
|
|
699
|
+
lockField: 'Lock {field}',
|
|
700
|
+
unlockField: 'Unlock {field}',
|
|
701
|
+
criticalFieldLockHint: 'Critical field — click to lock',
|
|
702
|
+
criticalFieldUnlockHint: 'Critical field — click to unlock for editing',
|
|
703
|
+
dismissBanner: 'Dismiss: {message}',
|
|
704
|
+
jumpToField: 'Click to jump to {field}',
|
|
705
|
+
// Banners and whole-record states
|
|
706
|
+
entityLabel: 'Entity: {entity}',
|
|
707
|
+
criticalFieldChanged: '🔒 Critical field changed: {fields} — this differs from the value at the start of this session.',
|
|
708
|
+
recordModified: '🔒 Record modified from session baseline — unsaved changes pending.',
|
|
709
|
+
accessDenied: 'You do not have permission to view this record.',
|
|
710
|
+
};
|
|
711
|
+
/**
|
|
712
|
+
* The resolution rule itself, over any key vocabulary.
|
|
713
|
+
*
|
|
714
|
+
* Exported because the builder package renders a second, disjoint set of words and needs
|
|
715
|
+
* the same rule — overrides first, then the English default, per key, with `{placeholder}`
|
|
716
|
+
* substitution. Sharing the function rather than the vocabulary is what keeps a renderer
|
|
717
|
+
* consumer from seeing a hundred and fifty builder keys in completion.
|
|
718
|
+
*/
|
|
719
|
+
function resolveUiText(overrides, defaults, key, language, params) {
|
|
720
|
+
const fallback = ownString(defaults, key);
|
|
721
|
+
const resolved = !overrides
|
|
722
|
+
? fallback
|
|
723
|
+
: typeof overrides === 'function'
|
|
724
|
+
? askResolver(overrides, key, fallback, language)
|
|
725
|
+
: // `resolveLabel` handles a bare string at runtime but does not say so in its
|
|
726
|
+
// signature, so the branch is taken here rather than leaning on an undeclared one.
|
|
727
|
+
resolveOne(own(overrides, key), language) || fallback;
|
|
728
|
+
return interpolate(resolved, params);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* An **own** entry, or `undefined`.
|
|
732
|
+
*
|
|
733
|
+
* `map[key]` walks the prototype chain, so `toString` answers with `Function` and
|
|
734
|
+
* `__proto__` with `Object.prototype` — neither of which is a string, and both of which
|
|
735
|
+
* reach `interpolate` and then a template. The key type rules that out in TypeScript; a key
|
|
736
|
+
* arriving from a translation catalogue at runtime, or from a host written in JavaScript,
|
|
737
|
+
* does not.
|
|
738
|
+
*/
|
|
739
|
+
function own(map, key) {
|
|
740
|
+
return Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
|
|
741
|
+
}
|
|
742
|
+
function ownString(defaults, key) {
|
|
743
|
+
const value = own(defaults, key);
|
|
744
|
+
return typeof value === 'string' ? value : '';
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* A resolver runs inside change detection, once per label per pass, in someone else's code.
|
|
748
|
+
*
|
|
749
|
+
* If it throws, the exception surfaces from a template expression and the form does not
|
|
750
|
+
* render at all — every form in the application, over one bad catalogue entry. Falling back
|
|
751
|
+
* to English is the same answer this function already gives for a resolver that returns
|
|
752
|
+
* nothing, and it keeps a translation fault a translation fault rather than an outage.
|
|
753
|
+
*/
|
|
754
|
+
function askResolver(resolver, key, fallback, language) {
|
|
755
|
+
try {
|
|
756
|
+
const answer = resolver(key, fallback, language);
|
|
757
|
+
return typeof answer === 'string' && answer ? answer : fallback;
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
return fallback;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function resolveOne(value, language) {
|
|
764
|
+
if (typeof value === 'string')
|
|
765
|
+
return value;
|
|
766
|
+
const resolved = resolveLabel(value, language);
|
|
767
|
+
// `resolveLabel` is typed to return a string and does, for every shape it is documented
|
|
768
|
+
// to take. A host handing it a number reaches `Object.values(42)`, which is empty.
|
|
769
|
+
return typeof resolved === 'string' ? resolved : '';
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Resolves the library's own UI text, honouring `UI_TEXT` before the defaults.
|
|
773
|
+
*
|
|
774
|
+
* Resolution is per key, not all-or-nothing, so a host replacing three buttons does not
|
|
775
|
+
* have to supply the other thirty — and an override that yields nothing for a key still
|
|
776
|
+
* renders English rather than a gap, which is what a translation catalogue asked for a
|
|
777
|
+
* missing entry typically hands back.
|
|
778
|
+
*/
|
|
779
|
+
class UiTextService {
|
|
780
|
+
overrides = inject(UI_TEXT, { optional: true });
|
|
781
|
+
/**
|
|
782
|
+
* One label by key, in `language`; English if nothing overrides it. `params` fill the
|
|
783
|
+
* `{placeholder}` slots, in whatever position the translation puts them.
|
|
784
|
+
*/
|
|
785
|
+
text(key, language = 'en', params) {
|
|
786
|
+
return resolveUiText(this.overrides, DEFAULT_UI_TEXT, key, language, params);
|
|
787
|
+
}
|
|
788
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: UiTextService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
789
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: UiTextService, providedIn: 'root' });
|
|
790
|
+
}
|
|
791
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: UiTextService, decorators: [{
|
|
792
|
+
type: Injectable,
|
|
793
|
+
args: [{ providedIn: 'root' }]
|
|
794
|
+
}] });
|
|
795
|
+
/** A placeholder with no matching param is left as written, which is visible in review. */
|
|
796
|
+
function interpolate(text, params) {
|
|
797
|
+
if (!params)
|
|
798
|
+
return text;
|
|
799
|
+
// `name in params` would answer for `toString` and `constructor` too, substituting a
|
|
800
|
+
// function body into a sentence. Only what the caller actually passed counts.
|
|
801
|
+
return text.replace(PLACEHOLDER, (match, name) => {
|
|
802
|
+
const value = own(params, name);
|
|
803
|
+
return value === undefined || value === null ? match : String(value);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
448
807
|
/**
|
|
449
808
|
* Markdown field: a long-form input that stores **markdown source**, never HTML.
|
|
450
809
|
*
|
|
@@ -462,6 +821,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
462
821
|
* the token's own note on why that is a backstop rather than a licence.
|
|
463
822
|
*/
|
|
464
823
|
class MarkdownFieldComponent {
|
|
824
|
+
/**
|
|
825
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
826
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
827
|
+
*/
|
|
828
|
+
instanceId = nextFieldInstanceId();
|
|
829
|
+
domId(suffix = '') {
|
|
830
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
831
|
+
}
|
|
832
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
833
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
834
|
+
messages = inject(ValidationMessagesService);
|
|
835
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
836
|
+
ui = inject(UiTextService);
|
|
465
837
|
field;
|
|
466
838
|
control;
|
|
467
839
|
language = 'en';
|
|
@@ -518,6 +890,25 @@ class MarkdownFieldComponent {
|
|
|
518
890
|
return null;
|
|
519
891
|
}
|
|
520
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
895
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
896
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
897
|
+
* of fifteen field types.
|
|
898
|
+
*/
|
|
899
|
+
get errorMessage() {
|
|
900
|
+
if (!this.control?.errors || !this.control.touched)
|
|
901
|
+
return '';
|
|
902
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
903
|
+
'required',
|
|
904
|
+
'email',
|
|
905
|
+
'min',
|
|
906
|
+
'max',
|
|
907
|
+
'minlength',
|
|
908
|
+
'maxlength',
|
|
909
|
+
'pattern',
|
|
910
|
+
]);
|
|
911
|
+
}
|
|
521
912
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MarkdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
522
913
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: MarkdownFieldComponent, isStandalone: true, selector: "ngx-markdown-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, usesOnChanges: true, ngImport: i0, template: `
|
|
523
914
|
<div
|
|
@@ -527,22 +918,34 @@ class MarkdownFieldComponent {
|
|
|
527
918
|
[class.ngx-field--readonly]="readonly"
|
|
528
919
|
[class.ngx-field--masked]="masked"
|
|
529
920
|
>
|
|
530
|
-
<label class="ngx-field__label" [attr.for]="
|
|
921
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
531
922
|
|
|
532
923
|
@if (masked) {
|
|
533
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
924
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
925
|
+
maskedText
|
|
926
|
+
}}</span>
|
|
534
927
|
} @else if (readonly) {
|
|
535
928
|
@if (rendered(); as html) {
|
|
536
|
-
<div
|
|
929
|
+
<div
|
|
930
|
+
class="ngx-field__value ngx-field__markdown"
|
|
931
|
+
[attr.data-testid]="'field-' + field.id + '-value'"
|
|
932
|
+
[innerHTML]="html"
|
|
933
|
+
></div>
|
|
537
934
|
} @else {
|
|
538
935
|
<!-- No renderer: the source is the display. The stylesheet keeps its line
|
|
539
936
|
breaks with white-space: pre-wrap, and interpolation escapes it. -->
|
|
540
|
-
<span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
937
|
+
<span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
938
|
+
control.value
|
|
939
|
+
}}</span>
|
|
541
940
|
}
|
|
542
941
|
} @else {
|
|
543
942
|
<div class="ngx-field__markdown-editor">
|
|
544
943
|
@if (canPreview) {
|
|
545
|
-
<div
|
|
944
|
+
<div
|
|
945
|
+
class="ngx-field__markdown-tabs"
|
|
946
|
+
role="group"
|
|
947
|
+
[attr.aria-label]="ui.text('markdownEditorMode', language, { label })"
|
|
948
|
+
>
|
|
546
949
|
<button
|
|
547
950
|
type="button"
|
|
548
951
|
class="ngx-field__markdown-tab"
|
|
@@ -550,7 +953,7 @@ class MarkdownFieldComponent {
|
|
|
550
953
|
[attr.aria-pressed]="!previewing()"
|
|
551
954
|
(click)="previewing.set(false)"
|
|
552
955
|
>
|
|
553
|
-
|
|
956
|
+
{{ ui.text('write', language) }}
|
|
554
957
|
</button>
|
|
555
958
|
<button
|
|
556
959
|
type="button"
|
|
@@ -559,16 +962,20 @@ class MarkdownFieldComponent {
|
|
|
559
962
|
[attr.aria-pressed]="previewing()"
|
|
560
963
|
(click)="previewing.set(true)"
|
|
561
964
|
>
|
|
562
|
-
|
|
965
|
+
{{ ui.text('preview', language) }}
|
|
563
966
|
</button>
|
|
564
967
|
</div>
|
|
565
968
|
}
|
|
566
969
|
|
|
567
970
|
@if (previewing()) {
|
|
568
|
-
<div
|
|
971
|
+
<div
|
|
972
|
+
class="ngx-field__markdown ngx-field__markdown--preview"
|
|
973
|
+
[attr.data-testid]="'field-' + field.id + '-preview-body'"
|
|
974
|
+
[innerHTML]="rendered()"
|
|
975
|
+
></div>
|
|
569
976
|
} @else {
|
|
570
977
|
<textarea
|
|
571
|
-
[id]="
|
|
978
|
+
[id]="domId()"
|
|
572
979
|
class="ngx-field__input ngx-field__input--markdown"
|
|
573
980
|
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
574
981
|
[formControl]="$any(control)"
|
|
@@ -579,8 +986,8 @@ class MarkdownFieldComponent {
|
|
|
579
986
|
}
|
|
580
987
|
</div>
|
|
581
988
|
|
|
582
|
-
@if (
|
|
583
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
989
|
+
@if (errorMessage) {
|
|
990
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
584
991
|
}
|
|
585
992
|
}
|
|
586
993
|
</div>
|
|
@@ -601,22 +1008,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
601
1008
|
[class.ngx-field--readonly]="readonly"
|
|
602
1009
|
[class.ngx-field--masked]="masked"
|
|
603
1010
|
>
|
|
604
|
-
<label class="ngx-field__label" [attr.for]="
|
|
1011
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
605
1012
|
|
|
606
1013
|
@if (masked) {
|
|
607
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1014
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1015
|
+
maskedText
|
|
1016
|
+
}}</span>
|
|
608
1017
|
} @else if (readonly) {
|
|
609
1018
|
@if (rendered(); as html) {
|
|
610
|
-
<div
|
|
1019
|
+
<div
|
|
1020
|
+
class="ngx-field__value ngx-field__markdown"
|
|
1021
|
+
[attr.data-testid]="'field-' + field.id + '-value'"
|
|
1022
|
+
[innerHTML]="html"
|
|
1023
|
+
></div>
|
|
611
1024
|
} @else {
|
|
612
1025
|
<!-- No renderer: the source is the display. The stylesheet keeps its line
|
|
613
1026
|
breaks with white-space: pre-wrap, and interpolation escapes it. -->
|
|
614
|
-
<span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1027
|
+
<span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1028
|
+
control.value
|
|
1029
|
+
}}</span>
|
|
615
1030
|
}
|
|
616
1031
|
} @else {
|
|
617
1032
|
<div class="ngx-field__markdown-editor">
|
|
618
1033
|
@if (canPreview) {
|
|
619
|
-
<div
|
|
1034
|
+
<div
|
|
1035
|
+
class="ngx-field__markdown-tabs"
|
|
1036
|
+
role="group"
|
|
1037
|
+
[attr.aria-label]="ui.text('markdownEditorMode', language, { label })"
|
|
1038
|
+
>
|
|
620
1039
|
<button
|
|
621
1040
|
type="button"
|
|
622
1041
|
class="ngx-field__markdown-tab"
|
|
@@ -624,7 +1043,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
624
1043
|
[attr.aria-pressed]="!previewing()"
|
|
625
1044
|
(click)="previewing.set(false)"
|
|
626
1045
|
>
|
|
627
|
-
|
|
1046
|
+
{{ ui.text('write', language) }}
|
|
628
1047
|
</button>
|
|
629
1048
|
<button
|
|
630
1049
|
type="button"
|
|
@@ -633,16 +1052,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
633
1052
|
[attr.aria-pressed]="previewing()"
|
|
634
1053
|
(click)="previewing.set(true)"
|
|
635
1054
|
>
|
|
636
|
-
|
|
1055
|
+
{{ ui.text('preview', language) }}
|
|
637
1056
|
</button>
|
|
638
1057
|
</div>
|
|
639
1058
|
}
|
|
640
1059
|
|
|
641
1060
|
@if (previewing()) {
|
|
642
|
-
<div
|
|
1061
|
+
<div
|
|
1062
|
+
class="ngx-field__markdown ngx-field__markdown--preview"
|
|
1063
|
+
[attr.data-testid]="'field-' + field.id + '-preview-body'"
|
|
1064
|
+
[innerHTML]="rendered()"
|
|
1065
|
+
></div>
|
|
643
1066
|
} @else {
|
|
644
1067
|
<textarea
|
|
645
|
-
[id]="
|
|
1068
|
+
[id]="domId()"
|
|
646
1069
|
class="ngx-field__input ngx-field__input--markdown"
|
|
647
1070
|
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
648
1071
|
[formControl]="$any(control)"
|
|
@@ -653,8 +1076,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
653
1076
|
}
|
|
654
1077
|
</div>
|
|
655
1078
|
|
|
656
|
-
@if (
|
|
657
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
1079
|
+
@if (errorMessage) {
|
|
1080
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
658
1081
|
}
|
|
659
1082
|
}
|
|
660
1083
|
</div>
|
|
@@ -673,6 +1096,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
673
1096
|
}] } });
|
|
674
1097
|
|
|
675
1098
|
class NumberFieldComponent {
|
|
1099
|
+
/**
|
|
1100
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
1101
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
1102
|
+
*/
|
|
1103
|
+
instanceId = nextFieldInstanceId();
|
|
1104
|
+
domId(suffix = '') {
|
|
1105
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
1106
|
+
}
|
|
1107
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
1108
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
676
1109
|
messages = inject(ValidationMessagesService);
|
|
677
1110
|
field;
|
|
678
1111
|
control;
|
|
@@ -694,32 +1127,44 @@ class NumberFieldComponent {
|
|
|
694
1127
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: NumberFieldComponent, isStandalone: true, selector: "ngx-number-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
695
1128
|
<div
|
|
696
1129
|
class="ngx-field ngx-field--number"
|
|
697
|
-
[attr.data-testid]="'field-' + field.id"
|
|
1130
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1131
|
+
[attr.data-field-type]="field.type"
|
|
698
1132
|
[class.ngx-field--readonly]="readonly"
|
|
699
1133
|
[class.ngx-field--masked]="masked"
|
|
700
1134
|
[class.ngx-field--invalid]="control && control.invalid && control.touched"
|
|
701
1135
|
>
|
|
702
|
-
<label class="ngx-field__label" [attr.for]="
|
|
1136
|
+
<label class="ngx-field__label" [attr.for]="domId()">
|
|
703
1137
|
{{ label }}
|
|
704
|
-
@if (field.validators?.required) {
|
|
1138
|
+
@if (field.validators?.required) {
|
|
1139
|
+
<span class="ngx-field__req">*</span>
|
|
1140
|
+
}
|
|
705
1141
|
</label>
|
|
706
1142
|
@if (masked) {
|
|
707
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1143
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1144
|
+
maskedText
|
|
1145
|
+
}}</span>
|
|
708
1146
|
} @else if (readonly) {
|
|
709
1147
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
|
|
710
1148
|
} @else {
|
|
711
1149
|
<input
|
|
712
|
-
[id]="
|
|
1150
|
+
[id]="domId()"
|
|
713
1151
|
type="number"
|
|
714
|
-
class="ngx-field__input"
|
|
1152
|
+
class="ngx-field__input"
|
|
1153
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
715
1154
|
[formControl]="$any(control)"
|
|
716
1155
|
[placeholder]="placeholder"
|
|
717
1156
|
[attr.disabled]="field.disabled ? true : null"
|
|
718
1157
|
[attr.aria-invalid]="control.invalid && control.touched"
|
|
719
|
-
[attr.aria-describedby]="errorMessage ?
|
|
1158
|
+
[attr.aria-describedby]="errorMessage ? domId('-error') : null"
|
|
720
1159
|
/>
|
|
721
1160
|
@if (errorMessage) {
|
|
722
|
-
<span
|
|
1161
|
+
<span
|
|
1162
|
+
class="ngx-field__error"
|
|
1163
|
+
[attr.data-testid]="'field-' + field.id + '-error'"
|
|
1164
|
+
[id]="domId('-error')"
|
|
1165
|
+
role="alert"
|
|
1166
|
+
>{{ errorMessage }}</span
|
|
1167
|
+
>
|
|
723
1168
|
}
|
|
724
1169
|
}
|
|
725
1170
|
</div>
|
|
@@ -735,32 +1180,44 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
735
1180
|
template: `
|
|
736
1181
|
<div
|
|
737
1182
|
class="ngx-field ngx-field--number"
|
|
738
|
-
[attr.data-testid]="'field-' + field.id"
|
|
1183
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1184
|
+
[attr.data-field-type]="field.type"
|
|
739
1185
|
[class.ngx-field--readonly]="readonly"
|
|
740
1186
|
[class.ngx-field--masked]="masked"
|
|
741
1187
|
[class.ngx-field--invalid]="control && control.invalid && control.touched"
|
|
742
1188
|
>
|
|
743
|
-
<label class="ngx-field__label" [attr.for]="
|
|
1189
|
+
<label class="ngx-field__label" [attr.for]="domId()">
|
|
744
1190
|
{{ label }}
|
|
745
|
-
@if (field.validators?.required) {
|
|
1191
|
+
@if (field.validators?.required) {
|
|
1192
|
+
<span class="ngx-field__req">*</span>
|
|
1193
|
+
}
|
|
746
1194
|
</label>
|
|
747
1195
|
@if (masked) {
|
|
748
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1196
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1197
|
+
maskedText
|
|
1198
|
+
}}</span>
|
|
749
1199
|
} @else if (readonly) {
|
|
750
1200
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
|
|
751
1201
|
} @else {
|
|
752
1202
|
<input
|
|
753
|
-
[id]="
|
|
1203
|
+
[id]="domId()"
|
|
754
1204
|
type="number"
|
|
755
|
-
class="ngx-field__input"
|
|
1205
|
+
class="ngx-field__input"
|
|
1206
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
756
1207
|
[formControl]="$any(control)"
|
|
757
1208
|
[placeholder]="placeholder"
|
|
758
1209
|
[attr.disabled]="field.disabled ? true : null"
|
|
759
1210
|
[attr.aria-invalid]="control.invalid && control.touched"
|
|
760
|
-
[attr.aria-describedby]="errorMessage ?
|
|
1211
|
+
[attr.aria-describedby]="errorMessage ? domId('-error') : null"
|
|
761
1212
|
/>
|
|
762
1213
|
@if (errorMessage) {
|
|
763
|
-
<span
|
|
1214
|
+
<span
|
|
1215
|
+
class="ngx-field__error"
|
|
1216
|
+
[attr.data-testid]="'field-' + field.id + '-error'"
|
|
1217
|
+
[id]="domId('-error')"
|
|
1218
|
+
role="alert"
|
|
1219
|
+
>{{ errorMessage }}</span
|
|
1220
|
+
>
|
|
764
1221
|
}
|
|
765
1222
|
}
|
|
766
1223
|
</div>
|
|
@@ -780,6 +1237,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
780
1237
|
|
|
781
1238
|
/** Currency field: number input with locale-aware currency symbol prefix. */
|
|
782
1239
|
class CurrencyFieldComponent {
|
|
1240
|
+
/**
|
|
1241
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
1242
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
1243
|
+
*/
|
|
1244
|
+
instanceId = nextFieldInstanceId();
|
|
1245
|
+
domId(suffix = '') {
|
|
1246
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
1247
|
+
}
|
|
1248
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
1249
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
1250
|
+
messages = inject(ValidationMessagesService);
|
|
783
1251
|
field;
|
|
784
1252
|
control;
|
|
785
1253
|
language = 'en';
|
|
@@ -794,27 +1262,59 @@ class CurrencyFieldComponent {
|
|
|
794
1262
|
/** Picks the currency symbol from the locale — defaults to '$'. */
|
|
795
1263
|
get symbol() {
|
|
796
1264
|
try {
|
|
797
|
-
return (
|
|
798
|
-
.
|
|
1265
|
+
return ((0)
|
|
1266
|
+
.toLocaleString(this.language, { style: 'currency', currency: 'USD', minimumFractionDigits: 0 })
|
|
1267
|
+
.replace(/[\d,.\s]/g, '')
|
|
1268
|
+
.trim() || '$');
|
|
799
1269
|
}
|
|
800
1270
|
catch {
|
|
801
1271
|
return '$';
|
|
802
1272
|
}
|
|
803
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
1276
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
1277
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
1278
|
+
* of fifteen field types.
|
|
1279
|
+
*/
|
|
1280
|
+
get errorMessage() {
|
|
1281
|
+
if (!this.control?.errors || !this.control.touched)
|
|
1282
|
+
return '';
|
|
1283
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
1284
|
+
'required',
|
|
1285
|
+
'email',
|
|
1286
|
+
'min',
|
|
1287
|
+
'max',
|
|
1288
|
+
'minlength',
|
|
1289
|
+
'maxlength',
|
|
1290
|
+
'pattern',
|
|
1291
|
+
]);
|
|
1292
|
+
}
|
|
804
1293
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: CurrencyFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
805
1294
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: CurrencyFieldComponent, isStandalone: true, selector: "ngx-currency-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
806
|
-
<div
|
|
807
|
-
|
|
808
|
-
|
|
1295
|
+
<div
|
|
1296
|
+
class="ngx-field ngx-field--currency"
|
|
1297
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1298
|
+
[attr.data-field-type]="field.type"
|
|
1299
|
+
[class.ngx-field--readonly]="readonly"
|
|
1300
|
+
[class.ngx-field--masked]="masked"
|
|
1301
|
+
>
|
|
1302
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
809
1303
|
@if (masked) {
|
|
810
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1304
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1305
|
+
maskedText
|
|
1306
|
+
}}</span>
|
|
811
1307
|
} @else if (readonly) {
|
|
812
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'"
|
|
1308
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'"
|
|
1309
|
+
>{{ symbol }}{{ control.value }}</span
|
|
1310
|
+
>
|
|
813
1311
|
} @else {
|
|
814
1312
|
<div class="ngx-field__currency-wrap">
|
|
815
1313
|
<span class="ngx-field__currency-symbol">{{ symbol }}</span>
|
|
816
1314
|
<input
|
|
817
|
-
|
|
1315
|
+
[id]="domId()"
|
|
1316
|
+
class="ngx-field__input ngx-field__input--currency"
|
|
1317
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
818
1318
|
type="number"
|
|
819
1319
|
step="0.01"
|
|
820
1320
|
[formControl]="$any(control)"
|
|
@@ -822,8 +1322,8 @@ class CurrencyFieldComponent {
|
|
|
822
1322
|
[attr.disabled]="field.disabled ? true : null"
|
|
823
1323
|
/>
|
|
824
1324
|
</div>
|
|
825
|
-
@if (
|
|
826
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
1325
|
+
@if (errorMessage) {
|
|
1326
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
827
1327
|
}
|
|
828
1328
|
}
|
|
829
1329
|
</div>
|
|
@@ -837,18 +1337,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
837
1337
|
standalone: true,
|
|
838
1338
|
imports: [ReactiveFormsModule],
|
|
839
1339
|
template: `
|
|
840
|
-
<div
|
|
841
|
-
|
|
842
|
-
|
|
1340
|
+
<div
|
|
1341
|
+
class="ngx-field ngx-field--currency"
|
|
1342
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1343
|
+
[attr.data-field-type]="field.type"
|
|
1344
|
+
[class.ngx-field--readonly]="readonly"
|
|
1345
|
+
[class.ngx-field--masked]="masked"
|
|
1346
|
+
>
|
|
1347
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
843
1348
|
@if (masked) {
|
|
844
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1349
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1350
|
+
maskedText
|
|
1351
|
+
}}</span>
|
|
845
1352
|
} @else if (readonly) {
|
|
846
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'"
|
|
1353
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'"
|
|
1354
|
+
>{{ symbol }}{{ control.value }}</span
|
|
1355
|
+
>
|
|
847
1356
|
} @else {
|
|
848
1357
|
<div class="ngx-field__currency-wrap">
|
|
849
1358
|
<span class="ngx-field__currency-symbol">{{ symbol }}</span>
|
|
850
1359
|
<input
|
|
851
|
-
|
|
1360
|
+
[id]="domId()"
|
|
1361
|
+
class="ngx-field__input ngx-field__input--currency"
|
|
1362
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
852
1363
|
type="number"
|
|
853
1364
|
step="0.01"
|
|
854
1365
|
[formControl]="$any(control)"
|
|
@@ -856,8 +1367,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
856
1367
|
[attr.disabled]="field.disabled ? true : null"
|
|
857
1368
|
/>
|
|
858
1369
|
</div>
|
|
859
|
-
@if (
|
|
860
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
1370
|
+
@if (errorMessage) {
|
|
1371
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
861
1372
|
}
|
|
862
1373
|
}
|
|
863
1374
|
</div>
|
|
@@ -877,6 +1388,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
877
1388
|
|
|
878
1389
|
/** Email field: type="email" input with inline validation hint. */
|
|
879
1390
|
class EmailFieldComponent {
|
|
1391
|
+
/**
|
|
1392
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
1393
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
1394
|
+
*/
|
|
1395
|
+
instanceId = nextFieldInstanceId();
|
|
1396
|
+
domId(suffix = '') {
|
|
1397
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
1398
|
+
}
|
|
1399
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
1400
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
1401
|
+
messages = inject(ValidationMessagesService);
|
|
880
1402
|
field;
|
|
881
1403
|
control;
|
|
882
1404
|
language = 'en';
|
|
@@ -888,34 +1410,60 @@ class EmailFieldComponent {
|
|
|
888
1410
|
get placeholder() {
|
|
889
1411
|
return resolveLabel(this.field?.placeholder, this.language);
|
|
890
1412
|
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
1415
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
1416
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
1417
|
+
* of fifteen field types.
|
|
1418
|
+
*/
|
|
1419
|
+
get errorMessage() {
|
|
1420
|
+
if (!this.control?.errors || !this.control.touched)
|
|
1421
|
+
return '';
|
|
1422
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
1423
|
+
'required',
|
|
1424
|
+
'email',
|
|
1425
|
+
'min',
|
|
1426
|
+
'max',
|
|
1427
|
+
'minlength',
|
|
1428
|
+
'maxlength',
|
|
1429
|
+
'pattern',
|
|
1430
|
+
]);
|
|
1431
|
+
}
|
|
891
1432
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EmailFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
892
1433
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EmailFieldComponent, isStandalone: true, selector: "ngx-email-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
893
|
-
<div
|
|
894
|
-
|
|
895
|
-
|
|
1434
|
+
<div
|
|
1435
|
+
class="ngx-field ngx-field--email"
|
|
1436
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1437
|
+
[attr.data-field-type]="field.type"
|
|
1438
|
+
[class.ngx-field--readonly]="readonly"
|
|
1439
|
+
[class.ngx-field--masked]="masked"
|
|
1440
|
+
>
|
|
1441
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
896
1442
|
@if (masked) {
|
|
897
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1443
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1444
|
+
maskedText
|
|
1445
|
+
}}</span>
|
|
898
1446
|
} @else if (readonly) {
|
|
899
1447
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
|
|
900
1448
|
<a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value || '—' }}</a>
|
|
901
1449
|
</span>
|
|
902
1450
|
} @else {
|
|
903
1451
|
<input
|
|
904
|
-
|
|
1452
|
+
[id]="domId()"
|
|
1453
|
+
class="ngx-field__input"
|
|
1454
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
905
1455
|
type="email"
|
|
906
1456
|
autocomplete="email"
|
|
907
1457
|
[formControl]="$any(control)"
|
|
908
1458
|
[placeholder]="placeholder || 'you@example.com'"
|
|
909
1459
|
[attr.disabled]="field.disabled ? true : null"
|
|
910
1460
|
/>
|
|
911
|
-
@if (
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
|
|
918
|
-
}
|
|
1461
|
+
@if (errorMessage) {
|
|
1462
|
+
<!-- One branch, resolved by the service. The template used to spell out "Email is
|
|
1463
|
+
required" and "Enter a valid email address" itself, so a host configuring
|
|
1464
|
+
validationMessages was overridden by markup it could not reach. The service
|
|
1465
|
+
defaults say the same thing. -->
|
|
1466
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
919
1467
|
}
|
|
920
1468
|
}
|
|
921
1469
|
</div>
|
|
@@ -929,32 +1477,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
929
1477
|
standalone: true,
|
|
930
1478
|
imports: [ReactiveFormsModule],
|
|
931
1479
|
template: `
|
|
932
|
-
<div
|
|
933
|
-
|
|
934
|
-
|
|
1480
|
+
<div
|
|
1481
|
+
class="ngx-field ngx-field--email"
|
|
1482
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1483
|
+
[attr.data-field-type]="field.type"
|
|
1484
|
+
[class.ngx-field--readonly]="readonly"
|
|
1485
|
+
[class.ngx-field--masked]="masked"
|
|
1486
|
+
>
|
|
1487
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
935
1488
|
@if (masked) {
|
|
936
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1489
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1490
|
+
maskedText
|
|
1491
|
+
}}</span>
|
|
937
1492
|
} @else if (readonly) {
|
|
938
1493
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
|
|
939
1494
|
<a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value || '—' }}</a>
|
|
940
1495
|
</span>
|
|
941
1496
|
} @else {
|
|
942
1497
|
<input
|
|
943
|
-
|
|
1498
|
+
[id]="domId()"
|
|
1499
|
+
class="ngx-field__input"
|
|
1500
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
944
1501
|
type="email"
|
|
945
1502
|
autocomplete="email"
|
|
946
1503
|
[formControl]="$any(control)"
|
|
947
1504
|
[placeholder]="placeholder || 'you@example.com'"
|
|
948
1505
|
[attr.disabled]="field.disabled ? true : null"
|
|
949
1506
|
/>
|
|
950
|
-
@if (
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
|
|
957
|
-
}
|
|
1507
|
+
@if (errorMessage) {
|
|
1508
|
+
<!-- One branch, resolved by the service. The template used to spell out "Email is
|
|
1509
|
+
required" and "Enter a valid email address" itself, so a host configuring
|
|
1510
|
+
validationMessages was overridden by markup it could not reach. The service
|
|
1511
|
+
defaults say the same thing. -->
|
|
1512
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
958
1513
|
}
|
|
959
1514
|
}
|
|
960
1515
|
</div>
|
|
@@ -974,6 +1529,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
974
1529
|
|
|
975
1530
|
/** Password field: masked input with show/hide eye toggle. */
|
|
976
1531
|
class PasswordFieldComponent {
|
|
1532
|
+
/**
|
|
1533
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
1534
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
1535
|
+
*/
|
|
1536
|
+
instanceId = nextFieldInstanceId();
|
|
1537
|
+
domId(suffix = '') {
|
|
1538
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
1539
|
+
}
|
|
1540
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
1541
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
1542
|
+
messages = inject(ValidationMessagesService);
|
|
1543
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
1544
|
+
ui = inject(UiTextService);
|
|
977
1545
|
field;
|
|
978
1546
|
control;
|
|
979
1547
|
language = 'en';
|
|
@@ -989,19 +1557,47 @@ class PasswordFieldComponent {
|
|
|
989
1557
|
toggleVisibility() {
|
|
990
1558
|
this.visible.update(v => !v);
|
|
991
1559
|
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
1562
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
1563
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
1564
|
+
* of fifteen field types.
|
|
1565
|
+
*/
|
|
1566
|
+
get errorMessage() {
|
|
1567
|
+
if (!this.control?.errors || !this.control.touched)
|
|
1568
|
+
return '';
|
|
1569
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
1570
|
+
'required',
|
|
1571
|
+
'email',
|
|
1572
|
+
'min',
|
|
1573
|
+
'max',
|
|
1574
|
+
'minlength',
|
|
1575
|
+
'maxlength',
|
|
1576
|
+
'pattern',
|
|
1577
|
+
]);
|
|
1578
|
+
}
|
|
992
1579
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: PasswordFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
993
1580
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: PasswordFieldComponent, isStandalone: true, selector: "ngx-password-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
994
|
-
<div
|
|
995
|
-
|
|
996
|
-
|
|
1581
|
+
<div
|
|
1582
|
+
class="ngx-field ngx-field--password"
|
|
1583
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1584
|
+
[attr.data-field-type]="field.type"
|
|
1585
|
+
[class.ngx-field--readonly]="readonly"
|
|
1586
|
+
[class.ngx-field--masked]="masked"
|
|
1587
|
+
>
|
|
1588
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
997
1589
|
@if (masked) {
|
|
998
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1590
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1591
|
+
maskedText
|
|
1592
|
+
}}</span>
|
|
999
1593
|
} @else if (readonly) {
|
|
1000
1594
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">••••••••</span>
|
|
1001
1595
|
} @else {
|
|
1002
1596
|
<div class="ngx-field__password-wrap">
|
|
1003
1597
|
<input
|
|
1004
|
-
|
|
1598
|
+
[id]="domId()"
|
|
1599
|
+
class="ngx-field__input"
|
|
1600
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1005
1601
|
[type]="visible() ? 'text' : 'password'"
|
|
1006
1602
|
autocomplete="current-password"
|
|
1007
1603
|
[formControl]="$any(control)"
|
|
@@ -1012,13 +1608,13 @@ class PasswordFieldComponent {
|
|
|
1012
1608
|
type="button"
|
|
1013
1609
|
class="ngx-field__eye-btn"
|
|
1014
1610
|
(click)="toggleVisibility()"
|
|
1015
|
-
[attr.aria-label]="visible() ? '
|
|
1611
|
+
[attr.aria-label]="ui.text(visible() ? 'hidePassword' : 'showPassword', language)"
|
|
1016
1612
|
>
|
|
1017
1613
|
{{ visible() ? '🙈' : '👁' }}
|
|
1018
1614
|
</button>
|
|
1019
1615
|
</div>
|
|
1020
|
-
@if (
|
|
1021
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
1616
|
+
@if (errorMessage) {
|
|
1617
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1022
1618
|
}
|
|
1023
1619
|
}
|
|
1024
1620
|
</div>
|
|
@@ -1032,17 +1628,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1032
1628
|
standalone: true,
|
|
1033
1629
|
imports: [ReactiveFormsModule],
|
|
1034
1630
|
template: `
|
|
1035
|
-
<div
|
|
1036
|
-
|
|
1037
|
-
|
|
1631
|
+
<div
|
|
1632
|
+
class="ngx-field ngx-field--password"
|
|
1633
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1634
|
+
[attr.data-field-type]="field.type"
|
|
1635
|
+
[class.ngx-field--readonly]="readonly"
|
|
1636
|
+
[class.ngx-field--masked]="masked"
|
|
1637
|
+
>
|
|
1638
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
1038
1639
|
@if (masked) {
|
|
1039
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1640
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1641
|
+
maskedText
|
|
1642
|
+
}}</span>
|
|
1040
1643
|
} @else if (readonly) {
|
|
1041
1644
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">••••••••</span>
|
|
1042
1645
|
} @else {
|
|
1043
1646
|
<div class="ngx-field__password-wrap">
|
|
1044
1647
|
<input
|
|
1045
|
-
|
|
1648
|
+
[id]="domId()"
|
|
1649
|
+
class="ngx-field__input"
|
|
1650
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1046
1651
|
[type]="visible() ? 'text' : 'password'"
|
|
1047
1652
|
autocomplete="current-password"
|
|
1048
1653
|
[formControl]="$any(control)"
|
|
@@ -1053,13 +1658,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1053
1658
|
type="button"
|
|
1054
1659
|
class="ngx-field__eye-btn"
|
|
1055
1660
|
(click)="toggleVisibility()"
|
|
1056
|
-
[attr.aria-label]="visible() ? '
|
|
1661
|
+
[attr.aria-label]="ui.text(visible() ? 'hidePassword' : 'showPassword', language)"
|
|
1057
1662
|
>
|
|
1058
1663
|
{{ visible() ? '🙈' : '👁' }}
|
|
1059
1664
|
</button>
|
|
1060
1665
|
</div>
|
|
1061
|
-
@if (
|
|
1062
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
1666
|
+
@if (errorMessage) {
|
|
1667
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1063
1668
|
}
|
|
1064
1669
|
}
|
|
1065
1670
|
</div>
|
|
@@ -1078,6 +1683,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1078
1683
|
}] } });
|
|
1079
1684
|
|
|
1080
1685
|
class CheckboxFieldComponent {
|
|
1686
|
+
/**
|
|
1687
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
1688
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
1689
|
+
*/
|
|
1690
|
+
instanceId = nextFieldInstanceId();
|
|
1691
|
+
domId(suffix = '') {
|
|
1692
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
1693
|
+
}
|
|
1694
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
1695
|
+
ui = inject(UiTextService);
|
|
1696
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
1697
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
1698
|
+
messages = inject(ValidationMessagesService);
|
|
1081
1699
|
field;
|
|
1082
1700
|
control;
|
|
1083
1701
|
language = 'en';
|
|
@@ -1086,27 +1704,53 @@ class CheckboxFieldComponent {
|
|
|
1086
1704
|
get label() {
|
|
1087
1705
|
return resolveLabel(this.field?.label, this.language);
|
|
1088
1706
|
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Configured messages reach this field too.
|
|
1709
|
+
*
|
|
1710
|
+
* It rendered no error element at all, so a required checkbox left unticked told the user
|
|
1711
|
+
* nothing — and `validationMessages` could not reach a field that never displayed one.
|
|
1712
|
+
*/
|
|
1713
|
+
get errorMessage() {
|
|
1714
|
+
if (!this.control?.errors || !this.control.touched)
|
|
1715
|
+
return '';
|
|
1716
|
+
return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
|
|
1717
|
+
}
|
|
1089
1718
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: CheckboxFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1090
1719
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: CheckboxFieldComponent, isStandalone: true, selector: "ngx-checkbox-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1091
|
-
<div
|
|
1092
|
-
|
|
1720
|
+
<div
|
|
1721
|
+
class="ngx-field ngx-field--checkbox"
|
|
1722
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1723
|
+
[attr.data-field-type]="field.type"
|
|
1724
|
+
[class.ngx-field--readonly]="readonly"
|
|
1725
|
+
[class.ngx-field--masked]="masked"
|
|
1726
|
+
>
|
|
1093
1727
|
@if (masked) {
|
|
1094
1728
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1095
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1729
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1730
|
+
maskedText
|
|
1731
|
+
}}</span>
|
|
1096
1732
|
} @else if (readonly) {
|
|
1097
1733
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1098
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1734
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1735
|
+
control.value ? ui.text('yes', language) : ui.text('no', language)
|
|
1736
|
+
}}</span>
|
|
1099
1737
|
} @else {
|
|
1100
|
-
<label class="ngx-field__label" [attr.for]="
|
|
1738
|
+
<label class="ngx-field__label" [attr.for]="domId()">
|
|
1101
1739
|
<input
|
|
1102
|
-
[id]="
|
|
1103
|
-
class="ngx-field__input"
|
|
1740
|
+
[id]="domId()"
|
|
1741
|
+
class="ngx-field__input"
|
|
1742
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1104
1743
|
type="checkbox"
|
|
1105
1744
|
[formControl]="$any(control)"
|
|
1106
1745
|
[attr.disabled]="field.disabled ? true : null"
|
|
1107
1746
|
/>
|
|
1108
1747
|
{{ label }}
|
|
1109
1748
|
</label>
|
|
1749
|
+
@if (errorMessage) {
|
|
1750
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
1751
|
+
errorMessage
|
|
1752
|
+
}}</span>
|
|
1753
|
+
}
|
|
1110
1754
|
}
|
|
1111
1755
|
</div>
|
|
1112
1756
|
`, 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
@@ -1119,25 +1763,40 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1119
1763
|
standalone: true,
|
|
1120
1764
|
imports: [ReactiveFormsModule],
|
|
1121
1765
|
template: `
|
|
1122
|
-
<div
|
|
1123
|
-
|
|
1766
|
+
<div
|
|
1767
|
+
class="ngx-field ngx-field--checkbox"
|
|
1768
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1769
|
+
[attr.data-field-type]="field.type"
|
|
1770
|
+
[class.ngx-field--readonly]="readonly"
|
|
1771
|
+
[class.ngx-field--masked]="masked"
|
|
1772
|
+
>
|
|
1124
1773
|
@if (masked) {
|
|
1125
1774
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1126
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1775
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1776
|
+
maskedText
|
|
1777
|
+
}}</span>
|
|
1127
1778
|
} @else if (readonly) {
|
|
1128
1779
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1129
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1780
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1781
|
+
control.value ? ui.text('yes', language) : ui.text('no', language)
|
|
1782
|
+
}}</span>
|
|
1130
1783
|
} @else {
|
|
1131
|
-
<label class="ngx-field__label" [attr.for]="
|
|
1784
|
+
<label class="ngx-field__label" [attr.for]="domId()">
|
|
1132
1785
|
<input
|
|
1133
|
-
[id]="
|
|
1134
|
-
class="ngx-field__input"
|
|
1786
|
+
[id]="domId()"
|
|
1787
|
+
class="ngx-field__input"
|
|
1788
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1135
1789
|
type="checkbox"
|
|
1136
1790
|
[formControl]="$any(control)"
|
|
1137
1791
|
[attr.disabled]="field.disabled ? true : null"
|
|
1138
1792
|
/>
|
|
1139
1793
|
{{ label }}
|
|
1140
1794
|
</label>
|
|
1795
|
+
@if (errorMessage) {
|
|
1796
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
1797
|
+
errorMessage
|
|
1798
|
+
}}</span>
|
|
1799
|
+
}
|
|
1141
1800
|
}
|
|
1142
1801
|
</div>
|
|
1143
1802
|
`,
|
|
@@ -1160,6 +1819,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1160
1819
|
* toggle switch with Yes/No labels for clearer binary state communication.
|
|
1161
1820
|
*/
|
|
1162
1821
|
class BooleanFieldComponent {
|
|
1822
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
1823
|
+
ui = inject(UiTextService);
|
|
1824
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
1825
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
1826
|
+
messages = inject(ValidationMessagesService);
|
|
1163
1827
|
field;
|
|
1164
1828
|
control;
|
|
1165
1829
|
language = 'en';
|
|
@@ -1168,16 +1832,36 @@ class BooleanFieldComponent {
|
|
|
1168
1832
|
get label() {
|
|
1169
1833
|
return resolveLabel(this.field?.label, this.language);
|
|
1170
1834
|
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Configured messages reach this field too.
|
|
1837
|
+
*
|
|
1838
|
+
* It rendered no error element at all, so a required checkbox left unticked told the user
|
|
1839
|
+
* nothing — and `validationMessages` could not reach a field that never displayed one.
|
|
1840
|
+
*/
|
|
1841
|
+
get errorMessage() {
|
|
1842
|
+
if (!this.control?.errors || !this.control.touched)
|
|
1843
|
+
return '';
|
|
1844
|
+
return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
|
|
1845
|
+
}
|
|
1171
1846
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BooleanFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1172
1847
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: BooleanFieldComponent, isStandalone: true, selector: "ngx-boolean-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1173
|
-
<div
|
|
1174
|
-
|
|
1848
|
+
<div
|
|
1849
|
+
class="ngx-field ngx-field--boolean"
|
|
1850
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1851
|
+
[attr.data-field-type]="field.type"
|
|
1852
|
+
[class.ngx-field--readonly]="readonly"
|
|
1853
|
+
[class.ngx-field--masked]="masked"
|
|
1854
|
+
>
|
|
1175
1855
|
@if (masked) {
|
|
1176
1856
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1177
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1857
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1858
|
+
maskedText
|
|
1859
|
+
}}</span>
|
|
1178
1860
|
} @else if (readonly) {
|
|
1179
1861
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1180
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1862
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1863
|
+
control.value ? ui.text('yes', language) : ui.text('no', language)
|
|
1864
|
+
}}</span>
|
|
1181
1865
|
} @else {
|
|
1182
1866
|
<div class="ngx-field__toggle-wrap">
|
|
1183
1867
|
<label class="ngx-field__toggle-label">{{ label }}</label>
|
|
@@ -1193,9 +1877,16 @@ class BooleanFieldComponent {
|
|
|
1193
1877
|
<span class="ngx-field__toggle-track">
|
|
1194
1878
|
<span class="ngx-field__toggle-thumb"></span>
|
|
1195
1879
|
</span>
|
|
1196
|
-
<span class="ngx-field__toggle-text">{{
|
|
1880
|
+
<span class="ngx-field__toggle-text">{{
|
|
1881
|
+
control.value ? ui.text('yes', language) : ui.text('no', language)
|
|
1882
|
+
}}</span>
|
|
1197
1883
|
</label>
|
|
1198
1884
|
</div>
|
|
1885
|
+
@if (errorMessage) {
|
|
1886
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
1887
|
+
errorMessage
|
|
1888
|
+
}}</span>
|
|
1889
|
+
}
|
|
1199
1890
|
}
|
|
1200
1891
|
</div>
|
|
1201
1892
|
`, 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"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
@@ -1208,14 +1899,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1208
1899
|
standalone: true,
|
|
1209
1900
|
imports: [ReactiveFormsModule],
|
|
1210
1901
|
template: `
|
|
1211
|
-
<div
|
|
1212
|
-
|
|
1902
|
+
<div
|
|
1903
|
+
class="ngx-field ngx-field--boolean"
|
|
1904
|
+
[attr.data-testid]="'field-' + field.id"
|
|
1905
|
+
[attr.data-field-type]="field.type"
|
|
1906
|
+
[class.ngx-field--readonly]="readonly"
|
|
1907
|
+
[class.ngx-field--masked]="masked"
|
|
1908
|
+
>
|
|
1213
1909
|
@if (masked) {
|
|
1214
1910
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1215
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
1911
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
1912
|
+
maskedText
|
|
1913
|
+
}}</span>
|
|
1216
1914
|
} @else if (readonly) {
|
|
1217
1915
|
<label class="ngx-field__label">{{ label }}</label>
|
|
1218
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1916
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
1917
|
+
control.value ? ui.text('yes', language) : ui.text('no', language)
|
|
1918
|
+
}}</span>
|
|
1219
1919
|
} @else {
|
|
1220
1920
|
<div class="ngx-field__toggle-wrap">
|
|
1221
1921
|
<label class="ngx-field__toggle-label">{{ label }}</label>
|
|
@@ -1231,9 +1931,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1231
1931
|
<span class="ngx-field__toggle-track">
|
|
1232
1932
|
<span class="ngx-field__toggle-thumb"></span>
|
|
1233
1933
|
</span>
|
|
1234
|
-
<span class="ngx-field__toggle-text">{{
|
|
1934
|
+
<span class="ngx-field__toggle-text">{{
|
|
1935
|
+
control.value ? ui.text('yes', language) : ui.text('no', language)
|
|
1936
|
+
}}</span>
|
|
1235
1937
|
</label>
|
|
1236
1938
|
</div>
|
|
1939
|
+
@if (errorMessage) {
|
|
1940
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
1941
|
+
errorMessage
|
|
1942
|
+
}}</span>
|
|
1943
|
+
}
|
|
1237
1944
|
}
|
|
1238
1945
|
</div>
|
|
1239
1946
|
`,
|
|
@@ -1251,6 +1958,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1251
1958
|
}] } });
|
|
1252
1959
|
|
|
1253
1960
|
class DateFieldComponent {
|
|
1961
|
+
/**
|
|
1962
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
1963
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
1964
|
+
*/
|
|
1965
|
+
instanceId = nextFieldInstanceId();
|
|
1966
|
+
domId(suffix = '') {
|
|
1967
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
1968
|
+
}
|
|
1969
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
1970
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
1971
|
+
messages = inject(ValidationMessagesService);
|
|
1254
1972
|
field;
|
|
1255
1973
|
control;
|
|
1256
1974
|
language = 'en';
|
|
@@ -1270,25 +1988,54 @@ class DateFieldComponent {
|
|
|
1270
1988
|
const parsed = new Date(value);
|
|
1271
1989
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString();
|
|
1272
1990
|
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
1993
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
1994
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
1995
|
+
* of fifteen field types.
|
|
1996
|
+
*/
|
|
1997
|
+
get errorMessage() {
|
|
1998
|
+
if (!this.control?.errors || !this.control.touched)
|
|
1999
|
+
return '';
|
|
2000
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
2001
|
+
'required',
|
|
2002
|
+
'email',
|
|
2003
|
+
'min',
|
|
2004
|
+
'max',
|
|
2005
|
+
'minlength',
|
|
2006
|
+
'maxlength',
|
|
2007
|
+
'pattern',
|
|
2008
|
+
]);
|
|
2009
|
+
}
|
|
1273
2010
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1274
2011
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DateFieldComponent, isStandalone: true, selector: "ngx-date-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1275
|
-
<div
|
|
1276
|
-
|
|
1277
|
-
|
|
2012
|
+
<div
|
|
2013
|
+
class="ngx-field ngx-field--date"
|
|
2014
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2015
|
+
[attr.data-field-type]="field.type"
|
|
2016
|
+
[class.ngx-field--readonly]="readonly"
|
|
2017
|
+
[class.ngx-field--masked]="masked"
|
|
2018
|
+
>
|
|
2019
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
1278
2020
|
@if (masked) {
|
|
1279
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2021
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2022
|
+
maskedText
|
|
2023
|
+
}}</span>
|
|
1280
2024
|
} @else if (readonly) {
|
|
1281
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2025
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2026
|
+
formatDate(control.value)
|
|
2027
|
+
}}</span>
|
|
1282
2028
|
} @else {
|
|
1283
2029
|
<input
|
|
1284
|
-
[id]="
|
|
1285
|
-
class="ngx-field__input"
|
|
2030
|
+
[id]="domId()"
|
|
2031
|
+
class="ngx-field__input"
|
|
2032
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1286
2033
|
type="date"
|
|
1287
2034
|
[formControl]="$any(control)"
|
|
1288
2035
|
[attr.disabled]="field.disabled ? true : null"
|
|
1289
2036
|
/>
|
|
1290
|
-
@if (
|
|
1291
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2037
|
+
@if (errorMessage) {
|
|
2038
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1292
2039
|
}
|
|
1293
2040
|
}
|
|
1294
2041
|
</div>
|
|
@@ -1302,23 +2049,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1302
2049
|
standalone: true,
|
|
1303
2050
|
imports: [ReactiveFormsModule],
|
|
1304
2051
|
template: `
|
|
1305
|
-
<div
|
|
1306
|
-
|
|
1307
|
-
|
|
2052
|
+
<div
|
|
2053
|
+
class="ngx-field ngx-field--date"
|
|
2054
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2055
|
+
[attr.data-field-type]="field.type"
|
|
2056
|
+
[class.ngx-field--readonly]="readonly"
|
|
2057
|
+
[class.ngx-field--masked]="masked"
|
|
2058
|
+
>
|
|
2059
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
1308
2060
|
@if (masked) {
|
|
1309
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2061
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2062
|
+
maskedText
|
|
2063
|
+
}}</span>
|
|
1310
2064
|
} @else if (readonly) {
|
|
1311
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2065
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2066
|
+
formatDate(control.value)
|
|
2067
|
+
}}</span>
|
|
1312
2068
|
} @else {
|
|
1313
2069
|
<input
|
|
1314
|
-
[id]="
|
|
1315
|
-
class="ngx-field__input"
|
|
2070
|
+
[id]="domId()"
|
|
2071
|
+
class="ngx-field__input"
|
|
2072
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1316
2073
|
type="date"
|
|
1317
2074
|
[formControl]="$any(control)"
|
|
1318
2075
|
[attr.disabled]="field.disabled ? true : null"
|
|
1319
2076
|
/>
|
|
1320
|
-
@if (
|
|
1321
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2077
|
+
@if (errorMessage) {
|
|
2078
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1322
2079
|
}
|
|
1323
2080
|
}
|
|
1324
2081
|
</div>
|
|
@@ -1365,6 +2122,17 @@ const pad = (n) => String(n).padStart(2, '0');
|
|
|
1365
2122
|
* conversions here are what keep a value stable across a round trip.
|
|
1366
2123
|
*/
|
|
1367
2124
|
class DateTimeFieldComponent {
|
|
2125
|
+
/**
|
|
2126
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
2127
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
2128
|
+
*/
|
|
2129
|
+
instanceId = nextFieldInstanceId();
|
|
2130
|
+
domId(suffix = '') {
|
|
2131
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
2132
|
+
}
|
|
2133
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
2134
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
2135
|
+
messages = inject(ValidationMessagesService);
|
|
1368
2136
|
field;
|
|
1369
2137
|
control;
|
|
1370
2138
|
language = 'en';
|
|
@@ -1402,26 +2170,55 @@ class DateTimeFieldComponent {
|
|
|
1402
2170
|
const d = parseStored(value);
|
|
1403
2171
|
return d ? d.toLocaleString() : '—';
|
|
1404
2172
|
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
2175
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
2176
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
2177
|
+
* of fifteen field types.
|
|
2178
|
+
*/
|
|
2179
|
+
get errorMessage() {
|
|
2180
|
+
if (!this.control?.errors || !this.control.touched)
|
|
2181
|
+
return '';
|
|
2182
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
2183
|
+
'required',
|
|
2184
|
+
'email',
|
|
2185
|
+
'min',
|
|
2186
|
+
'max',
|
|
2187
|
+
'minlength',
|
|
2188
|
+
'maxlength',
|
|
2189
|
+
'pattern',
|
|
2190
|
+
]);
|
|
2191
|
+
}
|
|
1405
2192
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateTimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1406
2193
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DateTimeFieldComponent, isStandalone: true, selector: "ngx-date-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1407
|
-
<div
|
|
1408
|
-
|
|
1409
|
-
|
|
2194
|
+
<div
|
|
2195
|
+
class="ngx-field ngx-field--datetime"
|
|
2196
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2197
|
+
[attr.data-field-type]="field.type"
|
|
2198
|
+
[class.ngx-field--readonly]="readonly"
|
|
2199
|
+
[class.ngx-field--masked]="masked"
|
|
2200
|
+
>
|
|
2201
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
1410
2202
|
@if (masked) {
|
|
1411
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2203
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2204
|
+
maskedText
|
|
2205
|
+
}}</span>
|
|
1412
2206
|
} @else if (readonly) {
|
|
1413
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2207
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2208
|
+
formatDateTime(control.value)
|
|
2209
|
+
}}</span>
|
|
1414
2210
|
} @else {
|
|
1415
2211
|
<input
|
|
1416
|
-
[id]="
|
|
1417
|
-
class="ngx-field__input"
|
|
2212
|
+
[id]="domId()"
|
|
2213
|
+
class="ngx-field__input"
|
|
2214
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1418
2215
|
type="datetime-local"
|
|
1419
2216
|
[value]="inputValue"
|
|
1420
2217
|
(change)="onInput($any($event.target).value)"
|
|
1421
2218
|
[attr.disabled]="field.disabled ? true : null"
|
|
1422
2219
|
/>
|
|
1423
|
-
@if (
|
|
1424
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2220
|
+
@if (errorMessage) {
|
|
2221
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1425
2222
|
}
|
|
1426
2223
|
}
|
|
1427
2224
|
</div>
|
|
@@ -1435,24 +2232,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1435
2232
|
standalone: true,
|
|
1436
2233
|
imports: [ReactiveFormsModule],
|
|
1437
2234
|
template: `
|
|
1438
|
-
<div
|
|
1439
|
-
|
|
1440
|
-
|
|
2235
|
+
<div
|
|
2236
|
+
class="ngx-field ngx-field--datetime"
|
|
2237
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2238
|
+
[attr.data-field-type]="field.type"
|
|
2239
|
+
[class.ngx-field--readonly]="readonly"
|
|
2240
|
+
[class.ngx-field--masked]="masked"
|
|
2241
|
+
>
|
|
2242
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
1441
2243
|
@if (masked) {
|
|
1442
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2244
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2245
|
+
maskedText
|
|
2246
|
+
}}</span>
|
|
1443
2247
|
} @else if (readonly) {
|
|
1444
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2248
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2249
|
+
formatDateTime(control.value)
|
|
2250
|
+
}}</span>
|
|
1445
2251
|
} @else {
|
|
1446
2252
|
<input
|
|
1447
|
-
[id]="
|
|
1448
|
-
class="ngx-field__input"
|
|
2253
|
+
[id]="domId()"
|
|
2254
|
+
class="ngx-field__input"
|
|
2255
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1449
2256
|
type="datetime-local"
|
|
1450
2257
|
[value]="inputValue"
|
|
1451
2258
|
(change)="onInput($any($event.target).value)"
|
|
1452
2259
|
[attr.disabled]="field.disabled ? true : null"
|
|
1453
2260
|
/>
|
|
1454
|
-
@if (
|
|
1455
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2261
|
+
@if (errorMessage) {
|
|
2262
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1456
2263
|
}
|
|
1457
2264
|
}
|
|
1458
2265
|
</div>
|
|
@@ -1479,6 +2286,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1479
2286
|
* and converting it to UTC would move it whenever the offset changed.
|
|
1480
2287
|
*/
|
|
1481
2288
|
class TimeFieldComponent {
|
|
2289
|
+
/**
|
|
2290
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
2291
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
2292
|
+
*/
|
|
2293
|
+
instanceId = nextFieldInstanceId();
|
|
2294
|
+
domId(suffix = '') {
|
|
2295
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
2296
|
+
}
|
|
2297
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
2298
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
2299
|
+
messages = inject(ValidationMessagesService);
|
|
1482
2300
|
field;
|
|
1483
2301
|
control;
|
|
1484
2302
|
language = 'en';
|
|
@@ -1495,25 +2313,54 @@ class TimeFieldComponent {
|
|
|
1495
2313
|
formatTime(value) {
|
|
1496
2314
|
return formatDisplayValue('time', undefined, value, this.language);
|
|
1497
2315
|
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
2318
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
2319
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
2320
|
+
* of fifteen field types.
|
|
2321
|
+
*/
|
|
2322
|
+
get errorMessage() {
|
|
2323
|
+
if (!this.control?.errors || !this.control.touched)
|
|
2324
|
+
return '';
|
|
2325
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
2326
|
+
'required',
|
|
2327
|
+
'email',
|
|
2328
|
+
'min',
|
|
2329
|
+
'max',
|
|
2330
|
+
'minlength',
|
|
2331
|
+
'maxlength',
|
|
2332
|
+
'pattern',
|
|
2333
|
+
]);
|
|
2334
|
+
}
|
|
1498
2335
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1499
2336
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: TimeFieldComponent, isStandalone: true, selector: "ngx-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1500
|
-
<div
|
|
1501
|
-
|
|
1502
|
-
|
|
2337
|
+
<div
|
|
2338
|
+
class="ngx-field ngx-field--time"
|
|
2339
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2340
|
+
[attr.data-field-type]="field.type"
|
|
2341
|
+
[class.ngx-field--readonly]="readonly"
|
|
2342
|
+
[class.ngx-field--masked]="masked"
|
|
2343
|
+
>
|
|
2344
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
1503
2345
|
@if (masked) {
|
|
1504
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2346
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2347
|
+
maskedText
|
|
2348
|
+
}}</span>
|
|
1505
2349
|
} @else if (readonly) {
|
|
1506
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2350
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2351
|
+
formatTime(control.value)
|
|
2352
|
+
}}</span>
|
|
1507
2353
|
} @else {
|
|
1508
2354
|
<input
|
|
1509
|
-
[id]="
|
|
1510
|
-
class="ngx-field__input"
|
|
2355
|
+
[id]="domId()"
|
|
2356
|
+
class="ngx-field__input"
|
|
2357
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1511
2358
|
type="time"
|
|
1512
2359
|
[formControl]="$any(control)"
|
|
1513
2360
|
[attr.disabled]="field.disabled ? true : null"
|
|
1514
2361
|
/>
|
|
1515
|
-
@if (
|
|
1516
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2362
|
+
@if (errorMessage) {
|
|
2363
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1517
2364
|
}
|
|
1518
2365
|
}
|
|
1519
2366
|
</div>
|
|
@@ -1527,23 +2374,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1527
2374
|
standalone: true,
|
|
1528
2375
|
imports: [ReactiveFormsModule],
|
|
1529
2376
|
template: `
|
|
1530
|
-
<div
|
|
1531
|
-
|
|
1532
|
-
|
|
2377
|
+
<div
|
|
2378
|
+
class="ngx-field ngx-field--time"
|
|
2379
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2380
|
+
[attr.data-field-type]="field.type"
|
|
2381
|
+
[class.ngx-field--readonly]="readonly"
|
|
2382
|
+
[class.ngx-field--masked]="masked"
|
|
2383
|
+
>
|
|
2384
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
1533
2385
|
@if (masked) {
|
|
1534
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2386
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2387
|
+
maskedText
|
|
2388
|
+
}}</span>
|
|
1535
2389
|
} @else if (readonly) {
|
|
1536
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2390
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2391
|
+
formatTime(control.value)
|
|
2392
|
+
}}</span>
|
|
1537
2393
|
} @else {
|
|
1538
2394
|
<input
|
|
1539
|
-
[id]="
|
|
1540
|
-
class="ngx-field__input"
|
|
2395
|
+
[id]="domId()"
|
|
2396
|
+
class="ngx-field__input"
|
|
2397
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
1541
2398
|
type="time"
|
|
1542
2399
|
[formControl]="$any(control)"
|
|
1543
2400
|
[attr.disabled]="field.disabled ? true : null"
|
|
1544
2401
|
/>
|
|
1545
|
-
@if (
|
|
1546
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2402
|
+
@if (errorMessage) {
|
|
2403
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1547
2404
|
}
|
|
1548
2405
|
}
|
|
1549
2406
|
</div>
|
|
@@ -1562,8 +2419,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1562
2419
|
}] } });
|
|
1563
2420
|
|
|
1564
2421
|
const MONTH_NAMES = [
|
|
1565
|
-
'January',
|
|
1566
|
-
'
|
|
2422
|
+
'January',
|
|
2423
|
+
'February',
|
|
2424
|
+
'March',
|
|
2425
|
+
'April',
|
|
2426
|
+
'May',
|
|
2427
|
+
'June',
|
|
2428
|
+
'July',
|
|
2429
|
+
'August',
|
|
2430
|
+
'September',
|
|
2431
|
+
'October',
|
|
2432
|
+
'November',
|
|
2433
|
+
'December',
|
|
1567
2434
|
];
|
|
1568
2435
|
const CURRENT_YEAR = new Date().getFullYear();
|
|
1569
2436
|
const YEARS = Array.from({ length: 80 }, (_, i) => CURRENT_YEAR - i);
|
|
@@ -1572,6 +2439,19 @@ const YEARS = Array.from({ length: 80 }, (_, i) => CURRENT_YEAR - i);
|
|
|
1572
2439
|
* Value stored as ISO partial date string "YYYY-MM" for unambiguous storage.
|
|
1573
2440
|
*/
|
|
1574
2441
|
class MonthYearFieldComponent {
|
|
2442
|
+
/**
|
|
2443
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
2444
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
2445
|
+
*/
|
|
2446
|
+
instanceId = nextFieldInstanceId();
|
|
2447
|
+
domId(suffix = '') {
|
|
2448
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
2449
|
+
}
|
|
2450
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
2451
|
+
ui = inject(UiTextService);
|
|
2452
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
2453
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
2454
|
+
messages = inject(ValidationMessagesService);
|
|
1575
2455
|
field;
|
|
1576
2456
|
control;
|
|
1577
2457
|
language = 'en';
|
|
@@ -1608,42 +2488,82 @@ class MonthYearFieldComponent {
|
|
|
1608
2488
|
const monthName = MONTH_NAMES[parseInt(monthNum, 10) - 1];
|
|
1609
2489
|
return monthName && year ? `${monthName} ${year}` : String(value);
|
|
1610
2490
|
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
2493
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
2494
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
2495
|
+
* of fifteen field types.
|
|
2496
|
+
*/
|
|
2497
|
+
get errorMessage() {
|
|
2498
|
+
if (!this.control?.errors || !this.control.touched)
|
|
2499
|
+
return '';
|
|
2500
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
2501
|
+
'required',
|
|
2502
|
+
'email',
|
|
2503
|
+
'min',
|
|
2504
|
+
'max',
|
|
2505
|
+
'minlength',
|
|
2506
|
+
'maxlength',
|
|
2507
|
+
'pattern',
|
|
2508
|
+
]);
|
|
2509
|
+
}
|
|
1611
2510
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MonthYearFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1612
2511
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: MonthYearFieldComponent, isStandalone: true, selector: "ngx-month-year-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1613
|
-
<div
|
|
1614
|
-
|
|
1615
|
-
|
|
2512
|
+
<div
|
|
2513
|
+
class="ngx-field ngx-field--month-year"
|
|
2514
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2515
|
+
[attr.data-field-type]="field.type"
|
|
2516
|
+
[class.ngx-field--readonly]="readonly"
|
|
2517
|
+
[class.ngx-field--masked]="masked"
|
|
2518
|
+
>
|
|
2519
|
+
<!--
|
|
2520
|
+
Points at the month select: one visible label stands in front of two controls, and a
|
|
2521
|
+
label pointing at neither of them announces as nothing and focuses nothing when
|
|
2522
|
+
clicked. Each select then carries its own name below, because "Billing Cycle" alone
|
|
2523
|
+
does not say which half of the pair the screen reader has landed on.
|
|
2524
|
+
-->
|
|
2525
|
+
<label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
|
|
1616
2526
|
@if (masked) {
|
|
1617
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2527
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2528
|
+
maskedText
|
|
2529
|
+
}}</span>
|
|
1618
2530
|
} @else if (readonly) {
|
|
1619
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2531
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2532
|
+
formatValue(control.value)
|
|
2533
|
+
}}</span>
|
|
1620
2534
|
} @else {
|
|
1621
2535
|
<div class="ngx-field__month-year-wrap">
|
|
1622
2536
|
<select
|
|
1623
|
-
|
|
2537
|
+
[id]="domId('-month')"
|
|
2538
|
+
class="ngx-field__input ngx-field__input--month"
|
|
2539
|
+
[attr.aria-label]="label + ' ' + ui.text('month', language)"
|
|
2540
|
+
[attr.data-testid]="'field-' + field.id + '-month'"
|
|
1624
2541
|
[value]="selectedMonth"
|
|
1625
2542
|
(change)="onMonthChange($any($event.target).value)"
|
|
1626
2543
|
[attr.disabled]="field.disabled ? true : null"
|
|
1627
2544
|
>
|
|
1628
|
-
<option value="">
|
|
2545
|
+
<option value="">{{ ui.text('month', language) }}</option>
|
|
1629
2546
|
@for (m of months; track m.value) {
|
|
1630
2547
|
<option [value]="m.value">{{ m.label }}</option>
|
|
1631
2548
|
}
|
|
1632
2549
|
</select>
|
|
1633
2550
|
<select
|
|
1634
|
-
|
|
2551
|
+
[id]="domId('-year')"
|
|
2552
|
+
class="ngx-field__input ngx-field__input--year"
|
|
2553
|
+
[attr.aria-label]="label + ' ' + ui.text('year', language)"
|
|
2554
|
+
[attr.data-testid]="'field-' + field.id + '-year'"
|
|
1635
2555
|
[value]="selectedYear"
|
|
1636
2556
|
(change)="onYearChange($any($event.target).value)"
|
|
1637
2557
|
[attr.disabled]="field.disabled ? true : null"
|
|
1638
2558
|
>
|
|
1639
|
-
<option value="">
|
|
2559
|
+
<option value="">{{ ui.text('year', language) }}</option>
|
|
1640
2560
|
@for (y of years; track y) {
|
|
1641
2561
|
<option [value]="y">{{ y }}</option>
|
|
1642
2562
|
}
|
|
1643
2563
|
</select>
|
|
1644
2564
|
</div>
|
|
1645
|
-
@if (
|
|
1646
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2565
|
+
@if (errorMessage) {
|
|
2566
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1647
2567
|
}
|
|
1648
2568
|
}
|
|
1649
2569
|
</div>
|
|
@@ -1657,40 +2577,61 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1657
2577
|
standalone: true,
|
|
1658
2578
|
imports: [ReactiveFormsModule],
|
|
1659
2579
|
template: `
|
|
1660
|
-
<div
|
|
1661
|
-
|
|
1662
|
-
|
|
2580
|
+
<div
|
|
2581
|
+
class="ngx-field ngx-field--month-year"
|
|
2582
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2583
|
+
[attr.data-field-type]="field.type"
|
|
2584
|
+
[class.ngx-field--readonly]="readonly"
|
|
2585
|
+
[class.ngx-field--masked]="masked"
|
|
2586
|
+
>
|
|
2587
|
+
<!--
|
|
2588
|
+
Points at the month select: one visible label stands in front of two controls, and a
|
|
2589
|
+
label pointing at neither of them announces as nothing and focuses nothing when
|
|
2590
|
+
clicked. Each select then carries its own name below, because "Billing Cycle" alone
|
|
2591
|
+
does not say which half of the pair the screen reader has landed on.
|
|
2592
|
+
-->
|
|
2593
|
+
<label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
|
|
1663
2594
|
@if (masked) {
|
|
1664
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2595
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2596
|
+
maskedText
|
|
2597
|
+
}}</span>
|
|
1665
2598
|
} @else if (readonly) {
|
|
1666
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2599
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2600
|
+
formatValue(control.value)
|
|
2601
|
+
}}</span>
|
|
1667
2602
|
} @else {
|
|
1668
2603
|
<div class="ngx-field__month-year-wrap">
|
|
1669
2604
|
<select
|
|
1670
|
-
|
|
2605
|
+
[id]="domId('-month')"
|
|
2606
|
+
class="ngx-field__input ngx-field__input--month"
|
|
2607
|
+
[attr.aria-label]="label + ' ' + ui.text('month', language)"
|
|
2608
|
+
[attr.data-testid]="'field-' + field.id + '-month'"
|
|
1671
2609
|
[value]="selectedMonth"
|
|
1672
2610
|
(change)="onMonthChange($any($event.target).value)"
|
|
1673
2611
|
[attr.disabled]="field.disabled ? true : null"
|
|
1674
2612
|
>
|
|
1675
|
-
<option value="">
|
|
2613
|
+
<option value="">{{ ui.text('month', language) }}</option>
|
|
1676
2614
|
@for (m of months; track m.value) {
|
|
1677
2615
|
<option [value]="m.value">{{ m.label }}</option>
|
|
1678
2616
|
}
|
|
1679
2617
|
</select>
|
|
1680
2618
|
<select
|
|
1681
|
-
|
|
2619
|
+
[id]="domId('-year')"
|
|
2620
|
+
class="ngx-field__input ngx-field__input--year"
|
|
2621
|
+
[attr.aria-label]="label + ' ' + ui.text('year', language)"
|
|
2622
|
+
[attr.data-testid]="'field-' + field.id + '-year'"
|
|
1682
2623
|
[value]="selectedYear"
|
|
1683
2624
|
(change)="onYearChange($any($event.target).value)"
|
|
1684
2625
|
[attr.disabled]="field.disabled ? true : null"
|
|
1685
2626
|
>
|
|
1686
|
-
<option value="">
|
|
2627
|
+
<option value="">{{ ui.text('year', language) }}</option>
|
|
1687
2628
|
@for (y of years; track y) {
|
|
1688
2629
|
<option [value]="y">{{ y }}</option>
|
|
1689
2630
|
}
|
|
1690
2631
|
</select>
|
|
1691
2632
|
</div>
|
|
1692
|
-
@if (
|
|
1693
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
2633
|
+
@if (errorMessage) {
|
|
2634
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
1694
2635
|
}
|
|
1695
2636
|
}
|
|
1696
2637
|
</div>
|
|
@@ -1951,6 +2892,18 @@ function refreshChoiceOptions(host, lookups) {
|
|
|
1951
2892
|
}
|
|
1952
2893
|
|
|
1953
2894
|
class DropdownFieldComponent {
|
|
2895
|
+
/**
|
|
2896
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
2897
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
2898
|
+
*/
|
|
2899
|
+
instanceId = nextFieldInstanceId();
|
|
2900
|
+
domId(suffix = '') {
|
|
2901
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
2902
|
+
}
|
|
2903
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
2904
|
+
ui = inject(UiTextService);
|
|
2905
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
2906
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
1954
2907
|
lookups = inject(LookupRegistryService);
|
|
1955
2908
|
messages = inject(ValidationMessagesService);
|
|
1956
2909
|
_field;
|
|
@@ -2019,30 +2972,38 @@ class DropdownFieldComponent {
|
|
|
2019
2972
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DropdownFieldComponent, isStandalone: true, selector: "ngx-dropdown-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
2020
2973
|
<div
|
|
2021
2974
|
class="ngx-field ngx-field--dropdown"
|
|
2022
|
-
[attr.data-testid]="'field-' + field.id"
|
|
2975
|
+
[attr.data-testid]="'field-' + field.id"
|
|
2976
|
+
[attr.data-field-type]="field.type"
|
|
2023
2977
|
[class.ngx-field--readonly]="readonly"
|
|
2024
2978
|
[class.ngx-field--masked]="masked"
|
|
2025
2979
|
[class.ngx-field--invalid]="control && control.invalid && control.touched"
|
|
2026
2980
|
>
|
|
2027
|
-
<label class="ngx-field__label" [attr.for]="
|
|
2981
|
+
<label class="ngx-field__label" [attr.for]="domId()">
|
|
2028
2982
|
{{ label }}
|
|
2029
|
-
@if (field.validators?.required) {
|
|
2983
|
+
@if (field.validators?.required) {
|
|
2984
|
+
<span class="ngx-field__req">*</span>
|
|
2985
|
+
}
|
|
2030
2986
|
</label>
|
|
2031
2987
|
@if (masked) {
|
|
2032
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
2988
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
2989
|
+
maskedText
|
|
2990
|
+
}}</span>
|
|
2033
2991
|
} @else if (readonly) {
|
|
2034
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2992
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
2993
|
+
getLabel(control.value)
|
|
2994
|
+
}}</span>
|
|
2035
2995
|
} @else {
|
|
2036
2996
|
<select
|
|
2037
|
-
[id]="
|
|
2038
|
-
class="ngx-field__input"
|
|
2997
|
+
[id]="domId()"
|
|
2998
|
+
class="ngx-field__input"
|
|
2999
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
2039
3000
|
[formControl]="$any(control)"
|
|
2040
3001
|
[compareWith]="compareFn"
|
|
2041
3002
|
[attr.disabled]="field.disabled ? true : null"
|
|
2042
3003
|
[attr.aria-invalid]="control.invalid && control.touched"
|
|
2043
|
-
[attr.aria-describedby]="errorMessage ?
|
|
3004
|
+
[attr.aria-describedby]="errorMessage ? domId('-error') : null"
|
|
2044
3005
|
>
|
|
2045
|
-
<option [value]="''">{{ placeholder || '
|
|
3006
|
+
<option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
|
|
2046
3007
|
@for (option of options(); track getOptLabel(option)) {
|
|
2047
3008
|
@if (isObjectVal(option)) {
|
|
2048
3009
|
<option [ngValue]="getOptStoredVal(option)">{{ getOptLabel(option) }}</option>
|
|
@@ -2052,7 +3013,13 @@ class DropdownFieldComponent {
|
|
|
2052
3013
|
}
|
|
2053
3014
|
</select>
|
|
2054
3015
|
@if (errorMessage) {
|
|
2055
|
-
<span
|
|
3016
|
+
<span
|
|
3017
|
+
class="ngx-field__error"
|
|
3018
|
+
[attr.data-testid]="'field-' + field.id + '-error'"
|
|
3019
|
+
[id]="domId('-error')"
|
|
3020
|
+
role="alert"
|
|
3021
|
+
>{{ errorMessage }}</span
|
|
3022
|
+
>
|
|
2056
3023
|
}
|
|
2057
3024
|
}
|
|
2058
3025
|
</div>
|
|
@@ -2068,30 +3035,38 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2068
3035
|
template: `
|
|
2069
3036
|
<div
|
|
2070
3037
|
class="ngx-field ngx-field--dropdown"
|
|
2071
|
-
[attr.data-testid]="'field-' + field.id"
|
|
3038
|
+
[attr.data-testid]="'field-' + field.id"
|
|
3039
|
+
[attr.data-field-type]="field.type"
|
|
2072
3040
|
[class.ngx-field--readonly]="readonly"
|
|
2073
3041
|
[class.ngx-field--masked]="masked"
|
|
2074
3042
|
[class.ngx-field--invalid]="control && control.invalid && control.touched"
|
|
2075
3043
|
>
|
|
2076
|
-
<label class="ngx-field__label" [attr.for]="
|
|
3044
|
+
<label class="ngx-field__label" [attr.for]="domId()">
|
|
2077
3045
|
{{ label }}
|
|
2078
|
-
@if (field.validators?.required) {
|
|
3046
|
+
@if (field.validators?.required) {
|
|
3047
|
+
<span class="ngx-field__req">*</span>
|
|
3048
|
+
}
|
|
2079
3049
|
</label>
|
|
2080
3050
|
@if (masked) {
|
|
2081
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
3051
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
3052
|
+
maskedText
|
|
3053
|
+
}}</span>
|
|
2082
3054
|
} @else if (readonly) {
|
|
2083
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3055
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3056
|
+
getLabel(control.value)
|
|
3057
|
+
}}</span>
|
|
2084
3058
|
} @else {
|
|
2085
3059
|
<select
|
|
2086
|
-
[id]="
|
|
2087
|
-
class="ngx-field__input"
|
|
3060
|
+
[id]="domId()"
|
|
3061
|
+
class="ngx-field__input"
|
|
3062
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
2088
3063
|
[formControl]="$any(control)"
|
|
2089
3064
|
[compareWith]="compareFn"
|
|
2090
3065
|
[attr.disabled]="field.disabled ? true : null"
|
|
2091
3066
|
[attr.aria-invalid]="control.invalid && control.touched"
|
|
2092
|
-
[attr.aria-describedby]="errorMessage ?
|
|
3067
|
+
[attr.aria-describedby]="errorMessage ? domId('-error') : null"
|
|
2093
3068
|
>
|
|
2094
|
-
<option [value]="''">{{ placeholder || '
|
|
3069
|
+
<option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
|
|
2095
3070
|
@for (option of options(); track getOptLabel(option)) {
|
|
2096
3071
|
@if (isObjectVal(option)) {
|
|
2097
3072
|
<option [ngValue]="getOptStoredVal(option)">{{ getOptLabel(option) }}</option>
|
|
@@ -2101,7 +3076,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2101
3076
|
}
|
|
2102
3077
|
</select>
|
|
2103
3078
|
@if (errorMessage) {
|
|
2104
|
-
<span
|
|
3079
|
+
<span
|
|
3080
|
+
class="ngx-field__error"
|
|
3081
|
+
[attr.data-testid]="'field-' + field.id + '-error'"
|
|
3082
|
+
[id]="domId('-error')"
|
|
3083
|
+
role="alert"
|
|
3084
|
+
>{{ errorMessage }}</span
|
|
3085
|
+
>
|
|
2105
3086
|
}
|
|
2106
3087
|
}
|
|
2107
3088
|
</div>
|
|
@@ -2121,6 +3102,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2121
3102
|
|
|
2122
3103
|
/** Radio field: a group of radio buttons built from field.options. */
|
|
2123
3104
|
class RadioFieldComponent {
|
|
3105
|
+
/**
|
|
3106
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
3107
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
3108
|
+
*/
|
|
3109
|
+
instanceId = nextFieldInstanceId();
|
|
3110
|
+
domId(suffix = '') {
|
|
3111
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
3112
|
+
}
|
|
3113
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
3114
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
3115
|
+
messages = inject(ValidationMessagesService);
|
|
2124
3116
|
lookups = inject(LookupRegistryService);
|
|
2125
3117
|
_field;
|
|
2126
3118
|
_language = 'en';
|
|
@@ -2156,12 +3148,19 @@ class RadioFieldComponent {
|
|
|
2156
3148
|
* into an id attribute.
|
|
2157
3149
|
*/
|
|
2158
3150
|
getRadioId(option) {
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
3151
|
+
return this.domId(`-${this.optionSlug(option)}`);
|
|
3152
|
+
}
|
|
3153
|
+
/**
|
|
3154
|
+
* The option's part of an id or a test hook.
|
|
3155
|
+
*
|
|
3156
|
+
* `??` cannot fire here: `resolveOptionValue` returns '' for an empty option, not null, so
|
|
3157
|
+
* every valueless option produced the same slug — and two radios sharing an id break the
|
|
3158
|
+
* `for` that ties each label to its input.
|
|
3159
|
+
*/
|
|
3160
|
+
optionSlug(option) {
|
|
2162
3161
|
const raw = String(this.getOptVal(option) ?? '').trim();
|
|
2163
3162
|
const text = raw || 'opt';
|
|
2164
|
-
return
|
|
3163
|
+
return text.trim().replace(/\s+/g, '_').toLowerCase();
|
|
2165
3164
|
}
|
|
2166
3165
|
getOptVal(option) {
|
|
2167
3166
|
return resolveOptionValue(option, this.language);
|
|
@@ -2184,14 +3183,40 @@ class RadioFieldComponent {
|
|
|
2184
3183
|
return cached;
|
|
2185
3184
|
return typeof value === 'object' ? resolveLabel(value, this.language) : String(value ?? '—');
|
|
2186
3185
|
}
|
|
3186
|
+
/**
|
|
3187
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
3188
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
3189
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
3190
|
+
* of fifteen field types.
|
|
3191
|
+
*/
|
|
3192
|
+
get errorMessage() {
|
|
3193
|
+
if (!this.control?.errors || !this.control.touched)
|
|
3194
|
+
return '';
|
|
3195
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
3196
|
+
'required',
|
|
3197
|
+
'email',
|
|
3198
|
+
'min',
|
|
3199
|
+
'max',
|
|
3200
|
+
'minlength',
|
|
3201
|
+
'maxlength',
|
|
3202
|
+
'pattern',
|
|
3203
|
+
]);
|
|
3204
|
+
}
|
|
2187
3205
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RadioFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2188
3206
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: RadioFieldComponent, isStandalone: true, selector: "ngx-radio-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
2189
|
-
<div
|
|
2190
|
-
|
|
3207
|
+
<div
|
|
3208
|
+
class="ngx-field ngx-field--radio"
|
|
3209
|
+
[attr.data-testid]="'field-' + field.id"
|
|
3210
|
+
[attr.data-field-type]="field.type"
|
|
3211
|
+
[class.ngx-field--readonly]="readonly"
|
|
3212
|
+
[class.ngx-field--masked]="masked"
|
|
3213
|
+
>
|
|
2191
3214
|
<fieldset class="ngx-field__fieldset">
|
|
2192
3215
|
<legend class="ngx-field__label">{{ label }}</legend>
|
|
2193
3216
|
@if (masked) {
|
|
2194
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
3217
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
3218
|
+
maskedText
|
|
3219
|
+
}}</span>
|
|
2195
3220
|
} @else if (readonly) {
|
|
2196
3221
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getSelectedLabel() }}</span>
|
|
2197
3222
|
} @else {
|
|
@@ -2200,6 +3225,7 @@ class RadioFieldComponent {
|
|
|
2200
3225
|
<label class="ngx-field__radio-option" [attr.for]="getRadioId(option)">
|
|
2201
3226
|
<input
|
|
2202
3227
|
[id]="getRadioId(option)"
|
|
3228
|
+
[attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
|
|
2203
3229
|
type="radio"
|
|
2204
3230
|
class="ngx-field__radio-input"
|
|
2205
3231
|
[formControl]="$any(control)"
|
|
@@ -2210,8 +3236,8 @@ class RadioFieldComponent {
|
|
|
2210
3236
|
</label>
|
|
2211
3237
|
}
|
|
2212
3238
|
</div>
|
|
2213
|
-
@if (
|
|
2214
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
3239
|
+
@if (errorMessage) {
|
|
3240
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
2215
3241
|
}
|
|
2216
3242
|
}
|
|
2217
3243
|
</fieldset>
|
|
@@ -2226,12 +3252,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2226
3252
|
standalone: true,
|
|
2227
3253
|
imports: [ReactiveFormsModule],
|
|
2228
3254
|
template: `
|
|
2229
|
-
<div
|
|
2230
|
-
|
|
3255
|
+
<div
|
|
3256
|
+
class="ngx-field ngx-field--radio"
|
|
3257
|
+
[attr.data-testid]="'field-' + field.id"
|
|
3258
|
+
[attr.data-field-type]="field.type"
|
|
3259
|
+
[class.ngx-field--readonly]="readonly"
|
|
3260
|
+
[class.ngx-field--masked]="masked"
|
|
3261
|
+
>
|
|
2231
3262
|
<fieldset class="ngx-field__fieldset">
|
|
2232
3263
|
<legend class="ngx-field__label">{{ label }}</legend>
|
|
2233
3264
|
@if (masked) {
|
|
2234
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
3265
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
3266
|
+
maskedText
|
|
3267
|
+
}}</span>
|
|
2235
3268
|
} @else if (readonly) {
|
|
2236
3269
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getSelectedLabel() }}</span>
|
|
2237
3270
|
} @else {
|
|
@@ -2240,6 +3273,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2240
3273
|
<label class="ngx-field__radio-option" [attr.for]="getRadioId(option)">
|
|
2241
3274
|
<input
|
|
2242
3275
|
[id]="getRadioId(option)"
|
|
3276
|
+
[attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
|
|
2243
3277
|
type="radio"
|
|
2244
3278
|
class="ngx-field__radio-input"
|
|
2245
3279
|
[formControl]="$any(control)"
|
|
@@ -2250,8 +3284,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2250
3284
|
</label>
|
|
2251
3285
|
}
|
|
2252
3286
|
</div>
|
|
2253
|
-
@if (
|
|
2254
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
3287
|
+
@if (errorMessage) {
|
|
3288
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
2255
3289
|
}
|
|
2256
3290
|
}
|
|
2257
3291
|
</fieldset>
|
|
@@ -2271,6 +3305,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2271
3305
|
}] } });
|
|
2272
3306
|
|
|
2273
3307
|
class MultiSelectFieldComponent {
|
|
3308
|
+
/**
|
|
3309
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
3310
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
3311
|
+
*/
|
|
3312
|
+
instanceId = nextFieldInstanceId();
|
|
3313
|
+
domId(suffix = '') {
|
|
3314
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
3315
|
+
}
|
|
3316
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
3317
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
3318
|
+
messages = inject(ValidationMessagesService);
|
|
2274
3319
|
lookups = inject(LookupRegistryService);
|
|
2275
3320
|
_field;
|
|
2276
3321
|
_language = 'en';
|
|
@@ -2324,19 +3369,48 @@ class MultiSelectFieldComponent {
|
|
|
2324
3369
|
})
|
|
2325
3370
|
.join(', ');
|
|
2326
3371
|
}
|
|
3372
|
+
/**
|
|
3373
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
3374
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
3375
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
3376
|
+
* of fifteen field types.
|
|
3377
|
+
*/
|
|
3378
|
+
get errorMessage() {
|
|
3379
|
+
if (!this.control?.errors || !this.control.touched)
|
|
3380
|
+
return '';
|
|
3381
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
3382
|
+
'required',
|
|
3383
|
+
'email',
|
|
3384
|
+
'min',
|
|
3385
|
+
'max',
|
|
3386
|
+
'minlength',
|
|
3387
|
+
'maxlength',
|
|
3388
|
+
'pattern',
|
|
3389
|
+
]);
|
|
3390
|
+
}
|
|
2327
3391
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiSelectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2328
3392
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: MultiSelectFieldComponent, isStandalone: true, selector: "ngx-multi-select-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
2329
|
-
<div
|
|
2330
|
-
|
|
2331
|
-
|
|
3393
|
+
<div
|
|
3394
|
+
class="ngx-field ngx-field--multiSelect"
|
|
3395
|
+
[attr.data-testid]="'field-' + field.id"
|
|
3396
|
+
[attr.data-field-type]="field.type"
|
|
3397
|
+
[class.ngx-field--readonly]="readonly"
|
|
3398
|
+
[class.ngx-field--masked]="masked"
|
|
3399
|
+
>
|
|
3400
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
2332
3401
|
@if (masked) {
|
|
2333
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
3402
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
3403
|
+
maskedText
|
|
3404
|
+
}}</span>
|
|
2334
3405
|
} @else if (readonly) {
|
|
2335
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3406
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3407
|
+
getLabels(control.value)
|
|
3408
|
+
}}</span>
|
|
2336
3409
|
} @else {
|
|
2337
3410
|
<select
|
|
2338
|
-
[id]="
|
|
2339
|
-
class="ngx-field__input"
|
|
3411
|
+
[id]="domId()"
|
|
3412
|
+
class="ngx-field__input"
|
|
3413
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
2340
3414
|
[formControl]="$any(control)"
|
|
2341
3415
|
[compareWith]="compareFn"
|
|
2342
3416
|
[attr.disabled]="field.disabled ? true : null"
|
|
@@ -2351,8 +3425,8 @@ class MultiSelectFieldComponent {
|
|
|
2351
3425
|
}
|
|
2352
3426
|
}
|
|
2353
3427
|
</select>
|
|
2354
|
-
@if (
|
|
2355
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
3428
|
+
@if (errorMessage) {
|
|
3429
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
2356
3430
|
}
|
|
2357
3431
|
}
|
|
2358
3432
|
</div>
|
|
@@ -2366,17 +3440,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2366
3440
|
standalone: true,
|
|
2367
3441
|
imports: [ReactiveFormsModule],
|
|
2368
3442
|
template: `
|
|
2369
|
-
<div
|
|
2370
|
-
|
|
2371
|
-
|
|
3443
|
+
<div
|
|
3444
|
+
class="ngx-field ngx-field--multiSelect"
|
|
3445
|
+
[attr.data-testid]="'field-' + field.id"
|
|
3446
|
+
[attr.data-field-type]="field.type"
|
|
3447
|
+
[class.ngx-field--readonly]="readonly"
|
|
3448
|
+
[class.ngx-field--masked]="masked"
|
|
3449
|
+
>
|
|
3450
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
2372
3451
|
@if (masked) {
|
|
2373
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
3452
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
3453
|
+
maskedText
|
|
3454
|
+
}}</span>
|
|
2374
3455
|
} @else if (readonly) {
|
|
2375
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3456
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3457
|
+
getLabels(control.value)
|
|
3458
|
+
}}</span>
|
|
2376
3459
|
} @else {
|
|
2377
3460
|
<select
|
|
2378
|
-
[id]="
|
|
2379
|
-
class="ngx-field__input"
|
|
3461
|
+
[id]="domId()"
|
|
3462
|
+
class="ngx-field__input"
|
|
3463
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
2380
3464
|
[formControl]="$any(control)"
|
|
2381
3465
|
[compareWith]="compareFn"
|
|
2382
3466
|
[attr.disabled]="field.disabled ? true : null"
|
|
@@ -2391,8 +3475,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2391
3475
|
}
|
|
2392
3476
|
}
|
|
2393
3477
|
</select>
|
|
2394
|
-
@if (
|
|
2395
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
3478
|
+
@if (errorMessage) {
|
|
3479
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
2396
3480
|
}
|
|
2397
3481
|
}
|
|
2398
3482
|
</div>
|
|
@@ -2758,6 +3842,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2758
3842
|
* `EntityRefSelectionService` so the owning form can run `autoPatch`.
|
|
2759
3843
|
*/
|
|
2760
3844
|
class EntityRefFieldComponent {
|
|
3845
|
+
/**
|
|
3846
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
3847
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
3848
|
+
*/
|
|
3849
|
+
instanceId = nextFieldInstanceId();
|
|
3850
|
+
domId(suffix = '') {
|
|
3851
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
3852
|
+
}
|
|
3853
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
3854
|
+
ui = inject(UiTextService);
|
|
3855
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
3856
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
3857
|
+
messages = inject(ValidationMessagesService);
|
|
2761
3858
|
field;
|
|
2762
3859
|
control;
|
|
2763
3860
|
language = 'en';
|
|
@@ -2822,7 +3919,7 @@ class EntityRefFieldComponent {
|
|
|
2822
3919
|
}
|
|
2823
3920
|
getLabel(value) {
|
|
2824
3921
|
const option = this.options().find(o => String(o.value) === String(value));
|
|
2825
|
-
return option?.label ??
|
|
3922
|
+
return option?.label ?? value ?? '—';
|
|
2826
3923
|
}
|
|
2827
3924
|
parentValue() {
|
|
2828
3925
|
const parentId = this.parentFieldId;
|
|
@@ -2838,9 +3935,7 @@ class EntityRefFieldComponent {
|
|
|
2838
3935
|
const parentControl = this.control?.parent?.get(parentId);
|
|
2839
3936
|
if (!parentControl)
|
|
2840
3937
|
return;
|
|
2841
|
-
parentControl.valueChanges
|
|
2842
|
-
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
2843
|
-
.subscribe(() => {
|
|
3938
|
+
parentControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
|
2844
3939
|
if (this.control.value) {
|
|
2845
3940
|
this.control.setValue('', { emitEvent: false });
|
|
2846
3941
|
this.selectionBus.emit(this.field.id, null);
|
|
@@ -2848,36 +3943,69 @@ class EntityRefFieldComponent {
|
|
|
2848
3943
|
void this.reload();
|
|
2849
3944
|
});
|
|
2850
3945
|
}
|
|
3946
|
+
/**
|
|
3947
|
+
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
3948
|
+
* validationMessages })` reaches this field. It used to render a fixed
|
|
3949
|
+
* "This field has an error", which made a documented, configurable feature work on three
|
|
3950
|
+
* of fifteen field types.
|
|
3951
|
+
*/
|
|
3952
|
+
get errorMessage() {
|
|
3953
|
+
if (!this.control?.errors || !this.control.touched)
|
|
3954
|
+
return '';
|
|
3955
|
+
return this.messages.resolve(this.control.errors, this.language, [
|
|
3956
|
+
'required',
|
|
3957
|
+
'email',
|
|
3958
|
+
'min',
|
|
3959
|
+
'max',
|
|
3960
|
+
'minlength',
|
|
3961
|
+
'maxlength',
|
|
3962
|
+
'pattern',
|
|
3963
|
+
]);
|
|
3964
|
+
}
|
|
2851
3965
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityRefFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2852
3966
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EntityRefFieldComponent, isStandalone: true, selector: "ngx-entity-ref-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
2853
|
-
<div
|
|
2854
|
-
|
|
2855
|
-
|
|
3967
|
+
<div
|
|
3968
|
+
class="ngx-field ngx-field--entity-ref"
|
|
3969
|
+
[attr.data-testid]="'field-' + field.id"
|
|
3970
|
+
[attr.data-field-type]="field.type"
|
|
3971
|
+
[class.ngx-field--readonly]="readonly"
|
|
3972
|
+
[class.ngx-field--masked]="masked"
|
|
3973
|
+
>
|
|
3974
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
2856
3975
|
@if (masked) {
|
|
2857
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
3976
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
3977
|
+
maskedText
|
|
3978
|
+
}}</span>
|
|
2858
3979
|
} @else if (readonly) {
|
|
2859
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3980
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
3981
|
+
getLabel(control.value)
|
|
3982
|
+
}}</span>
|
|
2860
3983
|
} @else {
|
|
2861
3984
|
@if (loading()) {
|
|
2862
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">
|
|
3985
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">{{
|
|
3986
|
+
ui.text('loading', language)
|
|
3987
|
+
}}</span>
|
|
2863
3988
|
} @else {
|
|
2864
3989
|
<select
|
|
2865
|
-
class="ngx-field__input"
|
|
2866
|
-
[
|
|
3990
|
+
class="ngx-field__input"
|
|
3991
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
3992
|
+
[id]="domId()"
|
|
2867
3993
|
[formControl]="$any(control)"
|
|
2868
3994
|
[attr.disabled]="field.disabled ? true : null"
|
|
2869
3995
|
>
|
|
2870
|
-
<option value="">{{ placeholder || '
|
|
3996
|
+
<option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
|
|
2871
3997
|
@for (option of options(); track option.value) {
|
|
2872
3998
|
<option [value]="option.value">{{ option.label }}</option>
|
|
2873
3999
|
}
|
|
2874
4000
|
</select>
|
|
2875
4001
|
@if (awaitingParent()) {
|
|
2876
|
-
<span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">
|
|
4002
|
+
<span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">{{
|
|
4003
|
+
ui.text('selectParentFirst', language, { field: parentFieldId ?? '' })
|
|
4004
|
+
}}</span>
|
|
2877
4005
|
}
|
|
2878
4006
|
}
|
|
2879
|
-
@if (
|
|
2880
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
4007
|
+
@if (errorMessage) {
|
|
4008
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
2881
4009
|
}
|
|
2882
4010
|
}
|
|
2883
4011
|
</div>
|
|
@@ -2891,34 +4019,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2891
4019
|
standalone: true,
|
|
2892
4020
|
imports: [ReactiveFormsModule],
|
|
2893
4021
|
template: `
|
|
2894
|
-
<div
|
|
2895
|
-
|
|
2896
|
-
|
|
4022
|
+
<div
|
|
4023
|
+
class="ngx-field ngx-field--entity-ref"
|
|
4024
|
+
[attr.data-testid]="'field-' + field.id"
|
|
4025
|
+
[attr.data-field-type]="field.type"
|
|
4026
|
+
[class.ngx-field--readonly]="readonly"
|
|
4027
|
+
[class.ngx-field--masked]="masked"
|
|
4028
|
+
>
|
|
4029
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
2897
4030
|
@if (masked) {
|
|
2898
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
4031
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
4032
|
+
maskedText
|
|
4033
|
+
}}</span>
|
|
2899
4034
|
} @else if (readonly) {
|
|
2900
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
4035
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
4036
|
+
getLabel(control.value)
|
|
4037
|
+
}}</span>
|
|
2901
4038
|
} @else {
|
|
2902
4039
|
@if (loading()) {
|
|
2903
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">
|
|
4040
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">{{
|
|
4041
|
+
ui.text('loading', language)
|
|
4042
|
+
}}</span>
|
|
2904
4043
|
} @else {
|
|
2905
4044
|
<select
|
|
2906
|
-
class="ngx-field__input"
|
|
2907
|
-
[
|
|
4045
|
+
class="ngx-field__input"
|
|
4046
|
+
[attr.data-testid]="'field-' + field.id + '-input'"
|
|
4047
|
+
[id]="domId()"
|
|
2908
4048
|
[formControl]="$any(control)"
|
|
2909
4049
|
[attr.disabled]="field.disabled ? true : null"
|
|
2910
4050
|
>
|
|
2911
|
-
<option value="">{{ placeholder || '
|
|
4051
|
+
<option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
|
|
2912
4052
|
@for (option of options(); track option.value) {
|
|
2913
4053
|
<option [value]="option.value">{{ option.label }}</option>
|
|
2914
4054
|
}
|
|
2915
4055
|
</select>
|
|
2916
4056
|
@if (awaitingParent()) {
|
|
2917
|
-
<span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">
|
|
4057
|
+
<span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">{{
|
|
4058
|
+
ui.text('selectParentFirst', language, { field: parentFieldId ?? '' })
|
|
4059
|
+
}}</span>
|
|
2918
4060
|
}
|
|
2919
4061
|
}
|
|
2920
|
-
@if (
|
|
2921
|
-
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">
|
|
4062
|
+
@if (errorMessage) {
|
|
4063
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
|
|
2922
4064
|
}
|
|
2923
4065
|
}
|
|
2924
4066
|
</div>
|
|
@@ -3179,6 +4321,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3179
4321
|
|
|
3180
4322
|
/** Renders a nested fieldset container for a group field's children. */
|
|
3181
4323
|
class GroupFieldComponent {
|
|
4324
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
4325
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
3182
4326
|
field;
|
|
3183
4327
|
control;
|
|
3184
4328
|
config;
|
|
@@ -3199,7 +4343,7 @@ class GroupFieldComponent {
|
|
|
3199
4343
|
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
3200
4344
|
<legend class="ngx-field__legend">{{ label }}</legend>
|
|
3201
4345
|
@if (masked) {
|
|
3202
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
4346
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{ maskedText }}</span>
|
|
3203
4347
|
} @else {
|
|
3204
4348
|
<div class="ngx-field__group-children">
|
|
3205
4349
|
@for (child of field.children ?? []; track child.id) {
|
|
@@ -3226,7 +4370,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3226
4370
|
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
3227
4371
|
<legend class="ngx-field__legend">{{ label }}</legend>
|
|
3228
4372
|
@if (masked) {
|
|
3229
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
4373
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{ maskedText }}</span>
|
|
3230
4374
|
} @else {
|
|
3231
4375
|
<div class="ngx-field__group-children">
|
|
3232
4376
|
@for (child of field.children ?? []; track child.id) {
|
|
@@ -3358,6 +4502,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3358
4502
|
|
|
3359
4503
|
/** Renders repeating rows for a FormArray, with Add Item and Remove. */
|
|
3360
4504
|
class ArrayFieldComponent {
|
|
4505
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
4506
|
+
ui = inject(UiTextService);
|
|
4507
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
4508
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
3361
4509
|
/**
|
|
3362
4510
|
* addItem/removeItem are public and may be called from outside this component's template,
|
|
3363
4511
|
* which under OnPush would not re-render. The host also watches the control, but this
|
|
@@ -3398,30 +4546,45 @@ class ArrayFieldComponent {
|
|
|
3398
4546
|
}
|
|
3399
4547
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ArrayFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3400
4548
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ArrayFieldComponent, isStandalone: true, selector: "ngx-array-field", inputs: { field: "field", control: "control", config: "config", language: "language", readonly: "readonly", masked: "masked", userRoles: "userRoles" }, ngImport: i0, template: `
|
|
3401
|
-
<div
|
|
3402
|
-
|
|
4549
|
+
<div
|
|
4550
|
+
class="ngx-field ngx-field--array"
|
|
4551
|
+
[attr.data-testid]="'field-' + field.id"
|
|
4552
|
+
[attr.data-field-type]="field.type"
|
|
4553
|
+
[class.ngx-field--readonly]="readonly"
|
|
4554
|
+
[class.ngx-field--masked]="masked"
|
|
4555
|
+
>
|
|
3403
4556
|
<div class="ngx-field__array-header">
|
|
3404
4557
|
<label class="ngx-field__label">{{ label }}</label>
|
|
3405
4558
|
@if (!readonly && !masked) {
|
|
3406
|
-
<button
|
|
3407
|
-
|
|
3408
|
-
|
|
4559
|
+
<button
|
|
4560
|
+
type="button"
|
|
4561
|
+
class="ngx-field__array-add-btn"
|
|
4562
|
+
[attr.data-testid]="'field-' + field.id + '-add'"
|
|
4563
|
+
(click)="addItem()"
|
|
4564
|
+
>
|
|
4565
|
+
{{ ui.text('addItem', language) }}
|
|
3409
4566
|
</button>
|
|
3410
4567
|
}
|
|
3411
4568
|
</div>
|
|
3412
4569
|
|
|
3413
4570
|
@if (masked) {
|
|
3414
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
4571
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
4572
|
+
maskedText
|
|
4573
|
+
}}</span>
|
|
3415
4574
|
} @else {
|
|
3416
4575
|
<div class="ngx-field__array-list">
|
|
3417
4576
|
@for (itemGroup of formArray.controls; track idx; let idx = $index) {
|
|
3418
4577
|
<div class="ngx-field__array-item" [attr.data-testid]="'field-' + field.id + '-row'">
|
|
3419
4578
|
<div class="ngx-field__array-item-header">
|
|
3420
|
-
<span class="ngx-field__array-item-title">
|
|
4579
|
+
<span class="ngx-field__array-item-title">{{ ui.text('itemNumber', language, { number: idx + 1 }) }}</span>
|
|
3421
4580
|
@if (!readonly) {
|
|
3422
|
-
<button
|
|
3423
|
-
|
|
3424
|
-
|
|
4581
|
+
<button
|
|
4582
|
+
type="button"
|
|
4583
|
+
class="ngx-field__array-remove-btn"
|
|
4584
|
+
[attr.data-testid]="'field-' + field.id + '-remove-' + idx"
|
|
4585
|
+
(click)="removeItem(idx)"
|
|
4586
|
+
>
|
|
4587
|
+
{{ ui.text('remove', language) }}
|
|
3425
4588
|
</button>
|
|
3426
4589
|
}
|
|
3427
4590
|
</div>
|
|
@@ -3442,7 +4605,9 @@ class ArrayFieldComponent {
|
|
|
3442
4605
|
</div>
|
|
3443
4606
|
}
|
|
3444
4607
|
@if (formArray.controls.length === 0) {
|
|
3445
|
-
<div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">
|
|
4608
|
+
<div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">
|
|
4609
|
+
{{ ui.text('noItems', language) }}
|
|
4610
|
+
</div>
|
|
3446
4611
|
}
|
|
3447
4612
|
</div>
|
|
3448
4613
|
}
|
|
@@ -3452,30 +4617,45 @@ class ArrayFieldComponent {
|
|
|
3452
4617
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ArrayFieldComponent, decorators: [{
|
|
3453
4618
|
type: Component,
|
|
3454
4619
|
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
|
|
3455
|
-
<div
|
|
3456
|
-
|
|
4620
|
+
<div
|
|
4621
|
+
class="ngx-field ngx-field--array"
|
|
4622
|
+
[attr.data-testid]="'field-' + field.id"
|
|
4623
|
+
[attr.data-field-type]="field.type"
|
|
4624
|
+
[class.ngx-field--readonly]="readonly"
|
|
4625
|
+
[class.ngx-field--masked]="masked"
|
|
4626
|
+
>
|
|
3457
4627
|
<div class="ngx-field__array-header">
|
|
3458
4628
|
<label class="ngx-field__label">{{ label }}</label>
|
|
3459
4629
|
@if (!readonly && !masked) {
|
|
3460
|
-
<button
|
|
3461
|
-
|
|
3462
|
-
|
|
4630
|
+
<button
|
|
4631
|
+
type="button"
|
|
4632
|
+
class="ngx-field__array-add-btn"
|
|
4633
|
+
[attr.data-testid]="'field-' + field.id + '-add'"
|
|
4634
|
+
(click)="addItem()"
|
|
4635
|
+
>
|
|
4636
|
+
{{ ui.text('addItem', language) }}
|
|
3463
4637
|
</button>
|
|
3464
4638
|
}
|
|
3465
4639
|
</div>
|
|
3466
4640
|
|
|
3467
4641
|
@if (masked) {
|
|
3468
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
4642
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
4643
|
+
maskedText
|
|
4644
|
+
}}</span>
|
|
3469
4645
|
} @else {
|
|
3470
4646
|
<div class="ngx-field__array-list">
|
|
3471
4647
|
@for (itemGroup of formArray.controls; track idx; let idx = $index) {
|
|
3472
4648
|
<div class="ngx-field__array-item" [attr.data-testid]="'field-' + field.id + '-row'">
|
|
3473
4649
|
<div class="ngx-field__array-item-header">
|
|
3474
|
-
<span class="ngx-field__array-item-title">
|
|
4650
|
+
<span class="ngx-field__array-item-title">{{ ui.text('itemNumber', language, { number: idx + 1 }) }}</span>
|
|
3475
4651
|
@if (!readonly) {
|
|
3476
|
-
<button
|
|
3477
|
-
|
|
3478
|
-
|
|
4652
|
+
<button
|
|
4653
|
+
type="button"
|
|
4654
|
+
class="ngx-field__array-remove-btn"
|
|
4655
|
+
[attr.data-testid]="'field-' + field.id + '-remove-' + idx"
|
|
4656
|
+
(click)="removeItem(idx)"
|
|
4657
|
+
>
|
|
4658
|
+
{{ ui.text('remove', language) }}
|
|
3479
4659
|
</button>
|
|
3480
4660
|
}
|
|
3481
4661
|
</div>
|
|
@@ -3496,7 +4676,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3496
4676
|
</div>
|
|
3497
4677
|
}
|
|
3498
4678
|
@if (formArray.controls.length === 0) {
|
|
3499
|
-
<div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">
|
|
4679
|
+
<div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">
|
|
4680
|
+
{{ ui.text('noItems', language) }}
|
|
4681
|
+
</div>
|
|
3500
4682
|
}
|
|
3501
4683
|
</div>
|
|
3502
4684
|
}
|
|
@@ -3611,6 +4793,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3611
4793
|
* it is stored as `{ file }` and previewed from an object URL (revoked when replaced/destroyed).
|
|
3612
4794
|
*/
|
|
3613
4795
|
class ImageFieldComponent {
|
|
4796
|
+
/**
|
|
4797
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
4798
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
4799
|
+
*/
|
|
4800
|
+
instanceId = nextFieldInstanceId();
|
|
4801
|
+
domId(suffix = '') {
|
|
4802
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
4803
|
+
}
|
|
4804
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
4805
|
+
ui = inject(UiTextService);
|
|
4806
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
4807
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
4808
|
+
messages = inject(ValidationMessagesService);
|
|
3614
4809
|
field;
|
|
3615
4810
|
control;
|
|
3616
4811
|
language = 'en';
|
|
@@ -3657,13 +4852,26 @@ class ImageFieldComponent {
|
|
|
3657
4852
|
ngOnDestroy() {
|
|
3658
4853
|
this.uploads.revokePreviewUrl(this.previewCache);
|
|
3659
4854
|
}
|
|
4855
|
+
/** Validation, as distinct from the upload failures this field already reported. */
|
|
4856
|
+
get errorMessage() {
|
|
4857
|
+
if (!this.control?.errors || !this.control.touched)
|
|
4858
|
+
return '';
|
|
4859
|
+
return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
|
|
4860
|
+
}
|
|
3660
4861
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImageFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3661
4862
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImageFieldComponent, isStandalone: true, selector: "ngx-image-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
3662
|
-
<div
|
|
3663
|
-
|
|
3664
|
-
|
|
4863
|
+
<div
|
|
4864
|
+
class="ngx-field ngx-field--image"
|
|
4865
|
+
[attr.data-testid]="'field-' + field.id"
|
|
4866
|
+
[attr.data-field-type]="field.type"
|
|
4867
|
+
[class.ngx-field--readonly]="readonly"
|
|
4868
|
+
[class.ngx-field--masked]="masked"
|
|
4869
|
+
>
|
|
4870
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
3665
4871
|
@if (masked) {
|
|
3666
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
4872
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
4873
|
+
maskedText
|
|
4874
|
+
}}</span>
|
|
3667
4875
|
} @else {
|
|
3668
4876
|
<div class="ngx-field__image-wrap">
|
|
3669
4877
|
@if (previewUrl()) {
|
|
@@ -3671,20 +4879,20 @@ class ImageFieldComponent {
|
|
|
3671
4879
|
} @else {
|
|
3672
4880
|
<div class="ngx-field__image-placeholder">
|
|
3673
4881
|
<span>📷</span>
|
|
3674
|
-
<span>
|
|
4882
|
+
<span>{{ ui.text('noImage', language) }}</span>
|
|
3675
4883
|
</div>
|
|
3676
4884
|
}
|
|
3677
4885
|
@if (!readonly) {
|
|
3678
4886
|
<div class="ngx-field__image-actions">
|
|
3679
4887
|
<label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
|
|
3680
4888
|
@if (uploading()) {
|
|
3681
|
-
<span>
|
|
4889
|
+
<span>{{ ui.text('uploading', language) }}</span>
|
|
3682
4890
|
} @else {
|
|
3683
|
-
<span>{{ previewUrl() ? '
|
|
4891
|
+
<span>{{ ui.text(previewUrl() ? 'changeImage' : 'uploadImage', language) }}</span>
|
|
3684
4892
|
<input
|
|
3685
4893
|
type="file"
|
|
3686
4894
|
class="ngx-field__file-input"
|
|
3687
|
-
[id]="
|
|
4895
|
+
[id]="domId()"
|
|
3688
4896
|
accept="image/*"
|
|
3689
4897
|
[disabled]="field.disabled || uploading()"
|
|
3690
4898
|
(change)="onFileSelect($any($event.target).files[0])"
|
|
@@ -3692,13 +4900,20 @@ class ImageFieldComponent {
|
|
|
3692
4900
|
}
|
|
3693
4901
|
</label>
|
|
3694
4902
|
@if (previewUrl()) {
|
|
3695
|
-
<button type="button" class="ngx-field__remove-btn" (click)="remove()">
|
|
4903
|
+
<button type="button" class="ngx-field__remove-btn" (click)="remove()">
|
|
4904
|
+
{{ ui.text('remove', language) }}
|
|
4905
|
+
</button>
|
|
3696
4906
|
}
|
|
3697
4907
|
</div>
|
|
3698
4908
|
}
|
|
3699
4909
|
</div>
|
|
3700
|
-
|
|
3701
|
-
|
|
4910
|
+
<!-- An upload failure and an unmet validator are different problems, and this only
|
|
4911
|
+
ever showed the first. A required file left unchosen said nothing, and a
|
|
4912
|
+
configured required message had no element to appear in. -->
|
|
4913
|
+
@if (uploadError() || errorMessage) {
|
|
4914
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
4915
|
+
uploadError() || errorMessage
|
|
4916
|
+
}}</span>
|
|
3702
4917
|
}
|
|
3703
4918
|
}
|
|
3704
4919
|
</div>
|
|
@@ -3712,11 +4927,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3712
4927
|
standalone: true,
|
|
3713
4928
|
imports: [],
|
|
3714
4929
|
template: `
|
|
3715
|
-
<div
|
|
3716
|
-
|
|
3717
|
-
|
|
4930
|
+
<div
|
|
4931
|
+
class="ngx-field ngx-field--image"
|
|
4932
|
+
[attr.data-testid]="'field-' + field.id"
|
|
4933
|
+
[attr.data-field-type]="field.type"
|
|
4934
|
+
[class.ngx-field--readonly]="readonly"
|
|
4935
|
+
[class.ngx-field--masked]="masked"
|
|
4936
|
+
>
|
|
4937
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
3718
4938
|
@if (masked) {
|
|
3719
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
4939
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
4940
|
+
maskedText
|
|
4941
|
+
}}</span>
|
|
3720
4942
|
} @else {
|
|
3721
4943
|
<div class="ngx-field__image-wrap">
|
|
3722
4944
|
@if (previewUrl()) {
|
|
@@ -3724,20 +4946,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3724
4946
|
} @else {
|
|
3725
4947
|
<div class="ngx-field__image-placeholder">
|
|
3726
4948
|
<span>📷</span>
|
|
3727
|
-
<span>
|
|
4949
|
+
<span>{{ ui.text('noImage', language) }}</span>
|
|
3728
4950
|
</div>
|
|
3729
4951
|
}
|
|
3730
4952
|
@if (!readonly) {
|
|
3731
4953
|
<div class="ngx-field__image-actions">
|
|
3732
4954
|
<label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
|
|
3733
4955
|
@if (uploading()) {
|
|
3734
|
-
<span>
|
|
4956
|
+
<span>{{ ui.text('uploading', language) }}</span>
|
|
3735
4957
|
} @else {
|
|
3736
|
-
<span>{{ previewUrl() ? '
|
|
4958
|
+
<span>{{ ui.text(previewUrl() ? 'changeImage' : 'uploadImage', language) }}</span>
|
|
3737
4959
|
<input
|
|
3738
4960
|
type="file"
|
|
3739
4961
|
class="ngx-field__file-input"
|
|
3740
|
-
[id]="
|
|
4962
|
+
[id]="domId()"
|
|
3741
4963
|
accept="image/*"
|
|
3742
4964
|
[disabled]="field.disabled || uploading()"
|
|
3743
4965
|
(change)="onFileSelect($any($event.target).files[0])"
|
|
@@ -3745,13 +4967,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3745
4967
|
}
|
|
3746
4968
|
</label>
|
|
3747
4969
|
@if (previewUrl()) {
|
|
3748
|
-
<button type="button" class="ngx-field__remove-btn" (click)="remove()">
|
|
4970
|
+
<button type="button" class="ngx-field__remove-btn" (click)="remove()">
|
|
4971
|
+
{{ ui.text('remove', language) }}
|
|
4972
|
+
</button>
|
|
3749
4973
|
}
|
|
3750
4974
|
</div>
|
|
3751
4975
|
}
|
|
3752
4976
|
</div>
|
|
3753
|
-
|
|
3754
|
-
|
|
4977
|
+
<!-- An upload failure and an unmet validator are different problems, and this only
|
|
4978
|
+
ever showed the first. A required file left unchosen said nothing, and a
|
|
4979
|
+
configured required message had no element to appear in. -->
|
|
4980
|
+
@if (uploadError() || errorMessage) {
|
|
4981
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
4982
|
+
uploadError() || errorMessage
|
|
4983
|
+
}}</span>
|
|
3755
4984
|
}
|
|
3756
4985
|
}
|
|
3757
4986
|
</div>
|
|
@@ -3774,6 +5003,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3774
5003
|
* Shares the `FileRef` / `UPLOAD_HANDLER` contract with image-field via FileUploadService.
|
|
3775
5004
|
*/
|
|
3776
5005
|
class FileFieldComponent {
|
|
5006
|
+
/**
|
|
5007
|
+
* Unique to this component instance: an `array` renders the same field once per row, and a
|
|
5008
|
+
* DOM id may not repeat. See `field-dom-id.ts`.
|
|
5009
|
+
*/
|
|
5010
|
+
instanceId = nextFieldInstanceId();
|
|
5011
|
+
domId(suffix = '') {
|
|
5012
|
+
return fieldDomId(this.field, this.instanceId, suffix);
|
|
5013
|
+
}
|
|
5014
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
5015
|
+
ui = inject(UiTextService);
|
|
5016
|
+
/** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
|
|
5017
|
+
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
5018
|
+
messages = inject(ValidationMessagesService);
|
|
3777
5019
|
field;
|
|
3778
5020
|
control;
|
|
3779
5021
|
language = 'en';
|
|
@@ -3814,17 +5056,30 @@ class FileFieldComponent {
|
|
|
3814
5056
|
this.control.setValue(null);
|
|
3815
5057
|
this.control.markAsTouched();
|
|
3816
5058
|
}
|
|
5059
|
+
/** Validation, as distinct from the upload failures this field already reported. */
|
|
5060
|
+
get errorMessage() {
|
|
5061
|
+
if (!this.control?.errors || !this.control.touched)
|
|
5062
|
+
return '';
|
|
5063
|
+
return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
|
|
5064
|
+
}
|
|
3817
5065
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FileFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3818
5066
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: FileFieldComponent, isStandalone: true, selector: "ngx-file-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
3819
|
-
<div
|
|
3820
|
-
|
|
3821
|
-
|
|
5067
|
+
<div
|
|
5068
|
+
class="ngx-field ngx-field--file"
|
|
5069
|
+
[attr.data-testid]="'field-' + field.id"
|
|
5070
|
+
[attr.data-field-type]="field.type"
|
|
5071
|
+
[class.ngx-field--readonly]="readonly"
|
|
5072
|
+
[class.ngx-field--masked]="masked"
|
|
5073
|
+
>
|
|
5074
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
3822
5075
|
@if (masked) {
|
|
3823
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
5076
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
5077
|
+
maskedText
|
|
5078
|
+
}}</span>
|
|
3824
5079
|
} @else if (readonly) {
|
|
3825
5080
|
@if (fileUrl()) {
|
|
3826
5081
|
<a class="ngx-field__file-link" [href]="fileUrl()!" target="_blank" rel="noopener">
|
|
3827
|
-
📎 {{ fileName() || '
|
|
5082
|
+
📎 {{ fileName() || ui.text('downloadFile', language) }}
|
|
3828
5083
|
</a>
|
|
3829
5084
|
} @else {
|
|
3830
5085
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">—</span>
|
|
@@ -3834,26 +5089,38 @@ class FileFieldComponent {
|
|
|
3834
5089
|
@if (fileName()) {
|
|
3835
5090
|
<div class="ngx-field__file-selected">
|
|
3836
5091
|
<span class="ngx-field__file-name">📎 {{ fileName() }}</span>
|
|
3837
|
-
<button
|
|
5092
|
+
<button
|
|
5093
|
+
type="button"
|
|
5094
|
+
class="ngx-field__remove-btn"
|
|
5095
|
+
(click)="remove()"
|
|
5096
|
+
[attr.aria-label]="ui.text('removeFile', language)"
|
|
5097
|
+
>
|
|
5098
|
+
✕
|
|
5099
|
+
</button>
|
|
3838
5100
|
</div>
|
|
3839
5101
|
}
|
|
3840
5102
|
<label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
|
|
3841
5103
|
@if (uploading()) {
|
|
3842
|
-
<span>
|
|
5104
|
+
<span>{{ ui.text('uploading', language) }}</span>
|
|
3843
5105
|
} @else {
|
|
3844
|
-
<span>{{ fileName() ? '
|
|
5106
|
+
<span>{{ fileName() ? ui.text('replaceFile', language) : ui.text('chooseFile', language) }}</span>
|
|
3845
5107
|
<input
|
|
3846
5108
|
type="file"
|
|
3847
5109
|
class="ngx-field__file-input"
|
|
3848
|
-
[id]="
|
|
5110
|
+
[id]="domId()"
|
|
3849
5111
|
[disabled]="field.disabled || uploading()"
|
|
3850
5112
|
(change)="onFileSelect($any($event.target).files[0])"
|
|
3851
5113
|
/>
|
|
3852
5114
|
}
|
|
3853
5115
|
</label>
|
|
3854
5116
|
</div>
|
|
3855
|
-
|
|
3856
|
-
|
|
5117
|
+
<!-- An upload failure and an unmet validator are different problems, and this only
|
|
5118
|
+
ever showed the first. A required file left unchosen said nothing, and a
|
|
5119
|
+
configured required message had no element to appear in. -->
|
|
5120
|
+
@if (uploadError() || errorMessage) {
|
|
5121
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
5122
|
+
uploadError() || errorMessage
|
|
5123
|
+
}}</span>
|
|
3857
5124
|
}
|
|
3858
5125
|
}
|
|
3859
5126
|
</div>
|
|
@@ -3867,15 +5134,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3867
5134
|
standalone: true,
|
|
3868
5135
|
imports: [],
|
|
3869
5136
|
template: `
|
|
3870
|
-
<div
|
|
3871
|
-
|
|
3872
|
-
|
|
5137
|
+
<div
|
|
5138
|
+
class="ngx-field ngx-field--file"
|
|
5139
|
+
[attr.data-testid]="'field-' + field.id"
|
|
5140
|
+
[attr.data-field-type]="field.type"
|
|
5141
|
+
[class.ngx-field--readonly]="readonly"
|
|
5142
|
+
[class.ngx-field--masked]="masked"
|
|
5143
|
+
>
|
|
5144
|
+
<label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
|
|
3873
5145
|
@if (masked) {
|
|
3874
|
-
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">
|
|
5146
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
|
|
5147
|
+
maskedText
|
|
5148
|
+
}}</span>
|
|
3875
5149
|
} @else if (readonly) {
|
|
3876
5150
|
@if (fileUrl()) {
|
|
3877
5151
|
<a class="ngx-field__file-link" [href]="fileUrl()!" target="_blank" rel="noopener">
|
|
3878
|
-
📎 {{ fileName() || '
|
|
5152
|
+
📎 {{ fileName() || ui.text('downloadFile', language) }}
|
|
3879
5153
|
</a>
|
|
3880
5154
|
} @else {
|
|
3881
5155
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">—</span>
|
|
@@ -3885,26 +5159,38 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3885
5159
|
@if (fileName()) {
|
|
3886
5160
|
<div class="ngx-field__file-selected">
|
|
3887
5161
|
<span class="ngx-field__file-name">📎 {{ fileName() }}</span>
|
|
3888
|
-
<button
|
|
5162
|
+
<button
|
|
5163
|
+
type="button"
|
|
5164
|
+
class="ngx-field__remove-btn"
|
|
5165
|
+
(click)="remove()"
|
|
5166
|
+
[attr.aria-label]="ui.text('removeFile', language)"
|
|
5167
|
+
>
|
|
5168
|
+
✕
|
|
5169
|
+
</button>
|
|
3889
5170
|
</div>
|
|
3890
5171
|
}
|
|
3891
5172
|
<label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
|
|
3892
5173
|
@if (uploading()) {
|
|
3893
|
-
<span>
|
|
5174
|
+
<span>{{ ui.text('uploading', language) }}</span>
|
|
3894
5175
|
} @else {
|
|
3895
|
-
<span>{{ fileName() ? '
|
|
5176
|
+
<span>{{ fileName() ? ui.text('replaceFile', language) : ui.text('chooseFile', language) }}</span>
|
|
3896
5177
|
<input
|
|
3897
5178
|
type="file"
|
|
3898
5179
|
class="ngx-field__file-input"
|
|
3899
|
-
[id]="
|
|
5180
|
+
[id]="domId()"
|
|
3900
5181
|
[disabled]="field.disabled || uploading()"
|
|
3901
5182
|
(change)="onFileSelect($any($event.target).files[0])"
|
|
3902
5183
|
/>
|
|
3903
5184
|
}
|
|
3904
5185
|
</label>
|
|
3905
5186
|
</div>
|
|
3906
|
-
|
|
3907
|
-
|
|
5187
|
+
<!-- An upload failure and an unmet validator are different problems, and this only
|
|
5188
|
+
ever showed the first. A required file left unchosen said nothing, and a
|
|
5189
|
+
configured required message had no element to appear in. -->
|
|
5190
|
+
@if (uploadError() || errorMessage) {
|
|
5191
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
|
|
5192
|
+
uploadError() || errorMessage
|
|
5193
|
+
}}</span>
|
|
3908
5194
|
}
|
|
3909
5195
|
}
|
|
3910
5196
|
</div>
|
|
@@ -4093,6 +5379,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
4093
5379
|
* keyboard shortcuts (Ctrl+S, Esc), and RBAC-gated submission.
|
|
4094
5380
|
*/
|
|
4095
5381
|
class DynamicFormComponent {
|
|
5382
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
5383
|
+
ui = inject(UiTextService);
|
|
4096
5384
|
// ─── Inputs ───────────────────────────────────────────────────────────────
|
|
4097
5385
|
/**
|
|
4098
5386
|
* The schema to render.
|
|
@@ -4233,18 +5521,46 @@ class DynamicFormComponent {
|
|
|
4233
5521
|
const subId = this.activeSubTab();
|
|
4234
5522
|
return subTabs.find(s => s.id === subId) ?? subTabs[0];
|
|
4235
5523
|
}
|
|
5524
|
+
/**
|
|
5525
|
+
* The component to mount for the active tab, or `null` if there is nothing to mount.
|
|
5526
|
+
*
|
|
5527
|
+
* A registry entry may name its component with a **selector string** — `COMMON_MODULES`
|
|
5528
|
+
* is a catalogue of names for the builder's picker, and the token's own example showed
|
|
5529
|
+
* one. `ngComponentOutlet` mounts a component *type*, so a string reached it and Angular
|
|
5530
|
+
* threw an assertion the moment somebody opened that tab. Following the documented shape
|
|
5531
|
+
* broke the feature, which is worse than the feature not existing.
|
|
5532
|
+
*
|
|
5533
|
+
* A string now resolves to nothing renderable and says why, once per module.
|
|
5534
|
+
*/
|
|
4236
5535
|
get activeTabModuleComponent() {
|
|
4237
5536
|
const active = this.activeSubTabConfig ?? this.activeTabConfig;
|
|
4238
5537
|
if (!active?.moduleName || !this.commonModulesRegistry)
|
|
4239
5538
|
return null;
|
|
4240
5539
|
const entry = this.commonModulesRegistry.find(m => m.id === active.moduleName || m.component === active.moduleName);
|
|
4241
|
-
|
|
5540
|
+
if (!entry)
|
|
5541
|
+
return null;
|
|
5542
|
+
if (typeof entry.component === 'string') {
|
|
5543
|
+
this.warnSelectorModule(entry.id, entry.component);
|
|
5544
|
+
return null;
|
|
5545
|
+
}
|
|
5546
|
+
return entry.component;
|
|
5547
|
+
}
|
|
5548
|
+
/** Warned once per module id, because a getter runs on every change-detection pass. */
|
|
5549
|
+
warnedSelectorModules = new Set();
|
|
5550
|
+
warnSelectorModule(id, selector) {
|
|
5551
|
+
if (this.warnedSelectorModules.has(id))
|
|
5552
|
+
return;
|
|
5553
|
+
this.warnedSelectorModules.add(id);
|
|
5554
|
+
console.warn(`[ngx-dynamic-entity] The common module '${id}' is registered with the selector ` +
|
|
5555
|
+
`'${selector}' rather than a component class, so there is nothing to mount for its ` +
|
|
5556
|
+
`tab. Register the component itself: ` +
|
|
5557
|
+
`{ id: '${id}', label: {...}, component: MyComponent }.`);
|
|
4242
5558
|
}
|
|
4243
5559
|
get fieldsForActiveTab() {
|
|
4244
5560
|
const active = this.activeSubTabConfig ?? this.activeTabConfig;
|
|
4245
5561
|
if (active?.moduleName)
|
|
4246
5562
|
return [];
|
|
4247
|
-
const rawFields = active ?
|
|
5563
|
+
const rawFields = active ? active.fields || [] : (this.config?.tabs || []).flatMap(t => t.fields || []);
|
|
4248
5564
|
const currentValues = this.formValues();
|
|
4249
5565
|
const hiddenFields = new Set(this.ruleResult().hiddenFields);
|
|
4250
5566
|
return rawFields.filter(field => evaluateFieldVisibility(field, currentValues) && !this.namesField(hiddenFields, field));
|
|
@@ -4454,6 +5770,18 @@ class DynamicFormComponent {
|
|
|
4454
5770
|
resolveFieldLabel(field) {
|
|
4455
5771
|
return resolveLabel(field.label, this.language);
|
|
4456
5772
|
}
|
|
5773
|
+
/**
|
|
5774
|
+
* The changed critical fields as one comma-separated string.
|
|
5775
|
+
*
|
|
5776
|
+
* Joined here rather than looped in the template because the banner is a sentence with the
|
|
5777
|
+
* list inside it, and a sentence has to reach `UI_TEXT` whole — a translation moves the
|
|
5778
|
+
* list somewhere else in the clause.
|
|
5779
|
+
*/
|
|
5780
|
+
changedCriticalFieldLabels() {
|
|
5781
|
+
return this.changedCriticalFields()
|
|
5782
|
+
.map(field => this.resolveFieldLabel(field))
|
|
5783
|
+
.join(', ');
|
|
5784
|
+
}
|
|
4457
5785
|
// ─── Form construction ────────────────────────────────────────────────────
|
|
4458
5786
|
buildForm() {
|
|
4459
5787
|
if (!this.config)
|
|
@@ -4504,9 +5832,7 @@ class DynamicFormComponent {
|
|
|
4504
5832
|
}
|
|
4505
5833
|
// Rebuilding must not stack subscriptions on successive config/data changes.
|
|
4506
5834
|
this.valueSub?.unsubscribe();
|
|
4507
|
-
const changes$ = this.changeDebounceMs > 0
|
|
4508
|
-
? this.form.valueChanges.pipe(debounceTime(this.changeDebounceMs))
|
|
4509
|
-
: this.form.valueChanges;
|
|
5835
|
+
const changes$ = this.changeDebounceMs > 0 ? this.form.valueChanges.pipe(debounceTime(this.changeDebounceMs)) : this.form.valueChanges;
|
|
4510
5836
|
// Validity is not value: an async validator settling flips `pending` and then `invalid`
|
|
4511
5837
|
// with no value change and no template event, so under OnPush the Save button would
|
|
4512
5838
|
// never re-enable. submitBlocked reads form.pending and form.invalid, so this is what
|
|
@@ -5013,9 +6339,7 @@ class DynamicFormComponent {
|
|
|
5013
6339
|
* filling the form in.
|
|
5014
6340
|
*/
|
|
5015
6341
|
driftHint(field) {
|
|
5016
|
-
const source = field.referencedEntityKey
|
|
5017
|
-
? `"${field.referencedEntityKey}"`
|
|
5018
|
-
: 'its source entity';
|
|
6342
|
+
const source = field.referencedEntityKey ? `"${field.referencedEntityKey}"` : 'its source entity';
|
|
5019
6343
|
return `This field is linked to ${source} and its definition there has changed since it was linked.`;
|
|
5020
6344
|
}
|
|
5021
6345
|
resolveTabLabel(tab) {
|
|
@@ -5066,11 +6390,11 @@ class DynamicFormComponent {
|
|
|
5066
6390
|
this.formReset.emit();
|
|
5067
6391
|
}
|
|
5068
6392
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5069
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\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 {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div\n role=\"tabpanel\"\n tabindex=\"-1\"\n #formPanel\n class=\"ngx-form__tabpanel\"\n [attr.aria-label]=\"activeTabLabel\"\n >\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span>\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\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\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media(max-width:768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\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],[formArray],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", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6393
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\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 {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\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\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media(max-width:768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\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],[formArray],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", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5070
6394
|
}
|
|
5071
6395
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
|
|
5072
6396
|
type: Component,
|
|
5073
|
-
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n
|
|
6397
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\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 {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\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\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media(max-width:768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"] }]
|
|
5074
6398
|
}], propDecorators: { config: [{
|
|
5075
6399
|
type: Input
|
|
5076
6400
|
}], rules: [{
|
|
@@ -5129,6 +6453,8 @@ const FIELD_NOT_FOUND = Symbol('field-not-found');
|
|
|
5129
6453
|
* interactive quick-jump links, and baseline modification tracking.
|
|
5130
6454
|
*/
|
|
5131
6455
|
class DynamicRecordFormComponent {
|
|
6456
|
+
/** Library chrome, overridable via UI_TEXT. */
|
|
6457
|
+
ui = inject(UiTextService);
|
|
5132
6458
|
config;
|
|
5133
6459
|
rules;
|
|
5134
6460
|
initialData;
|
|
@@ -5393,8 +6719,7 @@ class DynamicRecordFormComponent {
|
|
|
5393
6719
|
}
|
|
5394
6720
|
}
|
|
5395
6721
|
const scoped = this.rulesEvaluation.filterForTab(this.rules, tabId, this.config);
|
|
5396
|
-
const ruleErrors = this.rulesEvaluation.evaluate(scoped, form.formValues(), this.originalBaseline())
|
|
5397
|
-
.validationErrors;
|
|
6722
|
+
const ruleErrors = this.rulesEvaluation.evaluate(scoped, form.formValues(), this.originalBaseline()).validationErrors;
|
|
5398
6723
|
Object.assign(errors, ruleErrors);
|
|
5399
6724
|
if (Object.keys(errors).length > 0) {
|
|
5400
6725
|
this.sectionErrors.set(errors);
|
|
@@ -5561,6 +6886,11 @@ class DynamicRecordFormComponent {
|
|
|
5561
6886
|
formatFieldLabel(field) {
|
|
5562
6887
|
return resolveLabel(field.label, this.language);
|
|
5563
6888
|
}
|
|
6889
|
+
/** "Add <field> row" / "Edit <field> row" — one key each, because word order moves. */
|
|
6890
|
+
rowDrawerTitle(field) {
|
|
6891
|
+
const key = this.inlineRowIndex() === null ? 'addRowTitle' : 'editRowTitle';
|
|
6892
|
+
return this.ui.text(key, this.language, { field: this.formatFieldLabel(field) });
|
|
6893
|
+
}
|
|
5564
6894
|
/** Summary values render through the shared core formatter, not a local stringifier. */
|
|
5565
6895
|
formatFieldValue(field) {
|
|
5566
6896
|
return formatDisplayValue(field.type, field.options, this.fieldValue(field.id), this.language);
|
|
@@ -5620,11 +6950,11 @@ class DynamicRecordFormComponent {
|
|
|
5620
6950
|
}, { injector: this.injector });
|
|
5621
6951
|
}
|
|
5622
6952
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5623
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6953
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: config.entity }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5624
6954
|
}
|
|
5625
6955
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, decorators: [{
|
|
5626
6956
|
type: Component,
|
|
5627
|
-
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-record-form', standalone: true, imports: [CommonModule, ReactiveFormsModule, DynamicFormComponent, DynamicFieldComponent], template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"] }]
|
|
6957
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-record-form', standalone: true, imports: [CommonModule, ReactiveFormsModule, DynamicFormComponent, DynamicFieldComponent], template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: config.entity }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"] }]
|
|
5628
6958
|
}], propDecorators: { config: [{
|
|
5629
6959
|
type: Input
|
|
5630
6960
|
}], rules: [{
|
|
@@ -5666,5 +6996,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
5666
6996
|
* Generated bundle index. Do not edit.
|
|
5667
6997
|
*/
|
|
5668
6998
|
|
|
5669
|
-
export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UPLOAD_HANDLER, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
|
|
6999
|
+
export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
|
|
5670
7000
|
//# sourceMappingURL=ngx-dynamic-entity.mjs.map
|