@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 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.