ngx-dynamic-entity 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/esm2022/lib/field-types/array-field.component.mjs +13 -5
- package/esm2022/lib/field-types/boolean-field.component.mjs +4 -3
- package/esm2022/lib/field-types/checkbox-field.component.mjs +4 -3
- package/esm2022/lib/field-types/currency-field.component.mjs +4 -3
- package/esm2022/lib/field-types/date-field.component.mjs +4 -3
- package/esm2022/lib/field-types/date-time-field.component.mjs +138 -0
- package/esm2022/lib/field-types/dropdown-field.component.mjs +9 -7
- package/esm2022/lib/field-types/email-field.component.mjs +4 -3
- package/esm2022/lib/field-types/entity-ref-field.component.mjs +21 -7
- package/esm2022/lib/field-types/field-component.contract.mjs +2 -0
- package/esm2022/lib/field-types/file-field.component.mjs +4 -3
- package/esm2022/lib/field-types/group-field.component.mjs +5 -5
- package/esm2022/lib/field-types/image-field.component.mjs +4 -3
- package/esm2022/lib/field-types/month-year-field.component.mjs +4 -3
- package/esm2022/lib/field-types/multi-select-field.component.mjs +4 -3
- package/esm2022/lib/field-types/number-field.component.mjs +7 -11
- package/esm2022/lib/field-types/password-field.component.mjs +4 -3
- package/esm2022/lib/field-types/radio-field.component.mjs +4 -3
- package/esm2022/lib/field-types/text-field.component.mjs +25 -18
- package/esm2022/lib/field-types/textarea-field.component.mjs +4 -3
- package/esm2022/lib/field-types/time-field.component.mjs +96 -0
- package/esm2022/lib/form/dynamic-field/dynamic-field.component.mjs +55 -7
- package/esm2022/lib/form/dynamic-form.component.mjs +140 -15
- package/esm2022/lib/form/dynamic-record-form.component.mjs +54 -17
- package/esm2022/lib/providers/provide-field-types.mjs +5 -2
- package/esm2022/lib/providers/provide-ngx-dynamic-entity.mjs +7 -2
- package/esm2022/lib/services/cascade-data.service.mjs +2 -2
- package/esm2022/lib/services/entity-ref-registry.service.mjs +2 -2
- package/esm2022/lib/services/lookup-registry.service.mjs +2 -2
- package/esm2022/lib/services/validation-messages.service.mjs +56 -0
- package/esm2022/lib/services/validator-registry.service.mjs +24 -2
- package/esm2022/lib/tokens/injection-tokens.mjs +19 -6
- package/esm2022/public-api.mjs +9 -2
- package/fesm2022/ngx-dynamic-entity.mjs +663 -98
- package/fesm2022/ngx-dynamic-entity.mjs.map +1 -1
- package/lib/field-types/array-field.component.d.ts +7 -1
- package/lib/field-types/date-time-field.component.d.ts +30 -0
- package/lib/field-types/dropdown-field.component.d.ts +1 -0
- package/lib/field-types/entity-ref-field.component.d.ts +11 -1
- package/lib/field-types/field-component.contract.d.ts +49 -0
- package/lib/field-types/group-field.component.d.ts +1 -1
- package/lib/field-types/number-field.component.d.ts +1 -0
- package/lib/field-types/text-field.component.d.ts +10 -2
- package/lib/field-types/time-field.component.d.ts +27 -0
- package/lib/form/dynamic-field/dynamic-field.component.d.ts +39 -4
- package/lib/form/dynamic-form.component.d.ts +64 -2
- package/lib/form/dynamic-record-form.component.d.ts +19 -0
- package/lib/providers/provide-ngx-dynamic-entity.d.ts +17 -1
- package/lib/services/cascade-data.service.d.ts +1 -1
- package/lib/services/entity-ref-registry.service.d.ts +1 -1
- package/lib/services/lookup-registry.service.d.ts +1 -1
- package/lib/services/validation-messages.service.d.ts +43 -0
- package/lib/services/validator-registry.service.d.ts +13 -1
- package/lib/tokens/injection-tokens.d.ts +18 -5
- package/package.json +2 -2
- package/public-api.d.ts +4 -0
- package/styles.css +579 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, makeEnvironmentProviders,
|
|
2
|
+
import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, ChangeDetectorRef, Optional, Inject, EventEmitter, computed, HostListener, ViewChildren, Output, ElementRef, afterNextRender } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/forms';
|
|
4
4
|
import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup } from '@angular/forms';
|
|
5
|
-
import { resolveLabel, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, getTabPath, applyAutoPatch, applyPatchOnTrue
|
|
5
|
+
import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, getTabPath, applyAutoPatch, applyPatchOnTrue } 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';
|
|
@@ -29,7 +29,7 @@ class InMemoryEntityRefCacheStore {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Roles that see XXXXXXXXX for masked fields
|
|
32
|
+
/** Roles that see XXXXXXXXX for masked fields. Presentational only — enforce authz server-side. */
|
|
33
33
|
const MASKED_ROLES = new InjectionToken('MASKED_ROLES');
|
|
34
34
|
/**
|
|
35
35
|
* Consumer **overrides**: fieldType string → Angular component class.
|
|
@@ -86,8 +86,25 @@ const LOOKUP_REGISTRY = new InjectionToken('LOOKUP_REGISTRY');
|
|
|
86
86
|
* })
|
|
87
87
|
*/
|
|
88
88
|
const RECORD_MIGRATIONS = new InjectionToken('RECORD_MIGRATIONS');
|
|
89
|
+
/**
|
|
90
|
+
* Overrides for the messages shown under an invalid field, keyed by Angular error key
|
|
91
|
+
* (`required`, `minlength`, `pattern`, …) plus a few field-specific keys.
|
|
92
|
+
*
|
|
93
|
+
* The built-in messages are English literals. Registering here is what makes a non-English
|
|
94
|
+
* form possible without re-implementing every field component; unlisted keys keep their
|
|
95
|
+
* default, so overriding one does not mean supplying them all.
|
|
96
|
+
*/
|
|
97
|
+
const VALIDATION_MESSAGES = new InjectionToken('VALIDATION_MESSAGES');
|
|
89
98
|
/** Registry: validator key → ValidatorFn */
|
|
90
99
|
const VALIDATOR_REGISTRY = new InjectionToken('VALIDATOR_REGISTRY');
|
|
100
|
+
/**
|
|
101
|
+
* Registry: validator key → AsyncValidatorFn.
|
|
102
|
+
*
|
|
103
|
+
* Kept separate from VALIDATOR_REGISTRY because Angular applies the two differently: an async
|
|
104
|
+
* validator runs only once the synchronous ones pass, and puts the control into `pending`
|
|
105
|
+
* while it does. Name them from a schema with `validators: { customAsync: ['uniqueEmail'] }`.
|
|
106
|
+
*/
|
|
107
|
+
const ASYNC_VALIDATOR_REGISTRY = new InjectionToken('ASYNC_VALIDATOR_REGISTRY');
|
|
91
108
|
/** Registry: hook key → async (data, context) => data */
|
|
92
109
|
const HOOK_REGISTRY = new InjectionToken('HOOK_REGISTRY');
|
|
93
110
|
/**
|
|
@@ -125,10 +142,6 @@ const UPLOAD_HANDLER = new InjectionToken('UPLOAD_HANDLER');
|
|
|
125
142
|
* { provide: ENTITY_REF_CACHE_STORE, useClass: SessionStorageRefCache }
|
|
126
143
|
*/
|
|
127
144
|
const ENTITY_REF_CACHE_STORE = new InjectionToken('ENTITY_REF_CACHE_STORE', { providedIn: 'root', factory: () => new InMemoryEntityRefCacheStore() });
|
|
128
|
-
/**
|
|
129
|
-
* Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
|
|
130
|
-
* Defaults to allowing edits if not provided.
|
|
131
|
-
*/
|
|
132
145
|
/**
|
|
133
146
|
* Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
|
|
134
147
|
* Defaults to allowing edits if not provided.
|
|
@@ -156,16 +169,90 @@ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
|
|
|
156
169
|
{ provide: ENTITY_REF_REGISTRY, useValue: new Map(Object.entries(config.entityRefs ?? {})) },
|
|
157
170
|
{ provide: LOOKUP_REGISTRY, useValue: new Map(Object.entries(config.lookups ?? {})) },
|
|
158
171
|
{ provide: VALIDATOR_REGISTRY, useValue: new Map(Object.entries(config.validators ?? {})) },
|
|
172
|
+
{
|
|
173
|
+
provide: ASYNC_VALIDATOR_REGISTRY,
|
|
174
|
+
useValue: new Map(Object.entries(config.asyncValidators ?? {})),
|
|
175
|
+
},
|
|
159
176
|
{ provide: HOOK_REGISTRY, useValue: new Map(Object.entries(config.hooks ?? {})) },
|
|
160
177
|
{ provide: RECORD_MIGRATIONS, useValue: config.migrations ?? [] },
|
|
178
|
+
{ provide: VALIDATION_MESSAGES, useValue: config.validationMessages ?? {} },
|
|
161
179
|
]);
|
|
162
180
|
|
|
181
|
+
/** The built-in English messages. Every key here can be overridden individually. */
|
|
182
|
+
const DEFAULT_VALIDATION_MESSAGES = {
|
|
183
|
+
required: 'This field is required.',
|
|
184
|
+
requiredSelection: 'Please select an option.',
|
|
185
|
+
email: 'Please enter a valid email address.',
|
|
186
|
+
pattern: 'Invalid format.',
|
|
187
|
+
minlength: (_lang, err) => `Minimum ${err?.requiredLength} characters required.`,
|
|
188
|
+
maxlength: (_lang, err) => `Maximum ${err?.requiredLength} characters allowed.`,
|
|
189
|
+
min: (_lang, err) => `Value must be at least ${err?.min}.`,
|
|
190
|
+
max: (_lang, err) => `Value must not exceed ${err?.max}.`,
|
|
191
|
+
invalid: 'Invalid value.',
|
|
192
|
+
invalidNumber: 'Invalid number.',
|
|
193
|
+
invalidSelection: 'Invalid selection.',
|
|
194
|
+
};
|
|
195
|
+
class ValidationMessagesService {
|
|
196
|
+
overrides = inject(VALIDATION_MESSAGES, { optional: true }) ?? {};
|
|
197
|
+
/**
|
|
198
|
+
* The message for the first error present, in the order the caller lists.
|
|
199
|
+
*
|
|
200
|
+
* Order is the caller's because it is field-specific: a dropdown wants
|
|
201
|
+
* `requiredSelection` where a text input wants `required`, and the first match wins so the
|
|
202
|
+
* most specific message is the one shown.
|
|
203
|
+
*/
|
|
204
|
+
resolve(errors, language, order, fallbackKey = 'invalid') {
|
|
205
|
+
if (!errors)
|
|
206
|
+
return '';
|
|
207
|
+
for (const entry of order) {
|
|
208
|
+
// A tuple maps an Angular error key to a different message key: a dropdown raises the
|
|
209
|
+
// standard `required` error but reads better as "Please select an option", and both
|
|
210
|
+
// messages stay independently overridable.
|
|
211
|
+
const [errorKey, messageKey] = typeof entry === 'string' ? [entry, entry] : entry;
|
|
212
|
+
// `errors[errorKey]` is usually an object of detail, but `required` is simply `true`.
|
|
213
|
+
if (errors[errorKey] === undefined)
|
|
214
|
+
continue;
|
|
215
|
+
return this.messageFor(messageKey, language, errors[errorKey]);
|
|
216
|
+
}
|
|
217
|
+
return this.messageFor(fallbackKey, language, null);
|
|
218
|
+
}
|
|
219
|
+
/** One message by key, honouring an override before the default. */
|
|
220
|
+
messageFor(key, language, error) {
|
|
221
|
+
const message = this.overrides[key] ?? DEFAULT_VALIDATION_MESSAGES[key];
|
|
222
|
+
if (message === undefined)
|
|
223
|
+
return '';
|
|
224
|
+
return typeof message === 'function' ? message(language, error) : message;
|
|
225
|
+
}
|
|
226
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
227
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, providedIn: 'root' });
|
|
228
|
+
}
|
|
229
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, decorators: [{
|
|
230
|
+
type: Injectable,
|
|
231
|
+
args: [{ providedIn: 'root' }]
|
|
232
|
+
}] });
|
|
233
|
+
|
|
163
234
|
class TextFieldComponent {
|
|
235
|
+
messages = inject(ValidationMessagesService);
|
|
236
|
+
displaySub;
|
|
164
237
|
field;
|
|
165
|
-
control
|
|
238
|
+
set control(value) {
|
|
239
|
+
this._control = value;
|
|
240
|
+
this.displaySub?.unsubscribe();
|
|
241
|
+
this.displayValue.set(value?.value ?? null);
|
|
242
|
+
this.displaySub = value?.valueChanges.subscribe(v => this.displayValue.set(v));
|
|
243
|
+
}
|
|
244
|
+
get control() {
|
|
245
|
+
return this._control;
|
|
246
|
+
}
|
|
247
|
+
_control;
|
|
166
248
|
language = 'en';
|
|
167
249
|
readonly = false;
|
|
168
250
|
masked = false;
|
|
251
|
+
/** Readonly markup reads this signal so an external patch (autoPatch) is visible under OnPush. */
|
|
252
|
+
displayValue = signal(null);
|
|
253
|
+
ngOnDestroy() {
|
|
254
|
+
this.displaySub?.unsubscribe();
|
|
255
|
+
}
|
|
169
256
|
get label() {
|
|
170
257
|
return resolveLabel(this.field?.label, this.language);
|
|
171
258
|
}
|
|
@@ -175,18 +262,7 @@ class TextFieldComponent {
|
|
|
175
262
|
get errorMessage() {
|
|
176
263
|
if (!this.control || !this.control.errors || !this.control.touched)
|
|
177
264
|
return '';
|
|
178
|
-
|
|
179
|
-
if (errs['required'])
|
|
180
|
-
return 'This field is required.';
|
|
181
|
-
if (errs['email'])
|
|
182
|
-
return 'Please enter a valid email address.';
|
|
183
|
-
if (errs['minlength'])
|
|
184
|
-
return `Minimum ${errs['minlength'].requiredLength} characters required.`;
|
|
185
|
-
if (errs['maxlength'])
|
|
186
|
-
return `Maximum ${errs['maxlength'].requiredLength} characters allowed.`;
|
|
187
|
-
if (errs['pattern'])
|
|
188
|
-
return 'Invalid format.';
|
|
189
|
-
return 'Invalid value.';
|
|
265
|
+
return this.messages.resolve(this.control.errors, this.language, ['required', 'email', 'minlength', 'maxlength', 'pattern']);
|
|
190
266
|
}
|
|
191
267
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
192
268
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TextFieldComponent, isStandalone: true, selector: "ngx-text-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
@@ -201,7 +277,7 @@ class TextFieldComponent {
|
|
|
201
277
|
@if (masked) {
|
|
202
278
|
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
|
|
203
279
|
} @else if (readonly) {
|
|
204
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
280
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
|
|
205
281
|
} @else {
|
|
206
282
|
<input
|
|
207
283
|
[id]="field.id"
|
|
@@ -218,11 +294,12 @@ class TextFieldComponent {
|
|
|
218
294
|
}
|
|
219
295
|
}
|
|
220
296
|
</div>
|
|
221
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
297
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
222
298
|
}
|
|
223
299
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, decorators: [{
|
|
224
300
|
type: Component,
|
|
225
301
|
args: [{
|
|
302
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
226
303
|
selector: 'ngx-text-field',
|
|
227
304
|
standalone: true,
|
|
228
305
|
imports: [ReactiveFormsModule],
|
|
@@ -238,7 +315,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
238
315
|
@if (masked) {
|
|
239
316
|
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
|
|
240
317
|
} @else if (readonly) {
|
|
241
|
-
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
|
|
318
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
|
|
242
319
|
} @else {
|
|
243
320
|
<input
|
|
244
321
|
[id]="field.id"
|
|
@@ -304,11 +381,12 @@ class TextareaFieldComponent {
|
|
|
304
381
|
}
|
|
305
382
|
}
|
|
306
383
|
</div>
|
|
307
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
384
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
308
385
|
}
|
|
309
386
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextareaFieldComponent, decorators: [{
|
|
310
387
|
type: Component,
|
|
311
388
|
args: [{
|
|
389
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
312
390
|
selector: 'ngx-textarea-field',
|
|
313
391
|
standalone: true,
|
|
314
392
|
imports: [ReactiveFormsModule],
|
|
@@ -349,6 +427,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
349
427
|
}] } });
|
|
350
428
|
|
|
351
429
|
class NumberFieldComponent {
|
|
430
|
+
messages = inject(ValidationMessagesService);
|
|
352
431
|
field;
|
|
353
432
|
control;
|
|
354
433
|
language = 'en';
|
|
@@ -363,14 +442,7 @@ class NumberFieldComponent {
|
|
|
363
442
|
get errorMessage() {
|
|
364
443
|
if (!this.control || !this.control.errors || !this.control.touched)
|
|
365
444
|
return '';
|
|
366
|
-
|
|
367
|
-
if (errs['required'])
|
|
368
|
-
return 'This field is required.';
|
|
369
|
-
if (errs['min'])
|
|
370
|
-
return `Value must be at least ${errs['min'].min}.`;
|
|
371
|
-
if (errs['max'])
|
|
372
|
-
return `Value must not exceed ${errs['max'].max}.`;
|
|
373
|
-
return 'Invalid number.';
|
|
445
|
+
return this.messages.resolve(this.control.errors, this.language, ['required', 'min', 'max'], 'invalidNumber');
|
|
374
446
|
}
|
|
375
447
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
376
448
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: NumberFieldComponent, isStandalone: true, selector: "ngx-number-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
@@ -405,11 +477,12 @@ class NumberFieldComponent {
|
|
|
405
477
|
}
|
|
406
478
|
}
|
|
407
479
|
</div>
|
|
408
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
480
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
409
481
|
}
|
|
410
482
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, decorators: [{
|
|
411
483
|
type: Component,
|
|
412
484
|
args: [{
|
|
485
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
413
486
|
selector: 'ngx-number-field',
|
|
414
487
|
standalone: true,
|
|
415
488
|
imports: [ReactiveFormsModule],
|
|
@@ -508,11 +581,12 @@ class CurrencyFieldComponent {
|
|
|
508
581
|
}
|
|
509
582
|
}
|
|
510
583
|
</div>
|
|
511
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
584
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
512
585
|
}
|
|
513
586
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CurrencyFieldComponent, decorators: [{
|
|
514
587
|
type: Component,
|
|
515
588
|
args: [{
|
|
589
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
516
590
|
selector: 'ngx-currency-field',
|
|
517
591
|
standalone: true,
|
|
518
592
|
imports: [ReactiveFormsModule],
|
|
@@ -599,11 +673,12 @@ class EmailFieldComponent {
|
|
|
599
673
|
}
|
|
600
674
|
}
|
|
601
675
|
</div>
|
|
602
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
676
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
603
677
|
}
|
|
604
678
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EmailFieldComponent, decorators: [{
|
|
605
679
|
type: Component,
|
|
606
680
|
args: [{
|
|
681
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
607
682
|
selector: 'ngx-email-field',
|
|
608
683
|
standalone: true,
|
|
609
684
|
imports: [ReactiveFormsModule],
|
|
@@ -701,11 +776,12 @@ class PasswordFieldComponent {
|
|
|
701
776
|
}
|
|
702
777
|
}
|
|
703
778
|
</div>
|
|
704
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
779
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
705
780
|
}
|
|
706
781
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PasswordFieldComponent, decorators: [{
|
|
707
782
|
type: Component,
|
|
708
783
|
args: [{
|
|
784
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
709
785
|
selector: 'ngx-password-field',
|
|
710
786
|
standalone: true,
|
|
711
787
|
imports: [ReactiveFormsModule],
|
|
@@ -787,11 +863,12 @@ class CheckboxFieldComponent {
|
|
|
787
863
|
</label>
|
|
788
864
|
}
|
|
789
865
|
</div>
|
|
790
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
866
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
791
867
|
}
|
|
792
868
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CheckboxFieldComponent, decorators: [{
|
|
793
869
|
type: Component,
|
|
794
870
|
args: [{
|
|
871
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
795
872
|
selector: 'ngx-checkbox-field',
|
|
796
873
|
standalone: true,
|
|
797
874
|
imports: [ReactiveFormsModule],
|
|
@@ -875,11 +952,12 @@ class BooleanFieldComponent {
|
|
|
875
952
|
</div>
|
|
876
953
|
}
|
|
877
954
|
</div>
|
|
878
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
955
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
879
956
|
}
|
|
880
957
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BooleanFieldComponent, decorators: [{
|
|
881
958
|
type: Component,
|
|
882
959
|
args: [{
|
|
960
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
883
961
|
selector: 'ngx-boolean-field',
|
|
884
962
|
standalone: true,
|
|
885
963
|
imports: [ReactiveFormsModule],
|
|
@@ -968,11 +1046,12 @@ class DateFieldComponent {
|
|
|
968
1046
|
}
|
|
969
1047
|
}
|
|
970
1048
|
</div>
|
|
971
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
1049
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
972
1050
|
}
|
|
973
1051
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateFieldComponent, decorators: [{
|
|
974
1052
|
type: Component,
|
|
975
1053
|
args: [{
|
|
1054
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
976
1055
|
selector: 'ngx-date-field',
|
|
977
1056
|
standalone: true,
|
|
978
1057
|
imports: [ReactiveFormsModule],
|
|
@@ -1011,6 +1090,231 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1011
1090
|
type: Input
|
|
1012
1091
|
}] } });
|
|
1013
1092
|
|
|
1093
|
+
/**
|
|
1094
|
+
* Reads a stored value into a `Date`.
|
|
1095
|
+
*
|
|
1096
|
+
* A bare calendar date is pinned to *local* midnight. `new Date('2020-01-01')` reads a
|
|
1097
|
+
* date-only string as UTC midnight, which renders as the previous day anywhere west of
|
|
1098
|
+
* Greenwich — and every legacy `datetime` value has exactly that shape, because the type
|
|
1099
|
+
* used to render through `DateFieldComponent` and its `type="date"` input.
|
|
1100
|
+
*/
|
|
1101
|
+
function parseStored(value) {
|
|
1102
|
+
if (value === null || value === undefined || value === '')
|
|
1103
|
+
return null;
|
|
1104
|
+
if (value instanceof Date)
|
|
1105
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
1106
|
+
const raw = String(value);
|
|
1107
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
|
1108
|
+
if (dateOnly)
|
|
1109
|
+
return new Date(+dateOnly[1], +dateOnly[2] - 1, +dateOnly[3]);
|
|
1110
|
+
const parsed = new Date(raw);
|
|
1111
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
1112
|
+
}
|
|
1113
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1114
|
+
/**
|
|
1115
|
+
* Date & Time field: a single `datetime-local` input.
|
|
1116
|
+
*
|
|
1117
|
+
* The type is stored as ISO 8601 UTC, per the same convention `date` follows, while the
|
|
1118
|
+
* input works in the viewer's local zone — `datetime-local` has no offset, so the two
|
|
1119
|
+
* conversions here are what keep a value stable across a round trip.
|
|
1120
|
+
*/
|
|
1121
|
+
class DateTimeFieldComponent {
|
|
1122
|
+
field;
|
|
1123
|
+
control;
|
|
1124
|
+
language = 'en';
|
|
1125
|
+
readonly = false;
|
|
1126
|
+
masked = false;
|
|
1127
|
+
get label() {
|
|
1128
|
+
return resolveLabel(this.field?.label, this.language);
|
|
1129
|
+
}
|
|
1130
|
+
/** Stored ISO 8601 → the `YYYY-MM-DDTHH:mm` local form the input requires. */
|
|
1131
|
+
get inputValue() {
|
|
1132
|
+
const d = parseStored(this.control?.value);
|
|
1133
|
+
if (!d)
|
|
1134
|
+
return '';
|
|
1135
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1136
|
+
}
|
|
1137
|
+
/** The input's local `YYYY-MM-DDTHH:mm` → stored ISO 8601 UTC. */
|
|
1138
|
+
onInput(value) {
|
|
1139
|
+
if (!value) {
|
|
1140
|
+
this.control.setValue(null);
|
|
1141
|
+
this.control.markAsTouched();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const d = new Date(value);
|
|
1145
|
+
// An unparseable value is stored verbatim rather than silently discarded, so a validator
|
|
1146
|
+
// sees what the user actually entered.
|
|
1147
|
+
this.control.setValue(Number.isNaN(d.getTime()) ? value : d.toISOString());
|
|
1148
|
+
this.control.markAsTouched();
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Displays date *and* time. `DateFieldComponent` used `toLocaleDateString()` here, so a
|
|
1152
|
+
* datetime read back through the field showed no time while the same value in the record
|
|
1153
|
+
* summary — which goes through core's `formatDisplayValue` — showed it.
|
|
1154
|
+
*/
|
|
1155
|
+
formatDateTime(value) {
|
|
1156
|
+
const d = parseStored(value);
|
|
1157
|
+
return d ? d.toLocaleString() : '—';
|
|
1158
|
+
}
|
|
1159
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateTimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1160
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DateTimeFieldComponent, isStandalone: true, selector: "ngx-date-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1161
|
+
<div class="ngx-field ngx-field--datetime"
|
|
1162
|
+
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
1163
|
+
<label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
|
|
1164
|
+
@if (masked) {
|
|
1165
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
|
|
1166
|
+
} @else if (readonly) {
|
|
1167
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDateTime(control.value) }}</span>
|
|
1168
|
+
} @else {
|
|
1169
|
+
<input
|
|
1170
|
+
[id]="field.id"
|
|
1171
|
+
class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
|
|
1172
|
+
type="datetime-local"
|
|
1173
|
+
[value]="inputValue"
|
|
1174
|
+
(change)="onInput($any($event.target).value)"
|
|
1175
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
1176
|
+
/>
|
|
1177
|
+
@if (control.invalid && control.touched) {
|
|
1178
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
</div>
|
|
1182
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1183
|
+
}
|
|
1184
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateTimeFieldComponent, decorators: [{
|
|
1185
|
+
type: Component,
|
|
1186
|
+
args: [{
|
|
1187
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1188
|
+
selector: 'ngx-date-time-field',
|
|
1189
|
+
standalone: true,
|
|
1190
|
+
imports: [ReactiveFormsModule],
|
|
1191
|
+
template: `
|
|
1192
|
+
<div class="ngx-field ngx-field--datetime"
|
|
1193
|
+
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
1194
|
+
<label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
|
|
1195
|
+
@if (masked) {
|
|
1196
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
|
|
1197
|
+
} @else if (readonly) {
|
|
1198
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDateTime(control.value) }}</span>
|
|
1199
|
+
} @else {
|
|
1200
|
+
<input
|
|
1201
|
+
[id]="field.id"
|
|
1202
|
+
class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
|
|
1203
|
+
type="datetime-local"
|
|
1204
|
+
[value]="inputValue"
|
|
1205
|
+
(change)="onInput($any($event.target).value)"
|
|
1206
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
1207
|
+
/>
|
|
1208
|
+
@if (control.invalid && control.touched) {
|
|
1209
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
</div>
|
|
1213
|
+
`,
|
|
1214
|
+
}]
|
|
1215
|
+
}], propDecorators: { field: [{
|
|
1216
|
+
type: Input
|
|
1217
|
+
}], control: [{
|
|
1218
|
+
type: Input
|
|
1219
|
+
}], language: [{
|
|
1220
|
+
type: Input
|
|
1221
|
+
}], readonly: [{
|
|
1222
|
+
type: Input
|
|
1223
|
+
}], masked: [{
|
|
1224
|
+
type: Input
|
|
1225
|
+
}] } });
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Time field: a bare time of day, with no date and no zone attached.
|
|
1229
|
+
*
|
|
1230
|
+
* Stored as `HH:mm` — exactly the value `<input type="time">` reads and writes, so the
|
|
1231
|
+
* control binds straight through with no conversion. That is the whole reason this is a
|
|
1232
|
+
* separate type from `datetime`: a recurring 09:00 opening time is not a moment in time,
|
|
1233
|
+
* and converting it to UTC would move it whenever the offset changed.
|
|
1234
|
+
*/
|
|
1235
|
+
class TimeFieldComponent {
|
|
1236
|
+
field;
|
|
1237
|
+
control;
|
|
1238
|
+
language = 'en';
|
|
1239
|
+
readonly = false;
|
|
1240
|
+
masked = false;
|
|
1241
|
+
get label() {
|
|
1242
|
+
return resolveLabel(this.field?.label, this.language);
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Defers to core rather than parsing `HH:mm` locally. The other date-ish components each
|
|
1246
|
+
* grew their own formatter, and `datetime` ended up showing one thing in the field and
|
|
1247
|
+
* another in the record summary because the two drifted apart.
|
|
1248
|
+
*/
|
|
1249
|
+
formatTime(value) {
|
|
1250
|
+
return formatDisplayValue('time', undefined, value, this.language);
|
|
1251
|
+
}
|
|
1252
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1253
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TimeFieldComponent, isStandalone: true, selector: "ngx-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
1254
|
+
<div class="ngx-field ngx-field--time"
|
|
1255
|
+
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
1256
|
+
<label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
|
|
1257
|
+
@if (masked) {
|
|
1258
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
|
|
1259
|
+
} @else if (readonly) {
|
|
1260
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatTime(control.value) }}</span>
|
|
1261
|
+
} @else {
|
|
1262
|
+
<input
|
|
1263
|
+
[id]="field.id"
|
|
1264
|
+
class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
|
|
1265
|
+
type="time"
|
|
1266
|
+
[formControl]="$any(control)"
|
|
1267
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
1268
|
+
/>
|
|
1269
|
+
@if (control.invalid && control.touched) {
|
|
1270
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
</div>
|
|
1274
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1275
|
+
}
|
|
1276
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TimeFieldComponent, decorators: [{
|
|
1277
|
+
type: Component,
|
|
1278
|
+
args: [{
|
|
1279
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1280
|
+
selector: 'ngx-time-field',
|
|
1281
|
+
standalone: true,
|
|
1282
|
+
imports: [ReactiveFormsModule],
|
|
1283
|
+
template: `
|
|
1284
|
+
<div class="ngx-field ngx-field--time"
|
|
1285
|
+
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
1286
|
+
<label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
|
|
1287
|
+
@if (masked) {
|
|
1288
|
+
<span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
|
|
1289
|
+
} @else if (readonly) {
|
|
1290
|
+
<span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatTime(control.value) }}</span>
|
|
1291
|
+
} @else {
|
|
1292
|
+
<input
|
|
1293
|
+
[id]="field.id"
|
|
1294
|
+
class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
|
|
1295
|
+
type="time"
|
|
1296
|
+
[formControl]="$any(control)"
|
|
1297
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
1298
|
+
/>
|
|
1299
|
+
@if (control.invalid && control.touched) {
|
|
1300
|
+
<span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
</div>
|
|
1304
|
+
`,
|
|
1305
|
+
}]
|
|
1306
|
+
}], propDecorators: { field: [{
|
|
1307
|
+
type: Input
|
|
1308
|
+
}], control: [{
|
|
1309
|
+
type: Input
|
|
1310
|
+
}], language: [{
|
|
1311
|
+
type: Input
|
|
1312
|
+
}], readonly: [{
|
|
1313
|
+
type: Input
|
|
1314
|
+
}], masked: [{
|
|
1315
|
+
type: Input
|
|
1316
|
+
}] } });
|
|
1317
|
+
|
|
1014
1318
|
const MONTH_NAMES = [
|
|
1015
1319
|
'January', 'February', 'March', 'April', 'May', 'June',
|
|
1016
1320
|
'July', 'August', 'September', 'October', 'November', 'December',
|
|
@@ -1097,11 +1401,12 @@ class MonthYearFieldComponent {
|
|
|
1097
1401
|
}
|
|
1098
1402
|
}
|
|
1099
1403
|
</div>
|
|
1100
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }] });
|
|
1404
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1101
1405
|
}
|
|
1102
1406
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MonthYearFieldComponent, decorators: [{
|
|
1103
1407
|
type: Component,
|
|
1104
1408
|
args: [{
|
|
1409
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1105
1410
|
selector: 'ngx-month-year-field',
|
|
1106
1411
|
standalone: true,
|
|
1107
1412
|
imports: [ReactiveFormsModule],
|
|
@@ -1372,7 +1677,7 @@ const hostLastConfig = new WeakMap();
|
|
|
1372
1677
|
* Point a choice component's `options` signal at the right source (parity plan §6.3).
|
|
1373
1678
|
*
|
|
1374
1679
|
* Called from the `field` / `language` input setters of `dropdown`, `radio` and `multiSelect` —
|
|
1375
|
-
* the wiring is theirs, the decision is the service's, which is what keeps the
|
|
1680
|
+
* the wiring is theirs, the decision is the service's, which is what keeps the field
|
|
1376
1681
|
* five-input contract intact: options are never pushed in as a sixth input.
|
|
1377
1682
|
*
|
|
1378
1683
|
* Setters rather than `ngOnChanges` on purpose. `ngOnChanges` only fires for inputs Angular
|
|
@@ -1401,6 +1706,7 @@ function refreshChoiceOptions(host, lookups) {
|
|
|
1401
1706
|
|
|
1402
1707
|
class DropdownFieldComponent {
|
|
1403
1708
|
lookups = inject(LookupRegistryService);
|
|
1709
|
+
messages = inject(ValidationMessagesService);
|
|
1404
1710
|
_field;
|
|
1405
1711
|
_language = 'en';
|
|
1406
1712
|
/** Setter-based so options resolve however the input is set — see `refreshChoiceOptions`. */
|
|
@@ -1459,10 +1765,9 @@ class DropdownFieldComponent {
|
|
|
1459
1765
|
get errorMessage() {
|
|
1460
1766
|
if (!this.control || !this.control.errors || !this.control.touched)
|
|
1461
1767
|
return '';
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
return 'Invalid selection.';
|
|
1768
|
+
// `requiredSelection` rather than `required`: "Please select an option" reads better on a
|
|
1769
|
+
// dropdown than "This field is required", and both stay independently overridable.
|
|
1770
|
+
return this.messages.resolve(this.control.errors, this.language, [['required', 'requiredSelection']], 'invalidSelection');
|
|
1466
1771
|
}
|
|
1467
1772
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1468
1773
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DropdownFieldComponent, isStandalone: true, selector: "ngx-dropdown-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
@@ -1505,11 +1810,12 @@ class DropdownFieldComponent {
|
|
|
1505
1810
|
}
|
|
1506
1811
|
}
|
|
1507
1812
|
</div>
|
|
1508
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
1813
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1509
1814
|
}
|
|
1510
1815
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, decorators: [{
|
|
1511
1816
|
type: Component,
|
|
1512
1817
|
args: [{
|
|
1818
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1513
1819
|
selector: 'ngx-dropdown-field',
|
|
1514
1820
|
standalone: true,
|
|
1515
1821
|
imports: [ReactiveFormsModule],
|
|
@@ -1660,11 +1966,12 @@ class RadioFieldComponent {
|
|
|
1660
1966
|
}
|
|
1661
1967
|
</fieldset>
|
|
1662
1968
|
</div>
|
|
1663
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
1969
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1664
1970
|
}
|
|
1665
1971
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RadioFieldComponent, decorators: [{
|
|
1666
1972
|
type: Component,
|
|
1667
1973
|
args: [{
|
|
1974
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1668
1975
|
selector: 'ngx-radio-field',
|
|
1669
1976
|
standalone: true,
|
|
1670
1977
|
imports: [ReactiveFormsModule],
|
|
@@ -1799,11 +2106,12 @@ class MultiSelectFieldComponent {
|
|
|
1799
2106
|
}
|
|
1800
2107
|
}
|
|
1801
2108
|
</div>
|
|
1802
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
2109
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1803
2110
|
}
|
|
1804
2111
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MultiSelectFieldComponent, decorators: [{
|
|
1805
2112
|
type: Component,
|
|
1806
2113
|
args: [{
|
|
2114
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1807
2115
|
selector: 'ngx-multi-select-field',
|
|
1808
2116
|
standalone: true,
|
|
1809
2117
|
imports: [ReactiveFormsModule],
|
|
@@ -1854,7 +2162,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1854
2162
|
|
|
1855
2163
|
/**
|
|
1856
2164
|
* EntityRefRegistryService — resolves entity-ref option loader functions.
|
|
1857
|
-
*
|
|
2165
|
+
* Entity-ref options come ONLY from this registry.
|
|
1858
2166
|
* Never pass loaders via @Input() on field components.
|
|
1859
2167
|
*
|
|
1860
2168
|
* Consumers register loaders via provideNgxDynamicEntity({ entityRefs: { clients: () => [...] } }).
|
|
@@ -2046,7 +2354,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
2046
2354
|
/**
|
|
2047
2355
|
* CascadeDataService — loads entity-reference options and applies parent→child cascades.
|
|
2048
2356
|
*
|
|
2049
|
-
* Loaders come from the registry only
|
|
2357
|
+
* Loaders come from the registry only. This service owns the three things a
|
|
2050
2358
|
* field component should not: resolving the loader, coercing whatever it returned
|
|
2051
2359
|
* (array | Promise | Observable) into a Promise, and filtering by the parent's value.
|
|
2052
2360
|
*/
|
|
@@ -2226,6 +2534,9 @@ class EntityRefFieldComponent {
|
|
|
2226
2534
|
return;
|
|
2227
2535
|
void this.reload();
|
|
2228
2536
|
this.watchParent();
|
|
2537
|
+
this.control?.valueChanges
|
|
2538
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
2539
|
+
.subscribe(value => this.publishSelection(String(value ?? '')));
|
|
2229
2540
|
}
|
|
2230
2541
|
/** Reload options against the parent's current value. */
|
|
2231
2542
|
async reload() {
|
|
@@ -2243,8 +2554,20 @@ class EntityRefFieldComponent {
|
|
|
2243
2554
|
this.loading.set(false);
|
|
2244
2555
|
}
|
|
2245
2556
|
}
|
|
2246
|
-
|
|
2247
|
-
|
|
2557
|
+
/**
|
|
2558
|
+
* Publish the selected option so autoPatch can copy from its record.
|
|
2559
|
+
*
|
|
2560
|
+
* Driven from `valueChanges` rather than the select's `change` event: that event is the
|
|
2561
|
+
* same one the `formControl` directive listens to, so reading the control inside a
|
|
2562
|
+
* template `(change)` handler can see the previous value. `valueChanges` fires after the
|
|
2563
|
+
* model has updated — including Playwright `selectOption`, which updates the control
|
|
2564
|
+
* through the value accessor.
|
|
2565
|
+
*/
|
|
2566
|
+
onSelectionChange(value) {
|
|
2567
|
+
this.publishSelection(value ?? String(this.control?.value ?? ''));
|
|
2568
|
+
}
|
|
2569
|
+
publishSelection(current) {
|
|
2570
|
+
const selected = this.options().find(o => String(o.value) === current);
|
|
2248
2571
|
this.selectionBus.emit(this.field.id, selected ?? null);
|
|
2249
2572
|
}
|
|
2250
2573
|
getLabel(value) {
|
|
@@ -2293,7 +2616,6 @@ class EntityRefFieldComponent {
|
|
|
2293
2616
|
[id]="'field-' + field.id"
|
|
2294
2617
|
[formControl]="$any(control)"
|
|
2295
2618
|
[attr.disabled]="field.disabled ? true : null"
|
|
2296
|
-
(change)="onSelectionChange()"
|
|
2297
2619
|
>
|
|
2298
2620
|
<option value="">{{ placeholder || 'Select...' }}</option>
|
|
2299
2621
|
@for (option of options(); track option.value) {
|
|
@@ -2309,11 +2631,12 @@ class EntityRefFieldComponent {
|
|
|
2309
2631
|
}
|
|
2310
2632
|
}
|
|
2311
2633
|
</div>
|
|
2312
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
2634
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2313
2635
|
}
|
|
2314
2636
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefFieldComponent, decorators: [{
|
|
2315
2637
|
type: Component,
|
|
2316
2638
|
args: [{
|
|
2639
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
2317
2640
|
selector: 'ngx-entity-ref-field',
|
|
2318
2641
|
standalone: true,
|
|
2319
2642
|
imports: [ReactiveFormsModule],
|
|
@@ -2334,7 +2657,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
2334
2657
|
[id]="'field-' + field.id"
|
|
2335
2658
|
[formControl]="$any(control)"
|
|
2336
2659
|
[attr.disabled]="field.disabled ? true : null"
|
|
2337
|
-
(change)="onSelectionChange()"
|
|
2338
2660
|
>
|
|
2339
2661
|
<option value="">{{ placeholder || 'Select...' }}</option>
|
|
2340
2662
|
@for (option of options(); track option.value) {
|
|
@@ -2460,7 +2782,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
2460
2782
|
|
|
2461
2783
|
/**
|
|
2462
2784
|
* DynamicFieldComponent — mounts the correct field component for a given NestedFieldConfig.
|
|
2463
|
-
* Uses ViewContainerRef.createComponent() and passes inputs via setInput()
|
|
2785
|
+
* Uses ViewContainerRef.createComponent() and passes inputs via setInput(); see
|
|
2786
|
+
* DynamicFieldComponentContract for the five inputs every field component receives.
|
|
2464
2787
|
* Never add field-type-specific logic here — this component must remain generic.
|
|
2465
2788
|
*
|
|
2466
2789
|
* The 5 inputs (field, control, language, readonly, masked) are passed to every mounted
|
|
@@ -2477,6 +2800,12 @@ class DynamicFieldComponent {
|
|
|
2477
2800
|
fieldHost;
|
|
2478
2801
|
fieldRegistry = inject(FieldRegistryService);
|
|
2479
2802
|
rbacService = inject(RbacService);
|
|
2803
|
+
/**
|
|
2804
|
+
* Passed into `createComponent` so a hosted field sees this host's injector — including
|
|
2805
|
+
* `DynamicFormComponent`'s scoped `EntityRefSelectionService`. Without it, a standalone
|
|
2806
|
+
* field resolves the root bus and `autoPatch` never runs.
|
|
2807
|
+
*/
|
|
2808
|
+
injector = inject(Injector);
|
|
2480
2809
|
componentRef = null;
|
|
2481
2810
|
ngOnChanges(_changes) {
|
|
2482
2811
|
this.mountField();
|
|
@@ -2498,9 +2827,12 @@ class DynamicFieldComponent {
|
|
|
2498
2827
|
return;
|
|
2499
2828
|
}
|
|
2500
2829
|
this.fieldHost.clear();
|
|
2501
|
-
this.componentRef = this.fieldHost.createComponent(ComponentClass
|
|
2830
|
+
this.componentRef = this.fieldHost.createComponent(ComponentClass, {
|
|
2831
|
+
injector: this.injector,
|
|
2832
|
+
});
|
|
2502
2833
|
this.setInputs(this.componentRef, masked);
|
|
2503
|
-
this.
|
|
2834
|
+
this.watchControl();
|
|
2835
|
+
this.componentRef.changeDetectorRef.detectChanges();
|
|
2504
2836
|
}
|
|
2505
2837
|
/**
|
|
2506
2838
|
* Field types are opt-in (see `provideFieldTypes` / `provideBuiltInFieldTypes`), so an
|
|
@@ -2516,7 +2848,44 @@ class DynamicFieldComponent {
|
|
|
2516
2848
|
}
|
|
2517
2849
|
static warnedTypes = new Set();
|
|
2518
2850
|
/**
|
|
2519
|
-
*
|
|
2851
|
+
* Re-check the hosted field component.
|
|
2852
|
+
*
|
|
2853
|
+
* The field components are OnPush, so they re-render on an input change, an event from
|
|
2854
|
+
* their own template, or a signal they read. `form.markAllAsTouched()` is none of those —
|
|
2855
|
+
* it flips `touched` from outside, and every field's error message depends on it. Without
|
|
2856
|
+
* this, a blocked submit would mark the form touched and no error would appear.
|
|
2857
|
+
*/
|
|
2858
|
+
refresh() {
|
|
2859
|
+
this.componentRef?.changeDetectorRef.detectChanges();
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Re-check the hosted component whenever its control changes from outside.
|
|
2863
|
+
*
|
|
2864
|
+
* The field components are OnPush, so they re-render on an input change or an event from
|
|
2865
|
+
* their own template. A control mutated externally is neither — and that is not an edge
|
|
2866
|
+
* case: patchForm, reset, autoPatch and patchOnTrue all do exactly that. Without this, a
|
|
2867
|
+
* field patched from a record would keep rendering its previous value.
|
|
2868
|
+
*
|
|
2869
|
+
* `detectChanges()` rather than `markForCheck()`: a selection that arrives from a native
|
|
2870
|
+
* `change` event (or Playwright) may not be followed by another Angular tick, so a dirty
|
|
2871
|
+
* flag would never be flushed. Running CD on the hosted component now is the copy showing up.
|
|
2872
|
+
*
|
|
2873
|
+
* Done here rather than in each of the eighteen field components: the host owns the
|
|
2874
|
+
* component reference, so one subscription covers every type including custom ones.
|
|
2875
|
+
*/
|
|
2876
|
+
watchControl() {
|
|
2877
|
+
this.controlSub?.unsubscribe();
|
|
2878
|
+
if (!this.control)
|
|
2879
|
+
return;
|
|
2880
|
+
this.controlSub = this.control.valueChanges.subscribe(() => this.refresh());
|
|
2881
|
+
}
|
|
2882
|
+
controlSub;
|
|
2883
|
+
ngOnDestroy() {
|
|
2884
|
+
this.controlSub?.unsubscribe();
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Pass all 5 contract inputs via ComponentRef.setInput() — uniform for all types.
|
|
2888
|
+
* The public shape is DynamicFieldComponentContract.
|
|
2520
2889
|
* Must use setInput(), not property assignment: the field components declare inputs with
|
|
2521
2890
|
* definite assignment and no initializer, and the lib compiles with
|
|
2522
2891
|
* useDefineForClassFields:false, so `'field' in instance` is false until first set.
|
|
@@ -2529,11 +2898,12 @@ class DynamicFieldComponent {
|
|
|
2529
2898
|
ref.setInput('masked', masked);
|
|
2530
2899
|
}
|
|
2531
2900
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2532
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", 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 });
|
|
2901
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", 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 });
|
|
2533
2902
|
}
|
|
2534
2903
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, decorators: [{
|
|
2535
2904
|
type: Component,
|
|
2536
2905
|
args: [{
|
|
2906
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
2537
2907
|
selector: 'ngx-dynamic-field',
|
|
2538
2908
|
standalone: true,
|
|
2539
2909
|
template: `<ng-container #fieldHost></ng-container>`,
|
|
@@ -2557,7 +2927,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
2557
2927
|
args: ['fieldHost', { read: ViewContainerRef, static: true }]
|
|
2558
2928
|
}] } });
|
|
2559
2929
|
|
|
2560
|
-
/**
|
|
2930
|
+
/** Renders a nested fieldset container for a group field's children. */
|
|
2561
2931
|
class GroupFieldComponent {
|
|
2562
2932
|
field;
|
|
2563
2933
|
control;
|
|
@@ -2597,11 +2967,11 @@ class GroupFieldComponent {
|
|
|
2597
2967
|
</div>
|
|
2598
2968
|
}
|
|
2599
2969
|
</fieldset>
|
|
2600
|
-
`, isInline: true, styles: [".ngx-field--group{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#fafafa}.ngx-field__legend{font-weight:600;font-size:14px;padding:0 8px;color:#334155}.ngx-field__group-children{display:flex;flex-direction:column;gap:12px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
|
|
2970
|
+
`, isInline: true, styles: [".ngx-field--group{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#fafafa}.ngx-field__legend{font-weight:600;font-size:14px;padding:0 8px;color:#334155}.ngx-field__group-children{display:flex;flex-direction:column;gap:12px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2601
2971
|
}
|
|
2602
2972
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: GroupFieldComponent, decorators: [{
|
|
2603
2973
|
type: Component,
|
|
2604
|
-
args: [{ selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
|
|
2974
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
|
|
2605
2975
|
<fieldset class="ngx-field ngx-field--group"
|
|
2606
2976
|
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
2607
2977
|
<legend class="ngx-field__legend">{{ label }}</legend>
|
|
@@ -2646,6 +3016,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
2646
3016
|
*/
|
|
2647
3017
|
class ValidatorRegistryService {
|
|
2648
3018
|
consumerRegistry = inject(VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
|
|
3019
|
+
asyncRegistry = inject(ASYNC_VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
|
|
2649
3020
|
/**
|
|
2650
3021
|
* Resolve a validator key string to a ValidatorFn.
|
|
2651
3022
|
* Supports parameterized built-ins like 'min:0', 'maxLength:255'.
|
|
@@ -2706,6 +3077,27 @@ class ValidatorRegistryService {
|
|
|
2706
3077
|
}
|
|
2707
3078
|
return fnList;
|
|
2708
3079
|
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Resolve the async validators a field names, in `validators.customAsync`.
|
|
3082
|
+
*
|
|
3083
|
+
* Async validators are deliberately a separate registry and a separate config key: Angular
|
|
3084
|
+
* attaches them through `setAsyncValidators`, runs them only once the synchronous ones
|
|
3085
|
+
* pass, and holds the control in `pending` until they settle. Mixing them into the
|
|
3086
|
+
* synchronous list would silently never run them.
|
|
3087
|
+
*
|
|
3088
|
+
* An unknown name is skipped rather than throwing, matching the synchronous behaviour.
|
|
3089
|
+
*/
|
|
3090
|
+
resolveAsyncFromConfig(config) {
|
|
3091
|
+
if (!config || Array.isArray(config))
|
|
3092
|
+
return [];
|
|
3093
|
+
const out = [];
|
|
3094
|
+
for (const key of config.customAsync ?? []) {
|
|
3095
|
+
const fn = this.asyncRegistry.get(key);
|
|
3096
|
+
if (fn)
|
|
3097
|
+
out.push(fn);
|
|
3098
|
+
}
|
|
3099
|
+
return out;
|
|
3100
|
+
}
|
|
2709
3101
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2710
3102
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
|
|
2711
3103
|
}
|
|
@@ -2714,8 +3106,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
2714
3106
|
args: [{ providedIn: 'root' }]
|
|
2715
3107
|
}] });
|
|
2716
3108
|
|
|
2717
|
-
/**
|
|
3109
|
+
/** Renders repeating rows for a FormArray, with Add Item and Remove. */
|
|
2718
3110
|
class ArrayFieldComponent {
|
|
3111
|
+
/**
|
|
3112
|
+
* addItem/removeItem are public and may be called from outside this component's template,
|
|
3113
|
+
* which under OnPush would not re-render. The host also watches the control, but this
|
|
3114
|
+
* keeps the component correct on its own.
|
|
3115
|
+
*/
|
|
3116
|
+
cdr = inject(ChangeDetectorRef);
|
|
2719
3117
|
field;
|
|
2720
3118
|
control;
|
|
2721
3119
|
config;
|
|
@@ -2742,9 +3140,11 @@ class ArrayFieldComponent {
|
|
|
2742
3140
|
}
|
|
2743
3141
|
const group = this.fb.group(controls);
|
|
2744
3142
|
this.formArray.push(group);
|
|
3143
|
+
this.cdr.markForCheck();
|
|
2745
3144
|
}
|
|
2746
3145
|
removeItem(index) {
|
|
2747
3146
|
this.formArray.removeAt(index);
|
|
3147
|
+
this.cdr.markForCheck();
|
|
2748
3148
|
}
|
|
2749
3149
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ArrayFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2750
3150
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ArrayFieldComponent, isStandalone: true, selector: "ngx-array-field", inputs: { field: "field", control: "control", config: "config", language: "language", readonly: "readonly", masked: "masked", userRoles: "userRoles" }, ngImport: i0, template: `
|
|
@@ -2797,11 +3197,11 @@ class ArrayFieldComponent {
|
|
|
2797
3197
|
</div>
|
|
2798
3198
|
}
|
|
2799
3199
|
</div>
|
|
2800
|
-
`, isInline: true, styles: [".ngx-field--array{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#f8fafc}.ngx-field__array-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.ngx-field__array-add-btn{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;font-weight:500;cursor:pointer}.ngx-field__array-add-btn:hover{background:#1d4ed8}.ngx-field__array-list{display:flex;flex-direction:column;gap:12px}.ngx-field__array-item{background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:12px}.ngx-field__array-item-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #f1f5f9;padding-bottom:6px}.ngx-field__array-item-title{font-weight:600;font-size:13px;color:#475569}.ngx-field__array-remove-btn{background:#ef4444;color:#fff;border:none;padding:4px 8px;border-radius:4px;font-size:12px;cursor:pointer}.ngx-field__array-empty{color:#94a3b8;font-style:italic;font-size:13px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
|
|
3200
|
+
`, isInline: true, styles: [".ngx-field--array{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#f8fafc}.ngx-field__array-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.ngx-field__array-add-btn{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;font-weight:500;cursor:pointer}.ngx-field__array-add-btn:hover{background:#1d4ed8}.ngx-field__array-list{display:flex;flex-direction:column;gap:12px}.ngx-field__array-item{background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:12px}.ngx-field__array-item-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #f1f5f9;padding-bottom:6px}.ngx-field__array-item-title{font-weight:600;font-size:13px;color:#475569}.ngx-field__array-remove-btn{background:#ef4444;color:#fff;border:none;padding:4px 8px;border-radius:4px;font-size:12px;cursor:pointer}.ngx-field__array-empty{color:#94a3b8;font-style:italic;font-size:13px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2801
3201
|
}
|
|
2802
3202
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ArrayFieldComponent, decorators: [{
|
|
2803
3203
|
type: Component,
|
|
2804
|
-
args: [{ selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
|
|
3204
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
|
|
2805
3205
|
<div class="ngx-field ngx-field--array"
|
|
2806
3206
|
[attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
2807
3207
|
<div class="ngx-field__array-header">
|
|
@@ -3052,11 +3452,12 @@ class ImageFieldComponent {
|
|
|
3052
3452
|
}
|
|
3053
3453
|
}
|
|
3054
3454
|
</div>
|
|
3055
|
-
`, isInline: true });
|
|
3455
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3056
3456
|
}
|
|
3057
3457
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ImageFieldComponent, decorators: [{
|
|
3058
3458
|
type: Component,
|
|
3059
3459
|
args: [{
|
|
3460
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
3060
3461
|
selector: 'ngx-image-field',
|
|
3061
3462
|
standalone: true,
|
|
3062
3463
|
imports: [],
|
|
@@ -3206,11 +3607,12 @@ class FileFieldComponent {
|
|
|
3206
3607
|
}
|
|
3207
3608
|
}
|
|
3208
3609
|
</div>
|
|
3209
|
-
`, isInline: true });
|
|
3610
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3210
3611
|
}
|
|
3211
3612
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FileFieldComponent, decorators: [{
|
|
3212
3613
|
type: Component,
|
|
3213
3614
|
args: [{
|
|
3615
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
3214
3616
|
selector: 'ngx-file-field',
|
|
3215
3617
|
standalone: true,
|
|
3216
3618
|
imports: [],
|
|
@@ -3299,7 +3701,8 @@ function builtInFieldTypes() {
|
|
|
3299
3701
|
checkbox: CheckboxFieldComponent,
|
|
3300
3702
|
boolean: BooleanFieldComponent,
|
|
3301
3703
|
date: DateFieldComponent,
|
|
3302
|
-
datetime:
|
|
3704
|
+
datetime: DateTimeFieldComponent,
|
|
3705
|
+
time: TimeFieldComponent,
|
|
3303
3706
|
monthYear: MonthYearFieldComponent,
|
|
3304
3707
|
dropdown: DropdownFieldComponent,
|
|
3305
3708
|
radio: RadioFieldComponent,
|
|
@@ -3440,7 +3843,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
3440
3843
|
*/
|
|
3441
3844
|
class DynamicFormComponent {
|
|
3442
3845
|
// ─── Inputs ───────────────────────────────────────────────────────────────
|
|
3443
|
-
|
|
3846
|
+
/**
|
|
3847
|
+
* The schema to render.
|
|
3848
|
+
*
|
|
3849
|
+
* An accessor pair rather than a plain field: `ngOnChanges` normalises the option shape,
|
|
3850
|
+
* and that result is kept here instead of being written back over the input. Reassigning
|
|
3851
|
+
* an `@Input` works, but it mutates what the parent handed us — and a parent using OnPush
|
|
3852
|
+
* or signals would never observe the replacement anyway.
|
|
3853
|
+
*
|
|
3854
|
+
* Reads of `this.config` get the normalised copy once it exists, so every internal caller
|
|
3855
|
+
* is unaffected.
|
|
3856
|
+
*/
|
|
3857
|
+
set config(value) {
|
|
3858
|
+
this.rawConfig = value;
|
|
3859
|
+
this.normalizedConfig = undefined; // re-normalised in ngOnChanges
|
|
3860
|
+
}
|
|
3861
|
+
get config() {
|
|
3862
|
+
return this.normalizedConfig ?? this.rawConfig;
|
|
3863
|
+
}
|
|
3864
|
+
rawConfig;
|
|
3865
|
+
normalizedConfig;
|
|
3444
3866
|
rules;
|
|
3445
3867
|
initialData;
|
|
3446
3868
|
/** Session-original values for `VALUE_CHANGED` rules. Captured from the first build when omitted. */
|
|
@@ -3474,7 +3896,13 @@ class DynamicFormComponent {
|
|
|
3474
3896
|
formReset = new EventEmitter();
|
|
3475
3897
|
/** The visible tab changed. Lets a host track it without reaching into this component. */
|
|
3476
3898
|
activeTabChange = new EventEmitter();
|
|
3899
|
+
/**
|
|
3900
|
+
* Emitted when a `beforeSave` hook aborted the save — by returning false or throwing.
|
|
3901
|
+
* `formSubmit` does not fire in that case.
|
|
3902
|
+
*/
|
|
3903
|
+
saveRejected = new EventEmitter();
|
|
3477
3904
|
// ─── Services ─────────────────────────────────────────────────────────────
|
|
3905
|
+
cdr = inject(ChangeDetectorRef);
|
|
3478
3906
|
fb = inject(FormBuilder);
|
|
3479
3907
|
validatorRegistry = inject(ValidatorRegistryService);
|
|
3480
3908
|
hookRegistry = inject(HookRegistryService);
|
|
@@ -3493,9 +3921,14 @@ class DynamicFormComponent {
|
|
|
3493
3921
|
sessionBaseline = signal({});
|
|
3494
3922
|
/** Critical fields the user has explicitly unlocked for editing. */
|
|
3495
3923
|
unlockedFields = signal(new Set());
|
|
3924
|
+
/** The active tab's field panel, focused after a tab switch. */
|
|
3925
|
+
formPanel;
|
|
3926
|
+
/** The hosted field components, so an external control-state change can reach them. */
|
|
3927
|
+
fieldHosts;
|
|
3496
3928
|
// ─── Form ─────────────────────────────────────────────────────────────────
|
|
3497
3929
|
form;
|
|
3498
3930
|
valueSub;
|
|
3931
|
+
statusSub;
|
|
3499
3932
|
selectionSub;
|
|
3500
3933
|
/** Previous values, for detecting `patchOnTrue` false→true transitions. */
|
|
3501
3934
|
previousValues = {};
|
|
@@ -3618,9 +4051,19 @@ class DynamicFormComponent {
|
|
|
3618
4051
|
get hasRuleErrors() {
|
|
3619
4052
|
return Object.keys(this.ruleValidationErrors).length > 0;
|
|
3620
4053
|
}
|
|
4054
|
+
/**
|
|
4055
|
+
* True while an async validator is still running.
|
|
4056
|
+
*
|
|
4057
|
+
* Angular reports a control with an outstanding async check as `pending`, and `invalid` is
|
|
4058
|
+
* false until it settles — so without this a form could be submitted in the gap before a
|
|
4059
|
+
* uniqueness check came back.
|
|
4060
|
+
*/
|
|
4061
|
+
get isValidating() {
|
|
4062
|
+
return this.form.pending;
|
|
4063
|
+
}
|
|
3621
4064
|
/** Single source of truth for both the submit guard and the button's disabled state. */
|
|
3622
4065
|
get submitBlocked() {
|
|
3623
|
-
return this.form.invalid || this.hasRuleErrors;
|
|
4066
|
+
return this.form.invalid || this.form.pending || this.hasRuleErrors;
|
|
3624
4067
|
}
|
|
3625
4068
|
ngOnInit() {
|
|
3626
4069
|
this.selectionSub = this.entityRefSelection.selection$.subscribe(({ fieldId, option }) => {
|
|
@@ -3631,12 +4074,15 @@ class DynamicFormComponent {
|
|
|
3631
4074
|
if (changes['config'] || changes['userRoles']) {
|
|
3632
4075
|
this.permissionsCache = null;
|
|
3633
4076
|
}
|
|
3634
|
-
if (changes['config'] && this.
|
|
4077
|
+
if (changes['config'] && this.rawConfig) {
|
|
3635
4078
|
// Configs arrive as plain JSON from storage or an API, where TypeScript cannot enforce
|
|
3636
4079
|
// the option shape. Normalise here, at the library boundary, so everything downstream
|
|
3637
4080
|
// can rely on an option being a LocalizedText. Returns the same object when it already
|
|
3638
4081
|
// is, so a well-formed config costs nothing.
|
|
3639
|
-
|
|
4082
|
+
// Kept in a field of our own rather than written back to the @Input. Reassigning an
|
|
4083
|
+
// input works, but it surprises a parent that holds the same object — and a parent
|
|
4084
|
+
// using OnPush or signals would not see the replacement anyway.
|
|
4085
|
+
this.normalizedConfig = normalizeConfigOptions(this.config);
|
|
3640
4086
|
}
|
|
3641
4087
|
if (changes['config'] || changes['initialData']) {
|
|
3642
4088
|
this.buildForm();
|
|
@@ -3647,12 +4093,16 @@ class DynamicFormComponent {
|
|
|
3647
4093
|
}
|
|
3648
4094
|
ngOnDestroy() {
|
|
3649
4095
|
this.valueSub?.unsubscribe();
|
|
4096
|
+
this.statusSub?.unsubscribe();
|
|
3650
4097
|
this.selectionSub?.unsubscribe();
|
|
3651
4098
|
}
|
|
3652
4099
|
setActiveTab(tabId) {
|
|
3653
|
-
|
|
4100
|
+
const changed = this.activeTab() !== tabId;
|
|
4101
|
+
if (changed)
|
|
3654
4102
|
this.activeTabChange.emit(tabId);
|
|
3655
4103
|
this.activeTab.set(tabId);
|
|
4104
|
+
if (changed)
|
|
4105
|
+
this.focusActivePanel();
|
|
3656
4106
|
const parent = findTab(this.tabs, tabId);
|
|
3657
4107
|
if (parent?.children?.length) {
|
|
3658
4108
|
this.activeSubTab.set(parent.children[0].id);
|
|
@@ -3662,7 +4112,10 @@ class DynamicFormComponent {
|
|
|
3662
4112
|
}
|
|
3663
4113
|
}
|
|
3664
4114
|
setActiveSubTab(subTabId) {
|
|
4115
|
+
const changed = this.activeSubTab() !== subTabId;
|
|
3665
4116
|
this.activeSubTab.set(subTabId);
|
|
4117
|
+
if (changed)
|
|
4118
|
+
this.focusActivePanel();
|
|
3666
4119
|
}
|
|
3667
4120
|
getFieldSpan(field) {
|
|
3668
4121
|
const span = field.colSpan ?? 12;
|
|
@@ -3748,6 +4201,12 @@ class DynamicFormComponent {
|
|
|
3748
4201
|
const changes$ = this.changeDebounceMs > 0
|
|
3749
4202
|
? this.form.valueChanges.pipe(debounceTime(this.changeDebounceMs))
|
|
3750
4203
|
: this.form.valueChanges;
|
|
4204
|
+
// Validity is not value: an async validator settling flips `pending` and then `invalid`
|
|
4205
|
+
// with no value change and no template event, so under OnPush the Save button would
|
|
4206
|
+
// never re-enable. submitBlocked reads form.pending and form.invalid, so this is what
|
|
4207
|
+
// keeps it honest.
|
|
4208
|
+
this.statusSub?.unsubscribe();
|
|
4209
|
+
this.statusSub = this.form.statusChanges.subscribe(() => this.cdr.markForCheck());
|
|
3751
4210
|
this.valueSub = changes$.subscribe(() => {
|
|
3752
4211
|
const flattened = this.flattenFormValues();
|
|
3753
4212
|
this.formValues.set(flattened);
|
|
@@ -3770,7 +4229,8 @@ class DynamicFormComponent {
|
|
|
3770
4229
|
}
|
|
3771
4230
|
else {
|
|
3772
4231
|
const validators = this.validatorRegistry.resolveFromConfig(field.validators);
|
|
3773
|
-
|
|
4232
|
+
const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
|
|
4233
|
+
group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
|
|
3774
4234
|
}
|
|
3775
4235
|
}
|
|
3776
4236
|
/** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
|
|
@@ -3801,6 +4261,16 @@ class DynamicFormComponent {
|
|
|
3801
4261
|
group.patchValue(item);
|
|
3802
4262
|
return group;
|
|
3803
4263
|
}
|
|
4264
|
+
/**
|
|
4265
|
+
* Mark every control touched, then re-check the field components.
|
|
4266
|
+
*
|
|
4267
|
+
* They are OnPush and `touched` is flipped from outside their own template, so the second
|
|
4268
|
+
* half is what makes the error messages actually appear.
|
|
4269
|
+
*/
|
|
4270
|
+
markAllTouched() {
|
|
4271
|
+
this.form.markAllAsTouched();
|
|
4272
|
+
this.fieldHosts?.forEach(host => host.refresh());
|
|
4273
|
+
}
|
|
3804
4274
|
/**
|
|
3805
4275
|
* Bring a record up to the config's `version` before it reaches the form.
|
|
3806
4276
|
*
|
|
@@ -3970,6 +4440,10 @@ class DynamicFormComponent {
|
|
|
3970
4440
|
const control = this.resolveTargetControl(autoPatch, targetId);
|
|
3971
4441
|
control?.patchValue(value);
|
|
3972
4442
|
}
|
|
4443
|
+
// Readonly targets interpolate `control.value` under OnPush. `detectChanges` on the
|
|
4444
|
+
// hosted component flushes that write immediately — `markForCheck` waits for a later
|
|
4445
|
+
// tick that a Playwright selection never schedules.
|
|
4446
|
+
this.fieldHosts?.forEach(host => host.refresh());
|
|
3973
4447
|
}
|
|
3974
4448
|
/**
|
|
3975
4449
|
* `patchOnTrue` — when a boolean/checkbox field transitions to `true`,
|
|
@@ -4115,24 +4589,70 @@ class DynamicFormComponent {
|
|
|
4115
4589
|
};
|
|
4116
4590
|
return findInGroup(this.form);
|
|
4117
4591
|
}
|
|
4592
|
+
/**
|
|
4593
|
+
* Move focus into the newly shown panel.
|
|
4594
|
+
*
|
|
4595
|
+
* Activating a tab replaces everything below it while focus stays on the tab button, so a
|
|
4596
|
+
* screen-reader or keyboard user is given no indication that the content changed and must
|
|
4597
|
+
* tab back through the strip to reach it. Deferred a frame because the panel's contents
|
|
4598
|
+
* are rendered by the change detection this call is part of.
|
|
4599
|
+
*/
|
|
4600
|
+
focusActivePanel() {
|
|
4601
|
+
if (typeof requestAnimationFrame !== 'function')
|
|
4602
|
+
return;
|
|
4603
|
+
requestAnimationFrame(() => this.formPanel?.nativeElement?.focus?.());
|
|
4604
|
+
}
|
|
4605
|
+
/** Accessible name for the field panel — it is the tabpanel for the active tab. */
|
|
4606
|
+
get activeTabLabel() {
|
|
4607
|
+
const tab = this.activeSubTabConfig ?? this.activeTabConfig;
|
|
4608
|
+
return tab ? this.resolveTabLabel(tab) : '';
|
|
4609
|
+
}
|
|
4610
|
+
/**
|
|
4611
|
+
* Why a referenced field is flagged.
|
|
4612
|
+
*
|
|
4613
|
+
* `hasDrift` was written by the builder and read by nothing at runtime, so a field whose
|
|
4614
|
+
* source definition had changed since it was linked looked entirely normal to the person
|
|
4615
|
+
* filling the form in.
|
|
4616
|
+
*/
|
|
4617
|
+
driftHint(field) {
|
|
4618
|
+
const source = field.referencedEntityKey
|
|
4619
|
+
? `"${field.referencedEntityKey}"`
|
|
4620
|
+
: 'its source entity';
|
|
4621
|
+
return `This field is linked to ${source} and its definition there has changed since it was linked.`;
|
|
4622
|
+
}
|
|
4118
4623
|
resolveTabLabel(tab) {
|
|
4119
4624
|
return resolveLabel(tab.label, this.language);
|
|
4120
4625
|
}
|
|
4121
4626
|
async submit() {
|
|
4122
4627
|
if (!this.canSubmit || this.submitBlocked) {
|
|
4123
|
-
this.
|
|
4628
|
+
this.markAllTouched();
|
|
4124
4629
|
return;
|
|
4125
4630
|
}
|
|
4126
4631
|
const rawData = this.extractRecord();
|
|
4127
4632
|
let processedData = rawData;
|
|
4128
|
-
const hookKey = `${this.config?.entity}:beforeSave`;
|
|
4129
|
-
if (this.hookRegistry.has(hookKey)) {
|
|
4130
|
-
processedData = await this.hookRegistry.run(hookKey, processedData);
|
|
4131
|
-
}
|
|
4132
4633
|
this.isSaving.set(true);
|
|
4133
4634
|
try {
|
|
4635
|
+
// beforeSave can now abort the save: returning false, or throwing, stops it. Previously
|
|
4636
|
+
// its return value simply replaced the payload and the submit proceeded regardless, so
|
|
4637
|
+
// a hook that wanted to veto — a server-side check, a confirmation — had no way to.
|
|
4638
|
+
const hookKey = `${this.config?.entity}:beforeSave`;
|
|
4639
|
+
if (this.hookRegistry.has(hookKey)) {
|
|
4640
|
+
const result = await this.hookRegistry.run(hookKey, processedData);
|
|
4641
|
+
if (result === false) {
|
|
4642
|
+
this.saveRejected.emit({ reason: 'beforeSave returned false' });
|
|
4643
|
+
return;
|
|
4644
|
+
}
|
|
4645
|
+
// Anything else is the payload to submit; `undefined` means "unchanged".
|
|
4646
|
+
if (result !== undefined && result !== true)
|
|
4647
|
+
processedData = result;
|
|
4648
|
+
}
|
|
4134
4649
|
this.formSubmit.emit(processedData);
|
|
4135
4650
|
}
|
|
4651
|
+
catch (err) {
|
|
4652
|
+
// A hook that throws aborts the save and reports why, rather than the error escaping
|
|
4653
|
+
// into an unhandled rejection with the form left looking as though it saved.
|
|
4654
|
+
this.saveRejected.emit({ reason: err instanceof Error ? err.message : String(err), error: err });
|
|
4655
|
+
}
|
|
4136
4656
|
finally {
|
|
4137
4657
|
this.isSaving.set(false);
|
|
4138
4658
|
}
|
|
@@ -4148,11 +4668,11 @@ class DynamicFormComponent {
|
|
|
4148
4668
|
this.formReset.emit();
|
|
4149
4669
|
}
|
|
4150
4670
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4151
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!-- Module tab component OR Fields for active tab -->\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n >\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.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media (max-width: 768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],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", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }] });
|
|
4671
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div\n role=\"tabpanel\"\n tabindex=\"-1\"\n #formPanel\n class=\"ngx-form__tabpanel\"\n [attr.aria-label]=\"activeTabLabel\"\n >\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span>\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media (max-width: 768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],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", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4152
4672
|
}
|
|
4153
4673
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, decorators: [{
|
|
4154
4674
|
type: Component,
|
|
4155
|
-
args: [{ selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n
|
|
4675
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div\n role=\"tabpanel\"\n tabindex=\"-1\"\n #formPanel\n class=\"ngx-form__tabpanel\"\n [attr.aria-label]=\"activeTabLabel\"\n >\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span>\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media (max-width: 768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"] }]
|
|
4156
4676
|
}], propDecorators: { config: [{
|
|
4157
4677
|
type: Input
|
|
4158
4678
|
}], rules: [{
|
|
@@ -4187,6 +4707,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
4187
4707
|
type: Output
|
|
4188
4708
|
}], activeTabChange: [{
|
|
4189
4709
|
type: Output
|
|
4710
|
+
}], saveRejected: [{
|
|
4711
|
+
type: Output
|
|
4712
|
+
}], formPanel: [{
|
|
4713
|
+
type: ViewChild,
|
|
4714
|
+
args: ['formPanel']
|
|
4715
|
+
}], fieldHosts: [{
|
|
4716
|
+
type: ViewChildren,
|
|
4717
|
+
args: [DynamicFieldComponent]
|
|
4190
4718
|
}], handleKeyboardEvent: [{
|
|
4191
4719
|
type: HostListener,
|
|
4192
4720
|
args: ['window:keydown', ['$event']]
|
|
@@ -4230,6 +4758,8 @@ class DynamicRecordFormComponent {
|
|
|
4230
4758
|
sectionSave = new EventEmitter();
|
|
4231
4759
|
dynamicFormComp;
|
|
4232
4760
|
rulesEvaluation = inject(RulesEvaluationService);
|
|
4761
|
+
host = inject(ElementRef);
|
|
4762
|
+
injector = inject(Injector);
|
|
4233
4763
|
currentData = signal({});
|
|
4234
4764
|
originalBaseline = signal({});
|
|
4235
4765
|
/**
|
|
@@ -4619,29 +5149,64 @@ class DynamicRecordFormComponent {
|
|
|
4619
5149
|
formatFieldValue(field) {
|
|
4620
5150
|
return formatDisplayValue(field.type, field.options, this.fieldValue(field.id), this.language);
|
|
4621
5151
|
}
|
|
4622
|
-
|
|
4623
|
-
|
|
5152
|
+
/**
|
|
5153
|
+
* Finds which tab — and, where the field lives one level down, which sub-tab — owns a
|
|
5154
|
+
* field. The walk used to check top-level `fields` only, so every field in a sub-tab was
|
|
5155
|
+
* simply not found and the jump did nothing at all.
|
|
5156
|
+
*/
|
|
5157
|
+
locateField(fieldId) {
|
|
4624
5158
|
for (const tab of this.config?.tabs || []) {
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
4632
|
-
el.focus();
|
|
4633
|
-
}
|
|
4634
|
-
}, 50);
|
|
4635
|
-
break;
|
|
5159
|
+
if ((tab.fields || []).some(f => f.id === fieldId))
|
|
5160
|
+
return { tabId: tab.id };
|
|
5161
|
+
for (const child of tab.children || []) {
|
|
5162
|
+
if ((child.fields || []).some(f => f.id === fieldId)) {
|
|
5163
|
+
return { tabId: tab.id, subTabId: child.id };
|
|
5164
|
+
}
|
|
4636
5165
|
}
|
|
4637
5166
|
}
|
|
5167
|
+
return null;
|
|
5168
|
+
}
|
|
5169
|
+
/**
|
|
5170
|
+
* Switches to the tab holding `fieldId`, then scrolls it into view and moves focus to it.
|
|
5171
|
+
*
|
|
5172
|
+
* The wait for the new tab to render used to be a 50 ms `setTimeout`, which was wrong three
|
|
5173
|
+
* ways: `document` is undefined on a server render, 50 ms is a guess that a large tab can
|
|
5174
|
+
* outrun, and nothing cancelled the timer if the component went away first.
|
|
5175
|
+
* `afterNextRender` fixes all three — it never runs on the server, it runs when the panel
|
|
5176
|
+
* has actually rendered rather than when a guess expires, and it is tied to the injector so
|
|
5177
|
+
* destroying the component cancels it. The query is scoped to this component's own element,
|
|
5178
|
+
* so the library no longer reaches for the global `document` at all.
|
|
5179
|
+
*/
|
|
5180
|
+
jumpToField(fieldId) {
|
|
5181
|
+
const location = this.locateField(fieldId);
|
|
5182
|
+
if (!location)
|
|
5183
|
+
return;
|
|
5184
|
+
this.dynamicFormComp?.setActiveTab(location.tabId);
|
|
5185
|
+
// `setActiveTab` resets to a tab's first child, so the sub-tab has to be selected after
|
|
5186
|
+
// it, not before. Without this the walk found sub-tab fields and then rendered the wrong
|
|
5187
|
+
// panel, so the element the callback below looks for never existed.
|
|
5188
|
+
if (location.subTabId)
|
|
5189
|
+
this.dynamicFormComp?.setActiveSubTab(location.subTabId);
|
|
5190
|
+
afterNextRender(() => {
|
|
5191
|
+
// The field id comes from config, so it never goes into a selector string: no
|
|
5192
|
+
// escaping to get wrong, and no need for `CSS.escape`, which jsdom and older
|
|
5193
|
+
// browsers do not provide and whose absence throws silently inside a render hook.
|
|
5194
|
+
const wanted = `field-container-${fieldId}`;
|
|
5195
|
+
const el = Array.from(this.host.nativeElement.querySelectorAll('[id^="field-container-"]')).find(slot => slot.id === wanted);
|
|
5196
|
+
if (!el)
|
|
5197
|
+
return;
|
|
5198
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
5199
|
+
// The slot carries tabindex="-1" so this actually moves focus. Without it the jump
|
|
5200
|
+
// scrolled the field into view and left focus behind on the link.
|
|
5201
|
+
el.focus();
|
|
5202
|
+
}, { injector: this.injector });
|
|
4638
5203
|
}
|
|
4639
5204
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicRecordFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4640
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
|
|
5205
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4641
5206
|
}
|
|
4642
5207
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicRecordFormComponent, decorators: [{
|
|
4643
5208
|
type: Component,
|
|
4644
|
-
args: [{ selector: 'ngx-dynamic-record-form', standalone: true, imports: [CommonModule, ReactiveFormsModule, DynamicFormComponent, DynamicFieldComponent], template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"] }]
|
|
5209
|
+
args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-record-form', standalone: true, imports: [CommonModule, ReactiveFormsModule, DynamicFormComponent, DynamicFieldComponent], template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"] }]
|
|
4645
5210
|
}], propDecorators: { config: [{
|
|
4646
5211
|
type: Input
|
|
4647
5212
|
}], rules: [{
|
|
@@ -4683,5 +5248,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
4683
5248
|
* Generated bundle index. Do not edit.
|
|
4684
5249
|
*/
|
|
4685
5250
|
|
|
4686
|
-
export { ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DateFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MASKED_ROLES, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, UPLOAD_HANDLER, VALIDATOR_REGISTRY, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
|
|
5251
|
+
export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MASKED_ROLES, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UPLOAD_HANDLER, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
|
|
4687
5252
|
//# sourceMappingURL=ngx-dynamic-entity.mjs.map
|