@zvenigora/ng-eval-forms 0.1.0 → 0.2.1
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 +332 -27
- package/fesm2022/zvenigora-ng-eval-forms-reactive.mjs +7 -6
- package/fesm2022/zvenigora-ng-eval-forms-reactive.mjs.map +1 -1
- package/fesm2022/zvenigora-ng-eval-forms-signals.mjs +511 -0
- package/fesm2022/zvenigora-ng-eval-forms-signals.mjs.map +1 -0
- package/fesm2022/zvenigora-ng-eval-forms.mjs +90 -2
- package/fesm2022/zvenigora-ng-eval-forms.mjs.map +1 -1
- package/package.json +10 -2
- package/types/zvenigora-ng-eval-forms-reactive.d.ts +7 -6
- package/types/zvenigora-ng-eval-forms-signals.d.ts +135 -0
- package/types/zvenigora-ng-eval-forms.d.ts +61 -8
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @zvenigora/ng-eval-forms
|
|
2
2
|
|
|
3
|
-
Angular form field properties — `visible` and `text`
|
|
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`.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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",
|
|
66
|
-
"
|
|
69
|
+
"acorn-walk": "^8.3.0",
|
|
70
|
+
"@zvenigora/ng-eval-core": ">=0.3.0 <0.5.0",
|
|
67
71
|
"@zvenigora/ng-eval-signals": "^0.1.0"
|
|
68
72
|
}
|
|
69
73
|
```
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
an
|
|
75
|
-
Angular's own `exports` map rather than
|
|
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,
|
|
@@ -274,7 +332,28 @@ expression that fails here may have been typed into a form builder by an end use
|
|
|
274
332
|
right response to "the administrator wrote a bad rule" is a field that does not render, not
|
|
275
333
|
an application that throws on every change-detection pass.
|
|
276
334
|
|
|
277
|
-
|
|
335
|
+
`applyErrorPolicy` is that decision on its own, exported so an adapter applies the rule rather
|
|
336
|
+
than reimplementing it — and so you can apply it to a rule invocation you drive yourself:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { applyErrorPolicy } from '@zvenigora/ng-eval-forms';
|
|
340
|
+
// SignalContextWriteError is @zvenigora/ng-eval-signals' — neither adapter re-exports it.
|
|
341
|
+
|
|
342
|
+
const boom = () => { throw new Error('bad rule'); };
|
|
343
|
+
|
|
344
|
+
applyErrorPolicy(boom); // undefined — the default
|
|
345
|
+
applyErrorPolicy(boom, 'undefined'); // undefined
|
|
346
|
+
applyErrorPolicy(boom, () => '—'); // '—'
|
|
347
|
+
applyErrorPolicy(boom, 'throw'); // rethrows Error('bad rule')
|
|
348
|
+
applyErrorPolicy(() => 'fine'); // 'fine' — nothing thrown, nothing to police
|
|
349
|
+
|
|
350
|
+
// A write violation is rethrown whatever the policy says:
|
|
351
|
+
const write = () => { throw new SignalContextWriteError('country', 'country = "CA"'); };
|
|
352
|
+
|
|
353
|
+
applyErrorPolicy(write, 'undefined'); // throws SignalContextWriteError
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Two things are **not** routed through `options.onError`, in either adapter:
|
|
278
357
|
|
|
279
358
|
- **A parse error throws from `bindFieldProperties` itself**, whatever the policy.
|
|
280
359
|
Expressions are compiled eagerly, so `visible: 'country ==='` fails at bind time — and
|
|
@@ -284,6 +363,16 @@ Two things are **not** routed through it:
|
|
|
284
363
|
dataset. Swallowing it under the default would hand you a silent blank for a syntax bug
|
|
285
364
|
in the rule itself.
|
|
286
365
|
|
|
366
|
+
**That guarantee has one boundary, and it is stated here because the guarantee is.** It
|
|
367
|
+
holds for an assignment the expression makes directly. An assignment **nested inside a
|
|
368
|
+
call** — `[1].map(x => (country = 'CA'))` — is caught by the evaluator's own call wrapper
|
|
369
|
+
and re-raised as a plain `Error`, which loses the class the bypass matches on. It is then
|
|
370
|
+
routed by `onError` like any other failure, so under the default you get a blank field with
|
|
371
|
+
nothing in the console. It is a misuse inside a misuse — a rule author writing an assignment
|
|
372
|
+
writes `country = 'CA'`, not one buried in a `.map` callback — but it is the one shape where
|
|
373
|
+
the mechanism cannot see what it is for. The re-wrap belongs to
|
|
374
|
+
`@zvenigora/ng-eval-core` and cannot be fixed from this side.
|
|
375
|
+
|
|
287
376
|
## Reactivity, and its two holes
|
|
288
377
|
|
|
289
378
|
A property recomputes when a control it named emits on `valueChanges`. The mirror
|
|
@@ -394,16 +483,226 @@ throws, with an error naming `toSignal` and nothing naming this library. If you
|
|
|
394
483
|
one would silently pick up the ambient injection context when there is one, and teardown
|
|
395
484
|
would then run at a time that varied with where the call happened to sit.
|
|
396
485
|
|
|
486
|
+
## Signal Forms — `/signals`
|
|
487
|
+
|
|
488
|
+
**Requires Angular 22 or later.** Import from `@zvenigora/ng-eval-forms/signals`.
|
|
489
|
+
|
|
490
|
+
One factory, bound to one model signal, returning three registrars you call from inside a
|
|
491
|
+
`schema()` body beside Angular's own rules:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
import { signal } from '@angular/core';
|
|
495
|
+
import { form, schema } from '@angular/forms/signals';
|
|
496
|
+
import { TEXT, createExpressionRules } from '@zvenigora/ng-eval-forms/signals';
|
|
497
|
+
|
|
498
|
+
interface Order {
|
|
499
|
+
country: string;
|
|
500
|
+
state: string;
|
|
501
|
+
zip: string;
|
|
502
|
+
orderTotal: number;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const model = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 80 });
|
|
506
|
+
|
|
507
|
+
const rules = createExpressionRules(model);
|
|
508
|
+
|
|
509
|
+
const orderSchema = schema<Order>((p) => {
|
|
510
|
+
rules.evalVisible(p.state, "country === 'US'");
|
|
511
|
+
rules.evalText(p.zip, "orderTotal >= 100 ? 'Free shipping' : 'Standard'");
|
|
512
|
+
rules.evalDisabled(p.zip, "country !== 'US'", { reason: 'ZIP is US-only' });
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const f = form(model, orderSchema);
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
There is no `FormBinding` here and nothing to look a field up in: the rules register through
|
|
519
|
+
Angular's own primitives, so you read them back off Angular's field state.
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
f.state().hidden(); // => true
|
|
523
|
+
f.zip().metadata(TEXT)?.(); // => 'Standard'
|
|
524
|
+
f.zip().disabled(); // => true
|
|
525
|
+
f.zip().disabledReasons().map((r) => r.message); // => ['ZIP is US-only']
|
|
526
|
+
|
|
527
|
+
model.set({ country: 'US', state: '', zip: '', orderTotal: 120 });
|
|
528
|
+
|
|
529
|
+
f.state().hidden(); // => false
|
|
530
|
+
f.zip().metadata(TEXT)?.(); // => 'Free shipping'
|
|
531
|
+
f.zip().disabled(); // => false
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Recompute is Angular's own dependency tracking, per key: a rule that named `country`
|
|
535
|
+
re-evaluates when `country` changes and not when any other key does.
|
|
536
|
+
|
|
537
|
+
### The three registrars, and their polarity
|
|
538
|
+
|
|
539
|
+
| Registrar | Registers | A **true** expression means |
|
|
540
|
+
| :--- | :--- | :--- |
|
|
541
|
+
| `evalVisible(path, expression, options?)` | Angular's `hidden`, **inverted** | the field is **visible** |
|
|
542
|
+
| `evalText(path, expression, options?)` | `metadata(path, TEXT, …)` | — |
|
|
543
|
+
| `evalDisabled(path, expression, options?)` | Angular's `disabled`, uninverted | the field is **disabled** |
|
|
544
|
+
|
|
545
|
+
**`evalVisible` is named after the property, not after Angular's rule, and the inversion lives
|
|
546
|
+
inside the library.** That is the whole reason it exists rather than a thin `hidden` wrapper:
|
|
547
|
+
the same rule string means the same thing at both entry points, so `country === 'US'` is "show
|
|
548
|
+
it when the country is US" under `/reactive`'s `visible` and under `evalVisible` alike, with no
|
|
549
|
+
consumer's expression carrying a `!`. `evalDisabled` keeps Angular's polarity instead, because
|
|
550
|
+
`/reactive` ships no `disabled` — there is no second entry point for its expressions to agree
|
|
551
|
+
with, and `true` disabling is what an author expects.
|
|
552
|
+
|
|
553
|
+
**`reason` is a static string, never expression-derived.** Angular's `disabled` config is a
|
|
554
|
+
single field: `when` returns `boolean | string`, and a truthy string is *both* "disabled" and
|
|
555
|
+
"the reason". A registrar forwarding the expression's value raw would disable a field on the
|
|
556
|
+
string `'false'` **with the reason `"false"`** — [Coercion](#coercion)'s truthiness trap in a
|
|
557
|
+
new shape. Keeping the expression boolean and sourcing the reason from the registration is what
|
|
558
|
+
kills it. A *dynamic* reason is out of scope for the same reason.
|
|
559
|
+
|
|
560
|
+
### Lifetime — there is nothing to destroy
|
|
561
|
+
|
|
562
|
+
Unlike [`/reactive`](#lifetime), this entry point creates no `EvalSignal`, registers nothing
|
|
563
|
+
with a `DestroyRef`, and has **no `destroy()`**. Angular owns the field tree's lifetime and the
|
|
564
|
+
rules die with the schema. What is retained, on two different clocks: **per factory**, one
|
|
565
|
+
private memo of per-key `computed`s, bounded by the union of keys the rules name — it lives as
|
|
566
|
+
long as the `createExpressionRules` value does, which for a factory built at module scope is
|
|
567
|
+
longer than any one form; and **per rule per `form()`**, one evaluation context and one compiled
|
|
568
|
+
expression, garbage when that form is.
|
|
569
|
+
|
|
570
|
+
### Reuse a schema *function*, not a schema *value*
|
|
571
|
+
|
|
572
|
+
The registrars close over the **factory's** model, and a factory is bound to one model. So the
|
|
573
|
+
supported way to share rules across forms is a function of the rules, called once per form:
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
import { ExpressionRules } from '@zvenigora/ng-eval-forms/signals';
|
|
577
|
+
|
|
578
|
+
const makeSchema = (rules: ExpressionRules) =>
|
|
579
|
+
schema<Order>((p) => {
|
|
580
|
+
rules.evalVisible(p.state, "country === 'US'");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const modelA = signal<Order>({ country: 'US', state: '', zip: '', orderTotal: 0 });
|
|
584
|
+
const modelB = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 0 });
|
|
585
|
+
|
|
586
|
+
const fA = form(modelA, makeSchema(createExpressionRules(modelA)));
|
|
587
|
+
const fB = form(modelB, makeSchema(createExpressionRules(modelB)));
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**Sharing a schema *value* across models compiles, runs, and is wrong.** Angular re-invokes the
|
|
591
|
+
schema body once per `form()`, so each form does mint its own contexts — but every rule inside
|
|
592
|
+
them still reads the model the *factory* was given. A schema built from
|
|
593
|
+
`createExpressionRules(modelA)` and passed to `form(modelB, …)` yields a fully functional form B
|
|
594
|
+
rendering against form A's data, silently, with no error anywhere.
|
|
595
|
+
|
|
596
|
+
### Prototype-shadowed identifiers are rejected
|
|
597
|
+
|
|
598
|
+
An expression naming a member of `Object.prototype` — `constructor`, `toString`, `valueOf`,
|
|
599
|
+
`hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable`, `toLocaleString`, and the five
|
|
600
|
+
`__proto__`-style accessors `Object.getOwnPropertyNames(Object.prototype)` also returns —
|
|
601
|
+
**throws**, naming the expression and the identifier:
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
const bad = schema<Order>((p) => {
|
|
605
|
+
rules.evalVisible(p.state, 'constructor');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
form(model, bad); // throws: identifier 'constructor' is a member of Object.prototype …
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
Without the check the identifier resolves off the prototype to a *function*, a function is
|
|
612
|
+
truthy, and `evalVisible` would render precisely the field that has no data — with nothing
|
|
613
|
+
logged. The fix is to rename the model key.
|
|
614
|
+
|
|
615
|
+
**The throw arrives from `form()`, not from `schema()`.** The schema body is what registers, and
|
|
616
|
+
Angular invokes that body once per `form()` — so building the schema is silent and every
|
|
617
|
+
`form()` made from it throws. `/reactive` makes its (different, name-based) check at the
|
|
618
|
+
`bindFieldProperties(…)` call instead, so **the two entry points reject at different times**.
|
|
619
|
+
|
|
620
|
+
Three bounds on the check, none of them obvious from the paragraph above:
|
|
621
|
+
|
|
622
|
+
- **It is the expression that is checked, never the model.** A model key named off
|
|
623
|
+
`Object.prototype` that no expression names stays unreadable and unreported. That is
|
|
624
|
+
harmless — a key is only ever read because some expression names it — but it is not covered,
|
|
625
|
+
and it is the one thing `/reactive`'s control-name check catches that this does not.
|
|
626
|
+
- **A *member* expression is not this check's business.** `user.constructor` goes to
|
|
627
|
+
`@zvenigora/ng-eval-core`'s prototype-pollution guard, under the rules documented there.
|
|
628
|
+
- **It over-rejects a name the expression *binds* itself**, deliberately.
|
|
629
|
+
`'[1].map(valueOf => valueOf)'` throws, even though an arrow's own parameter shadows the
|
|
630
|
+
prototype and would have resolved correctly. A scope-aware guard would be a second copy of
|
|
631
|
+
the evaluator's frame logic, and one that drifted out of step would fail by
|
|
632
|
+
*under*-rejecting — a silent wrong answer in place of a rename. Rename the parameter.
|
|
633
|
+
(`'[1].map(valueOf => 1)'` registers: a binding that is never referenced is not visited.)
|
|
634
|
+
|
|
635
|
+
**`/reactive` makes no equivalent check on expressions** — see
|
|
636
|
+
[Expressions are not validated](#expressions-are-not-validated).
|
|
637
|
+
|
|
638
|
+
### `caseInsensitive` is in practice a *factory* option
|
|
639
|
+
|
|
640
|
+
`ExpressionRuleOptions` — `{ eval?, onError? }` — is accepted by the factory and by each
|
|
641
|
+
registration, and **registration wins per key**: a registration supplying only `onError` keeps
|
|
642
|
+
the factory's `eval`, and vice versa. Neither key is deep-merged.
|
|
643
|
+
|
|
644
|
+
That resolution is exact for `onError` and **only partial for `eval.caseInsensitive`**:
|
|
645
|
+
|
|
646
|
+
```ts
|
|
647
|
+
interface Profile {
|
|
648
|
+
country: string;
|
|
649
|
+
address: { name: string };
|
|
650
|
+
label: string;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const profile = signal<Profile>({ country: 'US', address: { name: 'HQ' }, label: '' });
|
|
654
|
+
|
|
655
|
+
const rules = createExpressionRules(profile); // caseInsensitive off at the factory
|
|
656
|
+
|
|
657
|
+
const profileSchema = schema<Profile>((p) => {
|
|
658
|
+
rules.evalText(p.label, 'Country + address.NAME', {
|
|
659
|
+
eval: { caseInsensitive: true }, // on for this registration
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
form(profile, profileSchema).label().metadata(TEXT)?.();
|
|
664
|
+
// => 'undefinedHQ'
|
|
665
|
+
// address.NAME resolved — a *property* name, corrected by the walk
|
|
666
|
+
// Country did not — an *identifier* key, still on the factory's setting
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
The walk is the only one of the three places the option must reach that a per-registration
|
|
670
|
+
value gets to. The other two — the factory's key memo, and the evaluation context every rule is
|
|
671
|
+
given — are built from the options handed to `createExpressionRules`, fixed at factory time; the
|
|
672
|
+
context is minted per rule, but always from that same fixed setting, so a registration cannot
|
|
673
|
+
move it. One expression then ends up obeying two casing rules. **Set `caseInsensitive` on the
|
|
674
|
+
factory** unless that is precisely what you want.
|
|
675
|
+
|
|
676
|
+
### Two things that are not available here
|
|
677
|
+
|
|
678
|
+
Neither is about resolution. Every key an expression can name resolves, at any spelling
|
|
679
|
+
`caseInsensitive` allows, whether or not the model held it when the form was built — there is
|
|
680
|
+
no `invalidate()` at this entry point and nothing to call it on.
|
|
681
|
+
|
|
682
|
+
- **The nested-signal diagnostic does not reach `/signals`.** A model property holding a signal
|
|
683
|
+
— `{ user: { name: signal('a') } }` — is read un-called by the member visitor, and
|
|
684
|
+
`@zvenigora/ng-eval-signals`' dev-mode warning never fires here. That shape is precisely what
|
|
685
|
+
the upstream scan reports, so the check is neither switched off nor blind to it: it runs over
|
|
686
|
+
the *source record* a context is built from, and this adapter hands it an empty one. Every
|
|
687
|
+
model key resolves through a lookup instead, where nothing scans. Widening the scan would not
|
|
688
|
+
recover it.
|
|
689
|
+
- **The form's key set is not enumerable from upstream**, because the memo is deliberately
|
|
690
|
+
private. That is the fix rather than the cost: an enumerable record is exactly what froze a
|
|
691
|
+
case-insensitively matched key to its first spelling for the life of the form. It is the
|
|
692
|
+
counterpart to [`/reactive`'s key-set caveat](#the-key-set-is-not-reactive) and the milder
|
|
693
|
+
one — nothing here goes stale.
|
|
694
|
+
|
|
397
695
|
## What is not here
|
|
398
696
|
|
|
399
697
|
Deferred deliberately, each additive when it arrives:
|
|
400
698
|
|
|
401
|
-
- **`disabled`.**
|
|
402
|
-
|
|
403
|
-
`
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
699
|
+
- **`disabled` at `/reactive`.** It ships at [`/signals`](#the-three-registrars-and-their-polarity)
|
|
700
|
+
and not here, and the asymmetry is the point rather than a gap. Applying it to a
|
|
701
|
+
`FormControl` means calling `control.disable()`, which is three problems at once: it is a
|
|
702
|
+
write back into the form rather than derived state; it emits on `valueChanges` by default, so
|
|
703
|
+
a rule naming its own field re-enters its own input and whether that converges depends on the
|
|
704
|
+
expression; and it removes the value from the parent's aggregate. Under Signal Forms
|
|
705
|
+
`disabled` is a schema rule over derived state and none of the three exists.
|
|
407
706
|
- **`required` and validators**, which affect form validity rather than presentation.
|
|
408
707
|
- **Form state keys**, `FormArray` and nested `FormGroup`, and field-local keys — see
|
|
409
708
|
[What an expression can name](#what-an-expression-can-name).
|
|
@@ -419,6 +718,12 @@ npx nx test eval-forms
|
|
|
419
718
|
npx nx run eval-forms:build:production
|
|
420
719
|
```
|
|
421
720
|
|
|
422
|
-
`readme-examples.spec.ts`
|
|
423
|
-
|
|
424
|
-
|
|
721
|
+
Two `readme-examples.spec.ts` files execute the runnable examples in this document, and the
|
|
722
|
+
split is not quite by folder: the one under `reactive/src/lib/` covers the `/reactive` blocks,
|
|
723
|
+
the shared core's two [Coercion](#coercion) blocks and the
|
|
724
|
+
[worked example](https://github.com/zvenigora/ng-eval/blob/master/docs/forms/worked-example.md);
|
|
725
|
+
the one under `signals/src/lib/` covers the `/signals` blocks **plus the one `/reactive` block
|
|
726
|
+
whose subject is the difference between the two entry points**, because that claim is a pair and
|
|
727
|
+
splitting it would let either half drift alone. So a documented example that stops working fails
|
|
728
|
+
the suite rather than shipping. The template and manifest blocks are not executable and are not
|
|
729
|
+
covered.
|
|
@@ -281,12 +281,13 @@ const validate = (schema, group) => {
|
|
|
281
281
|
* ```
|
|
282
282
|
*
|
|
283
283
|
* **One `EvalContext` per field, never one shared** (plan S 3.4.1). `get`
|
|
284
|
-
* resolves `scopes` and `original` *before* `lookups`, and
|
|
285
|
-
*
|
|
286
|
-
* one field's arrow
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* N x M: the properties of one field resolve against the
|
|
284
|
+
* resolves `scopes` and `original` *before* `lookups`, and an arrow function's
|
|
285
|
+
* parameter scope sits on the context for as long as its body runs - so under
|
|
286
|
+
* a shared context one field's arrow would shadow every other field's key of
|
|
287
|
+
* the same name for that window, and anything that strands a scope rather than
|
|
288
|
+
* popping it would do so for the life of the form. The count is N contexts for
|
|
289
|
+
* N fields, and not N x M: the properties of one field resolve against the
|
|
290
|
+
* same names.
|
|
290
291
|
*
|
|
291
292
|
* **The field half of each context is empty in this phase.**
|
|
292
293
|
* `createFieldContext` takes two sources and the second is `{}` here. What a
|