ngx-dynamic-entity 1.14.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/ngx-dynamic-entity.mjs +718 -318
- package/fesm2022/ngx-dynamic-entity.mjs.map +1 -1
- package/package.json +2 -2
- package/styles.css +2 -12
- package/types/ngx-dynamic-entity.d.ts +242 -45
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, ElementRef, computed, afterNextRender, HostListener, ViewChildren, Output } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/forms';
|
|
4
|
-
import { ReactiveFormsModule, Validators, FormBuilder,
|
|
5
|
-
import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch,
|
|
4
|
+
import { ReactiveFormsModule, Validators, FormBuilder, FormGroup, FormArray, FormsModule } from '@angular/forms';
|
|
5
|
+
import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, getTabPath, parseFieldRef, setTabData, setValueByPath, normalizeArrayStructures, getTabData, getValueByPath, normalizeConfigOptions, migrateRecord, ambiguousFieldIds, toRefToken, fieldRefFor, collectFieldScopes, refOf, applyAutoPatch, applyPatchOnTrue, fieldsUnderTab, valuesEqual, parseCsv, deriveImportColumns, suggestMapping, applyMapping, toCsv, stripIndices, CORE_VERSION, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
|
|
6
6
|
export { formatDisplayValue, normalizeConfig, normalizeOption, resolveLabel, resolveOptionLabel, resolveOptionValue, valuesMatch } from '@dynamic-entity/core';
|
|
7
7
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
8
8
|
import { Subject, isObservable, firstValueFrom } from 'rxjs';
|
|
@@ -443,7 +443,7 @@ function fieldDomId(field, instance, suffix = '') {
|
|
|
443
443
|
* is read first. `null` rather than an empty string when there is nothing to describe — an
|
|
444
444
|
* empty `aria-describedby` is a dangling reference, not an absent one.
|
|
445
445
|
*
|
|
446
|
-
* Shared rather than written into each of the
|
|
446
|
+
* Shared rather than written into each of the twenty-one field components, because the rule is
|
|
447
447
|
* the same everywhere and getting it wrong is invisible until somebody uses a screen reader.
|
|
448
448
|
*/
|
|
449
449
|
function fieldDescribedBy(domId, parts) {
|
|
@@ -1100,17 +1100,29 @@ class MarkdownFieldComponent {
|
|
|
1100
1100
|
* A renderer is consumer code and may throw on input it does not like. Letting that
|
|
1101
1101
|
* escape would take down the whole form over one malformed field, so it falls back to the
|
|
1102
1102
|
* source text — which is what the field would have shown with no renderer at all.
|
|
1103
|
+
*
|
|
1104
|
+
* Memoised on the source, because this is called from the template: without it the
|
|
1105
|
+
* consumer's markdown parser ran on every change-detection pass, for every markdown field
|
|
1106
|
+
* on screen, over the whole document — which on a form holding a few long ones is a parse
|
|
1107
|
+
* per keystroke *elsewhere* in the form. The cache is keyed by the exact source string,
|
|
1108
|
+
* so a value that comes back to something already rendered is free too.
|
|
1103
1109
|
*/
|
|
1110
|
+
cached;
|
|
1104
1111
|
rendered() {
|
|
1105
1112
|
const source = this.control?.value;
|
|
1106
1113
|
if (!this.render || typeof source !== 'string' || source === '')
|
|
1107
1114
|
return null;
|
|
1115
|
+
if (this.cached?.source === source)
|
|
1116
|
+
return this.cached.html;
|
|
1117
|
+
let html;
|
|
1108
1118
|
try {
|
|
1109
|
-
|
|
1119
|
+
html = this.render(source);
|
|
1110
1120
|
}
|
|
1111
1121
|
catch {
|
|
1112
|
-
|
|
1122
|
+
html = null;
|
|
1113
1123
|
}
|
|
1124
|
+
this.cached = { source, html };
|
|
1125
|
+
return html;
|
|
1114
1126
|
}
|
|
1115
1127
|
/**
|
|
1116
1128
|
* Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
|
|
@@ -1785,7 +1797,11 @@ class EmailFieldComponent {
|
|
|
1785
1797
|
}}</span>
|
|
1786
1798
|
} @else if (readonly) {
|
|
1787
1799
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
|
|
1788
|
-
|
|
1800
|
+
@if (control.value) {
|
|
1801
|
+
<a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value }}</a>
|
|
1802
|
+
} @else {
|
|
1803
|
+
—
|
|
1804
|
+
}
|
|
1789
1805
|
</span>
|
|
1790
1806
|
} @else {
|
|
1791
1807
|
<input
|
|
@@ -1848,7 +1864,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1848
1864
|
}}</span>
|
|
1849
1865
|
} @else if (readonly) {
|
|
1850
1866
|
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
|
|
1851
|
-
|
|
1867
|
+
@if (control.value) {
|
|
1868
|
+
<a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value }}</a>
|
|
1869
|
+
} @else {
|
|
1870
|
+
—
|
|
1871
|
+
}
|
|
1852
1872
|
</span>
|
|
1853
1873
|
} @else {
|
|
1854
1874
|
<input
|
|
@@ -3171,7 +3191,7 @@ class MonthYearFieldComponent {
|
|
|
3171
3191
|
>
|
|
3172
3192
|
<option value="">{{ ui.text('month', language) }}</option>
|
|
3173
3193
|
@for (m of months; track m.value) {
|
|
3174
|
-
<option [value]="m.value">{{ m.label }}</option>
|
|
3194
|
+
<option [value]="m.value" [selected]="m.value === selectedMonth">{{ m.label }}</option>
|
|
3175
3195
|
}
|
|
3176
3196
|
</select>
|
|
3177
3197
|
<select
|
|
@@ -3186,7 +3206,7 @@ class MonthYearFieldComponent {
|
|
|
3186
3206
|
>
|
|
3187
3207
|
<option value="">{{ ui.text('year', language) }}</option>
|
|
3188
3208
|
@for (y of years; track y) {
|
|
3189
|
-
<option [value]="y">{{ y }}</option>
|
|
3209
|
+
<option [value]="y" [selected]="y.toString() === selectedYear">{{ y }}</option>
|
|
3190
3210
|
}
|
|
3191
3211
|
</select>
|
|
3192
3212
|
</div>
|
|
@@ -3257,7 +3277,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3257
3277
|
>
|
|
3258
3278
|
<option value="">{{ ui.text('month', language) }}</option>
|
|
3259
3279
|
@for (m of months; track m.value) {
|
|
3260
|
-
<option [value]="m.value">{{ m.label }}</option>
|
|
3280
|
+
<option [value]="m.value" [selected]="m.value === selectedMonth">{{ m.label }}</option>
|
|
3261
3281
|
}
|
|
3262
3282
|
</select>
|
|
3263
3283
|
<select
|
|
@@ -3272,7 +3292,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3272
3292
|
>
|
|
3273
3293
|
<option value="">{{ ui.text('year', language) }}</option>
|
|
3274
3294
|
@for (y of years; track y) {
|
|
3275
|
-
<option [value]="y">{{ y }}</option>
|
|
3295
|
+
<option [value]="y" [selected]="y.toString() === selectedYear">{{ y }}</option>
|
|
3276
3296
|
}
|
|
3277
3297
|
</select>
|
|
3278
3298
|
</div>
|
|
@@ -3578,8 +3598,7 @@ class DropdownFieldComponent {
|
|
|
3578
3598
|
options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
|
|
3579
3599
|
compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
|
|
3580
3600
|
isObjectVal(option) {
|
|
3581
|
-
|
|
3582
|
-
return typeof val === 'object' && val !== null;
|
|
3601
|
+
return typeof option === 'object' && option !== null;
|
|
3583
3602
|
}
|
|
3584
3603
|
get label() {
|
|
3585
3604
|
return resolveLabel(this.field?.label, this.language);
|
|
@@ -3587,8 +3606,17 @@ class DropdownFieldComponent {
|
|
|
3587
3606
|
get placeholder() {
|
|
3588
3607
|
return resolveLabel(this.field?.placeholder, this.language);
|
|
3589
3608
|
}
|
|
3609
|
+
/**
|
|
3610
|
+
* The value this option stores — the option object itself.
|
|
3611
|
+
*
|
|
3612
|
+
* Kept as a method rather than binding `option` straight into the template, because it is
|
|
3613
|
+
* the one place that says *why* the two are the same thing. It used to delegate to
|
|
3614
|
+
* `getOptionStoredValue`, an exported identity function whose two branches returned their
|
|
3615
|
+
* argument unchanged; naming a transformation that does not happen is worse than naming
|
|
3616
|
+
* nothing, because a reader has to go and check.
|
|
3617
|
+
*/
|
|
3590
3618
|
getOptStoredVal(option) {
|
|
3591
|
-
return
|
|
3619
|
+
return option;
|
|
3592
3620
|
}
|
|
3593
3621
|
getOptLabel(option) {
|
|
3594
3622
|
return resolveOptionLabel(option, this.language);
|
|
@@ -3601,7 +3629,7 @@ class DropdownFieldComponent {
|
|
|
3601
3629
|
getLabel(value) {
|
|
3602
3630
|
if (value == null || value === '')
|
|
3603
3631
|
return '—';
|
|
3604
|
-
const option = this.options().find(o => valuesMatch(
|
|
3632
|
+
const option = this.options().find(o => valuesMatch(o, value, this.language));
|
|
3605
3633
|
if (option)
|
|
3606
3634
|
return this.getOptLabel(option);
|
|
3607
3635
|
const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
|
|
@@ -3798,8 +3826,14 @@ class RadioFieldComponent {
|
|
|
3798
3826
|
maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
|
|
3799
3827
|
messages = inject(ValidationMessagesService);
|
|
3800
3828
|
lookups = inject(LookupRegistryService);
|
|
3829
|
+
cdr = inject(ChangeDetectorRef);
|
|
3801
3830
|
_field;
|
|
3802
3831
|
_language = 'en';
|
|
3832
|
+
_control;
|
|
3833
|
+
_controlSub;
|
|
3834
|
+
ngOnDestroy() {
|
|
3835
|
+
this._controlSub?.unsubscribe();
|
|
3836
|
+
}
|
|
3803
3837
|
/** Setter-based so options resolve however the input is set — see `refreshChoiceOptions`. */
|
|
3804
3838
|
set field(value) {
|
|
3805
3839
|
this._field = value;
|
|
@@ -3815,16 +3849,39 @@ class RadioFieldComponent {
|
|
|
3815
3849
|
get language() {
|
|
3816
3850
|
return this._language;
|
|
3817
3851
|
}
|
|
3818
|
-
control
|
|
3852
|
+
set control(value) {
|
|
3853
|
+
this._control = value;
|
|
3854
|
+
this._controlSub?.unsubscribe();
|
|
3855
|
+
this._controlSub = this._control?.valueChanges?.subscribe(() => {
|
|
3856
|
+
this.cdr.markForCheck();
|
|
3857
|
+
});
|
|
3858
|
+
}
|
|
3859
|
+
get control() {
|
|
3860
|
+
return this._control;
|
|
3861
|
+
}
|
|
3819
3862
|
readonly = false;
|
|
3820
3863
|
masked = false;
|
|
3821
3864
|
/** Inline `options`, or the field's named list resolved through the registry (§6.3). */
|
|
3822
3865
|
options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
|
|
3866
|
+
onSelect(option) {
|
|
3867
|
+
this.control?.setValue(option);
|
|
3868
|
+
this.control?.markAsTouched();
|
|
3869
|
+
this.control?.markAsDirty();
|
|
3870
|
+
}
|
|
3823
3871
|
get label() {
|
|
3824
3872
|
return resolveLabel(this.field?.label, this.language);
|
|
3825
3873
|
}
|
|
3874
|
+
/**
|
|
3875
|
+
* The value this option stores — the option object itself.
|
|
3876
|
+
*
|
|
3877
|
+
* Kept as a method rather than binding `option` straight into the template, because it is
|
|
3878
|
+
* the one place that says *why* the two are the same thing. It used to delegate to
|
|
3879
|
+
* `getOptionStoredValue`, an exported identity function whose two branches returned their
|
|
3880
|
+
* argument unchanged; naming a transformation that does not happen is worse than naming
|
|
3881
|
+
* nothing, because a reader has to go and check.
|
|
3882
|
+
*/
|
|
3826
3883
|
getOptStoredVal(option) {
|
|
3827
|
-
return
|
|
3884
|
+
return option;
|
|
3828
3885
|
}
|
|
3829
3886
|
/**
|
|
3830
3887
|
* Stable DOM id for one radio, so the <label for> association works.
|
|
@@ -3852,6 +3909,10 @@ class RadioFieldComponent {
|
|
|
3852
3909
|
getOptLabel(option) {
|
|
3853
3910
|
return resolveOptionLabel(option, this.language);
|
|
3854
3911
|
}
|
|
3912
|
+
/** Whether the given option matches the control's current value. */
|
|
3913
|
+
isChecked(option) {
|
|
3914
|
+
return valuesMatch(this.control?.value, option, this.language);
|
|
3915
|
+
}
|
|
3855
3916
|
/** Read-only display — synchronous, per §6.2. See `DropdownFieldComponent.getLabel`. */
|
|
3856
3917
|
getSelectedLabel() {
|
|
3857
3918
|
const value = this.control?.value;
|
|
@@ -3859,7 +3920,7 @@ class RadioFieldComponent {
|
|
|
3859
3920
|
// `resolveLabel(null)` and rendered as blank — dropdown and multiSelect both show an em dash.
|
|
3860
3921
|
if (value === null || value === undefined || value === '')
|
|
3861
3922
|
return '—';
|
|
3862
|
-
const selected = this.options().find(o => valuesMatch(
|
|
3923
|
+
const selected = this.options().find(o => valuesMatch(o, value, this.language));
|
|
3863
3924
|
if (selected)
|
|
3864
3925
|
return this.getOptLabel(selected);
|
|
3865
3926
|
const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
|
|
@@ -3930,10 +3991,12 @@ class RadioFieldComponent {
|
|
|
3930
3991
|
[attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
|
|
3931
3992
|
type="radio"
|
|
3932
3993
|
class="ngx-field__radio-input"
|
|
3933
|
-
[
|
|
3994
|
+
[name]="domId()"
|
|
3934
3995
|
[attr.aria-describedby]="describedBy()"
|
|
3935
3996
|
[value]="getOptStoredVal(option)"
|
|
3997
|
+
[checked]="isChecked(option)"
|
|
3936
3998
|
[attr.disabled]="field.disabled ? true : null"
|
|
3999
|
+
(change)="onSelect(option)"
|
|
3937
4000
|
/>
|
|
3938
4001
|
<span class="ngx-field__radio-label">{{ getOptLabel(option) }}</span>
|
|
3939
4002
|
</label>
|
|
@@ -3947,7 +4010,7 @@ class RadioFieldComponent {
|
|
|
3947
4010
|
}
|
|
3948
4011
|
</fieldset>
|
|
3949
4012
|
</div>
|
|
3950
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }
|
|
4013
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3951
4014
|
}
|
|
3952
4015
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RadioFieldComponent, decorators: [{
|
|
3953
4016
|
type: Component,
|
|
@@ -3999,10 +4062,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3999
4062
|
[attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
|
|
4000
4063
|
type="radio"
|
|
4001
4064
|
class="ngx-field__radio-input"
|
|
4002
|
-
[
|
|
4065
|
+
[name]="domId()"
|
|
4003
4066
|
[attr.aria-describedby]="describedBy()"
|
|
4004
4067
|
[value]="getOptStoredVal(option)"
|
|
4068
|
+
[checked]="isChecked(option)"
|
|
4005
4069
|
[attr.disabled]="field.disabled ? true : null"
|
|
4070
|
+
(change)="onSelect(option)"
|
|
4006
4071
|
/>
|
|
4007
4072
|
<span class="ngx-field__radio-label">{{ getOptLabel(option) }}</span>
|
|
4008
4073
|
</label>
|
|
@@ -4067,14 +4132,22 @@ class MultiSelectFieldComponent {
|
|
|
4067
4132
|
options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
|
|
4068
4133
|
compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
|
|
4069
4134
|
isObjectVal(option) {
|
|
4070
|
-
|
|
4071
|
-
return typeof val === 'object' && val !== null;
|
|
4135
|
+
return typeof option === 'object' && option !== null;
|
|
4072
4136
|
}
|
|
4073
4137
|
get label() {
|
|
4074
4138
|
return resolveLabel(this.field?.label, this.language);
|
|
4075
4139
|
}
|
|
4140
|
+
/**
|
|
4141
|
+
* The value this option stores — the option object itself.
|
|
4142
|
+
*
|
|
4143
|
+
* Kept as a method rather than binding `option` straight into the template, because it is
|
|
4144
|
+
* the one place that says *why* the two are the same thing. It used to delegate to
|
|
4145
|
+
* `getOptionStoredValue`, an exported identity function whose two branches returned their
|
|
4146
|
+
* argument unchanged; naming a transformation that does not happen is worse than naming
|
|
4147
|
+
* nothing, because a reader has to go and check.
|
|
4148
|
+
*/
|
|
4076
4149
|
getOptStoredVal(option) {
|
|
4077
|
-
return
|
|
4150
|
+
return option;
|
|
4078
4151
|
}
|
|
4079
4152
|
getOptLabel(option) {
|
|
4080
4153
|
return resolveOptionLabel(option, this.language);
|
|
@@ -4085,7 +4158,7 @@ class MultiSelectFieldComponent {
|
|
|
4085
4158
|
return '—';
|
|
4086
4159
|
return values
|
|
4087
4160
|
.map(v => {
|
|
4088
|
-
const opt = this.options().find(o => valuesMatch(
|
|
4161
|
+
const opt = this.options().find(o => valuesMatch(o, v, this.language));
|
|
4089
4162
|
if (opt)
|
|
4090
4163
|
return this.getOptLabel(opt);
|
|
4091
4164
|
const cached = this.lookups.labelFor(this.field?.listName, v, this.language);
|
|
@@ -4877,7 +4950,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
4877
4950
|
*
|
|
4878
4951
|
* **Tree-shaking contract:** this service holds *no* static imports of field components.
|
|
4879
4952
|
* Nothing is bundled until something registers it, which is what makes the built-ins
|
|
4880
|
-
* opt-in. Register them with `provideBuiltInFieldTypes()` (all
|
|
4953
|
+
* opt-in. Register them with `provideBuiltInFieldTypes()` (all 21 keys) or a narrower
|
|
4881
4954
|
* subset via `provideFieldTypes({ ... })` — see `providers/provide-field-types.ts`.
|
|
4882
4955
|
*
|
|
4883
4956
|
* Resolution order: consumer token registry → runtime registrations → nothing.
|
|
@@ -5084,16 +5157,11 @@ class DynamicFieldComponent {
|
|
|
5084
5157
|
ref.setInput('masked', masked);
|
|
5085
5158
|
}
|
|
5086
5159
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5087
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", currentTabId: "currentTabId", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5160
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", currentTabId: "currentTabId", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true, styles: [":host{display:block;width:100%;min-width:0}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5088
5161
|
}
|
|
5089
5162
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldComponent, decorators: [{
|
|
5090
5163
|
type: Component,
|
|
5091
|
-
args: [{
|
|
5092
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
5093
|
-
selector: 'ngx-dynamic-field',
|
|
5094
|
-
standalone: true,
|
|
5095
|
-
template: `<ng-container #fieldHost></ng-container>`,
|
|
5096
|
-
}]
|
|
5164
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-field', standalone: true, template: `<ng-container #fieldHost></ng-container>`, styles: [":host{display:block;width:100%;min-width:0}\n"] }]
|
|
5097
5165
|
}], propDecorators: { field: [{
|
|
5098
5166
|
type: Input
|
|
5099
5167
|
}], control: [{
|
|
@@ -5286,8 +5354,20 @@ class ValidatorRegistryService {
|
|
|
5286
5354
|
fnList.push(Validators.minLength(config.minLength));
|
|
5287
5355
|
if (config.maxLength !== undefined)
|
|
5288
5356
|
fnList.push(Validators.maxLength(config.maxLength));
|
|
5289
|
-
if (config.pattern)
|
|
5290
|
-
|
|
5357
|
+
if (config.pattern) {
|
|
5358
|
+
// `Validators.pattern` compiles the string, so an unparseable one threw here — during
|
|
5359
|
+
// control construction, which takes the whole form down rather than one field's format
|
|
5360
|
+
// check. `validateConfig` reports it as an error; at runtime the field degrades to no
|
|
5361
|
+
// pattern validation, which is exactly what the server-side import path already does.
|
|
5362
|
+
try {
|
|
5363
|
+
fnList.push(Validators.pattern(config.pattern));
|
|
5364
|
+
}
|
|
5365
|
+
catch (err) {
|
|
5366
|
+
this.warnOnce(`pattern:${config.pattern}`, `The pattern "${config.pattern}" is not a valid regular expression ` +
|
|
5367
|
+
`(${err instanceof Error ? err.message : String(err)}), so no format check is ` +
|
|
5368
|
+
`applied to this field. Run "dynamic-entity validate" over the config to find it.`);
|
|
5369
|
+
}
|
|
5370
|
+
}
|
|
5291
5371
|
if (config.email)
|
|
5292
5372
|
fnList.push(Validators.email);
|
|
5293
5373
|
// Named validators from the consumer registry. This branch previously hardcoded the
|
|
@@ -5298,6 +5378,8 @@ class ValidatorRegistryService {
|
|
|
5298
5378
|
const fn = this.resolve(key);
|
|
5299
5379
|
if (fn)
|
|
5300
5380
|
fnList.push(fn);
|
|
5381
|
+
else
|
|
5382
|
+
this.warnUnresolved('custom', key);
|
|
5301
5383
|
}
|
|
5302
5384
|
return fnList;
|
|
5303
5385
|
}
|
|
@@ -5319,9 +5401,35 @@ class ValidatorRegistryService {
|
|
|
5319
5401
|
const fn = this.asyncRegistry.get(key);
|
|
5320
5402
|
if (fn)
|
|
5321
5403
|
out.push(fn);
|
|
5404
|
+
else
|
|
5405
|
+
this.warnUnresolved('customAsync', key);
|
|
5322
5406
|
}
|
|
5323
5407
|
return out;
|
|
5324
5408
|
}
|
|
5409
|
+
/**
|
|
5410
|
+
* Say when a named validator resolved to nothing.
|
|
5411
|
+
*
|
|
5412
|
+
* It used to be dropped in silence, which is the worst failure mode this library has: the
|
|
5413
|
+
* schema says the field is checked, the field is not checked, the form saves, and nothing
|
|
5414
|
+
* anywhere said so. For `customAsync` that is typically a uniqueness check, so the duplicate
|
|
5415
|
+
* lands in the database.
|
|
5416
|
+
*
|
|
5417
|
+
* Dev only, once per name — this runs while controls are built, which happens on every
|
|
5418
|
+
* config change. `validateConfig` with `knownValidators` is the build-time version of the
|
|
5419
|
+
* same check, and it fails CI rather than a console.
|
|
5420
|
+
*/
|
|
5421
|
+
warnUnresolved(kind, key) {
|
|
5422
|
+
this.warnOnce(`${kind}:${key}`, `No validator named "${key}" is registered, so validators.${kind} drops it and it never ` +
|
|
5423
|
+
`runs. Register it with provideNgxDynamicEntity({ ${kind === 'customAsync' ? 'asyncValidators' : 'validators'}: { '${key}': ... } }).`);
|
|
5424
|
+
}
|
|
5425
|
+
/** Per service instance, which is per injector — so a test gets a clean slate. */
|
|
5426
|
+
warned = new Set();
|
|
5427
|
+
warnOnce(id, message) {
|
|
5428
|
+
if (!isDevMode() || this.warned.has(id))
|
|
5429
|
+
return;
|
|
5430
|
+
this.warned.add(id);
|
|
5431
|
+
console.warn(`[ngx-dynamic-entity] ${message}`);
|
|
5432
|
+
}
|
|
5325
5433
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
5326
5434
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
|
|
5327
5435
|
}
|
|
@@ -6159,7 +6267,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
6159
6267
|
*
|
|
6160
6268
|
* `FieldRegistryService` imports no components. Whatever you register here is what gets
|
|
6161
6269
|
* bundled, so an app that only uses text/number/dropdown pays for three components and
|
|
6162
|
-
* their imports, not
|
|
6270
|
+
* their imports, not twenty-one.
|
|
6163
6271
|
*
|
|
6164
6272
|
* ```ts
|
|
6165
6273
|
* // Everything (convenient; bundles all built-ins):
|
|
@@ -6203,7 +6311,7 @@ function provideFieldTypes(types) {
|
|
|
6203
6311
|
{ provide: FIELD_TYPE_SETS, useValue: types, multi: true },
|
|
6204
6312
|
]);
|
|
6205
6313
|
}
|
|
6206
|
-
/** Register all
|
|
6314
|
+
/** Register all 21 built-in field type keys. Convenience for apps that use most of them. */
|
|
6207
6315
|
function provideBuiltInFieldTypes() {
|
|
6208
6316
|
return provideFieldTypes(builtInFieldTypes());
|
|
6209
6317
|
}
|
|
@@ -6232,14 +6340,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
6232
6340
|
}] });
|
|
6233
6341
|
|
|
6234
6342
|
/**
|
|
6235
|
-
* RulesEvaluationService — evaluates
|
|
6343
|
+
* RulesEvaluationService — evaluates rules, and owns what a result *means*.
|
|
6344
|
+
*
|
|
6345
|
+
* It used to be two passthrough methods, with the interpretation spread across the component:
|
|
6346
|
+
* the render filter decided one way what a rule result did to a field, and the validity sync
|
|
6347
|
+
* decided again forty lines further down. They disagreed about what "every field" meant, and
|
|
6348
|
+
* the consequence was a required field inside a `group` that a rule had hidden holding
|
|
6349
|
+
* `form.invalid` true forever with nothing on screen to explain it.
|
|
6350
|
+
*
|
|
6351
|
+
* Precedence lives here now, in one method that both callers go through. Still stateless —
|
|
6352
|
+
* every method takes the result it is interpreting, so there is nothing to go stale against a
|
|
6353
|
+
* rebuilt form and nothing to reset between configs.
|
|
6236
6354
|
*/
|
|
6237
6355
|
class RulesEvaluationService {
|
|
6238
6356
|
/**
|
|
6239
6357
|
* Evaluate rules against form values and optional original baseline values.
|
|
6240
6358
|
*/
|
|
6241
|
-
evaluate(rules, values, originalValues) {
|
|
6242
|
-
return evaluateFormRules(rules, values, originalValues);
|
|
6359
|
+
evaluate(rules, values, originalValues, options) {
|
|
6360
|
+
return evaluateFormRules(rules, values, originalValues, options);
|
|
6243
6361
|
}
|
|
6244
6362
|
/**
|
|
6245
6363
|
* Filter rules pertinent to a single tab.
|
|
@@ -6247,6 +6365,89 @@ class RulesEvaluationService {
|
|
|
6247
6365
|
filterForTab(rules, tabId, config) {
|
|
6248
6366
|
return filterRulesForTab(rules, tabId, config);
|
|
6249
6367
|
}
|
|
6368
|
+
/**
|
|
6369
|
+
* Whether a field renders, under the precedence documented on `RuleEvaluationResult`.
|
|
6370
|
+
*
|
|
6371
|
+
* An explicit hide beats everything. A show beats static hiding — `visibility: false`, or a
|
|
6372
|
+
* `showWhen` that does not currently hold — because a rule that could only hide what the
|
|
6373
|
+
* config already hides would have nothing to say, and `{ visibility: true }` was a
|
|
6374
|
+
* documented no-op until it did. Otherwise the config's own visibility decides.
|
|
6375
|
+
*/
|
|
6376
|
+
isFieldVisible(result, field, names, values) {
|
|
6377
|
+
const sets = this.nameSets(result);
|
|
6378
|
+
if (names.some(name => sets.hidden.has(name)))
|
|
6379
|
+
return false;
|
|
6380
|
+
if (names.some(name => sets.shown.has(name)))
|
|
6381
|
+
return true;
|
|
6382
|
+
return evaluateFieldVisibility(field, values);
|
|
6383
|
+
}
|
|
6384
|
+
/** The same precedence for a tab. */
|
|
6385
|
+
isTabVisible(result, tab) {
|
|
6386
|
+
if (result.hiddenTabs.includes(tab.id))
|
|
6387
|
+
return false;
|
|
6388
|
+
return tab.visibility !== false || result.shownTabs.includes(tab.id);
|
|
6389
|
+
}
|
|
6390
|
+
/**
|
|
6391
|
+
* Keep a hidden field's control out of form validity.
|
|
6392
|
+
*
|
|
6393
|
+
* A field hidden by a rule or a `showWhen` is filtered out of the render, but its
|
|
6394
|
+
* validators stay attached — so a hidden *required* field holds `form.invalid` true
|
|
6395
|
+
* forever, permanently disabling Save with nothing on screen to explain why.
|
|
6396
|
+
*
|
|
6397
|
+
* Disabling is the fix rather than stripping validators: Angular excludes disabled controls
|
|
6398
|
+
* from validity, and the field's own validators survive intact for when it comes back.
|
|
6399
|
+
* Values are untouched — a disabled control still carries its value, so rule evaluation and
|
|
6400
|
+
* the emitted record see exactly what they saw before. That also keeps this from feeding
|
|
6401
|
+
* back on itself: hiding a field cannot change the values the rules are evaluated over.
|
|
6402
|
+
*
|
|
6403
|
+
* The predicate is `isFieldVisible`, the same one the render filter uses, so what is on
|
|
6404
|
+
* screen and what counts toward validity cannot drift apart.
|
|
6405
|
+
*
|
|
6406
|
+
* Entries must arrive parent-first, which is what `collectFieldScopes` yields: a container's
|
|
6407
|
+
* children are skipped once the container itself is hidden, because Angular's `disable()`
|
|
6408
|
+
* cascades to descendants and a child's `enable()` walks back up recalculating ancestors.
|
|
6409
|
+
* Toggling a child inside a hidden group would fight its parent.
|
|
6410
|
+
*/
|
|
6411
|
+
syncHiddenFieldState(entries, ctx) {
|
|
6412
|
+
const hiddenContainers = [];
|
|
6413
|
+
for (const entry of entries) {
|
|
6414
|
+
const field = entry.field;
|
|
6415
|
+
if (!field?.id)
|
|
6416
|
+
continue;
|
|
6417
|
+
const address = ctx.addressOf(field);
|
|
6418
|
+
if (hiddenContainers.some(prefix => address.startsWith(`${prefix}.`)))
|
|
6419
|
+
continue;
|
|
6420
|
+
const hidden = !this.isFieldVisible(ctx.result, field, ctx.namesOf(field), ctx.values);
|
|
6421
|
+
if (hidden && (field.type === 'group' || field.type === 'array'))
|
|
6422
|
+
hiddenContainers.push(address);
|
|
6423
|
+
const ctrl = ctx.controlFor(entry);
|
|
6424
|
+
if (!ctrl)
|
|
6425
|
+
continue;
|
|
6426
|
+
if (hidden) {
|
|
6427
|
+
if (ctrl.enabled)
|
|
6428
|
+
ctrl.disable({ emitEvent: false });
|
|
6429
|
+
}
|
|
6430
|
+
else if (ctrl.disabled && !field.disabled) {
|
|
6431
|
+
ctrl.enable({ emitEvent: false });
|
|
6432
|
+
}
|
|
6433
|
+
}
|
|
6434
|
+
}
|
|
6435
|
+
/**
|
|
6436
|
+
* The hidden and shown names as sets, memoised against the result they came from.
|
|
6437
|
+
*
|
|
6438
|
+
* `isFieldVisible` runs once per field per change-detection pass; building two sets from
|
|
6439
|
+
* two arrays each time turns a rule result into allocation proportional to the form. A
|
|
6440
|
+
* `WeakMap` keyed by the result means it is built once per evaluation and collected with it.
|
|
6441
|
+
*/
|
|
6442
|
+
nameSets(result) {
|
|
6443
|
+
let sets = this.nameSetCache.get(result);
|
|
6444
|
+
if (!sets) {
|
|
6445
|
+
sets = { hidden: new Set(result.hiddenFields), shown: new Set(result.shownFields) };
|
|
6446
|
+
this.nameSetCache.set(result, sets);
|
|
6447
|
+
}
|
|
6448
|
+
return sets;
|
|
6449
|
+
}
|
|
6450
|
+
nameSetCache = new WeakMap();
|
|
6250
6451
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
6251
6452
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, providedIn: 'root' });
|
|
6252
6453
|
}
|
|
@@ -6318,6 +6519,255 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
6318
6519
|
args: [CONFIG_SOURCE]
|
|
6319
6520
|
}] }] });
|
|
6320
6521
|
|
|
6522
|
+
/**
|
|
6523
|
+
* FormStructureService — config ⇄ `FormGroup`, and nothing else.
|
|
6524
|
+
*
|
|
6525
|
+
* The one question this answers is *where a value lives*: which control a field owns, how
|
|
6526
|
+
* the tab tree nests into groups, how a record is read out of the tree and patched back in.
|
|
6527
|
+
* It was half of `DynamicFormComponent`, mixed in with tab state, focus management, rule
|
|
6528
|
+
* evaluation and RBAC — and the cost of that was not length but ownership: the bug Phase 2
|
|
6529
|
+
* fixed was two methods forty lines apart disagreeing about what "every field" meant.
|
|
6530
|
+
*
|
|
6531
|
+
* Deliberately stateless. Every method takes the form and the config it is operating on, so
|
|
6532
|
+
* there is no cached `this.form` to go stale against a rebuilt one, and every method is
|
|
6533
|
+
* testable without a component or a TestBed.
|
|
6534
|
+
*
|
|
6535
|
+
* Provided at the component, not the root: `FormBuilder` and the validator registry are the
|
|
6536
|
+
* only things injected, but keeping the service per form leaves room for that to change
|
|
6537
|
+
* without turning into shared mutable state.
|
|
6538
|
+
*/
|
|
6539
|
+
class FormStructureService {
|
|
6540
|
+
fb = inject(FormBuilder);
|
|
6541
|
+
validatorRegistry = inject(ValidatorRegistryService);
|
|
6542
|
+
/**
|
|
6543
|
+
* Build the control tree for a config.
|
|
6544
|
+
*
|
|
6545
|
+
* The tree mirrors the record's shape exactly: a tab opens a `FormGroup` under its id, a
|
|
6546
|
+
* `flatData` tab merges into its parent's, and a `group` field opens one for its children.
|
|
6547
|
+
* That correspondence is what lets a `[path]` ref resolve through `form.get()` and name
|
|
6548
|
+
* exactly one control — see `getControl`.
|
|
6549
|
+
*/
|
|
6550
|
+
buildForm(config) {
|
|
6551
|
+
const group = {};
|
|
6552
|
+
const buildTabControls = (tabs, parentGroup) => {
|
|
6553
|
+
for (const tab of tabs) {
|
|
6554
|
+
if (tab.flatData) {
|
|
6555
|
+
for (const field of tab.fields || []) {
|
|
6556
|
+
this.buildFieldControl(field, parentGroup);
|
|
6557
|
+
}
|
|
6558
|
+
if (tab.children)
|
|
6559
|
+
buildTabControls(tab.children, parentGroup);
|
|
6560
|
+
}
|
|
6561
|
+
else {
|
|
6562
|
+
const tabGroup = {};
|
|
6563
|
+
for (const field of tab.fields || []) {
|
|
6564
|
+
this.buildFieldControl(field, tabGroup);
|
|
6565
|
+
}
|
|
6566
|
+
if (tab.children)
|
|
6567
|
+
buildTabControls(tab.children, tabGroup);
|
|
6568
|
+
parentGroup[tab.id] = this.fb.group(tabGroup);
|
|
6569
|
+
}
|
|
6570
|
+
}
|
|
6571
|
+
};
|
|
6572
|
+
buildTabControls(config.tabs || [], group);
|
|
6573
|
+
return this.fb.group(group);
|
|
6574
|
+
}
|
|
6575
|
+
/** One field's control: a nested group, an empty array, or a leaf carrying its validators. */
|
|
6576
|
+
buildFieldControl(field, group) {
|
|
6577
|
+
if (field.type === 'group') {
|
|
6578
|
+
const subGroup = {};
|
|
6579
|
+
for (const child of field.children || []) {
|
|
6580
|
+
this.buildFieldControl(child, subGroup);
|
|
6581
|
+
}
|
|
6582
|
+
group[field.id] = this.fb.group(subGroup);
|
|
6583
|
+
}
|
|
6584
|
+
else if (field.type === 'array') {
|
|
6585
|
+
group[field.id] = this.fb.array([]);
|
|
6586
|
+
}
|
|
6587
|
+
else {
|
|
6588
|
+
const validators = this.validatorRegistry.resolveFromConfig(field.validators);
|
|
6589
|
+
const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
|
|
6590
|
+
group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
|
|
6591
|
+
}
|
|
6592
|
+
}
|
|
6593
|
+
/** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
|
|
6594
|
+
buildArrayRow(field, item) {
|
|
6595
|
+
if (!field?.children?.length)
|
|
6596
|
+
return this.fb.control(item);
|
|
6597
|
+
const rowGroup = {};
|
|
6598
|
+
for (const child of field.children) {
|
|
6599
|
+
this.buildFieldControl(child, rowGroup);
|
|
6600
|
+
}
|
|
6601
|
+
const group = this.fb.group(rowGroup);
|
|
6602
|
+
if (item && typeof item === 'object')
|
|
6603
|
+
group.patchValue(item);
|
|
6604
|
+
return group;
|
|
6605
|
+
}
|
|
6606
|
+
/** The `FormGroup` a tab's fields live in, honouring `flatData`. */
|
|
6607
|
+
getTabGroup(form, config, tabId) {
|
|
6608
|
+
if (!form)
|
|
6609
|
+
return null;
|
|
6610
|
+
const path = getTabPath(config?.tabs, tabId);
|
|
6611
|
+
if (!path || path.length === 0)
|
|
6612
|
+
return form;
|
|
6613
|
+
let curr = form;
|
|
6614
|
+
for (const p of path) {
|
|
6615
|
+
if (!curr || !(curr instanceof FormGroup))
|
|
6616
|
+
return null;
|
|
6617
|
+
curr = curr.get(p);
|
|
6618
|
+
}
|
|
6619
|
+
return curr instanceof FormGroup ? curr : null;
|
|
6620
|
+
}
|
|
6621
|
+
/**
|
|
6622
|
+
* Find a control by reference: a bracketed path, or a bare field id.
|
|
6623
|
+
*
|
|
6624
|
+
* A bracketed path names exactly one control, because the tree nests exactly as the path
|
|
6625
|
+
* does — so `form.get('work.address.city')` walks straight to it. A bare id is what every
|
|
6626
|
+
* config written before paths existed uses: it is looked for on `currentTabId` first, then
|
|
6627
|
+
* at the root, then by a recursive search whose result is whichever the walk reaches first.
|
|
6628
|
+
* That last branch is the ambiguity refs exist to remove, and callers that can name a path
|
|
6629
|
+
* should.
|
|
6630
|
+
*/
|
|
6631
|
+
getControl(form, config, fieldId, currentTabId) {
|
|
6632
|
+
if (!form)
|
|
6633
|
+
return null;
|
|
6634
|
+
const parsed = parseFieldRef(fieldId);
|
|
6635
|
+
if (parsed.kind === 'ref')
|
|
6636
|
+
return form.get(parsed.value);
|
|
6637
|
+
if (currentTabId) {
|
|
6638
|
+
const tabGrp = this.getTabGroup(form, config, currentTabId);
|
|
6639
|
+
const ctrl = tabGrp?.get(fieldId);
|
|
6640
|
+
if (ctrl)
|
|
6641
|
+
return ctrl;
|
|
6642
|
+
}
|
|
6643
|
+
const rootCtrl = form.get(fieldId);
|
|
6644
|
+
if (rootCtrl)
|
|
6645
|
+
return rootCtrl;
|
|
6646
|
+
const findInGroup = (group) => {
|
|
6647
|
+
for (const key of Object.keys(group.controls)) {
|
|
6648
|
+
const c = group.controls[key];
|
|
6649
|
+
if (key === fieldId)
|
|
6650
|
+
return c;
|
|
6651
|
+
if (c instanceof FormGroup) {
|
|
6652
|
+
const found = findInGroup(c);
|
|
6653
|
+
if (found)
|
|
6654
|
+
return found;
|
|
6655
|
+
}
|
|
6656
|
+
}
|
|
6657
|
+
return null;
|
|
6658
|
+
};
|
|
6659
|
+
return findInGroup(form);
|
|
6660
|
+
}
|
|
6661
|
+
/** Assemble the full nested record from per-tab groups, respecting flatData, refererField & arrays. */
|
|
6662
|
+
extractRecord(form, config) {
|
|
6663
|
+
if (!form || !config)
|
|
6664
|
+
return {};
|
|
6665
|
+
let record = {};
|
|
6666
|
+
const walkTabs = (tabs) => {
|
|
6667
|
+
for (const tab of tabs) {
|
|
6668
|
+
const fieldValBag = {};
|
|
6669
|
+
for (const field of tab.fields || []) {
|
|
6670
|
+
const ctrl = this.getControl(form, config, field.id, tab.id);
|
|
6671
|
+
if (ctrl)
|
|
6672
|
+
fieldValBag[field.id] = ctrl.value;
|
|
6673
|
+
}
|
|
6674
|
+
setTabData(record, tab.id, fieldValBag, config);
|
|
6675
|
+
for (const field of tab.fields || []) {
|
|
6676
|
+
if (field.refererField) {
|
|
6677
|
+
const ctrl = this.getControl(form, config, field.id, tab.id);
|
|
6678
|
+
if (ctrl)
|
|
6679
|
+
setValueByPath(record, field.refererField, ctrl.value);
|
|
6680
|
+
}
|
|
6681
|
+
}
|
|
6682
|
+
if (tab.children)
|
|
6683
|
+
walkTabs(tab.children);
|
|
6684
|
+
}
|
|
6685
|
+
};
|
|
6686
|
+
walkTabs(config.tabs || []);
|
|
6687
|
+
record = normalizeArrayStructures(record, config);
|
|
6688
|
+
return record;
|
|
6689
|
+
}
|
|
6690
|
+
/**
|
|
6691
|
+
* Write a record into the control tree.
|
|
6692
|
+
*
|
|
6693
|
+
* Returns the top-level keys that named a known field and were *not* consumed, so the
|
|
6694
|
+
* caller can say so — see `DynamicFormComponent.warnUnconsumedInitialData`. Reporting
|
|
6695
|
+
* rather than logging keeps the "warn once per form" bookkeeping with the form, and keeps
|
|
6696
|
+
* this service free of console output.
|
|
6697
|
+
*/
|
|
6698
|
+
patchForm(form, config, data, fieldsById) {
|
|
6699
|
+
if (!data || !form || !config)
|
|
6700
|
+
return [];
|
|
6701
|
+
const patchedFieldIds = new Set();
|
|
6702
|
+
const walkTabs = (tabs) => {
|
|
6703
|
+
for (const tab of tabs) {
|
|
6704
|
+
const tabData = getTabData(tab.id, data, config);
|
|
6705
|
+
for (const field of tab.fields || []) {
|
|
6706
|
+
let val = tabData && typeof tabData === 'object' ? tabData[field.id] : undefined;
|
|
6707
|
+
if (field.refererField) {
|
|
6708
|
+
const refVal = getValueByPath(data, field.refererField);
|
|
6709
|
+
if (refVal !== undefined)
|
|
6710
|
+
val = refVal;
|
|
6711
|
+
}
|
|
6712
|
+
if (val === undefined)
|
|
6713
|
+
continue;
|
|
6714
|
+
const ctrl = this.getControl(form, config, field.id, tab.id);
|
|
6715
|
+
if (!ctrl)
|
|
6716
|
+
continue;
|
|
6717
|
+
if (ctrl instanceof FormArray && Array.isArray(val)) {
|
|
6718
|
+
ctrl.clear();
|
|
6719
|
+
for (const item of val) {
|
|
6720
|
+
ctrl.push(this.buildArrayRow(fieldsById.get(field.id), item));
|
|
6721
|
+
}
|
|
6722
|
+
}
|
|
6723
|
+
else {
|
|
6724
|
+
ctrl.patchValue(val, { emitEvent: false });
|
|
6725
|
+
}
|
|
6726
|
+
patchedFieldIds.add(field.id);
|
|
6727
|
+
}
|
|
6728
|
+
if (tab.children)
|
|
6729
|
+
walkTabs(tab.children);
|
|
6730
|
+
}
|
|
6731
|
+
};
|
|
6732
|
+
walkTabs(config.tabs || []);
|
|
6733
|
+
return this.unconsumedKeys(config, data, fieldsById, patchedFieldIds);
|
|
6734
|
+
}
|
|
6735
|
+
/**
|
|
6736
|
+
* Top-level keys that name a field but reached no control.
|
|
6737
|
+
*
|
|
6738
|
+
* A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
|
|
6739
|
+
* `flatData: true`. Handing a flat record to a nested tab finds nothing, so the fields stay
|
|
6740
|
+
* empty — with no error and no clue.
|
|
6741
|
+
*
|
|
6742
|
+
* Only keys matching a known field id count: anything else is the consumer's own record
|
|
6743
|
+
* metadata (ids, timestamps, `_configVersion`) and is not our business.
|
|
6744
|
+
*/
|
|
6745
|
+
unconsumedKeys(config, data, fieldsById, patchedFieldIds) {
|
|
6746
|
+
if (!isDevMode())
|
|
6747
|
+
return [];
|
|
6748
|
+
const tabIds = new Set();
|
|
6749
|
+
const collectTabIds = (tabs) => {
|
|
6750
|
+
for (const tab of tabs) {
|
|
6751
|
+
tabIds.add(tab.id);
|
|
6752
|
+
if (tab.children)
|
|
6753
|
+
collectTabIds(tab.children);
|
|
6754
|
+
}
|
|
6755
|
+
};
|
|
6756
|
+
collectTabIds(config.tabs || []);
|
|
6757
|
+
return Object.keys(data).filter(key =>
|
|
6758
|
+
// A tab id at the root is the nested container, not a stray field.
|
|
6759
|
+
!tabIds.has(key) &&
|
|
6760
|
+
data[key] !== undefined &&
|
|
6761
|
+
fieldsById.has(key) &&
|
|
6762
|
+
!patchedFieldIds.has(key));
|
|
6763
|
+
}
|
|
6764
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
6765
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService });
|
|
6766
|
+
}
|
|
6767
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, decorators: [{
|
|
6768
|
+
type: Injectable
|
|
6769
|
+
}] });
|
|
6770
|
+
|
|
6321
6771
|
/**
|
|
6322
6772
|
* Grid width per field type, used only under `layout="auto"` and only where the field itself
|
|
6323
6773
|
* names no `colSpan`.
|
|
@@ -6437,6 +6887,8 @@ class DynamicFormComponent {
|
|
|
6437
6887
|
hookRegistry = inject(HookRegistryService);
|
|
6438
6888
|
rbacService = inject(RbacService);
|
|
6439
6889
|
rulesEvaluation = inject(RulesEvaluationService);
|
|
6890
|
+
/** config ⇄ FormGroup: where a value lives. See `FormStructureService`. */
|
|
6891
|
+
structure = inject(FormStructureService);
|
|
6440
6892
|
/** Resolves what is wrong with a field, for the error summary — see `resolveForField`. */
|
|
6441
6893
|
messages = inject(ValidationMessagesService);
|
|
6442
6894
|
entityRefSelection = inject(EntityRefSelectionService);
|
|
@@ -6475,12 +6927,29 @@ class DynamicFormComponent {
|
|
|
6475
6927
|
// invalid form is what produces the error summary. Guarding here instead meant Ctrl+S
|
|
6476
6928
|
// on an incomplete form did nothing at all — and unlike the button, a shortcut cannot
|
|
6477
6929
|
// even look disabled.
|
|
6930
|
+
// `void`, not an `await`: a `@HostListener` cannot be async without changing what
|
|
6931
|
+
// Angular does with its return value, and `submit()` reports its own failures through
|
|
6932
|
+
// `saveRejected`. Nothing here would have anywhere to put a rejection.
|
|
6478
6933
|
if (!this.readonly && this.canSubmit)
|
|
6479
|
-
this.submit();
|
|
6934
|
+
void this.submit();
|
|
6480
6935
|
}
|
|
6481
6936
|
}
|
|
6482
6937
|
// ─── Computed ─────────────────────────────────────────────────────────────
|
|
6483
|
-
ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline()
|
|
6938
|
+
ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline(), {
|
|
6939
|
+
// The engine reports rather than logs, because it also runs on a server. This is the
|
|
6940
|
+
// client's answer: say it once per distinct problem, in dev only. A rule the author
|
|
6941
|
+
// half-finished used to throw out of change detection; now it is skipped, and a
|
|
6942
|
+
// silently skipped rule is exactly as hard to diagnose as a thrown one.
|
|
6943
|
+
onProblem: message => this.warnOnce(message),
|
|
6944
|
+
}), ...(ngDevMode ? [{ debugName: "ruleResult" }] : /* istanbul ignore next */ []));
|
|
6945
|
+
/** Warned-about rule problems, per component instance — see `warnedAmbiguousIds`. */
|
|
6946
|
+
warnedProblems = new Set();
|
|
6947
|
+
warnOnce(message) {
|
|
6948
|
+
if (!isDevMode() || this.warnedProblems.has(message))
|
|
6949
|
+
return;
|
|
6950
|
+
this.warnedProblems.add(message);
|
|
6951
|
+
console.warn(`[ngx-dynamic-entity] ${message}`);
|
|
6952
|
+
}
|
|
6484
6953
|
/**
|
|
6485
6954
|
* Critical fields whose value differs from the session baseline.
|
|
6486
6955
|
* These drive the deferred `VALUE_CHANGED` banner — a critical edit is announced once,
|
|
@@ -6497,8 +6966,10 @@ class DynamicFormComponent {
|
|
|
6497
6966
|
return this.config?.tabs || [];
|
|
6498
6967
|
}
|
|
6499
6968
|
get visibleTabs() {
|
|
6500
|
-
|
|
6501
|
-
|
|
6969
|
+
return this.tabs.filter(tab => this.isTabVisible(tab));
|
|
6970
|
+
}
|
|
6971
|
+
isTabVisible(tab) {
|
|
6972
|
+
return this.rulesEvaluation.isTabVisible(this.ruleResult(), tab);
|
|
6502
6973
|
}
|
|
6503
6974
|
get activeTabConfig() {
|
|
6504
6975
|
const tabId = this.activeTab();
|
|
@@ -6510,8 +6981,7 @@ class DynamicFormComponent {
|
|
|
6510
6981
|
const active = this.activeTabConfig;
|
|
6511
6982
|
if (!active?.children || active.children.length === 0)
|
|
6512
6983
|
return [];
|
|
6513
|
-
|
|
6514
|
-
return active.children.filter(tab => tab.visibility !== false && !hidden.includes(tab.id));
|
|
6984
|
+
return active.children.filter(tab => this.isTabVisible(tab));
|
|
6515
6985
|
}
|
|
6516
6986
|
get activeSubTabConfig() {
|
|
6517
6987
|
const subTabs = this.visibleSubTabs;
|
|
@@ -6561,8 +7031,15 @@ class DynamicFormComponent {
|
|
|
6561
7031
|
return [];
|
|
6562
7032
|
const rawFields = active ? active.fields || [] : (this.config?.tabs || []).flatMap(t => t.fields || []);
|
|
6563
7033
|
const currentValues = this.formValues();
|
|
6564
|
-
|
|
6565
|
-
|
|
7034
|
+
return rawFields.filter(field => this.isFieldVisible(field, currentValues));
|
|
7035
|
+
}
|
|
7036
|
+
/**
|
|
7037
|
+
* Whether a field renders. Precedence lives in `RulesEvaluationService`, which is also
|
|
7038
|
+
* what `syncHiddenFieldState` asks — so what is on screen and what counts toward validity
|
|
7039
|
+
* cannot drift apart.
|
|
7040
|
+
*/
|
|
7041
|
+
isFieldVisible(field, values) {
|
|
7042
|
+
return this.rulesEvaluation.isFieldVisible(this.ruleResult(), field, this.namesOf(field), values);
|
|
6566
7043
|
}
|
|
6567
7044
|
/**
|
|
6568
7045
|
* Entity-level permissions for the current roles.
|
|
@@ -6788,19 +7265,36 @@ class DynamicFormComponent {
|
|
|
6788
7265
|
if (this.invalidFieldsCache)
|
|
6789
7266
|
return this.invalidFieldsCache;
|
|
6790
7267
|
const out = [];
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
7268
|
+
/*
|
|
7269
|
+
* Descends into `group` children rather than stopping at the container.
|
|
7270
|
+
*
|
|
7271
|
+
* A `FormGroup` is invalid whenever any descendant is, so reporting the container said
|
|
7272
|
+
* "Contacts is invalid" and left the user to open it and hunt. The offending leaf is
|
|
7273
|
+
* both the useful answer and the one `jumpToField` can actually move focus to.
|
|
7274
|
+
*
|
|
7275
|
+
* An `array` is reported as itself: its rows are built and destroyed at runtime, so
|
|
7276
|
+
* there is no configured child to name and nothing stable to jump to.
|
|
7277
|
+
*/
|
|
7278
|
+
const collectField = (field, tab, parentId) => {
|
|
7279
|
+
const control = this.getControl(field.id, tab.id);
|
|
7280
|
+
if (!control || control.disabled || control.valid || control.pending)
|
|
7281
|
+
return;
|
|
7282
|
+
if (field.type === 'group' && field.children?.length) {
|
|
7283
|
+
for (const child of field.children)
|
|
7284
|
+
collectField(child, tab, parentId);
|
|
7285
|
+
return;
|
|
6803
7286
|
}
|
|
7287
|
+
// The same message the field renders under itself — see `resolveForField`. Saying
|
|
7288
|
+
// only *which* field is wrong leaves the user to go and look at each one; saying what
|
|
7289
|
+
// is wrong with it is usually enough to fix it without leaving the summary.
|
|
7290
|
+
const message = this.messages.resolveForField(control.errors, this.language, field.type);
|
|
7291
|
+
out.push(parentId
|
|
7292
|
+
? { field, message, tabId: parentId, subTabId: tab.id }
|
|
7293
|
+
: { field, message, tabId: tab.id });
|
|
7294
|
+
};
|
|
7295
|
+
const collect = (tab, parentId) => {
|
|
7296
|
+
for (const field of tab.fields ?? [])
|
|
7297
|
+
collectField(field, tab, parentId);
|
|
6804
7298
|
for (const child of tab.children ?? [])
|
|
6805
7299
|
collect(child, parentId ?? tab.id);
|
|
6806
7300
|
};
|
|
@@ -6852,16 +7346,31 @@ class DynamicFormComponent {
|
|
|
6852
7346
|
el.focus();
|
|
6853
7347
|
}, { injector: this.injector });
|
|
6854
7348
|
}
|
|
7349
|
+
/**
|
|
7350
|
+
* Which tab (and sub-tab) holds a field.
|
|
7351
|
+
*
|
|
7352
|
+
* Descends into `group`/`array` children: this compared top-level fields only, so "jump to
|
|
7353
|
+
* the first invalid field" found nothing for a nested one and silently did nothing. Only
|
|
7354
|
+
* sub-tabs one level deep are named, because that is what the tab strip can select —
|
|
7355
|
+
* anything deeper resolves to the ancestor it renders under.
|
|
7356
|
+
*/
|
|
6855
7357
|
locateField(fieldId) {
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
7358
|
+
const holds = (fields) => (fields ?? []).some(f => f?.id === fieldId || holds(f?.children));
|
|
7359
|
+
const search = (tabs, tabId, subTabId) => {
|
|
7360
|
+
for (const tab of tabs ?? []) {
|
|
7361
|
+
if (!tab?.id)
|
|
7362
|
+
continue;
|
|
7363
|
+
const at = tabId ?? tab.id;
|
|
7364
|
+
const sub = tabId ? (subTabId ?? tab.id) : undefined;
|
|
7365
|
+
if (holds(tab.fields))
|
|
7366
|
+
return sub ? { tabId: at, subTabId: sub } : { tabId: at };
|
|
7367
|
+
const found = search(tab.children, at, sub);
|
|
7368
|
+
if (found)
|
|
7369
|
+
return found;
|
|
6862
7370
|
}
|
|
6863
|
-
|
|
6864
|
-
|
|
7371
|
+
return null;
|
|
7372
|
+
};
|
|
7373
|
+
return search(this.tabs);
|
|
6865
7374
|
}
|
|
6866
7375
|
// ─── criticalField locking ────────────────────────────────────────────────
|
|
6867
7376
|
/** A critical field stays read-only until the user deliberately unlocks it. */
|
|
@@ -6921,29 +7430,9 @@ class DynamicFormComponent {
|
|
|
6921
7430
|
buildForm() {
|
|
6922
7431
|
if (!this.config)
|
|
6923
7432
|
return;
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
if (tab.flatData) {
|
|
6928
|
-
for (const field of tab.fields || []) {
|
|
6929
|
-
this.buildFieldControl(field, parentGroup);
|
|
6930
|
-
}
|
|
6931
|
-
if (tab.children)
|
|
6932
|
-
buildTabControls(tab.children, parentGroup);
|
|
6933
|
-
}
|
|
6934
|
-
else {
|
|
6935
|
-
const tabGroup = {};
|
|
6936
|
-
for (const field of tab.fields || []) {
|
|
6937
|
-
this.buildFieldControl(field, tabGroup);
|
|
6938
|
-
}
|
|
6939
|
-
if (tab.children)
|
|
6940
|
-
buildTabControls(tab.children, tabGroup);
|
|
6941
|
-
parentGroup[tab.id] = this.fb.group(tabGroup);
|
|
6942
|
-
}
|
|
6943
|
-
}
|
|
6944
|
-
};
|
|
6945
|
-
buildTabControls(this.config.tabs || [], group);
|
|
6946
|
-
this.form = this.fb.group(group);
|
|
7433
|
+
this.form = this.structure.buildForm(this.config);
|
|
7434
|
+
// A rebuild replaces every control, and the scope index is keyed by config identity.
|
|
7435
|
+
this.scopeCache = undefined;
|
|
6947
7436
|
this.warnAmbiguousRuleReferences();
|
|
6948
7437
|
if (this.initialData) {
|
|
6949
7438
|
this.patchForm(this.upgradeRecord(this.initialData));
|
|
@@ -6989,24 +7478,6 @@ class DynamicFormComponent {
|
|
|
6989
7478
|
this.formChange.emit(this.extractRecord());
|
|
6990
7479
|
});
|
|
6991
7480
|
}
|
|
6992
|
-
buildFieldControl(field, group) {
|
|
6993
|
-
if (field.type === 'group') {
|
|
6994
|
-
const subGroup = {};
|
|
6995
|
-
for (const child of field.children || []) {
|
|
6996
|
-
this.buildFieldControl(child, subGroup);
|
|
6997
|
-
}
|
|
6998
|
-
group[field.id] = this.fb.group(subGroup);
|
|
6999
|
-
}
|
|
7000
|
-
else if (field.type === 'array') {
|
|
7001
|
-
group[field.id] = this.fb.array([]);
|
|
7002
|
-
}
|
|
7003
|
-
else {
|
|
7004
|
-
const validators = this.validatorRegistry.resolveFromConfig(field.validators);
|
|
7005
|
-
const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
|
|
7006
|
-
group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
|
|
7007
|
-
}
|
|
7008
|
-
}
|
|
7009
|
-
/** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
|
|
7010
7481
|
/**
|
|
7011
7482
|
* The `FormArray` behind an `array` field, for callers that manage rows themselves —
|
|
7012
7483
|
* the record editor's inline row drawer.
|
|
@@ -7023,16 +7494,7 @@ class DynamicFormComponent {
|
|
|
7023
7494
|
return this.buildArrayRow(field, value);
|
|
7024
7495
|
}
|
|
7025
7496
|
buildArrayRow(field, item) {
|
|
7026
|
-
|
|
7027
|
-
return this.fb.control(item);
|
|
7028
|
-
const rowGroup = {};
|
|
7029
|
-
for (const child of field.children) {
|
|
7030
|
-
this.buildFieldControl(child, rowGroup);
|
|
7031
|
-
}
|
|
7032
|
-
const group = this.fb.group(rowGroup);
|
|
7033
|
-
if (item && typeof item === 'object')
|
|
7034
|
-
group.patchValue(item);
|
|
7035
|
-
return group;
|
|
7497
|
+
return this.structure.buildArrayRow(field, item);
|
|
7036
7498
|
}
|
|
7037
7499
|
/**
|
|
7038
7500
|
* Mark every control touched, then re-check the field components.
|
|
@@ -7065,42 +7527,11 @@ class DynamicFormComponent {
|
|
|
7065
7527
|
return result.record;
|
|
7066
7528
|
}
|
|
7067
7529
|
patchForm(data) {
|
|
7068
|
-
if (!data || !this.form)
|
|
7530
|
+
if (!data || !this.form || !this.config)
|
|
7069
7531
|
return;
|
|
7070
7532
|
const fieldsById = new Map(this.allFields().map(f => [f.id, f]));
|
|
7071
|
-
const
|
|
7072
|
-
|
|
7073
|
-
for (const tab of tabs) {
|
|
7074
|
-
const tabData = getTabData(tab.id, data, this.config);
|
|
7075
|
-
for (const field of tab.fields || []) {
|
|
7076
|
-
let val = tabData && typeof tabData === 'object' ? tabData[field.id] : undefined;
|
|
7077
|
-
if (field.refererField) {
|
|
7078
|
-
const refVal = getValueByPath(data, field.refererField);
|
|
7079
|
-
if (refVal !== undefined)
|
|
7080
|
-
val = refVal;
|
|
7081
|
-
}
|
|
7082
|
-
if (val === undefined)
|
|
7083
|
-
continue;
|
|
7084
|
-
const ctrl = this.getControl(field.id, tab.id);
|
|
7085
|
-
if (!ctrl)
|
|
7086
|
-
continue;
|
|
7087
|
-
if (ctrl instanceof FormArray && Array.isArray(val)) {
|
|
7088
|
-
ctrl.clear();
|
|
7089
|
-
for (const item of val) {
|
|
7090
|
-
ctrl.push(this.buildArrayRow(fieldsById.get(field.id), item));
|
|
7091
|
-
}
|
|
7092
|
-
}
|
|
7093
|
-
else {
|
|
7094
|
-
ctrl.patchValue(val, { emitEvent: false });
|
|
7095
|
-
}
|
|
7096
|
-
patchedFieldIds.add(field.id);
|
|
7097
|
-
}
|
|
7098
|
-
if (tab.children)
|
|
7099
|
-
walkTabs(tab.children);
|
|
7100
|
-
}
|
|
7101
|
-
};
|
|
7102
|
-
walkTabs(this.config.tabs || []);
|
|
7103
|
-
this.warnUnconsumedInitialData(data, fieldsById, patchedFieldIds);
|
|
7533
|
+
const unconsumed = this.structure.patchForm(this.form, this.config, data, fieldsById);
|
|
7534
|
+
this.warnUnconsumedInitialData(unconsumed);
|
|
7104
7535
|
}
|
|
7105
7536
|
/**
|
|
7106
7537
|
* A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
|
|
@@ -7108,34 +7539,18 @@ class DynamicFormComponent {
|
|
|
7108
7539
|
* stay empty — with no error and no clue. That silent miss is the most expensive way to
|
|
7109
7540
|
* lose an hour with this library, so name it in dev builds.
|
|
7110
7541
|
*
|
|
7111
|
-
*
|
|
7112
|
-
*
|
|
7113
|
-
* our business.
|
|
7542
|
+
* Which keys were missed is `FormStructureService`'s answer; warning once per key per form
|
|
7543
|
+
* is this component's, because it is the thing that lives as long as the form does.
|
|
7114
7544
|
*/
|
|
7115
|
-
warnUnconsumedInitialData(
|
|
7116
|
-
|
|
7545
|
+
warnUnconsumedInitialData(keys) {
|
|
7546
|
+
const fresh = keys.filter(key => !this.warnedUnconsumedKeys.has(key));
|
|
7547
|
+
if (!fresh.length)
|
|
7117
7548
|
return;
|
|
7118
|
-
const
|
|
7119
|
-
const collectTabIds = (tabs) => {
|
|
7120
|
-
for (const tab of tabs) {
|
|
7121
|
-
tabIds.add(tab.id);
|
|
7122
|
-
if (tab.children)
|
|
7123
|
-
collectTabIds(tab.children);
|
|
7124
|
-
}
|
|
7125
|
-
};
|
|
7126
|
-
collectTabIds(this.config.tabs || []);
|
|
7127
|
-
const unconsumed = Object.keys(data).filter(key => !tabIds.has(key) && // a tab id at the root is the nested container, not a stray field
|
|
7128
|
-
data[key] !== undefined &&
|
|
7129
|
-
fieldsById.has(key) &&
|
|
7130
|
-
!patchedFieldIds.has(key) &&
|
|
7131
|
-
!this.warnedUnconsumedKeys.has(key));
|
|
7132
|
-
if (!unconsumed.length)
|
|
7133
|
-
return;
|
|
7134
|
-
for (const key of unconsumed)
|
|
7549
|
+
for (const key of fresh)
|
|
7135
7550
|
this.warnedUnconsumedKeys.add(key);
|
|
7136
|
-
const shown =
|
|
7137
|
-
const more =
|
|
7138
|
-
const plural =
|
|
7551
|
+
const shown = fresh.slice(0, 5).join(', ');
|
|
7552
|
+
const more = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : '';
|
|
7553
|
+
const plural = fresh.length === 1;
|
|
7139
7554
|
console.warn(`[ngx-dynamic-entity] initialData has top-level ${plural ? 'key' : 'keys'} matching ` +
|
|
7140
7555
|
`${plural ? 'a field that was' : 'fields that were'} not populated: ${shown}${more}. ` +
|
|
7141
7556
|
`A record is nested by tab id ({ tabId: { fieldId: value } }) unless the tab sets ` +
|
|
@@ -7146,34 +7561,7 @@ class DynamicFormComponent {
|
|
|
7146
7561
|
warnedUnconsumedKeys = new Set();
|
|
7147
7562
|
/** Assemble full nested record from per-tab FormGroups, respecting flatData, refererField & arrays. */
|
|
7148
7563
|
extractRecord() {
|
|
7149
|
-
|
|
7150
|
-
return {};
|
|
7151
|
-
let record = {};
|
|
7152
|
-
const walkTabs = (tabs) => {
|
|
7153
|
-
for (const tab of tabs) {
|
|
7154
|
-
const fieldValBag = {};
|
|
7155
|
-
for (const field of tab.fields || []) {
|
|
7156
|
-
const ctrl = this.getControl(field.id, tab.id);
|
|
7157
|
-
if (ctrl) {
|
|
7158
|
-
fieldValBag[field.id] = ctrl.value;
|
|
7159
|
-
}
|
|
7160
|
-
}
|
|
7161
|
-
setTabData(record, tab.id, fieldValBag, this.config);
|
|
7162
|
-
for (const field of tab.fields || []) {
|
|
7163
|
-
if (field.refererField) {
|
|
7164
|
-
const ctrl = this.getControl(field.id, tab.id);
|
|
7165
|
-
if (ctrl) {
|
|
7166
|
-
setValueByPath(record, field.refererField, ctrl.value);
|
|
7167
|
-
}
|
|
7168
|
-
}
|
|
7169
|
-
}
|
|
7170
|
-
if (tab.children)
|
|
7171
|
-
walkTabs(tab.children);
|
|
7172
|
-
}
|
|
7173
|
-
};
|
|
7174
|
-
walkTabs(this.config.tabs || []);
|
|
7175
|
-
record = normalizeArrayStructures(record, this.config);
|
|
7176
|
-
return record;
|
|
7564
|
+
return this.structure.extractRecord(this.form, this.config);
|
|
7177
7565
|
}
|
|
7178
7566
|
/**
|
|
7179
7567
|
* Warns when a rule names a field id that two scopes define.
|
|
@@ -7211,15 +7599,24 @@ class DynamicFormComponent {
|
|
|
7211
7599
|
continue;
|
|
7212
7600
|
const id = parsed.value;
|
|
7213
7601
|
const scopes = ambiguous.get(id);
|
|
7214
|
-
if (!scopes ||
|
|
7602
|
+
if (!scopes || this.warnedAmbiguousIds.has(id))
|
|
7215
7603
|
continue;
|
|
7216
|
-
|
|
7604
|
+
this.warnedAmbiguousIds.add(id);
|
|
7217
7605
|
console.warn(`[ngx-dynamic-entity] A rule references field "${id}", which is defined in ${scopes.join(' and ')}. ` +
|
|
7218
7606
|
`A bare id cannot say which one is meant, so the rule will read whichever the form finds first. ` +
|
|
7219
7607
|
`Name it by path instead, as [${scopes[0]}.${id}].`);
|
|
7220
7608
|
}
|
|
7221
7609
|
}
|
|
7222
|
-
|
|
7610
|
+
/**
|
|
7611
|
+
* Ids already warned about, per component instance.
|
|
7612
|
+
*
|
|
7613
|
+
* This was `private static`, so it was module-global mutable state that nothing ever
|
|
7614
|
+
* cleared: under SSR every render of every form in the process accumulated into one set
|
|
7615
|
+
* that lived as long as the server did, and the first request to render a config
|
|
7616
|
+
* suppressed the warning for every request after it. Per instance, the warning is once per
|
|
7617
|
+
* form — which is what "warn once" was meant to mean.
|
|
7618
|
+
*/
|
|
7619
|
+
warnedAmbiguousIds = new Set();
|
|
7223
7620
|
/**
|
|
7224
7621
|
* The value map rules and `showWhen` are evaluated against.
|
|
7225
7622
|
*
|
|
@@ -7233,23 +7630,75 @@ class DynamicFormComponent {
|
|
|
7233
7630
|
* unknown>` and needs no knowledge of refs at all — the extra keys simply resolve.
|
|
7234
7631
|
*/
|
|
7235
7632
|
/**
|
|
7236
|
-
*
|
|
7633
|
+
* Every name a rule may address this field by.
|
|
7237
7634
|
*
|
|
7238
7635
|
* A rule target is either a bare field id — how every config so far addresses a field — or
|
|
7239
|
-
* a bracketed path, which is the only way to name one of two fields that share an id.
|
|
7240
|
-
*
|
|
7636
|
+
* a bracketed path, which is the only way to name one of two fields that share an id. Both
|
|
7637
|
+
* have to match, or a rule written either way would fail to hide what it targeted.
|
|
7638
|
+
*
|
|
7639
|
+
* The path comes last, and `syncHiddenFieldState` relies on that: it uses the last entry
|
|
7640
|
+
* as the field's address when deciding whether a hidden container already covers it.
|
|
7241
7641
|
*/
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7642
|
+
namesOf(field) {
|
|
7643
|
+
const path = field.refererField ?? this.pathFor(field);
|
|
7644
|
+
return path ? [field.id, toRefToken(path)] : [field.id];
|
|
7645
|
+
}
|
|
7646
|
+
/**
|
|
7647
|
+
* The field's dotted address, unbracketed.
|
|
7648
|
+
*
|
|
7649
|
+
* The positional path rather than an authored `refererField`: this is compared as a prefix
|
|
7650
|
+
* to decide whether a hidden container already covers a field, and containment is a fact
|
|
7651
|
+
* about where the field *is*. A `refererField` is a binding override — it says where the
|
|
7652
|
+
* value goes, not which group renders it.
|
|
7653
|
+
*/
|
|
7654
|
+
addressOf(field) {
|
|
7655
|
+
return this.pathFor(field) ?? field.id;
|
|
7247
7656
|
}
|
|
7248
7657
|
/** The field's declared path, or the one its position implies when it carries none. */
|
|
7249
7658
|
pathFor(field) {
|
|
7250
|
-
const entry =
|
|
7659
|
+
const entry = this.scopeEntryFor(field);
|
|
7251
7660
|
return entry ? fieldRefFor(entry.scope, field.id) : null;
|
|
7252
7661
|
}
|
|
7662
|
+
/**
|
|
7663
|
+
* Every field in the config with the scope its value is stored under, cached per config.
|
|
7664
|
+
*
|
|
7665
|
+
* `collectFieldScopes` walks the whole tree. It was called once per field from `pathFor`,
|
|
7666
|
+
* which `namesField` calls, which the render filter calls for every field on the active
|
|
7667
|
+
* tab — a quadratic walk on every change-detection pass. Keyed by config identity, so a
|
|
7668
|
+
* new config invalidates it without an explicit lifecycle hook to forget.
|
|
7669
|
+
*/
|
|
7670
|
+
fieldScopes() {
|
|
7671
|
+
if (this.scopeCache?.config !== this.config) {
|
|
7672
|
+
const entries = collectFieldScopes(this.config);
|
|
7673
|
+
this.scopeCache = {
|
|
7674
|
+
config: this.config,
|
|
7675
|
+
entries,
|
|
7676
|
+
byField: new Map(entries.map(entry => [entry.field, entry])),
|
|
7677
|
+
};
|
|
7678
|
+
}
|
|
7679
|
+
return this.scopeCache.entries;
|
|
7680
|
+
}
|
|
7681
|
+
scopeEntryFor(field) {
|
|
7682
|
+
this.fieldScopes();
|
|
7683
|
+
return this.scopeCache?.byField.get(field);
|
|
7684
|
+
}
|
|
7685
|
+
scopeCache;
|
|
7686
|
+
/**
|
|
7687
|
+
* The control a scope entry addresses, named by path rather than by bare id.
|
|
7688
|
+
*
|
|
7689
|
+
* `getControl(field.id)` with no tab falls through to a first-match recursive search of the
|
|
7690
|
+
* whole form, so with `address` on two tabs, hiding one disabled the other. The control
|
|
7691
|
+
* tree mirrors the scope path exactly — `buildTabControls` nests by tab id and
|
|
7692
|
+
* `buildFieldControl` nests a `group` under its own id — so a `[path]` ref resolves through
|
|
7693
|
+
* `form.get()` on `getControl`'s first branch and names exactly one control.
|
|
7694
|
+
*
|
|
7695
|
+
* A field inside an `array` has no static path: its controls live in `FormArray` rows built
|
|
7696
|
+
* per row. `form.get()` returns null for those and they are left alone, which is correct —
|
|
7697
|
+
* a row's controls are created and destroyed with the row.
|
|
7698
|
+
*/
|
|
7699
|
+
controlForEntry(entry) {
|
|
7700
|
+
return this.getControl(toRefToken(fieldRefFor(entry.scope, entry.field.id)));
|
|
7701
|
+
}
|
|
7253
7702
|
flattenFormValues() {
|
|
7254
7703
|
const out = {};
|
|
7255
7704
|
for (const entry of collectFieldScopes(this.config)) {
|
|
@@ -7264,18 +7713,6 @@ class DynamicFormComponent {
|
|
|
7264
7713
|
}
|
|
7265
7714
|
return out;
|
|
7266
7715
|
}
|
|
7267
|
-
getTabGroup(tabId) {
|
|
7268
|
-
const path = getTabPath(this.config?.tabs, tabId);
|
|
7269
|
-
if (!path || path.length === 0)
|
|
7270
|
-
return this.form;
|
|
7271
|
-
let curr = this.form;
|
|
7272
|
-
for (const p of path) {
|
|
7273
|
-
if (!curr || !(curr instanceof FormGroup))
|
|
7274
|
-
return null;
|
|
7275
|
-
curr = curr.get(p);
|
|
7276
|
-
}
|
|
7277
|
-
return curr instanceof FormGroup ? curr : null;
|
|
7278
|
-
}
|
|
7279
7716
|
// ─── autoPatch / patchOnTrue ──────────────────────────────────────────────
|
|
7280
7717
|
/**
|
|
7281
7718
|
* `autoPatch` — copy mapped fields out of the record the user just selected in an
|
|
@@ -7317,65 +7754,48 @@ class DynamicFormComponent {
|
|
|
7317
7754
|
}
|
|
7318
7755
|
}
|
|
7319
7756
|
}
|
|
7320
|
-
/**
|
|
7757
|
+
/**
|
|
7758
|
+
* Prefer a control declared on the configured target tab; fall back to a top-level control.
|
|
7759
|
+
*
|
|
7760
|
+
* "Declared on the tab" now means anywhere under it, sub-tabs and `group` children
|
|
7761
|
+
* included. It meant the tab's own top-level fields only, so an `autoPatch` mapping onto a
|
|
7762
|
+
* field inside a group resolved to `null` and copied nothing, silently.
|
|
7763
|
+
*/
|
|
7321
7764
|
resolveTargetControl(autoPatch, targetId) {
|
|
7322
7765
|
const tab = findTab(this.tabs, autoPatch.targetTab);
|
|
7323
|
-
|
|
7324
|
-
|
|
7766
|
+
if (!tab)
|
|
7767
|
+
return this.getControl(targetId, autoPatch.targetTab);
|
|
7768
|
+
const entry = fieldsUnderTab(this.config, autoPatch.targetTab).find(e => {
|
|
7769
|
+
if (!e.field?.id)
|
|
7770
|
+
return false;
|
|
7771
|
+
const parsed = parseFieldRef(targetId);
|
|
7772
|
+
return parsed.kind === 'ref'
|
|
7773
|
+
? fieldRefFor(e.scope, e.field.id) === parsed.value
|
|
7774
|
+
: e.field.id === parsed.value;
|
|
7775
|
+
});
|
|
7776
|
+
if (!entry)
|
|
7325
7777
|
return null;
|
|
7326
|
-
return this.
|
|
7778
|
+
return this.controlForEntry(entry);
|
|
7327
7779
|
}
|
|
7328
7780
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
7329
7781
|
/**
|
|
7330
|
-
*
|
|
7331
|
-
*
|
|
7332
|
-
* their parent, and toggling a child's state independently would fight the parent's.
|
|
7333
|
-
*/
|
|
7334
|
-
tabFields() {
|
|
7335
|
-
const out = [];
|
|
7336
|
-
const walk = (tabs) => {
|
|
7337
|
-
for (const tab of tabs ?? []) {
|
|
7338
|
-
out.push(...(tab.fields ?? []));
|
|
7339
|
-
walk(tab.children);
|
|
7340
|
-
}
|
|
7341
|
-
};
|
|
7342
|
-
walk(this.config?.tabs);
|
|
7343
|
-
return out;
|
|
7344
|
-
}
|
|
7345
|
-
/**
|
|
7346
|
-
* Keep a hidden field's control out of form validity.
|
|
7782
|
+
* Keep a hidden field's control out of form validity — see
|
|
7783
|
+
* `RulesEvaluationService.syncHiddenFieldState`, which owns the rule and the reasoning.
|
|
7347
7784
|
*
|
|
7348
|
-
*
|
|
7349
|
-
*
|
|
7350
|
-
*
|
|
7351
|
-
*
|
|
7352
|
-
* Disabling is the fix rather than stripping validators: Angular excludes disabled
|
|
7353
|
-
* controls from validity, and the field's own validators survive intact for when it
|
|
7354
|
-
* comes back. Values are untouched — `flattenFormValues` and `extractRecord` both read
|
|
7355
|
-
* `control.value`, which a disabled control still carries, so rule evaluation and the
|
|
7356
|
-
* emitted record see exactly what they saw before. That also keeps this from feeding
|
|
7357
|
-
* back on itself: hiding a field cannot change the values the rules are evaluated over.
|
|
7358
|
-
*
|
|
7359
|
-
* The visibility predicate is the same one `fieldsForActiveTab` filters with, so what is
|
|
7360
|
-
* rendered and what counts toward validity cannot drift apart.
|
|
7785
|
+
* This supplies the three things it cannot know: which fields exist and in what order
|
|
7786
|
+
* (`collectFieldScopes`, parent-first), what each one is called, and which control each
|
|
7787
|
+
* one addresses.
|
|
7361
7788
|
*/
|
|
7362
7789
|
syncHiddenFieldState(values) {
|
|
7363
7790
|
if (this.preview)
|
|
7364
7791
|
return; // preview freezes the whole form on purpose
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
if (ctrl.enabled)
|
|
7373
|
-
ctrl.disable({ emitEvent: false });
|
|
7374
|
-
}
|
|
7375
|
-
else if (ctrl.disabled && !field.disabled) {
|
|
7376
|
-
ctrl.enable({ emitEvent: false });
|
|
7377
|
-
}
|
|
7378
|
-
}
|
|
7792
|
+
this.rulesEvaluation.syncHiddenFieldState(this.fieldScopes(), {
|
|
7793
|
+
result: this.ruleResult(),
|
|
7794
|
+
values,
|
|
7795
|
+
namesOf: field => this.namesOf(field),
|
|
7796
|
+
addressOf: field => this.addressOf(field),
|
|
7797
|
+
controlFor: entry => this.controlForEntry(entry),
|
|
7798
|
+
});
|
|
7379
7799
|
}
|
|
7380
7800
|
/** Every field in the config, including `group`/`array` children. */
|
|
7381
7801
|
allFields() {
|
|
@@ -7403,51 +7823,31 @@ class DynamicFormComponent {
|
|
|
7403
7823
|
return supplied;
|
|
7404
7824
|
return this.sessionBaseline();
|
|
7405
7825
|
}
|
|
7826
|
+
/**
|
|
7827
|
+
* Whether a critical field still holds what it held when the session started.
|
|
7828
|
+
*
|
|
7829
|
+
* The same question `VALUE_CHANGED` asks, so it is answered by the same comparator rather
|
|
7830
|
+
* than by a third one. The local version compared objects through `JSON.stringify`, which
|
|
7831
|
+
* is key-order sensitive — two serialisers writing the same option in a different key
|
|
7832
|
+
* order made the field read as edited — and had its own idea of which values count as
|
|
7833
|
+
* empty. `valuesEqual` is order-independent and treats `null` and `undefined` as the same
|
|
7834
|
+
* absence; the empty-string case is kept here, because a cleared text input is not an edit.
|
|
7835
|
+
*/
|
|
7406
7836
|
sameValue(a, b) {
|
|
7407
|
-
if (a === b)
|
|
7408
|
-
return true;
|
|
7409
7837
|
const aEmpty = a === null || a === undefined || a === '';
|
|
7410
7838
|
const bEmpty = b === null || b === undefined || b === '';
|
|
7411
7839
|
if (aEmpty && bEmpty)
|
|
7412
7840
|
return true;
|
|
7413
|
-
|
|
7414
|
-
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
7415
|
-
}
|
|
7416
|
-
return false;
|
|
7841
|
+
return valuesEqual(a, b);
|
|
7417
7842
|
}
|
|
7843
|
+
/**
|
|
7844
|
+
* Find a control by a bracketed path or a bare field id.
|
|
7845
|
+
*
|
|
7846
|
+
* Public API, so it stays on the component and delegates — see `FormStructureService`,
|
|
7847
|
+
* which owns the resolution rule and is where the behaviour is documented.
|
|
7848
|
+
*/
|
|
7418
7849
|
getControl(fieldId, currentTabId) {
|
|
7419
|
-
|
|
7420
|
-
return null;
|
|
7421
|
-
// A bracketed path names exactly one control, and the form nests by tab exactly as the
|
|
7422
|
-
// path does — so `form.get('work.address')` walks straight to it. Anything that names a
|
|
7423
|
-
// field can therefore use a path: `showWhen` keys, `patchOnTrue` mappings and `autoPatch`
|
|
7424
|
-
// targets, not only rules.
|
|
7425
|
-
const parsed = parseFieldRef(fieldId);
|
|
7426
|
-
if (parsed.kind === 'ref')
|
|
7427
|
-
return this.form.get(parsed.value);
|
|
7428
|
-
if (currentTabId) {
|
|
7429
|
-
const tabGrp = this.getTabGroup(currentTabId);
|
|
7430
|
-
const ctrl = tabGrp?.get(fieldId);
|
|
7431
|
-
if (ctrl)
|
|
7432
|
-
return ctrl;
|
|
7433
|
-
}
|
|
7434
|
-
const rootCtrl = this.form.get(fieldId);
|
|
7435
|
-
if (rootCtrl)
|
|
7436
|
-
return rootCtrl;
|
|
7437
|
-
const findInGroup = (group) => {
|
|
7438
|
-
for (const key of Object.keys(group.controls)) {
|
|
7439
|
-
const c = group.controls[key];
|
|
7440
|
-
if (key === fieldId)
|
|
7441
|
-
return c;
|
|
7442
|
-
if (c instanceof FormGroup) {
|
|
7443
|
-
const found = findInGroup(c);
|
|
7444
|
-
if (found)
|
|
7445
|
-
return found;
|
|
7446
|
-
}
|
|
7447
|
-
}
|
|
7448
|
-
return null;
|
|
7449
|
-
};
|
|
7450
|
-
return findInGroup(this.form);
|
|
7850
|
+
return this.structure.getControl(this.form, this.config, fieldId, currentTabId);
|
|
7451
7851
|
}
|
|
7452
7852
|
/**
|
|
7453
7853
|
* Move focus into the newly shown panel.
|
|
@@ -7547,11 +7947,11 @@ class DynamicFormComponent {
|
|
|
7547
7947
|
this.formReset.emit();
|
|
7548
7948
|
}
|
|
7549
7949
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
7550
|
-
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", layout: "layout" }, 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\" [class.ngx-form--readonly]=\"readonly\" 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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\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:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!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 });
|
|
7950
|
+
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", layout: "layout" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService, FormStructureService], 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\" [class.ngx-form--readonly]=\"readonly\" 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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\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:var(--ngx-gap, 18px);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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!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 });
|
|
7551
7951
|
}
|
|
7552
7952
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
|
|
7553
7953
|
type: Component,
|
|
7554
|
-
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\" [class.ngx-form--readonly]=\"readonly\" 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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\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:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
|
|
7954
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService, FormStructureService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" 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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\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:var(--ngx-gap, 18px);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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
|
|
7555
7955
|
}], propDecorators: { config: [{
|
|
7556
7956
|
type: Input
|
|
7557
7957
|
}], rules: [{
|