@zvenigora/ng-eval-forms 0.1.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 +424 -0
- package/fesm2022/zvenigora-ng-eval-forms-reactive.mjs +477 -0
- package/fesm2022/zvenigora-ng-eval-forms-reactive.mjs.map +1 -0
- package/fesm2022/zvenigora-ng-eval-forms.mjs +132 -0
- package/fesm2022/zvenigora-ng-eval-forms.mjs.map +1 -0
- package/package.json +44 -0
- package/types/zvenigora-ng-eval-forms-reactive.d.ts +221 -0
- package/types/zvenigora-ng-eval-forms.d.ts +139 -0
package/README.md
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# @zvenigora/ng-eval-forms
|
|
2
|
+
|
|
3
|
+
Angular form field properties — `visible` and `text` — driven by **string** expressions
|
|
4
|
+
resolved at runtime.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
{ name: 'state', visible: "country === 'US'" }
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
That rule is a string. It can be fetched from an API, typed into a form-builder UI by an
|
|
11
|
+
administrator, or stored in a database and versioned separately from your application —
|
|
12
|
+
because nothing about it is compiled in.
|
|
13
|
+
|
|
14
|
+
The sources for this package are in the main [@zvenigora/ng-eval](https://github.com/zvenigora/ng-eval)
|
|
15
|
+
repo. Expression syntax, security and the evaluator's own options are documented in the
|
|
16
|
+
[repository README](https://github.com/zvenigora/ng-eval#readme); reactivity and the
|
|
17
|
+
`EvalSignal` type are documented in
|
|
18
|
+
[`@zvenigora/ng-eval-signals`](https://github.com/zvenigora/ng-eval/tree/master/modules/eval-signals#readme).
|
|
19
|
+
This file documents the forms library.
|
|
20
|
+
|
|
21
|
+
## When *not* to use this library
|
|
22
|
+
|
|
23
|
+
Angular 22 ships [Signal Forms](https://angular.dev/guide/forms/signals), whose schema
|
|
24
|
+
already expresses conditional `disabled`, `hidden`, `readonly`, `required` and arbitrary
|
|
25
|
+
per-field metadata. Every one of those rules is a `LogicFn` — a TypeScript closure,
|
|
26
|
+
compiled into your bundle.
|
|
27
|
+
|
|
28
|
+
**If your conditions are known at compile time and you are on Angular 22, use Signal
|
|
29
|
+
Forms' schema and not this library.** It will be faster, fully typed, and one dependency
|
|
30
|
+
lighter.
|
|
31
|
+
|
|
32
|
+
What this library adds is the one thing a closure cannot be: **a condition that is a
|
|
33
|
+
string, resolved at runtime.** That covers form definitions served by an API, rules
|
|
34
|
+
authored by an end user, and rules versioned independently of the application release.
|
|
35
|
+
Against the obvious alternative for those cases — `new Function(…)` — it brings what
|
|
36
|
+
`@zvenigora/ng-eval-core` already is: a sandboxed evaluator with no `eval`, safe under a
|
|
37
|
+
strict CSP, with prototype-pollution blocking, dependency introspection and
|
|
38
|
+
case-insensitive resolution.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install @zvenigora/ng-eval-forms @zvenigora/ng-eval-signals @zvenigora/ng-eval-core
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Entry points
|
|
47
|
+
|
|
48
|
+
| Import | Contains |
|
|
49
|
+
| :--- | :--- |
|
|
50
|
+
| `@zvenigora/ng-eval-forms` | The shared core — context composition and the coercion and error-policy rules every adapter uses. Imports nothing from `@angular/core` or `@angular/forms`. |
|
|
51
|
+
| `@zvenigora/ng-eval-forms/reactive` | The Angular Reactive Forms adapter — `FormGroup` / `FormControl`. **This is the entry point you want.** |
|
|
52
|
+
|
|
53
|
+
A `/signals` entry point for Angular's Signal Forms is designed but not built; see
|
|
54
|
+
[the Phase 4 plan](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/phase-4-plan.md)
|
|
55
|
+
§ 9.
|
|
56
|
+
|
|
57
|
+
## Versions
|
|
58
|
+
|
|
59
|
+
The package declares **one** peer range, at the floor:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@angular/core": ">=19.0.0",
|
|
64
|
+
"@angular/forms": ">=19.0.0",
|
|
65
|
+
"rxjs": "^7.8.0",
|
|
66
|
+
"@zvenigora/ng-eval-core": "^0.3.0",
|
|
67
|
+
"@zvenigora/ng-eval-signals": "^0.1.0"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`peerDependencies` are declared per **package**, not per entry point, so a narrower range
|
|
72
|
+
for a single entry point is not expressible. `/reactive` — everything this release ships —
|
|
73
|
+
works from Angular 19. When `/signals` arrives it will require **Angular 22 or later**, and
|
|
74
|
+
an older consumer importing it gets `Cannot find module '@angular/forms/signals'` from
|
|
75
|
+
Angular's own `exports` map rather than anything this library declares.
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { Injectable, Injector, OnDestroy, inject } from '@angular/core';
|
|
81
|
+
import { FormControl, FormGroup } from '@angular/forms';
|
|
82
|
+
import { bindFieldProperties } from '@zvenigora/ng-eval-forms/reactive';
|
|
83
|
+
|
|
84
|
+
@Injectable()
|
|
85
|
+
export class OrderFormService implements OnDestroy {
|
|
86
|
+
private readonly injector = inject(Injector);
|
|
87
|
+
|
|
88
|
+
readonly form = new FormGroup({
|
|
89
|
+
country: new FormControl('CA'),
|
|
90
|
+
state: new FormControl(''),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
readonly binding = bindFieldProperties(
|
|
94
|
+
[{ name: 'state', visible: "country === 'US'" }],
|
|
95
|
+
this.form,
|
|
96
|
+
{ injector: this.injector }
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
ngOnDestroy() {
|
|
100
|
+
this.binding.destroy();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Reading the bound property — `form` and `binding` are the service's, and the `injector` in
|
|
106
|
+
the later examples is the same one it injected:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
binding.fields['state'].visible?.(); // => false
|
|
110
|
+
|
|
111
|
+
form.controls.country.setValue('US');
|
|
112
|
+
|
|
113
|
+
binding.fields['state'].visible?.(); // => true
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
In a template, where `orderForm` is the injected `OrderFormService`:
|
|
117
|
+
|
|
118
|
+
```html
|
|
119
|
+
<form [formGroup]="orderForm.form">
|
|
120
|
+
<select formControlName="country"> … </select>
|
|
121
|
+
|
|
122
|
+
@if (orderForm.binding.fields['state'].visible?.()) {
|
|
123
|
+
<input formControlName="state" />
|
|
124
|
+
}
|
|
125
|
+
</form>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The recompute is Angular's own dependency tracking, per key: the expression named
|
|
129
|
+
`country`, so it recomputes when `country` changes and not when any other control does. A
|
|
130
|
+
worked example of a whole form is in
|
|
131
|
+
[docs/forms/worked-example.md](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/worked-example.md).
|
|
132
|
+
|
|
133
|
+
## How it fits together
|
|
134
|
+
|
|
135
|
+
| Symbol | Entry point | What it is |
|
|
136
|
+
| :--- | :--- | :--- |
|
|
137
|
+
| `bindFieldProperties(schema, group, options)` | `/reactive` | The primary API. Validates the schema, mirrors the group, returns a `FormBinding`. |
|
|
138
|
+
| `FormBinding` | `/reactive` | `{ fields: Record<string, FieldProperties>; destroy(): void }`. |
|
|
139
|
+
| `FieldSchema` | `/reactive` | `{ name, visible?, text? }` — one field's rules. |
|
|
140
|
+
| `FieldProperties` | `/reactive` | `{ visible?: EvalSignal<boolean>; text?: EvalSignal<string> }`. Both optional, because the schema's rules are. |
|
|
141
|
+
| `createControlSource(group, options)` | `/reactive` | The mirror on its own, for callers composing contexts by hand. |
|
|
142
|
+
| `createFieldContext(formSource, fieldSource, options?)` | core | Composes one `EvalContext` out of a form-wide and a field-local source. |
|
|
143
|
+
| `toVisible(value)` / `toText(value)` | core | The two coercions, exported so an adapter or a test can apply the same rule. |
|
|
144
|
+
| `ExpressionErrorPolicy` | core | `'throw' \| 'undefined' \| ((error) => unknown)`. |
|
|
145
|
+
|
|
146
|
+
`FieldProperties` members are `EvalSignal`, not plain `Signal`, and both extra members
|
|
147
|
+
matter here: `invalidate()` is the escape hatch described under
|
|
148
|
+
[Reactivity](#reactivity-and-its-two-holes), and `destroy()` is what
|
|
149
|
+
[Lifetime](#lifetime) counts.
|
|
150
|
+
|
|
151
|
+
## The field schema
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
interface FieldSchema {
|
|
155
|
+
readonly name: string;
|
|
156
|
+
readonly visible?: string; // coerced by truthiness
|
|
157
|
+
readonly text?: string; // coerced to a string
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
That is the whole descriptor. It is deliberately **not** a schema language — see
|
|
162
|
+
[What is not here](#what-is-not-here).
|
|
163
|
+
|
|
164
|
+
A field does **not** have to name a control. `{ name: 'state', … }` is legal on a form with
|
|
165
|
+
no `state` control; the name is how you look the properties up in `binding.fields`, and
|
|
166
|
+
rules naming a missing field resolve `undefined`.
|
|
167
|
+
|
|
168
|
+
### It is validated when you bind
|
|
169
|
+
|
|
170
|
+
A schema that arrives from a server can be malformed in ways an expression cannot be, so
|
|
171
|
+
`bindFieldProperties` checks four things and **throws** rather than letting them surface
|
|
172
|
+
later as an evaluation result nobody can trace:
|
|
173
|
+
|
|
174
|
+
| Rejected | Why it is not a warning |
|
|
175
|
+
| :--- | :--- |
|
|
176
|
+
| A duplicate field name | The second would silently replace the first. |
|
|
177
|
+
| A `visible` / `text` that is not a string | Reaches the compiler as something it cannot parse. |
|
|
178
|
+
| A field name that is a member of `Object.prototype` | See below. |
|
|
179
|
+
| A control in the group that is not a `FormControl` | Nested groups and `FormArray` are out of scope; the alternative is a group's *aggregate object* arriving where a value was expected. |
|
|
180
|
+
|
|
181
|
+
**Field and control names may not be `constructor`, `toString`, `valueOf`,
|
|
182
|
+
`hasOwnProperty`, `__proto__` or any other member of `Object.prototype`.** `FormGroup`
|
|
183
|
+
accepts such a key — it rejects only names containing a dot — and an expression naming one
|
|
184
|
+
then reads the prototype's value, which is a *function*, which is truthy. A `visible` rule
|
|
185
|
+
would render precisely the field that has no data, with no error anywhere. This is checked
|
|
186
|
+
over the schema's names *and* the group's controls, because the two are different sets and
|
|
187
|
+
the second is worse: the control exists and its value is unreadable.
|
|
188
|
+
|
|
189
|
+
**The check runs at construction only.** A control added afterwards is mirrored but not
|
|
190
|
+
re-validated, so a `constructor` or a nested `FormGroup` introduced by a later `addControl`
|
|
191
|
+
gets none of the diagnostics above. Rejecting it from inside the diff is not on offer:
|
|
192
|
+
the diff runs in a subscriber, where a throw is reported out of band and far from the
|
|
193
|
+
`addControl` that caused it — and it could not undo that call anyway. Validate a control
|
|
194
|
+
set you assemble dynamically, or re-bind.
|
|
195
|
+
|
|
196
|
+
## What an expression can name
|
|
197
|
+
|
|
198
|
+
**The values of the form's controls, by control name.** Every control is mirrored,
|
|
199
|
+
including disabled ones — a disabled control is excluded from its parent's aggregate value,
|
|
200
|
+
but its own value is still readable here.
|
|
201
|
+
|
|
202
|
+
Not addressable in this release:
|
|
203
|
+
|
|
204
|
+
- **Form state.** `touched`, `dirty`, `pristine`, `valid` and `status` are not keys. The
|
|
205
|
+
shape they should take is unresolved (a flat record cannot hold per-field state without
|
|
206
|
+
either nesting signals or collapsing everything into one signal, which destroys the
|
|
207
|
+
per-key tracking the design rests on), and real conditional-visibility rules read sibling
|
|
208
|
+
*values*.
|
|
209
|
+
- **Nested groups and `FormArray`.** Flat forms only, and enforced rather than documented —
|
|
210
|
+
see the validation table above.
|
|
211
|
+
- **Field-local keys.** `createFieldContext` takes a second, field-local source and the
|
|
212
|
+
`/reactive` binding passes `{}`. What a field-local key set should *contain* is not
|
|
213
|
+
specified anywhere yet, and inventing keys to fill a parameter is how a public surface
|
|
214
|
+
acquires members nobody chose.
|
|
215
|
+
|
|
216
|
+
### An empty control reads as absent
|
|
217
|
+
|
|
218
|
+
`EvalContext.get` treats `undefined` as absent at every step, and there is no way to tell
|
|
219
|
+
"no such key" from "key bound to `undefined`". An empty `FormControl` therefore behaves as
|
|
220
|
+
though the field were not there:
|
|
221
|
+
|
|
222
|
+
- `visible: "promoCode"` on an empty `promoCode` is `false`.
|
|
223
|
+
- Where a field-local key and a form key collide, the field wins **while its value is not
|
|
224
|
+
`undefined`** — an empty field falls through and the form value shows through instead.
|
|
225
|
+
|
|
226
|
+
This is a limitation, not a design goal; distinguishing the two would need a sentinel
|
|
227
|
+
threaded through `EvalContext.get`, which belongs to `@zvenigora/ng-eval-core`.
|
|
228
|
+
|
|
229
|
+
## Coercion
|
|
230
|
+
|
|
231
|
+
`visible` is **JavaScript truthiness, and nothing cleverer**:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
toVisible(undefined); // => false — an empty or missing field is not visible
|
|
235
|
+
toVisible(0); // => false
|
|
236
|
+
toVisible('false'); // => true — a non-empty string
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
That third line is the one to know about, and it is the one a form-builder UI storing every
|
|
240
|
+
value as a string will hit. It is truthiness rather than parsing: a coercion that read
|
|
241
|
+
`'false'` as false would then owe an answer for `'no'`, `'0'` and `'off'`, and JavaScript
|
|
242
|
+
has one for none of them. Write `visible: "flag === 'true'"` if that is what you mean.
|
|
243
|
+
|
|
244
|
+
`text` is `String(value)`, with `null` and `undefined` mapping to `''`:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
toText(null); // => '' — never the literal text "null"
|
|
248
|
+
toText(undefined); // => ''
|
|
249
|
+
toText(0); // => '0' — not ''
|
|
250
|
+
toText(false); // => 'false'
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Every *other* falsy value stringifies normally. Mapping all falsy values to `''` is the
|
|
254
|
+
obvious way to write this rule wrongly, and it blanks a field whose value is legitimately
|
|
255
|
+
zero.
|
|
256
|
+
|
|
257
|
+
The coercion sits in front of the signal rather than inside the walk, so a **destroyed**
|
|
258
|
+
property still answers `false` / `''` rather than leaking `undefined` into a template.
|
|
259
|
+
|
|
260
|
+
## When a rule fails
|
|
261
|
+
|
|
262
|
+
`options.onError` decides, and **the default is `'undefined'`** — the opposite of
|
|
263
|
+
`@zvenigora/ng-eval-signals`' default:
|
|
264
|
+
|
|
265
|
+
| Value | Effect |
|
|
266
|
+
| :--- | :--- |
|
|
267
|
+
| `'undefined'` *(default)* | The property resolves `undefined`, which coerces to `false` / `''`. |
|
|
268
|
+
| `'throw'` | Rethrow. |
|
|
269
|
+
| `(error) => unknown` | Your function's return value becomes the property's value. |
|
|
270
|
+
|
|
271
|
+
The default differs from upstream's because the author differs. An expression that fails in
|
|
272
|
+
`@zvenigora/ng-eval-signals` was written by the developer reading the stack trace; an
|
|
273
|
+
expression that fails here may have been typed into a form builder by an end user, and the
|
|
274
|
+
right response to "the administrator wrote a bad rule" is a field that does not render, not
|
|
275
|
+
an application that throws on every change-detection pass.
|
|
276
|
+
|
|
277
|
+
Two things are **not** routed through it:
|
|
278
|
+
|
|
279
|
+
- **A parse error throws from `bindFieldProperties` itself**, whatever the policy.
|
|
280
|
+
Expressions are compiled eagerly, so `visible: 'country ==='` fails at bind time — and
|
|
281
|
+
the binding releases everything it had already built before rethrowing.
|
|
282
|
+
- **An assignment throws `SignalContextWriteError`, in every mode.** Expression keys are
|
|
283
|
+
read-only, and a write violation is *static* — illegal on every recompute with every
|
|
284
|
+
dataset. Swallowing it under the default would hand you a silent blank for a syntax bug
|
|
285
|
+
in the rule itself.
|
|
286
|
+
|
|
287
|
+
## Reactivity, and its two holes
|
|
288
|
+
|
|
289
|
+
A property recomputes when a control it named emits on `valueChanges`. The mirror
|
|
290
|
+
subscribes **per control, never to the group**, so a disabled control stays readable, and
|
|
291
|
+
tracking is per key rather than per form.
|
|
292
|
+
|
|
293
|
+
Two cases the mirror cannot see. Both take the same hatch — `invalidate()` — and there is
|
|
294
|
+
one corner at the end of the second that no hatch reaches:
|
|
295
|
+
|
|
296
|
+
### `{ emitEvent: false }` freezes a value
|
|
297
|
+
|
|
298
|
+
`setValue`, `patchValue`, `reset`, `enable` and `disable` all accept it, and it does what it
|
|
299
|
+
says: no event, so no recompute, so a stale property with no error. There is no fix
|
|
300
|
+
available from this side — the observable is the only signal there is. `invalidate()` is
|
|
301
|
+
the documented hatch for exactly this case:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
const form = new FormGroup({ country: new FormControl('CA') });
|
|
305
|
+
|
|
306
|
+
const binding = bindFieldProperties(
|
|
307
|
+
[{ name: 'country', text: 'country' }],
|
|
308
|
+
form,
|
|
309
|
+
{ injector }
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
binding.fields['country'].text?.(); // => 'CA'
|
|
313
|
+
|
|
314
|
+
form.controls.country.setValue('US', { emitEvent: false });
|
|
315
|
+
|
|
316
|
+
binding.fields['country'].text?.(); // => 'CA' — stale
|
|
317
|
+
|
|
318
|
+
binding.fields['country'].text?.invalidate();
|
|
319
|
+
|
|
320
|
+
binding.fields['country'].text?.(); // => 'US'
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
(The first read is not decoration. A property is a `computed()`, so one that has never been
|
|
324
|
+
read has nothing cached and answers with whatever the form holds *now* — the staleness
|
|
325
|
+
starts at the first read, not at the write.)
|
|
326
|
+
|
|
327
|
+
### The key **set** is not reactive
|
|
328
|
+
|
|
329
|
+
Values are reactive; the *set of keys* is not. A property that already read `age` recorded
|
|
330
|
+
a dependency on that key, and `removeControl('age')` does not itself produce a recompute:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// A control set that changes at runtime needs an index-signature type:
|
|
334
|
+
// `addControl` / `removeControl` on a group typed from an object literal
|
|
335
|
+
// accept only the keys that literal had.
|
|
336
|
+
const form = new FormGroup<{ [key: string]: AbstractControl }>({
|
|
337
|
+
age: new FormControl(30),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const binding = bindFieldProperties([{ name: 'age', text: 'age' }], form, { injector });
|
|
341
|
+
|
|
342
|
+
binding.fields['age'].text?.(); // => '30'
|
|
343
|
+
|
|
344
|
+
form.removeControl('age');
|
|
345
|
+
|
|
346
|
+
binding.fields['age'].text?.(); // => '30' — the value it last read
|
|
347
|
+
|
|
348
|
+
binding.fields['age'].text?.invalidate();
|
|
349
|
+
|
|
350
|
+
binding.fields['age'].text?.(); // => '' — the key is gone
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
From the next recompute onward it is reactive again against whatever now holds the key, so
|
|
354
|
+
one `invalidate()` per structural change is the whole obligation. **The properties to
|
|
355
|
+
invalidate are the ones whose expressions name the affected key.**
|
|
356
|
+
|
|
357
|
+
And there is one corner with **no hatch at all**: `addControl` / `removeControl` /
|
|
358
|
+
`setControl` called with `{ emitEvent: false }` suppress `group.events`, so the mirror
|
|
359
|
+
never learns the control set changed. `invalidate()` cannot rescue that — re-running the
|
|
360
|
+
expression finds the same stale mirror. Do not pass `{ emitEvent: false }` to the
|
|
361
|
+
control-set methods on a mirrored group.
|
|
362
|
+
|
|
363
|
+
## Lifetime
|
|
364
|
+
|
|
365
|
+
**`destroy()` is yours to call.** Every signal a binding creates is built with an explicit
|
|
366
|
+
injector, which means none of them registers its own teardown:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
const binding = bindFieldProperties(schema, form, { injector });
|
|
370
|
+
|
|
371
|
+
// …
|
|
372
|
+
|
|
373
|
+
binding.destroy(); // releases every property signal and every subscription
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
It is idempotent, and it releases the whole mirror — every per-control subscription and the
|
|
377
|
+
`group.events` one — in a single call.
|
|
378
|
+
|
|
379
|
+
There is a **net** under that, and it is a net rather than a substitute: the binding
|
|
380
|
+
registers its teardown on the `DestroyRef` of the injector you passed, so a binding wired to
|
|
381
|
+
a component or route injector is released when that injector dies even if nobody called
|
|
382
|
+
`destroy()`. A binding built on the *root* injector is released at the end of the
|
|
383
|
+
application and no sooner.
|
|
384
|
+
|
|
385
|
+
### Constructed, not computed
|
|
386
|
+
|
|
387
|
+
**Build the binding in a service or a factory, and call `destroy()` from the same place.**
|
|
388
|
+
`bindFieldProperties` uses `toSignal` internally, which opens with
|
|
389
|
+
`assertNotInReactiveContext` — so calling it from inside an `effect()` or a `computed()`
|
|
390
|
+
throws, with an error naming `toSignal` and nothing naming this library. If you see
|
|
391
|
+
`NG0602` and no `toSignal` of your own, this is why.
|
|
392
|
+
|
|
393
|
+
`options.injector` is **required** for the same reason it is required upstream: an optional
|
|
394
|
+
one would silently pick up the ambient injection context when there is one, and teardown
|
|
395
|
+
would then run at a time that varied with where the call happened to sit.
|
|
396
|
+
|
|
397
|
+
## What is not here
|
|
398
|
+
|
|
399
|
+
Deferred deliberately, each additive when it arrives:
|
|
400
|
+
|
|
401
|
+
- **`disabled`.** Applying it means calling `control.disable()`, which is three problems at
|
|
402
|
+
once: it is a write back into the form rather than derived state; it emits on
|
|
403
|
+
`valueChanges` by default, so a rule naming its own field re-enters its own input and
|
|
404
|
+
whether that converges depends on the expression; and it removes the value from the
|
|
405
|
+
parent's aggregate. Under Signal Forms none of the three exists, which is why `disabled`
|
|
406
|
+
is a better fit for the future `/signals` entry point than for this one.
|
|
407
|
+
- **`required` and validators**, which affect form validity rather than presentation.
|
|
408
|
+
- **Form state keys**, `FormArray` and nested `FormGroup`, and field-local keys — see
|
|
409
|
+
[What an expression can name](#what-an-expression-can-name).
|
|
410
|
+
- **Anything asynchronous.** Every property is derived synchronously from control values,
|
|
411
|
+
which is all the mirror supplies; there is no `await` inside an expression and no async
|
|
412
|
+
variant of the binding. The upstream question is
|
|
413
|
+
[`@zvenigora/ng-eval-signals`](https://github.com/zvenigora/ng-eval/tree/master/modules/eval-signals#async-expressions)'.
|
|
414
|
+
|
|
415
|
+
## Development
|
|
416
|
+
|
|
417
|
+
```sh
|
|
418
|
+
npx nx test eval-forms
|
|
419
|
+
npx nx run eval-forms:build:production
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`readme-examples.spec.ts` executes the runnable examples in this file and in the worked
|
|
423
|
+
example, so a documented example that stops working fails the suite rather than shipping.
|
|
424
|
+
The template and manifest blocks are not executable and are not covered.
|