@zvenigora/ng-eval-forms 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @zvenigora/ng-eval-forms
2
2
 
3
- Angular form field properties — `visible` and `text` — driven by **string** expressions
4
- resolved at runtime.
3
+ Angular form field properties — `visible` and `text`, plus `disabled` at the `/signals` entry
4
+ point — driven by **string** expressions resolved at runtime.
5
5
 
6
6
  ```ts
7
7
  { name: 'state', visible: "country === 'US'" }
@@ -45,36 +45,54 @@ npm install @zvenigora/ng-eval-forms @zvenigora/ng-eval-signals @zvenigora/ng-ev
45
45
 
46
46
  ## Entry points
47
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.** |
48
+ | Import | Contains | Requires |
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`. `visible` and `text`. | Angular 19+ |
52
+ | `@zvenigora/ng-eval-forms/signals` | The Angular **[Signal Forms](https://angular.dev/guide/forms/signals)** adapter — `schema()` / `form()`. `visible`, `text` and `disabled`. | **Angular 22+** |
52
53
 
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.
54
+ **Pick the adapter that matches the forms API you already use.** The two are independent — the
55
+ same expression string means the same thing at both — and neither imports the other. The design
56
+ and the measurements behind `/signals` are in
57
+ [the Phase 6 plan](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/phase-6-plan.md).
56
58
 
57
59
  ## Versions
58
60
 
59
- The package declares **one** peer range, at the floor:
61
+ `peerDependencies` are declared per **package**, not per entry point, so the manifest holds one
62
+ set of ranges covering both adapters:
60
63
 
61
64
  ```json
62
65
  "peerDependencies": {
63
66
  "@angular/core": ">=19.0.0",
64
67
  "@angular/forms": ">=19.0.0",
65
68
  "rxjs": "^7.8.0",
69
+ "acorn-walk": "^8.3.0",
66
70
  "@zvenigora/ng-eval-core": "^0.3.0",
67
71
  "@zvenigora/ng-eval-signals": "^0.1.0"
68
72
  }
69
73
  ```
70
74
 
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.
75
+ **The Angular range is at the floor, and the floor is `/reactive`'s.** `/signals` requires
76
+ **Angular 22 or later** — that is when `@angular/forms/signals` ships — and the manifest cannot
77
+ say so: narrowing it to `>=22.0.0` would break every Reactive Forms consumer on 19–21 to add a
78
+ diagnostic for an entry point they do not import. An older consumer importing `/signals` gets
79
+ `Cannot find module '@angular/forms/signals'` from Angular's own `exports` map rather than
80
+ anything this library declares.
81
+
82
+ **`acorn-walk` is new in 0.2.0 and imposes no new install.** `/signals` walks the parsed
83
+ expression with it. A consumer of this package already peer-depends on
84
+ `@zvenigora/ng-eval-core`, whose own peers include `acorn-walk ^8.3.0`, so npm 7+ has already
85
+ placed it; it is declared here because importing it undeclared resolves today by accident of
86
+ hoisting and would not resolve at all under pnpm's isolated layout. `acorn` itself is
87
+ deliberately **not** declared — this package imports no `acorn` symbol, and `acorn-walk`
88
+ depends on it directly.
76
89
 
77
- ## Quick start
90
+ ## Quick start — Reactive Forms (`/reactive`)
91
+
92
+ Everything from here to [Lifetime](#lifetime) is the `/reactive` adapter, except for three
93
+ sections that cover both: [How it fits together](#how-it-fits-together), [Coercion](#coercion)
94
+ and [When a rule fails](#when-a-rule-fails). Signal Forms has
95
+ [its own section](#signal-forms--signals) below.
78
96
 
79
97
  ```ts
80
98
  import { Injectable, Injector, OnDestroy, inject } from '@angular/core';
@@ -142,6 +160,11 @@ worked example of a whole form is in
142
160
  | `createFieldContext(formSource, fieldSource, options?)` | core | Composes one `EvalContext` out of a form-wide and a field-local source. |
143
161
  | `toVisible(value)` / `toText(value)` | core | The two coercions, exported so an adapter or a test can apply the same rule. |
144
162
  | `ExpressionErrorPolicy` | core | `'throw' \| 'undefined' \| ((error) => unknown)`. |
163
+ | `applyErrorPolicy(run, policy?)` | core | Runs `run` under a policy, **new in 0.2.0**. Rethrows a `SignalContextWriteError` whatever the policy says — see [When a rule fails](#when-a-rule-fails). Exported so an adapter applies the rule rather than reimplementing it. |
164
+ | `createExpressionRules(model, options?)` | `/signals` | The primary API. Binds one model signal and returns `evalVisible` / `evalText` / `evalDisabled`. |
165
+ | `ExpressionRules` | `/signals` | The three registrars, each `(path, expression, options?) => void`. |
166
+ | `ExpressionRuleOptions` | `/signals` | `{ eval?: EvalOptions; onError?: ExpressionErrorPolicy }`, accepted by the factory and per registration. |
167
+ | `TEXT` | `/signals` | The metadata key `evalText` writes through and `field().metadata(TEXT)` reads back. |
145
168
 
146
169
  `FieldProperties` members are `EvalSignal`, not plain `Signal`, and both extra members
147
170
  matter here: `invalidate()` is the escape hatch described under
@@ -193,6 +216,41 @@ the diff runs in a subscriber, where a throw is reported out of band and far fro
193
216
  `addControl` that caused it — and it could not undo that call anyway. Validate a control
194
217
  set you assemble dynamically, or re-bind.
195
218
 
219
+ ### Expressions are not validated
220
+
221
+ **No check in the table above inspects an expression.** Two of the four are on **names** — the
222
+ schema's field names and the group's control names; the other two are on a rule's *type* and a
223
+ control's *class*. `bindFieldProperties` compiles each expression, so one that does not *parse*
224
+ throws; nothing inspects what a parsed expression **names**.
225
+
226
+ **So `visible: "constructor"` binds here without complaint, and renders the field:**
227
+
228
+ ```ts
229
+ const form = new FormGroup({ country: new FormControl('CA') });
230
+
231
+ const binding = bindFieldProperties(
232
+ [{ name: 'city', visible: 'constructor' }],
233
+ form,
234
+ { injector }
235
+ );
236
+
237
+ binding.fields['city'].visible?.(); // => true — against a form with no `city` and no
238
+ // `constructor`, with nothing logged
239
+ ```
240
+
241
+ The identifier resolves off `Object.prototype` to a *function*, a function is truthy, and
242
+ truthiness means visible. It is the same failure the field-name check above prevents, reached
243
+ through the expression instead of through the name.
244
+
245
+ **`/signals` rejects this and `/reactive` does not**, so one authored rule string behaves two
246
+ ways: it throws under `@zvenigora/ng-eval-forms/signals` and silently renders a data-less field
247
+ here. The asymmetry is deliberate rather than an oversight — adding the check to `/reactive`
248
+ would make an expression that registers today start throwing, which is a breaking change to a
249
+ released entry point, and it is logged as a question for a later major in the
250
+ [roadmap](https://github.com/zvenigora/ng-eval/blob/master/ROADMAP.md). Until it is answered:
251
+ if your model keys or expressions can come from a server or a form-builder UI, prefer
252
+ `/signals`, or screen the expressions yourself.
253
+
196
254
  ## What an expression can name
197
255
 
198
256
  **The values of the form's controls, by control name.** Every control is mirrored,
@@ -284,6 +342,16 @@ Two things are **not** routed through it:
284
342
  dataset. Swallowing it under the default would hand you a silent blank for a syntax bug
285
343
  in the rule itself.
286
344
 
345
+ **That guarantee has one boundary, and it is stated here because the guarantee is.** It
346
+ holds for an assignment the expression makes directly. An assignment **nested inside a
347
+ call** — `[1].map(x => (country = 'CA'))` — is caught by the evaluator's own call wrapper
348
+ and re-raised as a plain `Error`, which loses the class the bypass matches on. It is then
349
+ routed by `onError` like any other failure, so under the default you get a blank field with
350
+ nothing in the console. It is a misuse inside a misuse — a rule author writing an assignment
351
+ writes `country = 'CA'`, not one buried in a `.map` callback — but it is the one shape where
352
+ the mechanism cannot see what it is for. The re-wrap belongs to
353
+ `@zvenigora/ng-eval-core` and cannot be fixed from this side.
354
+
287
355
  ## Reactivity, and its two holes
288
356
 
289
357
  A property recomputes when a control it named emits on `valueChanges`. The mirror
@@ -394,16 +462,226 @@ throws, with an error naming `toSignal` and nothing naming this library. If you
394
462
  one would silently pick up the ambient injection context when there is one, and teardown
395
463
  would then run at a time that varied with where the call happened to sit.
396
464
 
465
+ ## Signal Forms — `/signals`
466
+
467
+ **Requires Angular 22 or later.** Import from `@zvenigora/ng-eval-forms/signals`.
468
+
469
+ One factory, bound to one model signal, returning three registrars you call from inside a
470
+ `schema()` body beside Angular's own rules:
471
+
472
+ ```ts
473
+ import { signal } from '@angular/core';
474
+ import { form, schema } from '@angular/forms/signals';
475
+ import { TEXT, createExpressionRules } from '@zvenigora/ng-eval-forms/signals';
476
+
477
+ interface Order {
478
+ country: string;
479
+ state: string;
480
+ zip: string;
481
+ orderTotal: number;
482
+ }
483
+
484
+ const model = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 80 });
485
+
486
+ const rules = createExpressionRules(model);
487
+
488
+ const orderSchema = schema<Order>((p) => {
489
+ rules.evalVisible(p.state, "country === 'US'");
490
+ rules.evalText(p.zip, "orderTotal >= 100 ? 'Free shipping' : 'Standard'");
491
+ rules.evalDisabled(p.zip, "country !== 'US'", { reason: 'ZIP is US-only' });
492
+ });
493
+
494
+ const f = form(model, orderSchema);
495
+ ```
496
+
497
+ There is no `FormBinding` here and nothing to look a field up in: the rules register through
498
+ Angular's own primitives, so you read them back off Angular's field state.
499
+
500
+ ```ts
501
+ f.state().hidden(); // => true
502
+ f.zip().metadata(TEXT)?.(); // => 'Standard'
503
+ f.zip().disabled(); // => true
504
+ f.zip().disabledReasons().map((r) => r.message); // => ['ZIP is US-only']
505
+
506
+ model.set({ country: 'US', state: '', zip: '', orderTotal: 120 });
507
+
508
+ f.state().hidden(); // => false
509
+ f.zip().metadata(TEXT)?.(); // => 'Free shipping'
510
+ f.zip().disabled(); // => false
511
+ ```
512
+
513
+ Recompute is Angular's own dependency tracking, per key: a rule that named `country`
514
+ re-evaluates when `country` changes and not when any other key does.
515
+
516
+ ### The three registrars, and their polarity
517
+
518
+ | Registrar | Registers | A **true** expression means |
519
+ | :--- | :--- | :--- |
520
+ | `evalVisible(path, expression, options?)` | Angular's `hidden`, **inverted** | the field is **visible** |
521
+ | `evalText(path, expression, options?)` | `metadata(path, TEXT, …)` | — |
522
+ | `evalDisabled(path, expression, options?)` | Angular's `disabled`, uninverted | the field is **disabled** |
523
+
524
+ **`evalVisible` is named after the property, not after Angular's rule, and the inversion lives
525
+ inside the library.** That is the whole reason it exists rather than a thin `hidden` wrapper:
526
+ the same rule string means the same thing at both entry points, so `country === 'US'` is "show
527
+ it when the country is US" under `/reactive`'s `visible` and under `evalVisible` alike, with no
528
+ consumer's expression carrying a `!`. `evalDisabled` keeps Angular's polarity instead, because
529
+ `/reactive` ships no `disabled` — there is no second entry point for its expressions to agree
530
+ with, and `true` disabling is what an author expects.
531
+
532
+ **`reason` is a static string, never expression-derived.** Angular's `disabled` config is a
533
+ single field: `when` returns `boolean | string`, and a truthy string is *both* "disabled" and
534
+ "the reason". A registrar forwarding the expression's value raw would disable a field on the
535
+ string `'false'` **with the reason `"false"`** — [Coercion](#coercion)'s truthiness trap in a
536
+ new shape. Keeping the expression boolean and sourcing the reason from the registration is what
537
+ kills it. A *dynamic* reason is out of scope for the same reason.
538
+
539
+ ### Lifetime — there is nothing to destroy
540
+
541
+ Unlike [`/reactive`](#lifetime), this entry point creates no `EvalSignal`, registers nothing
542
+ with a `DestroyRef`, and has **no `destroy()`**. Angular owns the field tree's lifetime and the
543
+ rules die with the schema. What is retained, on two different clocks: **per factory**, one
544
+ private memo of per-key `computed`s, bounded by the union of keys the rules name — it lives as
545
+ long as the `createExpressionRules` value does, which for a factory built at module scope is
546
+ longer than any one form; and **per rule per `form()`**, one evaluation context and one compiled
547
+ expression, garbage when that form is.
548
+
549
+ ### Reuse a schema *function*, not a schema *value*
550
+
551
+ The registrars close over the **factory's** model, and a factory is bound to one model. So the
552
+ supported way to share rules across forms is a function of the rules, called once per form:
553
+
554
+ ```ts
555
+ import { ExpressionRules } from '@zvenigora/ng-eval-forms/signals';
556
+
557
+ const makeSchema = (rules: ExpressionRules) =>
558
+ schema<Order>((p) => {
559
+ rules.evalVisible(p.state, "country === 'US'");
560
+ });
561
+
562
+ const modelA = signal<Order>({ country: 'US', state: '', zip: '', orderTotal: 0 });
563
+ const modelB = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 0 });
564
+
565
+ const fA = form(modelA, makeSchema(createExpressionRules(modelA)));
566
+ const fB = form(modelB, makeSchema(createExpressionRules(modelB)));
567
+ ```
568
+
569
+ **Sharing a schema *value* across models compiles, runs, and is wrong.** Angular re-invokes the
570
+ schema body once per `form()`, so each form does mint its own contexts — but every rule inside
571
+ them still reads the model the *factory* was given. A schema built from
572
+ `createExpressionRules(modelA)` and passed to `form(modelB, …)` yields a fully functional form B
573
+ rendering against form A's data, silently, with no error anywhere.
574
+
575
+ ### Prototype-shadowed identifiers are rejected
576
+
577
+ An expression naming a member of `Object.prototype` — `constructor`, `toString`, `valueOf`,
578
+ `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable`, `toLocaleString`, and the five
579
+ `__proto__`-style accessors `Object.getOwnPropertyNames(Object.prototype)` also returns —
580
+ **throws**, naming the expression and the identifier:
581
+
582
+ ```ts
583
+ const bad = schema<Order>((p) => {
584
+ rules.evalVisible(p.state, 'constructor');
585
+ });
586
+
587
+ form(model, bad); // throws: identifier 'constructor' is a member of Object.prototype …
588
+ ```
589
+
590
+ Without the check the identifier resolves off the prototype to a *function*, a function is
591
+ truthy, and `evalVisible` would render precisely the field that has no data — with nothing
592
+ logged. The fix is to rename the model key.
593
+
594
+ **The throw arrives from `form()`, not from `schema()`.** The schema body is what registers, and
595
+ Angular invokes that body once per `form()` — so building the schema is silent and every
596
+ `form()` made from it throws. `/reactive` makes its (different, name-based) check at the
597
+ `bindFieldProperties(…)` call instead, so **the two entry points reject at different times**.
598
+
599
+ Three bounds on the check, none of them obvious from the paragraph above:
600
+
601
+ - **It is the expression that is checked, never the model.** A model key named off
602
+ `Object.prototype` that no expression names stays unreadable and unreported. That is
603
+ harmless — a key is only ever read because some expression names it — but it is not covered,
604
+ and it is the one thing `/reactive`'s control-name check catches that this does not.
605
+ - **A *member* expression is not this check's business.** `user.constructor` goes to
606
+ `@zvenigora/ng-eval-core`'s prototype-pollution guard, under the rules documented there.
607
+ - **It over-rejects a name the expression *binds* itself**, deliberately.
608
+ `'[1].map(valueOf => valueOf)'` throws, even though an arrow's own parameter shadows the
609
+ prototype and would have resolved correctly. A scope-aware guard would be a second copy of
610
+ the evaluator's frame logic, and one that drifted out of step would fail by
611
+ *under*-rejecting — a silent wrong answer in place of a rename. Rename the parameter.
612
+ (`'[1].map(valueOf => 1)'` registers: a binding that is never referenced is not visited.)
613
+
614
+ **`/reactive` makes no equivalent check on expressions** — see
615
+ [Expressions are not validated](#expressions-are-not-validated).
616
+
617
+ ### `caseInsensitive` is in practice a *factory* option
618
+
619
+ `ExpressionRuleOptions` — `{ eval?, onError? }` — is accepted by the factory and by each
620
+ registration, and **registration wins per key**: a registration supplying only `onError` keeps
621
+ the factory's `eval`, and vice versa. Neither key is deep-merged.
622
+
623
+ That resolution is exact for `onError` and **only partial for `eval.caseInsensitive`**:
624
+
625
+ ```ts
626
+ interface Profile {
627
+ country: string;
628
+ address: { name: string };
629
+ label: string;
630
+ }
631
+
632
+ const profile = signal<Profile>({ country: 'US', address: { name: 'HQ' }, label: '' });
633
+
634
+ const rules = createExpressionRules(profile); // caseInsensitive off at the factory
635
+
636
+ const profileSchema = schema<Profile>((p) => {
637
+ rules.evalText(p.label, 'Country + address.NAME', {
638
+ eval: { caseInsensitive: true }, // on for this registration
639
+ });
640
+ });
641
+
642
+ form(profile, profileSchema).label().metadata(TEXT)?.();
643
+ // => 'undefinedHQ'
644
+ // address.NAME resolved — a *property* name, corrected by the walk
645
+ // Country did not — an *identifier* key, still on the factory's setting
646
+ ```
647
+
648
+ The walk is the only one of the three places the option must reach that a per-registration
649
+ value gets to. The other two — the factory's key memo, and the evaluation context every rule is
650
+ given — are built from the options handed to `createExpressionRules`, fixed at factory time; the
651
+ context is minted per rule, but always from that same fixed setting, so a registration cannot
652
+ move it. One expression then ends up obeying two casing rules. **Set `caseInsensitive` on the
653
+ factory** unless that is precisely what you want.
654
+
655
+ ### Two things that are not available here
656
+
657
+ Neither is about resolution. Every key an expression can name resolves, at any spelling
658
+ `caseInsensitive` allows, whether or not the model held it when the form was built — there is
659
+ no `invalidate()` at this entry point and nothing to call it on.
660
+
661
+ - **The nested-signal diagnostic does not reach `/signals`.** A model property holding a signal
662
+ — `{ user: { name: signal('a') } }` — is read un-called by the member visitor, and
663
+ `@zvenigora/ng-eval-signals`' dev-mode warning never fires here. That shape is precisely what
664
+ the upstream scan reports, so the check is neither switched off nor blind to it: it runs over
665
+ the *source record* a context is built from, and this adapter hands it an empty one. Every
666
+ model key resolves through a lookup instead, where nothing scans. Widening the scan would not
667
+ recover it.
668
+ - **The form's key set is not enumerable from upstream**, because the memo is deliberately
669
+ private. That is the fix rather than the cost: an enumerable record is exactly what froze a
670
+ case-insensitively matched key to its first spelling for the life of the form. It is the
671
+ counterpart to [`/reactive`'s key-set caveat](#the-key-set-is-not-reactive) and the milder
672
+ one — nothing here goes stale.
673
+
397
674
  ## What is not here
398
675
 
399
676
  Deferred deliberately, each additive when it arrives:
400
677
 
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.
678
+ - **`disabled` at `/reactive`.** It ships at [`/signals`](#the-three-registrars-and-their-polarity)
679
+ and not here, and the asymmetry is the point rather than a gap. Applying it to a
680
+ `FormControl` means calling `control.disable()`, which is three problems at once: it is a
681
+ write back into the form rather than derived state; it emits on `valueChanges` by default, so
682
+ a rule naming its own field re-enters its own input and whether that converges depends on the
683
+ expression; and it removes the value from the parent's aggregate. Under Signal Forms
684
+ `disabled` is a schema rule over derived state and none of the three exists.
407
685
  - **`required` and validators**, which affect form validity rather than presentation.
408
686
  - **Form state keys**, `FormArray` and nested `FormGroup`, and field-local keys — see
409
687
  [What an expression can name](#what-an-expression-can-name).
@@ -419,6 +697,12 @@ npx nx test eval-forms
419
697
  npx nx run eval-forms:build:production
420
698
  ```
421
699
 
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.
700
+ Two `readme-examples.spec.ts` files execute the runnable examples in this document, and the
701
+ split is not quite by folder: the one under `reactive/src/lib/` covers the `/reactive` blocks,
702
+ the shared core's two [Coercion](#coercion) blocks and the
703
+ [worked example](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/worked-example.md);
704
+ the one under `signals/src/lib/` covers the `/signals` blocks **plus the one `/reactive` block
705
+ whose subject is the difference between the two entry points**, because that claim is a pair and
706
+ splitting it would let either half drift alone. So a documented example that stops working fails
707
+ the suite rather than shipping. The template and manifest blocks are not executable and are not
708
+ covered.