@ng-simplicity/forms-core 1.1.3 → 1.1.5
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 +24 -3
- package/fesm2022/ng-simplicity-forms-core.mjs +556 -71
- package/fesm2022/ng-simplicity-forms-core.mjs.map +1 -1
- package/index.d.ts +237 -41
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { Subject, takeUntil, BehaviorSubject, map,
|
|
1
|
+
import { Subject, takeUntil, BehaviorSubject, switchMap, defer, map, distinctUntilChanged, merge, of } from 'rxjs';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
|
-
import { Directive, InjectionToken, inject, Injectable, ChangeDetectorRef, Injector, ViewContainerRef, Input, computed, ChangeDetectionStrategy, Component, ElementRef, Sanitizer, SecurityContext, NgModule } from '@angular/core';
|
|
3
|
+
import { Directive, InjectionToken, inject, Injectable, ChangeDetectorRef, Injector, ViewContainerRef, Input, computed, ChangeDetectionStrategy, Component, ElementRef, afterNextRender, Sanitizer, SecurityContext, NgModule } from '@angular/core';
|
|
4
4
|
import { v4 } from 'uuid';
|
|
5
|
+
import { FormArray, FormGroup, UntypedFormGroup, FormControl, Validators, UntypedFormArray, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|
5
6
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
6
|
-
import { UntypedFormGroup, FormControl, FormGroup, FormArray, UntypedFormArray, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|
7
7
|
import { CommonModule } from '@angular/common';
|
|
8
8
|
|
|
9
9
|
/** Base class that ties RxJS subscriptions to the component's lifetime. */
|
|
@@ -32,8 +32,18 @@ const NGS_FORMS_ITEM_DATA = new InjectionToken('ngs-forms-item-data');
|
|
|
32
32
|
* @internal
|
|
33
33
|
*/
|
|
34
34
|
const NGS_FORMS_CONTROL_ADD_REMOVE_FN = new InjectionToken('ngs-form-control-remove-fn');
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Injection token for the row index when a component is rendered inside a form array list.
|
|
37
|
+
*
|
|
38
|
+
* @remarks This is the index the row had when the component was created. Rows before it can be removed later,
|
|
39
|
+
* so to find a row's current position use `NGS_FORMS_ARRAY_ROW` with `formArray.controls.indexOf(row)`.
|
|
40
|
+
*/
|
|
36
41
|
const NGS_FORMS_ITEM_INDEX = new InjectionToken('ngs-forms-item-index');
|
|
42
|
+
/**
|
|
43
|
+
* Injection token for the `UntypedFormGroup` of the form array row a component is rendered in. Unlike
|
|
44
|
+
* `NGS_FORMS_ITEM_INDEX`, it stays correct when earlier rows are removed.
|
|
45
|
+
*/
|
|
46
|
+
const NGS_FORMS_ARRAY_ROW = new InjectionToken('ngs-forms-array-row');
|
|
37
47
|
/**
|
|
38
48
|
* @internal
|
|
39
49
|
*/
|
|
@@ -43,10 +53,12 @@ const NgsFormsFormControlAddRemoveFunctions = {
|
|
|
43
53
|
remove: (control, controlName) => formGroup.removeControl(controlName),
|
|
44
54
|
}),
|
|
45
55
|
formArray: (formArray) => ({
|
|
46
|
-
add: (control
|
|
47
|
-
remove: (control
|
|
56
|
+
add: (control) => formArray.push(control),
|
|
57
|
+
remove: (control) => {
|
|
48
58
|
const index = formArray.controls.indexOf(control);
|
|
49
|
-
|
|
59
|
+
if (index >= 0) {
|
|
60
|
+
formArray.removeAt(index);
|
|
61
|
+
}
|
|
50
62
|
},
|
|
51
63
|
}),
|
|
52
64
|
};
|
|
@@ -65,6 +77,202 @@ function ngsDefaults(target, ...sources) {
|
|
|
65
77
|
return result;
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Builds the object for `NgsFormsService.setServerErrorMessages()` from a flat list of paths and error keys, for
|
|
82
|
+
* APIs that return their errors that way. Array indexes create arrays, and rows without errors are filled with `{}`.
|
|
83
|
+
* Two errors on the same path become an array of keys.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* constructServerErrorMessageObject([
|
|
88
|
+
* { jsonPath: 'registration.userName', validator: 'taken' },
|
|
89
|
+
* { jsonPath: 'registration.contacts[2].phone', validator: 'invalidPhone' },
|
|
90
|
+
* ]);
|
|
91
|
+
* // { registration: { userName: 'taken', contacts: [{}, {}, { phone: 'invalidPhone' }] } }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
function constructServerErrorMessageObject(errors) {
|
|
95
|
+
const result = {};
|
|
96
|
+
for (const { jsonPath, validator } of errors) {
|
|
97
|
+
const segments = parseJsonPath(jsonPath);
|
|
98
|
+
if (!segments.length || !validator)
|
|
99
|
+
continue;
|
|
100
|
+
let node = result;
|
|
101
|
+
segments.forEach((segment, i) => {
|
|
102
|
+
const isLast = i === segments.length - 1;
|
|
103
|
+
if (Array.isArray(node) && typeof segment === 'number') {
|
|
104
|
+
while (node.length < segment)
|
|
105
|
+
node.push({});
|
|
106
|
+
}
|
|
107
|
+
if (isLast) {
|
|
108
|
+
const existing = node[segment];
|
|
109
|
+
if (typeof existing === 'string') {
|
|
110
|
+
node[segment] = existing === validator ? existing : [existing, validator];
|
|
111
|
+
}
|
|
112
|
+
else if (Array.isArray(existing) && existing.every((e) => typeof e === 'string')) {
|
|
113
|
+
if (!existing.includes(validator))
|
|
114
|
+
existing.push(validator);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
node[segment] = validator;
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const nextIsIndex = typeof segments[i + 1] === 'number';
|
|
122
|
+
const existing = node[segment];
|
|
123
|
+
if (existing === undefined || existing === null || typeof existing !== 'object') {
|
|
124
|
+
node[segment] = nextIsIndex ? [] : {};
|
|
125
|
+
}
|
|
126
|
+
else if (nextIsIndex && !Array.isArray(existing) && Object.keys(existing).length === 0) {
|
|
127
|
+
// A `{}` placeholder for an array row that is itself an array.
|
|
128
|
+
node[segment] = [];
|
|
129
|
+
}
|
|
130
|
+
node = node[segment];
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
/** Splits `a.b[2]['c d']` into `['a', 'b', 2, 'c d']`. */
|
|
136
|
+
function parseJsonPath(jsonPath) {
|
|
137
|
+
const path = (jsonPath ?? '').trim().replace(/^\$\.?/, '');
|
|
138
|
+
const segments = [];
|
|
139
|
+
const pattern = /\[\s*(\d+)\s*\]|\[\s*(['"])(.*?)\2\s*\]|([^.[\]]+)/g;
|
|
140
|
+
let match;
|
|
141
|
+
while ((match = pattern.exec(path)) !== null) {
|
|
142
|
+
if (match[1] !== undefined)
|
|
143
|
+
segments.push(Number(match[1]));
|
|
144
|
+
else if (match[3] !== undefined)
|
|
145
|
+
segments.push(match[3]);
|
|
146
|
+
else if (match[4] !== undefined)
|
|
147
|
+
segments.push(match[4]);
|
|
148
|
+
}
|
|
149
|
+
return segments;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Marker on the error value of a server error (`control.errors[key][NGS_FORMS_SERVER_ERROR] === true`). When the
|
|
153
|
+
* key has no entry in `errorMessageMap`, the key itself is shown as the message, so servers can send message text.
|
|
154
|
+
*/
|
|
155
|
+
const NGS_FORMS_SERVER_ERROR = 'ngsServerError';
|
|
156
|
+
/**
|
|
157
|
+
* Applies server errors to a form and clears each one when its control's value changes.
|
|
158
|
+
*
|
|
159
|
+
* @internal Used by `NgsFormsService`.
|
|
160
|
+
*/
|
|
161
|
+
class NgsFormsServerErrorRegistry {
|
|
162
|
+
entries = new Map();
|
|
163
|
+
/**
|
|
164
|
+
* Validator that reports a control's server errors. Form items include it whenever they set their validators,
|
|
165
|
+
* so the errors survive re-validation until the control's value changes.
|
|
166
|
+
*/
|
|
167
|
+
validator = (control) => this.validate(control);
|
|
168
|
+
/** `true` while at least one server error is set. */
|
|
169
|
+
get hasErrors() {
|
|
170
|
+
return this.entries.size > 0;
|
|
171
|
+
}
|
|
172
|
+
/** Replaces every server error with `errors`. Returns the paths that matched no control. */
|
|
173
|
+
set(root, errors) {
|
|
174
|
+
this.clear();
|
|
175
|
+
const unmatched = [];
|
|
176
|
+
this.apply(root, errors, '', unmatched);
|
|
177
|
+
return unmatched;
|
|
178
|
+
}
|
|
179
|
+
/** Removes every server error. */
|
|
180
|
+
clear() {
|
|
181
|
+
if (!this.entries.size)
|
|
182
|
+
return;
|
|
183
|
+
const controls = Array.from(this.entries.keys());
|
|
184
|
+
this.entries.clear();
|
|
185
|
+
controls.forEach((control) => control.updateValueAndValidity());
|
|
186
|
+
}
|
|
187
|
+
/** Forgets every server error without re-validating, for when the controls are being thrown away. */
|
|
188
|
+
reset() {
|
|
189
|
+
this.entries.clear();
|
|
190
|
+
}
|
|
191
|
+
apply(control, errors, path, unmatched) {
|
|
192
|
+
if (errors === null || errors === undefined)
|
|
193
|
+
return;
|
|
194
|
+
if (typeof errors === 'string' || (Array.isArray(errors) && errors.length > 0 && errors.every((e) => typeof e === 'string'))) {
|
|
195
|
+
this.applyKeys(control, typeof errors === 'string' ? [errors] : errors);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(errors)) {
|
|
199
|
+
if (!(control instanceof FormArray)) {
|
|
200
|
+
if (errors.some((e) => !isEmpty(e)))
|
|
201
|
+
unmatched.push(path);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
errors.forEach((rowErrors, index) => {
|
|
205
|
+
if (isEmpty(rowErrors))
|
|
206
|
+
return;
|
|
207
|
+
const row = control.at(index);
|
|
208
|
+
const rowPath = `${path}[${index}]`;
|
|
209
|
+
if (!row) {
|
|
210
|
+
unmatched.push(rowPath);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.apply(row, rowErrors, rowPath, unmatched);
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!(control instanceof FormGroup)) {
|
|
218
|
+
if (!isEmpty(errors))
|
|
219
|
+
unmatched.push(path);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (const [name, childErrors] of Object.entries(errors)) {
|
|
223
|
+
if (isEmpty(childErrors))
|
|
224
|
+
continue;
|
|
225
|
+
const childPath = path ? `${path}.${name}` : name;
|
|
226
|
+
const child = control.get([name]);
|
|
227
|
+
if (!child) {
|
|
228
|
+
unmatched.push(childPath);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
this.apply(child, childErrors, childPath, unmatched);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
applyKeys(control, keys) {
|
|
235
|
+
this.entries.set(control, { keys, value: control.value });
|
|
236
|
+
if (!control.hasValidator(this.validator)) {
|
|
237
|
+
control.addValidators(this.validator);
|
|
238
|
+
}
|
|
239
|
+
// Show the message straight away, without waiting for the user to touch the field.
|
|
240
|
+
control.markAsTouched();
|
|
241
|
+
control.updateValueAndValidity();
|
|
242
|
+
}
|
|
243
|
+
validate(control) {
|
|
244
|
+
const entry = this.entries.get(control);
|
|
245
|
+
if (!entry)
|
|
246
|
+
return null;
|
|
247
|
+
if (!sameValue(control.value, entry.value)) {
|
|
248
|
+
this.entries.delete(control);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
return Object.fromEntries(entry.keys.map((key) => [key, { [NGS_FORMS_SERVER_ERROR]: true }]));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function isEmpty(errors) {
|
|
255
|
+
if (errors === null || errors === undefined || errors === '')
|
|
256
|
+
return true;
|
|
257
|
+
if (Array.isArray(errors))
|
|
258
|
+
return errors.every(isEmpty);
|
|
259
|
+
if (typeof errors === 'object')
|
|
260
|
+
return Object.values(errors).every(isEmpty);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
function sameValue(a, b) {
|
|
264
|
+
if (Object.is(a, b))
|
|
265
|
+
return true;
|
|
266
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
267
|
+
return false;
|
|
268
|
+
try {
|
|
269
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
68
276
|
/**
|
|
69
277
|
* Root base class for every form item component. Reads the item's configuration from `NGS_FORMS_ITEM_DATA`.
|
|
70
278
|
*
|
|
@@ -148,10 +356,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
|
|
|
148
356
|
* ```
|
|
149
357
|
*/
|
|
150
358
|
class NgsFormsService {
|
|
151
|
-
/**
|
|
152
|
-
* @internal
|
|
153
|
-
*/
|
|
154
|
-
attempt = 0;
|
|
155
359
|
/**
|
|
156
360
|
* Emits the root form group's value every time it changes. Combine with `toSignal()` to read it in a template.
|
|
157
361
|
*
|
|
@@ -161,8 +365,20 @@ class NgsFormsService {
|
|
|
161
365
|
/** The latest value emitted by `formValue$`. */
|
|
162
366
|
formValue = {};
|
|
163
367
|
internalFormService;
|
|
164
|
-
|
|
368
|
+
formGroupBinding;
|
|
165
369
|
formGroup = new UntypedFormGroup({});
|
|
370
|
+
/** The config from the latest `setFormConfig()` call, re-applied when `<ngs-form>` (re)connects. */
|
|
371
|
+
lastConfig;
|
|
372
|
+
missingFormTimer;
|
|
373
|
+
destroyed = false;
|
|
374
|
+
/** Patches still being applied while the form renders, in call order. */
|
|
375
|
+
pendingPatches = [];
|
|
376
|
+
patchTimer;
|
|
377
|
+
/** The controls that existed when the pending patches were last applied. */
|
|
378
|
+
patchedStructure;
|
|
379
|
+
patchRounds = 0;
|
|
380
|
+
/** Render passes in a row in which no control was added or removed. */
|
|
381
|
+
stablePatchRounds = 0;
|
|
166
382
|
/** `true` once the user has changed any control in the form. */
|
|
167
383
|
get dirty() {
|
|
168
384
|
return this.formGroup?.dirty ?? false;
|
|
@@ -180,27 +396,53 @@ class NgsFormsService {
|
|
|
180
396
|
/**
|
|
181
397
|
* Renders a form from `formConfig`, replacing any form that is already shown and resetting the submitted state.
|
|
182
398
|
*
|
|
183
|
-
* @remarks It is safe to call before `<ngs-form>` has initialised (for example in `ngOnInit`): the
|
|
184
|
-
*
|
|
399
|
+
* @remarks It is safe to call before `<ngs-form>` has initialised (for example in `ngOnInit`): the latest config
|
|
400
|
+
* is kept and applied as soon as the form component connects, and also when it is destroyed and created again
|
|
401
|
+
* (inside an `@if` or a tab, for example). An error is logged if no form has connected after half a second.
|
|
185
402
|
*/
|
|
186
403
|
setFormConfig(formConfig) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.attempt++;
|
|
193
|
-
setTimeout(() => this.setFormConfig(formConfig), 25);
|
|
404
|
+
this.lastConfig = formConfig;
|
|
405
|
+
// Patches made for the previous form do not carry over. Patches made after this call are kept until it renders.
|
|
406
|
+
this.cancelPendingPatches();
|
|
407
|
+
if (this.internalServiceIsSet) {
|
|
408
|
+
this.internalFormService.setFormData(formConfig);
|
|
194
409
|
return;
|
|
195
410
|
}
|
|
196
|
-
this.
|
|
411
|
+
if (this.missingFormTimer === undefined && !this.destroyed) {
|
|
412
|
+
this.missingFormTimer = setTimeout(() => {
|
|
413
|
+
this.missingFormTimer = undefined;
|
|
414
|
+
if (!this.internalServiceIsSet && !this.destroyed) {
|
|
415
|
+
console.error('Internal form service is unavailable. Is the ngs-form root tag added to the page?');
|
|
416
|
+
}
|
|
417
|
+
}, 500);
|
|
418
|
+
}
|
|
197
419
|
}
|
|
198
420
|
/**
|
|
199
421
|
* @internal
|
|
200
422
|
*/
|
|
201
423
|
setInternalService(internalFormsService) {
|
|
424
|
+
this.formGroupBinding?.unsubscribe();
|
|
202
425
|
this.internalFormService = internalFormsService;
|
|
203
426
|
this.bindFormGroup();
|
|
427
|
+
if (this.lastConfig) {
|
|
428
|
+
internalFormsService.setFormData(this.lastConfig);
|
|
429
|
+
}
|
|
430
|
+
if (this.pendingPatches.length) {
|
|
431
|
+
this.schedulePatchRound();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* @internal
|
|
436
|
+
* Disconnects `internalFormsService` when its `<ngs-form>` is destroyed. Does nothing if another form has
|
|
437
|
+
* connected since.
|
|
438
|
+
*/
|
|
439
|
+
clearInternalService(internalFormsService) {
|
|
440
|
+
if (this.internalFormService !== internalFormsService)
|
|
441
|
+
return;
|
|
442
|
+
this.formGroupBinding?.unsubscribe();
|
|
443
|
+
this.formGroupBinding = undefined;
|
|
444
|
+
this.internalFormService = undefined;
|
|
445
|
+
this.formGroup = new UntypedFormGroup({});
|
|
204
446
|
}
|
|
205
447
|
/**
|
|
206
448
|
* Marks the form as submitted (or not). While submitted, every invalid control shows its error message even if
|
|
@@ -212,13 +454,14 @@ class NgsFormsService {
|
|
|
212
454
|
this.internalFormService.setIsSubmitted(isSubmitted);
|
|
213
455
|
}
|
|
214
456
|
bindFormGroup() {
|
|
215
|
-
this.internalFormService.formGroup
|
|
457
|
+
this.formGroupBinding = this.internalFormService.formGroup$
|
|
458
|
+
.pipe(switchMap((fg) => {
|
|
216
459
|
this.formGroup = fg;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
460
|
+
return fg.valueChanges;
|
|
461
|
+
}))
|
|
462
|
+
.subscribe((v) => {
|
|
463
|
+
this.formValue = v;
|
|
464
|
+
this.formValue$.next(v);
|
|
222
465
|
});
|
|
223
466
|
}
|
|
224
467
|
/**
|
|
@@ -233,16 +476,106 @@ class NgsFormsService {
|
|
|
233
476
|
this.internalFormService?.updateGlobalState(state);
|
|
234
477
|
}
|
|
235
478
|
ngOnDestroy() {
|
|
236
|
-
this.
|
|
479
|
+
this.destroyed = true;
|
|
480
|
+
clearTimeout(this.missingFormTimer);
|
|
481
|
+
this.missingFormTimer = undefined;
|
|
482
|
+
this.cancelPendingPatches();
|
|
483
|
+
this.formGroupBinding?.unsubscribe();
|
|
484
|
+
this.formValue$.complete();
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Shows errors from the server on the matching controls, replacing any server errors shown before.
|
|
488
|
+
*
|
|
489
|
+
* @param errors - An object shaped like the form value (including the root group name) whose leaves are error
|
|
490
|
+
* keys, for example `{ registration: { userName: 'taken', contacts: [{}, {}, { phone: 'invalid' }] } }`. Each key
|
|
491
|
+
* is looked up in that control's `errorMessageMap`; when it has no entry the key itself is shown, so the server
|
|
492
|
+
* can also send the message text. Use `constructServerErrorMessageObject()` to build it from a list of paths.
|
|
493
|
+
* @returns The paths that matched no rendered control (a hidden item or a missing array row, for example), so you
|
|
494
|
+
* can show those errors elsewhere.
|
|
495
|
+
*
|
|
496
|
+
* @remarks The controls are marked as touched so the messages show at once, and they stay invalid until their
|
|
497
|
+
* value changes. All server errors are cleared by the next call, by `clearServerErrorMessages()`, by
|
|
498
|
+
* `setFormConfig()`, and when rows are added to or removed from a form array, because rows are matched by index.
|
|
499
|
+
*/
|
|
500
|
+
setServerErrorMessages(errors) {
|
|
501
|
+
if (!this.internalFormService) {
|
|
502
|
+
console.error('setServerErrorMessages() was called before <ngs-form> connected.');
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
return this.internalFormService.serverErrors.set(this.formGroup, errors);
|
|
506
|
+
}
|
|
507
|
+
/** Removes every error set with `setServerErrorMessages()`. */
|
|
508
|
+
clearServerErrorMessages() {
|
|
509
|
+
this.internalFormService?.serverErrors.clear();
|
|
237
510
|
}
|
|
238
511
|
/**
|
|
239
512
|
* Patches values into the form, using the same shape as `formValue$` (including the root group name).
|
|
240
513
|
*
|
|
241
|
-
* @remarks
|
|
242
|
-
*
|
|
514
|
+
* @remarks It is safe to call straight after `setFormConfig()`, or before `<ngs-form>` has connected. The values
|
|
515
|
+
* are applied at once to the controls that exist, kept, and applied again each time new controls appear, until
|
|
516
|
+
* the form stops changing. That covers items that render later and items behind `visible$` that the patched values
|
|
517
|
+
* themselves reveal (and anything those reveal in turn). Later calls are applied after earlier ones. A form array
|
|
518
|
+
* only receives values for rows it has, so give it enough rows with `initialItemCount`. `setFormConfig()` drops
|
|
519
|
+
* patches that are still being applied to the previous form.
|
|
243
520
|
*/
|
|
244
521
|
patchValue(entityData) {
|
|
245
|
-
this.
|
|
522
|
+
this.pendingPatches.push(entityData);
|
|
523
|
+
this.applyPendingPatches();
|
|
524
|
+
this.patchRounds = 0;
|
|
525
|
+
this.stablePatchRounds = 0;
|
|
526
|
+
this.schedulePatchRound();
|
|
527
|
+
}
|
|
528
|
+
/** Maximum number of render passes a patch waits for new controls before it is dropped. */
|
|
529
|
+
static MAX_PATCH_ROUNDS = 20;
|
|
530
|
+
applyPendingPatches() {
|
|
531
|
+
for (const patch of this.pendingPatches) {
|
|
532
|
+
this.formGroup.patchValue(patch);
|
|
533
|
+
}
|
|
534
|
+
this.patchedStructure = this.internalServiceIsSet ? controlStructure(this.formGroup) : undefined;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Waits one render pass, then re-applies the pending patches if controls were added or removed. Stops once two
|
|
538
|
+
* passes in a row change nothing (rendering can take a pass to start), or after `MAX_PATCH_ROUNDS` passes. Waits
|
|
539
|
+
* without counting while no form is connected.
|
|
540
|
+
*/
|
|
541
|
+
schedulePatchRound() {
|
|
542
|
+
clearTimeout(this.patchTimer);
|
|
543
|
+
if (this.destroyed || !this.pendingPatches.length)
|
|
544
|
+
return;
|
|
545
|
+
if (!this.internalServiceIsSet) {
|
|
546
|
+
// `setInternalService()` starts the rounds once <ngs-form> connects.
|
|
547
|
+
this.patchTimer = undefined;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
this.patchTimer = setTimeout(() => {
|
|
551
|
+
this.patchTimer = undefined;
|
|
552
|
+
if (this.destroyed || !this.internalServiceIsSet)
|
|
553
|
+
return;
|
|
554
|
+
const structure = controlStructure(this.formGroup);
|
|
555
|
+
if (structure === this.patchedStructure) {
|
|
556
|
+
if (++this.stablePatchRounds >= 2) {
|
|
557
|
+
this.cancelPendingPatches();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.schedulePatchRound();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
this.stablePatchRounds = 0;
|
|
564
|
+
this.applyPendingPatches();
|
|
565
|
+
if (++this.patchRounds >= NgsFormsService.MAX_PATCH_ROUNDS) {
|
|
566
|
+
this.cancelPendingPatches();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.schedulePatchRound();
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
cancelPendingPatches() {
|
|
573
|
+
clearTimeout(this.patchTimer);
|
|
574
|
+
this.patchTimer = undefined;
|
|
575
|
+
this.pendingPatches = [];
|
|
576
|
+
this.patchedStructure = undefined;
|
|
577
|
+
this.patchRounds = 0;
|
|
578
|
+
this.stablePatchRounds = 0;
|
|
246
579
|
}
|
|
247
580
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
248
581
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsService });
|
|
@@ -250,6 +583,18 @@ class NgsFormsService {
|
|
|
250
583
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsService, decorators: [{
|
|
251
584
|
type: Injectable
|
|
252
585
|
}] });
|
|
586
|
+
/** A string listing the paths of every control under `control`, so a change in the form's controls can be seen. */
|
|
587
|
+
function controlStructure(control, path = '') {
|
|
588
|
+
if (control instanceof FormGroup) {
|
|
589
|
+
return Object.keys(control.controls)
|
|
590
|
+
.map((name) => controlStructure(control.controls[name], `${path}.${name}`))
|
|
591
|
+
.join('|') + `|${path}{}`;
|
|
592
|
+
}
|
|
593
|
+
if (control instanceof FormArray) {
|
|
594
|
+
return control.controls.map((row, i) => controlStructure(row, `${path}[${i}]`)).join('|') + `|${path}[]`;
|
|
595
|
+
}
|
|
596
|
+
return path;
|
|
597
|
+
}
|
|
253
598
|
|
|
254
599
|
/**
|
|
255
600
|
* Per form state holder that connects `<ngs-form>`, its items and `NgsFormsService`.
|
|
@@ -262,18 +607,23 @@ class NgsFormsInternalService {
|
|
|
262
607
|
formConfig$ = new BehaviorSubject(undefined);
|
|
263
608
|
isSubmitted$ = new BehaviorSubject(false);
|
|
264
609
|
state = new BehaviorSubject({});
|
|
610
|
+
/** Server errors set with `NgsFormsService.setServerErrorMessages()`. */
|
|
611
|
+
serverErrors = new NgsFormsServerErrorRegistry();
|
|
265
612
|
setIsSubmitted(isSubmitted) {
|
|
266
613
|
this.isSubmitted$.next(isSubmitted);
|
|
267
614
|
}
|
|
615
|
+
/** `true` when no rendered control fails its validators. A form whose controls are all disabled counts as valid. */
|
|
268
616
|
checkIsValid() {
|
|
269
|
-
|
|
617
|
+
const formGroup = this.formGroup$.value;
|
|
618
|
+
if (!formGroup)
|
|
270
619
|
return false;
|
|
271
|
-
return
|
|
620
|
+
return formGroup.valid || formGroup.disabled === true;
|
|
272
621
|
}
|
|
273
622
|
setFormData(formConfig) {
|
|
274
623
|
const state = { ...(formConfig.initialState ?? {}) };
|
|
275
624
|
state['global'] = { ...(formConfig.globalState ?? {}) };
|
|
276
625
|
this.state.next(state);
|
|
626
|
+
this.serverErrors.reset();
|
|
277
627
|
this.formGroup$.next(new UntypedFormGroup({}));
|
|
278
628
|
this.formConfig$.next(formConfig);
|
|
279
629
|
this.isSubmitted$.next(false);
|
|
@@ -302,18 +652,13 @@ class NgsFormsInternalService {
|
|
|
302
652
|
*/
|
|
303
653
|
subscribeToState(uuid, name, initialState) {
|
|
304
654
|
this.seedComponentState(uuid, initialState);
|
|
305
|
-
|
|
306
|
-
return this.state.pipe(map((allState) => {
|
|
655
|
+
// `defer` gives every subscriber its own comparison, so each one receives the current state first.
|
|
656
|
+
return defer(() => this.state.pipe(map((allState) => {
|
|
307
657
|
const stateByUuid = allState[uuid] || {};
|
|
308
658
|
const stateByName = name ? (allState[name] || {}) : {};
|
|
309
659
|
const globalState = allState['global'] || {};
|
|
310
660
|
return ngsDefaults(stateByUuid, ngsDefaults(stateByName, globalState));
|
|
311
|
-
}),
|
|
312
|
-
const stringifiedState = JSON.stringify(currentState);
|
|
313
|
-
const isUpdated = stringifiedState !== last;
|
|
314
|
-
last = stringifiedState;
|
|
315
|
-
return isUpdated;
|
|
316
|
-
}));
|
|
661
|
+
}), distinctUntilChanged((previous, current) => JSON.stringify(previous) === JSON.stringify(current))));
|
|
317
662
|
}
|
|
318
663
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsInternalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
319
664
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsInternalService });
|
|
@@ -326,6 +671,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
|
|
|
326
671
|
* Base class for items that own a form control. Creates the control (seeded with `value` when the config has one),
|
|
327
672
|
* applies `validators` / `validators$` and `disabled` / `disabled$`, registers it with the parent group under
|
|
328
673
|
* `config.name` and keeps `errorMessage` up to date.
|
|
674
|
+
*
|
|
675
|
+
* @typeParam T - The item's config type.
|
|
676
|
+
* @typeParam C - The type of `control`. Inputs use the default `UntypedFormControl`; groups and arrays set it in
|
|
677
|
+
* their constructor to an `UntypedFormGroup` or `UntypedFormArray`.
|
|
329
678
|
*/
|
|
330
679
|
class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassFormComponent {
|
|
331
680
|
/** The item's form control. Bind it with `[formControl]`. */
|
|
@@ -347,15 +696,24 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
|
|
|
347
696
|
const initialValue = this.config.value;
|
|
348
697
|
this.control = new FormControl(initialValue, { validators: [] });
|
|
349
698
|
}
|
|
699
|
+
const control = this.control;
|
|
350
700
|
this.bindValidators();
|
|
351
|
-
this.formAddRemoveFns.add(
|
|
701
|
+
this.formAddRemoveFns.add(control, this.config.name);
|
|
352
702
|
this.bindDisabled();
|
|
353
|
-
//
|
|
354
|
-
|
|
703
|
+
// Recompute the error message on value, status and touched changes, and when the form is submitted.
|
|
704
|
+
// `control.events` (Angular 18+) includes touched changes, so blurring an untouched field shows its error.
|
|
705
|
+
// Angular 17 has no `events`, so there the message updates on value and status changes only.
|
|
706
|
+
const controlEvents$ = control.events ?? merge(control.valueChanges, control.statusChanges);
|
|
707
|
+
this.subscribe(merge(controlEvents$, this.privateService.isSubmitted$), () => {
|
|
355
708
|
this.updateErrorMessage();
|
|
356
709
|
this.changeDetectorRef.markForCheck();
|
|
357
710
|
});
|
|
358
711
|
}
|
|
712
|
+
/** Whether the control currently has `Validators.required` or `Validators.requiredTrue`. */
|
|
713
|
+
get isRequired() {
|
|
714
|
+
const control = this.control;
|
|
715
|
+
return !!control && (control.hasValidator(Validators.required) || control.hasValidator(Validators.requiredTrue));
|
|
716
|
+
}
|
|
359
717
|
updateErrorMessage() {
|
|
360
718
|
if (!this.control) {
|
|
361
719
|
this.errorMessage = '';
|
|
@@ -373,21 +731,34 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
|
|
|
373
731
|
}
|
|
374
732
|
const firstErrorKey = Object.keys(errors)[0];
|
|
375
733
|
const map = this.config.errorMessageMap;
|
|
376
|
-
|
|
734
|
+
const isServerError = errors[firstErrorKey]?.[NGS_FORMS_SERVER_ERROR] === true;
|
|
735
|
+
// A server error without a mapped message shows the server's text.
|
|
736
|
+
this.errorMessage = map?.[firstErrorKey] || (isServerError ? firstErrorKey : `Field is invalid: ${firstErrorKey}`);
|
|
377
737
|
}
|
|
378
738
|
ngOnDestroy() {
|
|
379
739
|
super.ngOnDestroy();
|
|
380
740
|
this.formAddRemoveFns.remove(this.control, this.config.name);
|
|
381
741
|
}
|
|
382
742
|
bindValidators() {
|
|
383
|
-
|
|
743
|
+
const control = this.control;
|
|
744
|
+
// The server error validator is always kept, so server errors survive until the control's value changes.
|
|
745
|
+
// Optional so that tests which stub `NgsFormsInternalService` keep working.
|
|
746
|
+
const serverValidators = this.privateService.serverErrors ? [this.privateService.serverErrors.validator] : [];
|
|
747
|
+
if (!this.config.validators && !this.config.validators$) {
|
|
748
|
+
control.addValidators(serverValidators);
|
|
384
749
|
return;
|
|
750
|
+
}
|
|
385
751
|
if (this.config.validators) {
|
|
386
|
-
|
|
752
|
+
control.setValidators([...this.config.validators, ...serverValidators]);
|
|
753
|
+
// A group or array computed its status when it was created, before these validators were set.
|
|
754
|
+
control.updateValueAndValidity({ emitEvent: false });
|
|
387
755
|
return;
|
|
388
756
|
}
|
|
389
757
|
if (this.config.validators$) {
|
|
390
|
-
this.subscribe(this.config.validators$, (validators) =>
|
|
758
|
+
this.subscribe(this.config.validators$, (validators) => {
|
|
759
|
+
control.setValidators([...validators, ...serverValidators]);
|
|
760
|
+
control.updateValueAndValidity();
|
|
761
|
+
});
|
|
391
762
|
}
|
|
392
763
|
}
|
|
393
764
|
bindDisabled() {
|
|
@@ -399,14 +770,21 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
|
|
|
399
770
|
this.disabledFromConfig = !!disabled;
|
|
400
771
|
this.applyDisabled(control);
|
|
401
772
|
});
|
|
773
|
+
const reapply = () => {
|
|
774
|
+
if (this.disabledFromConfig && control.enabled) {
|
|
775
|
+
control.disable();
|
|
776
|
+
}
|
|
777
|
+
};
|
|
402
778
|
// Child controls are added to groups and arrays after the container has been disabled, and adding an enabled
|
|
403
779
|
// child re-enables its parent. Re-apply the configured state whenever a container's status changes.
|
|
404
780
|
if (control instanceof FormGroup || control instanceof FormArray) {
|
|
405
|
-
this.subscribe(control.statusChanges,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
781
|
+
this.subscribe(control.statusChanges, reapply);
|
|
782
|
+
}
|
|
783
|
+
// Enabling a parent group or array enables every child in it. The parent emits its status after its children
|
|
784
|
+
// are enabled, so re-apply the configured state then. (Doing it on the child's own status change would be undone
|
|
785
|
+
// by the end of the child's `enable()`, which re-enables the bound DOM element.)
|
|
786
|
+
if (control.parent) {
|
|
787
|
+
this.subscribe(control.parent.statusChanges, reapply);
|
|
410
788
|
}
|
|
411
789
|
}
|
|
412
790
|
applyDisabled(control) {
|
|
@@ -485,10 +863,13 @@ class NgsFormItemController extends NgsSubscriber {
|
|
|
485
863
|
addRemoveControlFn;
|
|
486
864
|
index;
|
|
487
865
|
changeDetectorRef;
|
|
866
|
+
arrayRow;
|
|
488
867
|
componentRef;
|
|
489
|
-
parentVisibility = new BehaviorSubject(false);
|
|
490
868
|
lastVisible;
|
|
491
|
-
|
|
869
|
+
/** Incremented whenever a scheduled create becomes stale (hidden again, or destroyed). */
|
|
870
|
+
createToken = 0;
|
|
871
|
+
destroyed = false;
|
|
872
|
+
constructor(injector, viewContainerRef, itemData, addRemoveControlFn, index, changeDetectorRef, arrayRow) {
|
|
492
873
|
super();
|
|
493
874
|
this.injector = injector;
|
|
494
875
|
this.viewContainerRef = viewContainerRef;
|
|
@@ -496,9 +877,12 @@ class NgsFormItemController extends NgsSubscriber {
|
|
|
496
877
|
this.addRemoveControlFn = addRemoveControlFn;
|
|
497
878
|
this.index = index;
|
|
498
879
|
this.changeDetectorRef = changeDetectorRef;
|
|
880
|
+
this.arrayRow = arrayRow;
|
|
499
881
|
this.bindVisibility();
|
|
500
882
|
}
|
|
501
883
|
destroy() {
|
|
884
|
+
this.destroyed = true;
|
|
885
|
+
this.createToken++;
|
|
502
886
|
this.detach();
|
|
503
887
|
super.ngOnDestroy();
|
|
504
888
|
}
|
|
@@ -506,7 +890,7 @@ class NgsFormItemController extends NgsSubscriber {
|
|
|
506
890
|
const config = this.itemData.config;
|
|
507
891
|
const visible$ = config.visible$ ?? (config.visible !== undefined ? of(config.visible) : undefined);
|
|
508
892
|
if (!visible$) {
|
|
509
|
-
|
|
893
|
+
this.scheduleCreate();
|
|
510
894
|
return;
|
|
511
895
|
}
|
|
512
896
|
this.subscribe(visible$, (visible) => {
|
|
@@ -514,17 +898,37 @@ class NgsFormItemController extends NgsSubscriber {
|
|
|
514
898
|
return;
|
|
515
899
|
this.lastVisible = visible;
|
|
516
900
|
if (visible) {
|
|
517
|
-
|
|
901
|
+
this.scheduleCreate();
|
|
518
902
|
return;
|
|
519
903
|
}
|
|
904
|
+
// Cancels a create that was scheduled but has not run yet.
|
|
905
|
+
this.createToken++;
|
|
520
906
|
this.detach();
|
|
521
907
|
});
|
|
522
908
|
}
|
|
909
|
+
/**
|
|
910
|
+
* Creates the component in a microtask. A create that is overtaken by a later visibility change or by
|
|
911
|
+
* `destroy()` before it runs is dropped.
|
|
912
|
+
*/
|
|
913
|
+
scheduleCreate() {
|
|
914
|
+
const token = ++this.createToken;
|
|
915
|
+
Promise.resolve().then(() => {
|
|
916
|
+
if (this.destroyed || token !== this.createToken)
|
|
917
|
+
return;
|
|
918
|
+
this.create();
|
|
919
|
+
});
|
|
920
|
+
}
|
|
523
921
|
create() {
|
|
922
|
+
if (this.componentRef && !this.componentRef.hostView.destroyed) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
524
925
|
const providers = [{ provide: NGS_FORMS_ITEM_DATA, useValue: this.itemData }];
|
|
525
926
|
if (this.index !== undefined) {
|
|
526
927
|
providers.push({ provide: NGS_FORMS_ITEM_INDEX, useValue: this.index });
|
|
527
928
|
}
|
|
929
|
+
if (this.arrayRow) {
|
|
930
|
+
providers.push({ provide: NGS_FORMS_ARRAY_ROW, useValue: this.arrayRow });
|
|
931
|
+
}
|
|
528
932
|
if (this.addRemoveControlFn) {
|
|
529
933
|
providers.push({ provide: NGS_FORMS_CONTROL_ADD_REMOVE_FN, useValue: this.addRemoveControlFn });
|
|
530
934
|
}
|
|
@@ -540,11 +944,10 @@ class NgsFormItemController extends NgsSubscriber {
|
|
|
540
944
|
this.componentRef = this.viewContainerRef.createComponent(componentType, { injector: myInjector });
|
|
541
945
|
this.componentRef.changeDetectorRef?.detectChanges();
|
|
542
946
|
this.changeDetectorRef?.markForCheck();
|
|
543
|
-
this.parentVisibility.next(true);
|
|
544
947
|
}
|
|
545
948
|
detach() {
|
|
546
|
-
this.parentVisibility.next(false);
|
|
547
949
|
this.componentRef?.destroy();
|
|
950
|
+
this.componentRef = undefined;
|
|
548
951
|
this.changeDetectorRef?.markForCheck();
|
|
549
952
|
}
|
|
550
953
|
}
|
|
@@ -563,7 +966,8 @@ class NgsFormsFormItemDirective {
|
|
|
563
966
|
/** Form array that the item's control is added to. */
|
|
564
967
|
formArray;
|
|
565
968
|
/**
|
|
566
|
-
* Index of the item inside a form array row, provided to children as `NGS_FORMS_ITEM_INDEX`.
|
|
969
|
+
* Index of the item inside a form array row, provided to children as `NGS_FORMS_ITEM_INDEX`. When it is set, the
|
|
970
|
+
* `ngs-form-group` is also provided to children as `NGS_FORMS_ARRAY_ROW`.
|
|
567
971
|
*/
|
|
568
972
|
index;
|
|
569
973
|
controller;
|
|
@@ -571,7 +975,24 @@ class NgsFormsFormItemDirective {
|
|
|
571
975
|
injector = inject(Injector);
|
|
572
976
|
changeDetectorRef = inject(ChangeDetectorRef);
|
|
573
977
|
formComponentRegistryService = inject(NgsFormsComponentRegistryService);
|
|
978
|
+
initialized = false;
|
|
574
979
|
ngOnInit() {
|
|
980
|
+
this.initialized = true;
|
|
981
|
+
this.build();
|
|
982
|
+
}
|
|
983
|
+
ngOnChanges(changes) {
|
|
984
|
+
if (!this.initialized) {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
// A new item, group or array means a different control, so the component is rebuilt. An index change on its own
|
|
988
|
+
// keeps the component: the row it belongs to is still the same, and `NGS_FORMS_ARRAY_ROW` follows it.
|
|
989
|
+
if (changes['itemData'] || changes['formGroup'] || changes['formArray']) {
|
|
990
|
+
this.controller?.destroy();
|
|
991
|
+
this.controller = undefined;
|
|
992
|
+
this.build();
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
build() {
|
|
575
996
|
if (!this.itemData) {
|
|
576
997
|
return console.error('Form item without form data');
|
|
577
998
|
}
|
|
@@ -582,13 +1003,13 @@ class NgsFormsFormItemDirective {
|
|
|
582
1003
|
if (this.formGroup) {
|
|
583
1004
|
addRemoveFn = NgsFormsFormControlAddRemoveFunctions.formGroup(this.formGroup);
|
|
584
1005
|
}
|
|
585
|
-
this.controller = new NgsFormItemController(this.injector, this.viewContainerRef, this.itemData, addRemoveFn, this.index, this.changeDetectorRef);
|
|
1006
|
+
this.controller = new NgsFormItemController(this.injector, this.viewContainerRef, this.itemData, addRemoveFn, this.index, this.changeDetectorRef, this.index !== undefined ? this.formGroup : undefined);
|
|
586
1007
|
}
|
|
587
1008
|
ngOnDestroy() {
|
|
588
1009
|
this.controller?.destroy();
|
|
589
1010
|
}
|
|
590
1011
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormItemDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
591
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.19", type: NgsFormsFormItemDirective, isStandalone: true, selector: "[ngs-form-item]", inputs: { itemData: ["ngs-form-item", "itemData"], formGroup: ["ngs-form-group", "formGroup"], formArray: ["ngs-form-array", "formArray"], index: ["ngs-form-index", "index"] }, ngImport: i0 });
|
|
1012
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.19", type: NgsFormsFormItemDirective, isStandalone: true, selector: "[ngs-form-item]", inputs: { itemData: ["ngs-form-item", "itemData"], formGroup: ["ngs-form-group", "formGroup"], formArray: ["ngs-form-array", "formArray"], index: ["ngs-form-index", "index"] }, usesOnChanges: true, ngImport: i0 });
|
|
592
1013
|
}
|
|
593
1014
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormItemDirective, decorators: [{
|
|
594
1015
|
type: Directive,
|
|
@@ -633,6 +1054,9 @@ class NgsFormComponent {
|
|
|
633
1054
|
constructor() {
|
|
634
1055
|
this.ngsFormsService.setInternalService(this.ngsInternalFormsService);
|
|
635
1056
|
}
|
|
1057
|
+
ngOnDestroy() {
|
|
1058
|
+
this.ngsFormsService.clearInternalService(this.ngsInternalFormsService);
|
|
1059
|
+
}
|
|
636
1060
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
637
1061
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormComponent, isStandalone: true, selector: "ngs-form", providers: [NgsFormsInternalService], ngImport: i0, template: "<!-- Tracking by form group re-creates the tree whenever setFormConfig() is called again, since each call creates a new group. -->\n@for (entry of rendered(); track entry.group) {\n <ng-container [ngs-form-group]=\"entry.group\" [ngs-form-item]=\"entry.root\"></ng-container>\n}\n", dependencies: [{ kind: "directive", type: NgsFormsFormItemDirective, selector: "[ngs-form-item]", inputs: ["ngs-form-item", "ngs-form-group", "ngs-form-array", "ngs-form-index"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
638
1062
|
}
|
|
@@ -754,8 +1178,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
|
|
|
754
1178
|
class NgsFormsFormArrayInternalService {
|
|
755
1179
|
config = inject(NGS_FORMS_ITEM_DATA).config;
|
|
756
1180
|
formArray = new UntypedFormArray([]);
|
|
1181
|
+
formsInternalService = inject(NgsFormsInternalService, { optional: true });
|
|
757
1182
|
/** Emits after a row is added or removed. */
|
|
758
1183
|
itemsChanged = new Subject();
|
|
1184
|
+
/** Emits the index of a row right after it is removed. */
|
|
1185
|
+
rowRemoved = new Subject();
|
|
759
1186
|
constructor() {
|
|
760
1187
|
if (this.config.initialItemCount) {
|
|
761
1188
|
for (let i = 0; i < this.config.initialItemCount; i++) {
|
|
@@ -764,16 +1191,21 @@ class NgsFormsFormArrayInternalService {
|
|
|
764
1191
|
}
|
|
765
1192
|
}
|
|
766
1193
|
ngOnDestroy() {
|
|
1194
|
+
this.itemsChanged.complete();
|
|
1195
|
+
this.rowRemoved.complete();
|
|
767
1196
|
}
|
|
768
1197
|
/** Removes the row at `index`, unless that would go below `minItems`. */
|
|
769
1198
|
removeAt(index) {
|
|
770
1199
|
if (this.config?.minItems && this.formArray.controls.length <= this.config.minItems) {
|
|
771
1200
|
return;
|
|
772
1201
|
}
|
|
773
|
-
if (
|
|
1202
|
+
if (index < 0 || index >= this.formArray.controls.length) {
|
|
774
1203
|
return;
|
|
775
1204
|
}
|
|
1205
|
+
// Server errors for rows are matched by index, so they are cleared once the rows move.
|
|
1206
|
+
this.formsInternalService?.serverErrors?.clear();
|
|
776
1207
|
this.formArray.removeAt(index);
|
|
1208
|
+
this.rowRemoved.next(index);
|
|
777
1209
|
this.itemsChanged.next();
|
|
778
1210
|
}
|
|
779
1211
|
/** Appends an empty row, unless the array already has `maxItems` rows. */
|
|
@@ -781,6 +1213,7 @@ class NgsFormsFormArrayInternalService {
|
|
|
781
1213
|
if (this.config?.maxItems && this.formArray.controls.length >= this.config.maxItems) {
|
|
782
1214
|
return;
|
|
783
1215
|
}
|
|
1216
|
+
this.formsInternalService?.serverErrors?.clear();
|
|
784
1217
|
this.formArray.push(new UntypedFormGroup({}));
|
|
785
1218
|
this.itemsChanged.next();
|
|
786
1219
|
}
|
|
@@ -859,10 +1292,33 @@ class NgsFormsFormArrayListComponent extends NgsFormsBaseClassFormComponent {
|
|
|
859
1292
|
internalArrayService = inject(NgsFormsFormArrayInternalService);
|
|
860
1293
|
myFormArray = this.internalArrayService.formArray;
|
|
861
1294
|
changeDetectorRef = inject(ChangeDetectorRef);
|
|
1295
|
+
elementRef = inject(ElementRef);
|
|
1296
|
+
injector = inject(Injector);
|
|
862
1297
|
ngOnInit() {
|
|
863
1298
|
this.subscribe(this.internalArrayService.itemsChanged, () => {
|
|
864
1299
|
this.changeDetectorRef.markForCheck();
|
|
865
1300
|
});
|
|
1301
|
+
this.subscribe(this.internalArrayService.rowRemoved, (index) => this.restoreFocusAfterRemove(index));
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* When the focused element (usually the row's Remove button) was removed with its row, moves focus to the
|
|
1305
|
+
* same kind of control in the row that took its place, so keyboard users are not sent back to the top of the page.
|
|
1306
|
+
*/
|
|
1307
|
+
restoreFocusAfterRemove(index) {
|
|
1308
|
+
afterNextRender(() => {
|
|
1309
|
+
const active = document.activeElement;
|
|
1310
|
+
if (active && active !== document.body) {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const host = this.elementRef.nativeElement;
|
|
1314
|
+
const rows = Array.from(host.querySelectorAll(':scope > [data-index]'));
|
|
1315
|
+
const row = rows[Math.min(index, rows.length - 1)];
|
|
1316
|
+
const focusable = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
|
1317
|
+
const target = row?.querySelector('ngs-forms-form-array-remove-item button:not([disabled])') ??
|
|
1318
|
+
row?.querySelector(focusable) ??
|
|
1319
|
+
host.closest('ngs-forms-form-array')?.querySelector(focusable);
|
|
1320
|
+
target?.focus();
|
|
1321
|
+
}, { injector: this.injector });
|
|
866
1322
|
}
|
|
867
1323
|
/** Creates a `form-array-list` item. */
|
|
868
1324
|
static create(config) {
|
|
@@ -894,8 +1350,26 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
|
|
|
894
1350
|
internalArrayService = inject(NgsFormsFormArrayInternalService);
|
|
895
1351
|
elementRef = inject(ElementRef);
|
|
896
1352
|
indexFromDi = inject(NGS_FORMS_ITEM_INDEX, { optional: true });
|
|
897
|
-
|
|
1353
|
+
row = inject(NGS_FORMS_ARRAY_ROW, { optional: true });
|
|
1354
|
+
changeDetectorRef = inject(ChangeDetectorRef);
|
|
1355
|
+
/** Accessible name of the button, from `ariaLabel` or `${buttonText} item ${index + 1}`. */
|
|
1356
|
+
get ariaLabel() {
|
|
1357
|
+
const index = this.myIndex;
|
|
1358
|
+
const label = this.config.ariaLabel;
|
|
1359
|
+
if (typeof label === 'function')
|
|
1360
|
+
return label(index);
|
|
1361
|
+
if (label)
|
|
1362
|
+
return label;
|
|
1363
|
+
return `${this.config.buttonText || 'Remove'} item ${index + 1}`;
|
|
1364
|
+
}
|
|
1365
|
+
/** Current index of the row this button belongs to. */
|
|
898
1366
|
get myIndex() {
|
|
1367
|
+
if (this.row) {
|
|
1368
|
+
const index = this.internalArrayService.formArray.controls.indexOf(this.row);
|
|
1369
|
+
if (index >= 0) {
|
|
1370
|
+
return index;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
899
1373
|
if (this.indexFromDi !== null && this.indexFromDi !== undefined) {
|
|
900
1374
|
return this.indexFromDi;
|
|
901
1375
|
}
|
|
@@ -912,6 +1386,10 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
|
|
|
912
1386
|
constructor() {
|
|
913
1387
|
super();
|
|
914
1388
|
}
|
|
1389
|
+
ngOnInit() {
|
|
1390
|
+
// The row's position changes when earlier rows are removed, which changes the default accessible name.
|
|
1391
|
+
this.subscribe(this.internalArrayService.itemsChanged, () => this.changeDetectorRef.markForCheck());
|
|
1392
|
+
}
|
|
915
1393
|
/** Creates a `form-array-remove-item` item. */
|
|
916
1394
|
static create(config) {
|
|
917
1395
|
return {
|
|
@@ -924,11 +1402,11 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
|
|
|
924
1402
|
this.internalArrayService.removeAt(this.myIndex);
|
|
925
1403
|
}
|
|
926
1404
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormArrayRemoveItemComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
927
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormArrayRemoveItemComponent, isStandalone: true, selector: "ngs-forms-form-array-remove-item", usesInheritance: true, ngImport: i0, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1405
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormArrayRemoveItemComponent, isStandalone: true, selector: "ngs-forms-form-array-remove-item", usesInheritance: true, ngImport: i0, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n [attr.aria-label]=\"ariaLabel\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
928
1406
|
}
|
|
929
1407
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormArrayRemoveItemComponent, decorators: [{
|
|
930
1408
|
type: Component,
|
|
931
|
-
args: [{ selector: 'ngs-forms-form-array-remove-item', changeDetection: ChangeDetectionStrategy.OnPush, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n" }]
|
|
1409
|
+
args: [{ selector: 'ngs-forms-form-array-remove-item', changeDetection: ChangeDetectionStrategy.OnPush, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n [attr.aria-label]=\"ariaLabel\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n" }]
|
|
932
1410
|
}], ctorParameters: () => [] });
|
|
933
1411
|
|
|
934
1412
|
/**
|
|
@@ -963,6 +1441,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
|
|
|
963
1441
|
args: [{ selector: 'ngs-form-component-text-div', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"{{ config.classes }}\">{{ config.text }}</div>\n" }]
|
|
964
1442
|
}] });
|
|
965
1443
|
|
|
1444
|
+
;
|
|
1445
|
+
|
|
966
1446
|
/**
|
|
967
1447
|
* Renders an HTML string inside the form. The HTML is passed through Angular's sanitizer.
|
|
968
1448
|
*
|
|
@@ -996,8 +1476,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
|
|
|
996
1476
|
args: [{ selector: 'ngs-form-component-html-content', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div [innerHTML]=\"html\"></div>\n" }]
|
|
997
1477
|
}], ctorParameters: () => [] });
|
|
998
1478
|
|
|
1479
|
+
/** @internal */
|
|
999
1480
|
const sectionConfigDefaults = {
|
|
1000
1481
|
titleClass: 'h3',
|
|
1482
|
+
headingLevel: 3,
|
|
1001
1483
|
subtitleClass: 'lead',
|
|
1002
1484
|
};
|
|
1003
1485
|
|
|
@@ -1006,10 +1488,13 @@ const sectionConfigDefaults = {
|
|
|
1006
1488
|
* parent group.
|
|
1007
1489
|
*
|
|
1008
1490
|
* @remarks Registry key: `section`. Config: `title`, `subtitle`, `titleClass` (default `h3`),
|
|
1009
|
-
* `subtitleClass` (default `lead`) and `items`.
|
|
1491
|
+
* `headingLevel` (default 3), `subtitleClass` (default `lead`) and `items`. The section is exposed to assistive
|
|
1492
|
+
* technology as a group named by its title.
|
|
1010
1493
|
*/
|
|
1011
1494
|
class NgsFormsFormSectionComponent extends NgsFormsBaseClassItemsContainerBase {
|
|
1012
1495
|
static key = 'section';
|
|
1496
|
+
/** Element id of the title, used to name the section's group. */
|
|
1497
|
+
titleId = `${this.itemData.uuid || v4()}-section-title`;
|
|
1013
1498
|
/** Creates a `section` item. */
|
|
1014
1499
|
static create(config) {
|
|
1015
1500
|
return {
|
|
@@ -1022,11 +1507,11 @@ class NgsFormsFormSectionComponent extends NgsFormsBaseClassItemsContainerBase {
|
|
|
1022
1507
|
this.config = ngsDefaults(this.config, sectionConfigDefaults);
|
|
1023
1508
|
}
|
|
1024
1509
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormSectionComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
|
|
1025
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormSectionComponent, isStandalone: true, selector: "ngs-forms-form-section", usesInheritance: true, ngImport: i0, template: "<div class=\"form-section\">\n <div class=\"form-section-title {{config.titleClass}}\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgsFormsFormItemDirective, selector: "[ngs-form-item]", inputs: ["ngs-form-item", "ngs-form-group", "ngs-form-array", "ngs-form-index"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1510
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormSectionComponent, isStandalone: true, selector: "ngs-forms-form-section", usesInheritance: true, ngImport: i0, template: "<div class=\"form-section\" role=\"group\" [attr.aria-labelledby]=\"titleId\">\n <div class=\"form-section-title {{config.titleClass}}\" role=\"heading\" [attr.aria-level]=\"config.headingLevel\" [id]=\"titleId\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgsFormsFormItemDirective, selector: "[ngs-form-item]", inputs: ["ngs-form-item", "ngs-form-group", "ngs-form-array", "ngs-form-index"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1026
1511
|
}
|
|
1027
1512
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormSectionComponent, decorators: [{
|
|
1028
1513
|
type: Component,
|
|
1029
|
-
args: [{ selector: 'ngs-forms-form-section', imports: [NgsFormsFormItemDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-section\">\n <div class=\"form-section-title {{config.titleClass}}\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n" }]
|
|
1514
|
+
args: [{ selector: 'ngs-forms-form-section', imports: [NgsFormsFormItemDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-section\" role=\"group\" [attr.aria-labelledby]=\"titleId\">\n <div class=\"form-section-title {{config.titleClass}}\" role=\"heading\" [attr.aria-level]=\"config.headingLevel\" [id]=\"titleId\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n" }]
|
|
1030
1515
|
}] });
|
|
1031
1516
|
|
|
1032
1517
|
// --
|
|
@@ -1066,5 +1551,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
|
|
|
1066
1551
|
* Generated bundle index. Do not edit.
|
|
1067
1552
|
*/
|
|
1068
1553
|
|
|
1069
|
-
export { NGS_FORMS_CONTROL_ADD_REMOVE_FN, NGS_FORMS_ITEM_DATA, NGS_FORMS_ITEM_INDEX, NgsFormComponent, NgsFormsBaseClassFormComponent, NgsFormsBaseClassFormInputComponent, NgsFormsBaseClassFormItemInputWithOptionsComponent, NgsFormsBaseClassItemsContainerBase, NgsFormsColumnComponent, NgsFormsComponentRegistryService, NgsFormsCoreModule, NgsFormsFormArrayAddItemComponent, NgsFormsFormArrayContainerComponent, NgsFormsFormArrayInternalService, NgsFormsFormArrayListComponent, NgsFormsFormArrayRemoveItemComponent, NgsFormsFormControlAddRemoveFunctions, NgsFormsFormGroupComponent, NgsFormsFormItemDirective, NgsFormsFormItemWithVisibleAndValidatorsBase, NgsFormsFormSectionComponent, NgsFormsHtmlContentComponent, NgsFormsInternalService, NgsFormsRowComponent, NgsFormsService, NgsFormsTextDivComponent, NgsSubscriber, ngsDefaults };
|
|
1554
|
+
export { NGS_FORMS_ARRAY_ROW, NGS_FORMS_CONTROL_ADD_REMOVE_FN, NGS_FORMS_ITEM_DATA, NGS_FORMS_ITEM_INDEX, NGS_FORMS_SERVER_ERROR, NgsFormComponent, NgsFormsBaseClassFormComponent, NgsFormsBaseClassFormInputComponent, NgsFormsBaseClassFormItemInputWithOptionsComponent, NgsFormsBaseClassItemsContainerBase, NgsFormsColumnComponent, NgsFormsComponentRegistryService, NgsFormsCoreModule, NgsFormsFormArrayAddItemComponent, NgsFormsFormArrayContainerComponent, NgsFormsFormArrayInternalService, NgsFormsFormArrayListComponent, NgsFormsFormArrayRemoveItemComponent, NgsFormsFormControlAddRemoveFunctions, NgsFormsFormGroupComponent, NgsFormsFormItemDirective, NgsFormsFormItemWithVisibleAndValidatorsBase, NgsFormsFormSectionComponent, NgsFormsHtmlContentComponent, NgsFormsInternalService, NgsFormsRowComponent, NgsFormsServerErrorRegistry, NgsFormsService, NgsFormsTextDivComponent, NgsSubscriber, constructServerErrorMessageObject, ngsDefaults, sectionConfigDefaults };
|
|
1070
1555
|
//# sourceMappingURL=ng-simplicity-forms-core.mjs.map
|