@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createSignalContext } from '@zvenigora/ng-eval-signals';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Coerces an expression's result to the boolean a `visible` rule needs.
|
|
5
|
+
*
|
|
6
|
+
* **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author
|
|
7
|
+
* writing `visible: "country === 'US'"` gets a boolean already; the coercion
|
|
8
|
+
* exists for the ones who write `visible: "country"`.
|
|
9
|
+
*
|
|
10
|
+
* `undefined` is therefore the load-bearing input rather than an edge case.
|
|
11
|
+
* An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:
|
|
12
|
+
* a field key bound to `undefined` is treated as absent, and an empty
|
|
13
|
+
* `FormControl` is the common case - so both arrive as `undefined`, and both
|
|
14
|
+
* mean "not visible".
|
|
15
|
+
*
|
|
16
|
+
* The string `'false'` is **visible**, because it is a non-empty string. That
|
|
17
|
+
* is truthiness rather than parsing, and it is the answer a form-builder UI
|
|
18
|
+
* storing every value as a string will hit. Documented in the README rather
|
|
19
|
+
* than special-cased: a coercion that parsed `'false'` would then owe an
|
|
20
|
+
* answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.
|
|
21
|
+
*
|
|
22
|
+
* Both adapters call this, which is why it is in the core: `/reactive` and
|
|
23
|
+
* `/signals` differ in how they schedule the recompute and not in what the
|
|
24
|
+
* result means (S 3.4.5).
|
|
25
|
+
*
|
|
26
|
+
* @param value - Whatever the expression evaluated to.
|
|
27
|
+
* @returns `true` when the value is truthy.
|
|
28
|
+
*/
|
|
29
|
+
const toVisible = (value) => !!value;
|
|
30
|
+
/**
|
|
31
|
+
* Coerces an expression's result to the string a `text` rule needs.
|
|
32
|
+
*
|
|
33
|
+
* `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).
|
|
34
|
+
* Those two are the carve-out and the only one: rendering the literal text
|
|
35
|
+
* `undefined` into a label is the failure this rule exists to prevent, and it
|
|
36
|
+
* is what a bare `String(value)` would do for the most ordinary input a form
|
|
37
|
+
* has - the empty control of S 3.4.3.
|
|
38
|
+
*
|
|
39
|
+
* Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is
|
|
40
|
+
* `'false'`. Mapping all falsy values to `''` would be the obvious way to
|
|
41
|
+
* write this rule wrongly, and it would blank a field whose value is
|
|
42
|
+
* legitimately zero.
|
|
43
|
+
*
|
|
44
|
+
* @param value - Whatever the expression evaluated to.
|
|
45
|
+
* @returns The value as a string, or `''` when it is `null` or `undefined`.
|
|
46
|
+
*/
|
|
47
|
+
const toText = (value) => value === null || value === undefined ? '' : String(value);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Composes one `EvalContext` for one field, out of two live sources.
|
|
51
|
+
*
|
|
52
|
+
* The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The
|
|
53
|
+
* field half is a `createSignalContext` over `fieldSource`; the form half is a
|
|
54
|
+
* second resolver pushed onto the same context's `lookups`. Both close over
|
|
55
|
+
* their source and read it at resolve time, so a key added to either after
|
|
56
|
+
* construction resolves - there is no third object to keep in sync, and no
|
|
57
|
+
* write-through to N contexts.
|
|
58
|
+
*
|
|
59
|
+
* `EvalContext.get` walks `lookups` in order and takes the first
|
|
60
|
+
* non-`undefined`, so the field half is consulted first.
|
|
61
|
+
*
|
|
62
|
+
* One context per field, never one shared across fields (plan S 3.4.1):
|
|
63
|
+
* `get` resolves `scopes` and `original` *before* `lookups`, so anything left
|
|
64
|
+
* on a shared context - an arrow function's leaked scope, say - would be read
|
|
65
|
+
* ahead of every other field's source key of the same name, for the life of
|
|
66
|
+
* the form.
|
|
67
|
+
*
|
|
68
|
+
* **Resolution is live; appearance is not reactive.** An expression that read
|
|
69
|
+
* a then-missing key subscribed to nothing, so the owner of the source calls
|
|
70
|
+
* `invalidate()` when the *key set* changes. A change to a *value* is
|
|
71
|
+
* Angular's job and needs nothing.
|
|
72
|
+
*
|
|
73
|
+
* **Precedence is value-dependent, not permanent.** A field key wins while
|
|
74
|
+
* its value is *present*: `EvalContext.get` treats `undefined` as absent at
|
|
75
|
+
* every step, and the field resolver returns `undefined` both for "no such
|
|
76
|
+
* key" and for "key bound to `undefined`" - so an empty `FormControl`, which
|
|
77
|
+
* is the common case rather than an exotic one, falls through to the form
|
|
78
|
+
* value. Documented as a limitation (plan S 3.4.3): distinguishing the two
|
|
79
|
+
* would need a sentinel threaded through `EvalContext.get`, which is
|
|
80
|
+
* `eval-core`'s and out of this phase's scope.
|
|
81
|
+
*
|
|
82
|
+
* @param formSource - The form-wide keys, shared by every field of the form.
|
|
83
|
+
* @param fieldSource - The field-local keys. These win on a name collision,
|
|
84
|
+
* while their value is not `undefined`.
|
|
85
|
+
* @param options - Passed to `createSignalContext` for **both** halves;
|
|
86
|
+
* configures this context and its resolvers, not the walk.
|
|
87
|
+
* As upstream, `caseInsensitive` corrects identifier keys
|
|
88
|
+
* here but not *property* names - the member visitor reads
|
|
89
|
+
* those off the state's options, so the same options must
|
|
90
|
+
* also reach `simpleEval` / `createState`.
|
|
91
|
+
*/
|
|
92
|
+
const createFieldContext = (formSource, fieldSource, options) => {
|
|
93
|
+
const context = createSignalContext(fieldSource, options);
|
|
94
|
+
// The form half **borrows upstream's resolver rather than rewriting it**
|
|
95
|
+
// (S 3.4.2, candidate A, settled in step 2). The second context is
|
|
96
|
+
// discarded and only its closure survives, so this is still one
|
|
97
|
+
// `EvalContext` per field and the field half is still consulted first.
|
|
98
|
+
//
|
|
99
|
+
// The deciding reason is row 2 of S 3.4.2's measured table, and it is that
|
|
100
|
+
// the hand-written read is *wrong*, not merely thinner: it returns a signal
|
|
101
|
+
// un-called, so a form key holding `signal(undefined)` resolves to the
|
|
102
|
+
// signal **function**, which is truthy - and `visible: "country"` then
|
|
103
|
+
// renders a field precisely when its value is absent, silently, on an empty
|
|
104
|
+
// control. That is every form's first render. Recorded here because the
|
|
105
|
+
// alternative was rejected on correctness and stays rejected even once
|
|
106
|
+
// S 3.5 names a mechanism that keeps a plain-value record live.
|
|
107
|
+
//
|
|
108
|
+
// Borrowing also brings own-property semantics - so a server-supplied field
|
|
109
|
+
// named `constructor` cannot resolve off `Object.prototype` *through this
|
|
110
|
+
// half*, which is a narrower claim than it looks and no test reaches it:
|
|
111
|
+
// `get` consults `original` before `lookups`, and `getContextValue` reads a
|
|
112
|
+
// plain object as a bare property access, so an inherited name resolves
|
|
113
|
+
// there first and never arrives here (S 3.4.3's third layer, upstream and
|
|
114
|
+
// out of this phase's scope). It also brings `caseInsensitive` correction
|
|
115
|
+
// and `warnOnNestedSignals` over the form source, with no import beyond the
|
|
116
|
+
// one already here.
|
|
117
|
+
//
|
|
118
|
+
// The scan is the one cost: `createSignalContext` runs
|
|
119
|
+
// `warnOnNestedSignals` unconditionally, so a form of N fields scans the
|
|
120
|
+
// same form source N times and, in dev mode, would warn N times under
|
|
121
|
+
// *upstream's* symbol name for a call this library made. Construction-time
|
|
122
|
+
// only - no per-node or per-recompute path is touched.
|
|
123
|
+
context.lookups.push(...createSignalContext(formSource, options).lookups);
|
|
124
|
+
return context;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Generated bundle index. Do not edit.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
export { createFieldContext, toText, toVisible };
|
|
132
|
+
//# sourceMappingURL=zvenigora-ng-eval-forms.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zvenigora-ng-eval-forms.mjs","sources":["../../../../modules/eval-forms/src/lib/coercion.ts","../../../../modules/eval-forms/src/lib/field-context.ts","../../../../modules/eval-forms/src/zvenigora-ng-eval-forms.ts"],"sourcesContent":["/**\r\n * Coerces an expression's result to the boolean a `visible` rule needs.\r\n *\r\n * **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author\r\n * writing `visible: \"country === 'US'\"` gets a boolean already; the coercion\r\n * exists for the ones who write `visible: \"country\"`.\r\n *\r\n * `undefined` is therefore the load-bearing input rather than an edge case.\r\n * An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:\r\n * a field key bound to `undefined` is treated as absent, and an empty\r\n * `FormControl` is the common case - so both arrive as `undefined`, and both\r\n * mean \"not visible\".\r\n *\r\n * The string `'false'` is **visible**, because it is a non-empty string. That\r\n * is truthiness rather than parsing, and it is the answer a form-builder UI\r\n * storing every value as a string will hit. Documented in the README rather\r\n * than special-cased: a coercion that parsed `'false'` would then owe an\r\n * answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.\r\n *\r\n * Both adapters call this, which is why it is in the core: `/reactive` and\r\n * `/signals` differ in how they schedule the recompute and not in what the\r\n * result means (S 3.4.5).\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns `true` when the value is truthy.\r\n */\r\nexport const toVisible = (value: unknown): boolean => !!value;\r\n\r\n/**\r\n * Coerces an expression's result to the string a `text` rule needs.\r\n *\r\n * `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).\r\n * Those two are the carve-out and the only one: rendering the literal text\r\n * `undefined` into a label is the failure this rule exists to prevent, and it\r\n * is what a bare `String(value)` would do for the most ordinary input a form\r\n * has - the empty control of S 3.4.3.\r\n *\r\n * Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is\r\n * `'false'`. Mapping all falsy values to `''` would be the obvious way to\r\n * write this rule wrongly, and it would blank a field whose value is\r\n * legitimately zero.\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns The value as a string, or `''` when it is `null` or `undefined`.\r\n */\r\nexport const toText = (value: unknown): string =>\r\n value === null || value === undefined ? '' : String(value);\r\n","import { EvalContext, EvalOptions } from '@zvenigora/ng-eval-core';\r\nimport { SignalContextSource, createSignalContext } from '@zvenigora/ng-eval-signals';\r\n\r\n/**\r\n * Composes one `EvalContext` for one field, out of two live sources.\r\n *\r\n * The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The\r\n * field half is a `createSignalContext` over `fieldSource`; the form half is a\r\n * second resolver pushed onto the same context's `lookups`. Both close over\r\n * their source and read it at resolve time, so a key added to either after\r\n * construction resolves - there is no third object to keep in sync, and no\r\n * write-through to N contexts.\r\n *\r\n * `EvalContext.get` walks `lookups` in order and takes the first\r\n * non-`undefined`, so the field half is consulted first.\r\n *\r\n * One context per field, never one shared across fields (plan S 3.4.1):\r\n * `get` resolves `scopes` and `original` *before* `lookups`, so anything left\r\n * on a shared context - an arrow function's leaked scope, say - would be read\r\n * ahead of every other field's source key of the same name, for the life of\r\n * the form.\r\n *\r\n * **Resolution is live; appearance is not reactive.** An expression that read\r\n * a then-missing key subscribed to nothing, so the owner of the source calls\r\n * `invalidate()` when the *key set* changes. A change to a *value* is\r\n * Angular's job and needs nothing.\r\n *\r\n * **Precedence is value-dependent, not permanent.** A field key wins while\r\n * its value is *present*: `EvalContext.get` treats `undefined` as absent at\r\n * every step, and the field resolver returns `undefined` both for \"no such\r\n * key\" and for \"key bound to `undefined`\" - so an empty `FormControl`, which\r\n * is the common case rather than an exotic one, falls through to the form\r\n * value. Documented as a limitation (plan S 3.4.3): distinguishing the two\r\n * would need a sentinel threaded through `EvalContext.get`, which is\r\n * `eval-core`'s and out of this phase's scope.\r\n *\r\n * @param formSource - The form-wide keys, shared by every field of the form.\r\n * @param fieldSource - The field-local keys. These win on a name collision,\r\n * while their value is not `undefined`.\r\n * @param options - Passed to `createSignalContext` for **both** halves;\r\n * configures this context and its resolvers, not the walk.\r\n * As upstream, `caseInsensitive` corrects identifier keys\r\n * here but not *property* names - the member visitor reads\r\n * those off the state's options, so the same options must\r\n * also reach `simpleEval` / `createState`.\r\n */\r\nexport const createFieldContext = (\r\n formSource: SignalContextSource,\r\n fieldSource: SignalContextSource,\r\n options?: EvalOptions\r\n): EvalContext => {\r\n\r\n const context = createSignalContext(fieldSource, options);\r\n\r\n // The form half **borrows upstream's resolver rather than rewriting it**\r\n // (S 3.4.2, candidate A, settled in step 2). The second context is\r\n // discarded and only its closure survives, so this is still one\r\n // `EvalContext` per field and the field half is still consulted first.\r\n //\r\n // The deciding reason is row 2 of S 3.4.2's measured table, and it is that\r\n // the hand-written read is *wrong*, not merely thinner: it returns a signal\r\n // un-called, so a form key holding `signal(undefined)` resolves to the\r\n // signal **function**, which is truthy - and `visible: \"country\"` then\r\n // renders a field precisely when its value is absent, silently, on an empty\r\n // control. That is every form's first render. Recorded here because the\r\n // alternative was rejected on correctness and stays rejected even once\r\n // S 3.5 names a mechanism that keeps a plain-value record live.\r\n //\r\n // Borrowing also brings own-property semantics - so a server-supplied field\r\n // named `constructor` cannot resolve off `Object.prototype` *through this\r\n // half*, which is a narrower claim than it looks and no test reaches it:\r\n // `get` consults `original` before `lookups`, and `getContextValue` reads a\r\n // plain object as a bare property access, so an inherited name resolves\r\n // there first and never arrives here (S 3.4.3's third layer, upstream and\r\n // out of this phase's scope). It also brings `caseInsensitive` correction\r\n // and `warnOnNestedSignals` over the form source, with no import beyond the\r\n // one already here.\r\n //\r\n // The scan is the one cost: `createSignalContext` runs\r\n // `warnOnNestedSignals` unconditionally, so a form of N fields scans the\r\n // same form source N times and, in dev mode, would warn N times under\r\n // *upstream's* symbol name for a call this library made. Construction-time\r\n // only - no per-node or per-recompute path is touched.\r\n context.lookups.push(...createSignalContext(formSource, options).lookups);\r\n\r\n return context;\r\n};\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;AAyBG;AACI,MAAM,SAAS,GAAG,CAAC,KAAc,KAAc,CAAC,CAAC;AAExD;;;;;;;;;;;;;;;;AAgBG;AACI,MAAM,MAAM,GAAG,CAAC,KAAc,KACnC,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,GAAG,EAAE,GAAG,MAAM,CAAC,KAAK;;AC3C3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CG;AACI,MAAM,kBAAkB,GAAG,CAChC,UAA+B,EAC/B,WAAgC,EAChC,OAAqB,KACN;IAEf,MAAM,OAAO,GAAG,mBAAmB,CAAC,WAAW,EAAE,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BzD,IAAA,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC;AAEzE,IAAA,OAAO,OAAO;AAChB;;ACtFA;;AAEG;;;;"}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zvenigora/ng-eval-forms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Angular form field properties from ng-eval expressions",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/zvenigora/ng-eval.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"Angular",
|
|
11
|
+
"Eval",
|
|
12
|
+
"Forms"
|
|
13
|
+
],
|
|
14
|
+
"author": "Zvenigora (https://github.com/zvenigora)",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/Zvenigora/ng-eval#readme",
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@angular/core": ">=19.0.0",
|
|
19
|
+
"@angular/forms": ">=19.0.0",
|
|
20
|
+
"rxjs": "^7.8.0",
|
|
21
|
+
"@zvenigora/ng-eval-core": "^0.3.0",
|
|
22
|
+
"@zvenigora/ng-eval-signals": "^0.1.0"
|
|
23
|
+
},
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"module": "fesm2022/zvenigora-ng-eval-forms.mjs",
|
|
26
|
+
"typings": "types/zvenigora-ng-eval-forms.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
"./package.json": {
|
|
29
|
+
"default": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./types/zvenigora-ng-eval-forms.d.ts",
|
|
33
|
+
"default": "./fesm2022/zvenigora-ng-eval-forms.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./reactive": {
|
|
36
|
+
"types": "./types/zvenigora-ng-eval-forms-reactive.d.ts",
|
|
37
|
+
"default": "./fesm2022/zvenigora-ng-eval-forms-reactive.mjs"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"type": "module",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"tslib": "^2.3.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Injector } from '@angular/core';
|
|
2
|
+
import { FormGroup } from '@angular/forms';
|
|
3
|
+
import { SignalContextSource, EvalSignal } from '@zvenigora/ng-eval-signals';
|
|
4
|
+
import { ExpressionErrorPolicy } from '@zvenigora/ng-eval-forms';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builds the form-wide source of an expression context from a `FormGroup`.
|
|
8
|
+
*
|
|
9
|
+
* One mirror **per control, never per group** (plan S 3.5.1). A disabled
|
|
10
|
+
* control is excluded from its parent's aggregate value and there is no
|
|
11
|
+
* `rawValueChanges` to go with `getRawValue()`, so a group-backed mirror would
|
|
12
|
+
* lose a field the moment anything disabled it - and this library computes
|
|
13
|
+
* disablement, so one field's rule would silently blank the value every *other*
|
|
14
|
+
* field's rules resolve against. A control's own `valueChanges` keeps emitting
|
|
15
|
+
* regardless of its enabled state.
|
|
16
|
+
*
|
|
17
|
+
* **The record holds live values behind accessors, not the mirrored signals**
|
|
18
|
+
* (S 3.5.5). Each key is an enumerable accessor that reads its change-signal -
|
|
19
|
+
* which is what a surrounding `computed()` records as a dependency, per key -
|
|
20
|
+
* and then answers with the control's *current* value. Reading the control
|
|
21
|
+
* rather than the signal is what makes `invalidate()` a working hatch for
|
|
22
|
+
* trap 3: a `{ emitEvent: false }` write leaves the change-signal stale, and a
|
|
23
|
+
* record that handed out that signal would have nothing to recover, however
|
|
24
|
+
* often it was re-evaluated.
|
|
25
|
+
*
|
|
26
|
+
* **One entry per key, for the life of that key** (S 3.5.5). A replaced control
|
|
27
|
+
* instance re-points its channel; it does not get a new accessor or a new
|
|
28
|
+
* signal. Building fresh ones would leave every property that had already read
|
|
29
|
+
* the key tracking the *dead* control's signal - frozen at its last value, for
|
|
30
|
+
* the life of the binding, which is the failure trap 5 exists to prevent
|
|
31
|
+
* reappearing one layer up.
|
|
32
|
+
*
|
|
33
|
+
* **The returned record must be passed by reference, never spread or cloned.**
|
|
34
|
+
* `{ ...source }` flattens the accessors to the values they happened to hold,
|
|
35
|
+
* so the copy records no dependency and every property over it freezes at its
|
|
36
|
+
* first value with no error. The type - `Record<string, unknown>` - gives no
|
|
37
|
+
* hint of this, and phase-3-plan S 9.2 describes composing sources by
|
|
38
|
+
* spreading, which is why it is said here.
|
|
39
|
+
*
|
|
40
|
+
* **`invalidate()` covers a stale *value*, not a suppressed *control-set*
|
|
41
|
+
* change.** Trap 3's hatch works because the accessor re-reads the control, so
|
|
42
|
+
* re-running the walk recovers. `setControl` / `addControl` / `removeControl`
|
|
43
|
+
* with `{ emitEvent: false }` suppress `group.events` instead, so `sync` never
|
|
44
|
+
* runs and the channel still points at the dead instance - there is nothing
|
|
45
|
+
* for a re-run to recover, in the same structural sense that rules out a
|
|
46
|
+
* signal-valued record. Current behaviour, pinned here rather than fixed:
|
|
47
|
+
* the observable is the only signal there is.
|
|
48
|
+
*
|
|
49
|
+
* @param group - A flat `FormGroup`. Nested groups and `FormArray` are out of
|
|
50
|
+
* scope for this phase.
|
|
51
|
+
* @param options - `injector` scopes every subscription here to the form's
|
|
52
|
+
* lifetime; it is required rather than optional because an
|
|
53
|
+
* explicit injector means no `DestroyRef` auto-teardown, and
|
|
54
|
+
* an optional one would silently vary that (plan S 3.7).
|
|
55
|
+
*/
|
|
56
|
+
declare const createControlSource: (group: FormGroup, options: {
|
|
57
|
+
injector: Injector;
|
|
58
|
+
}) => SignalContextSource;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One field's rules, as strings resolved at runtime.
|
|
62
|
+
*
|
|
63
|
+
* This is the whole descriptor. It is deliberately not a schema *language*:
|
|
64
|
+
* `visible` and `text` are the two properties this phase ships (plan S 3.6),
|
|
65
|
+
* `disabled` is deferred with three reasons rather than a shrug, and
|
|
66
|
+
* `required` is deferred with the validators.
|
|
67
|
+
*/
|
|
68
|
+
interface FieldSchema {
|
|
69
|
+
/**
|
|
70
|
+
* The field's name, and the key its control is mirrored under.
|
|
71
|
+
*
|
|
72
|
+
* It does not have to name a control: a rule naming a field the group has
|
|
73
|
+
* no control for resolves `undefined`, which is S 3.4.3's ordinary case
|
|
74
|
+
* rather than an error. It may not be a member of `Object.prototype` -
|
|
75
|
+
* see {@link bindFieldProperties}.
|
|
76
|
+
*/
|
|
77
|
+
readonly name: string;
|
|
78
|
+
/** An expression whose value is coerced by truthiness (`toVisible`). */
|
|
79
|
+
readonly visible?: string;
|
|
80
|
+
/** An expression whose value is coerced to a string (`toText`). */
|
|
81
|
+
readonly text?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The signals bound for one field - one per rule the schema supplied.
|
|
85
|
+
*
|
|
86
|
+
* Typed as `EvalSignal`, not `Signal`, and both extra members are load-bearing
|
|
87
|
+
* on this path (plan S 5): `invalidate()` is trap 3's hatch for a
|
|
88
|
+
* `{ emitEvent: false }` write and S 3.4.2's hatch for a key-set change, and
|
|
89
|
+
* `destroy()` is what step 5's teardown counts. Typing these as `Signal` would
|
|
90
|
+
* put both out of reach through the published surface.
|
|
91
|
+
*
|
|
92
|
+
* The type arguments are `boolean` and `string` rather than S 5's original
|
|
93
|
+
* `unknown`, **because the coercion of S 3.6 happens here**. That section was
|
|
94
|
+
* written before the coercion's placement was settled and `coercion.ts` states
|
|
95
|
+
* the settlement - "both adapters call this" - so `unknown` would now describe
|
|
96
|
+
* the value the property is built *from* rather than the one it answers with.
|
|
97
|
+
*/
|
|
98
|
+
interface FieldProperties {
|
|
99
|
+
readonly visible?: EvalSignal<boolean>;
|
|
100
|
+
readonly text?: EvalSignal<string>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* What {@link bindFieldProperties} returns: the bound fields, and the one call
|
|
104
|
+
* that ends them.
|
|
105
|
+
*
|
|
106
|
+
* **Nested rather than a `destroy` written onto the record** (plan S 5's step-5
|
|
107
|
+
* amendment). The record is keyed by *field name*, `destroy` is a legal field
|
|
108
|
+
* name, and S 0's premise is that those names arrive from a server rather than
|
|
109
|
+
* from the consumer - so a flat shape would put a silent collision between the
|
|
110
|
+
* consumer's data and this library's API in precisely the case where the
|
|
111
|
+
* consumer controls the names least.
|
|
112
|
+
*/
|
|
113
|
+
interface FormBinding {
|
|
114
|
+
/** The bound properties, keyed by field name. */
|
|
115
|
+
readonly fields: Record<string, FieldProperties>;
|
|
116
|
+
/**
|
|
117
|
+
* Destroys every `EvalSignal` this binding created and releases the mirror.
|
|
118
|
+
*
|
|
119
|
+
* **Call it.** Every signal here is built with an explicit injector, and
|
|
120
|
+
* that means no `DestroyRef` registration of its own (`eval-signal.ts`) - so
|
|
121
|
+
* nothing auto-destroys and all N x M are the caller's (plan S 3.7).
|
|
122
|
+
*
|
|
123
|
+
* There is one net under that, and it is a net rather than a substitute: the
|
|
124
|
+
* binding registers this on the `DestroyRef` of the injector it was given,
|
|
125
|
+
* so a binding wired to a component or route injector is released when that
|
|
126
|
+
* injector dies even if nobody called it. It is not a licence to skip the
|
|
127
|
+
* call - a binding built on the root injector is released at the end of the
|
|
128
|
+
* application and no sooner.
|
|
129
|
+
*
|
|
130
|
+
* Idempotent, and that is load-bearing rather than polite: the scope is an
|
|
131
|
+
* `EnvironmentInjector`, and `R3Injector.destroy()` throws NG0205 when it
|
|
132
|
+
* has already run.
|
|
133
|
+
*/
|
|
134
|
+
destroy(): void;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Binds a schema to a `FormGroup`, producing one `EvalSignal` per rule.
|
|
138
|
+
*
|
|
139
|
+
* ```ts
|
|
140
|
+
* const binding = bindFieldProperties(
|
|
141
|
+
* [{ name: 'state', visible: "country === 'US'" }],
|
|
142
|
+
* form,
|
|
143
|
+
* { injector }
|
|
144
|
+
* );
|
|
145
|
+
*
|
|
146
|
+
* binding.fields['state'].visible(); // recomputes when `country` changes
|
|
147
|
+
*
|
|
148
|
+
* binding.destroy(); // yours to call - nothing here
|
|
149
|
+
* // auto-destroys (S 3.7)
|
|
150
|
+
* ```
|
|
151
|
+
*
|
|
152
|
+
* **One `EvalContext` per field, never one shared** (plan S 3.4.1). `get`
|
|
153
|
+
* resolves `scopes` and `original` *before* `lookups`, and
|
|
154
|
+
* `arrow-function-expression.ts` pushes its scope with no `try`/`finally` - so
|
|
155
|
+
* one field's arrow function that throws leaves a scope on the context for the
|
|
156
|
+
* life of the form, and under a shared context it would shadow every other
|
|
157
|
+
* field's key of the same name. The count is N contexts for N fields, and not
|
|
158
|
+
* N x M: the properties of one field resolve against the same names.
|
|
159
|
+
*
|
|
160
|
+
* **The field half of each context is empty in this phase.**
|
|
161
|
+
* `createFieldContext` takes two sources and the second is `{}` here. What a
|
|
162
|
+
* field-local key set should *contain* is specified nowhere - S 3.4.3's
|
|
163
|
+
* "a field named `value`, `name` or `index`" illustrates collision semantics
|
|
164
|
+
* rather than naming keys - and inventing three keys to fill a parameter is
|
|
165
|
+
* how a public surface acquires members nobody chose. The consequence, stated
|
|
166
|
+
* rather than left to be found: S 3.4.3's precedence rule is asserted at the
|
|
167
|
+
* core in `field-context.spec.ts` and ships **untested end to end**, because
|
|
168
|
+
* no `/reactive` path produces a field-local key. It gets settled by whichever
|
|
169
|
+
* phase first has a consumer for one.
|
|
170
|
+
*
|
|
171
|
+
* **`createControlSource` is called once, for the whole form.** Per field
|
|
172
|
+
* would build N mirrors over one group: N x the live `toSignal` subscriptions
|
|
173
|
+
* step 5 counts, and trap 5's O(N) diff running N times per `group.events`
|
|
174
|
+
* emission, on a signal that already fires several times per interaction.
|
|
175
|
+
*
|
|
176
|
+
* **Constructed, not computed.** `toSignal` opens with
|
|
177
|
+
* `assertNotInReactiveContext`, so calling this from inside an `effect()` or a
|
|
178
|
+
* `computed()` throws out of the mirror with an error naming `toSignal` and
|
|
179
|
+
* nothing naming this library. A form binding belongs in a service or a
|
|
180
|
+
* factory, which is the same premise `injector` being required rests on.
|
|
181
|
+
*
|
|
182
|
+
* **Teardown runs off a child injector, and there is only one of them**
|
|
183
|
+
* (S 3.7, amended in step 5). The binding opens an `EnvironmentInjector` under
|
|
184
|
+
* the caller's and scopes *both* the mirror and every `createEvalSignal` to it,
|
|
185
|
+
* so `destroy()` releases the whole mirror - every per-key `toSignal`
|
|
186
|
+
* subscription and the `group.events` one - in a single call, and a bind that
|
|
187
|
+
* throws part-way through releases exactly the same thing however far it got.
|
|
188
|
+
* The plan's earlier premise, that releasing the mirror needed a handle
|
|
189
|
+
* `createControlSource` does not return, was wrong: step 3 had already measured
|
|
190
|
+
* that destroying the injector drops every control's observer count to 0 and
|
|
191
|
+
* ends the diffing.
|
|
192
|
+
*
|
|
193
|
+
* The `EvalSignal`s are **not** covered by that, and that is the point of the
|
|
194
|
+
* count: built with an explicit injector, they take no `DestroyRef`
|
|
195
|
+
* registration, so `destroy()` walks them itself and N x M of them is what a
|
|
196
|
+
* spec asserts rather than "the form's destroy ran".
|
|
197
|
+
*
|
|
198
|
+
* @param schema - The fields to bind. Validated at construction: a duplicate
|
|
199
|
+
* name, a non-string rule, a name that resolves off
|
|
200
|
+
* `Object.prototype`, and a control that is not a
|
|
201
|
+
* `FormControl` all throw here rather than surfacing later as
|
|
202
|
+
* an evaluation result nobody can trace.
|
|
203
|
+
* @param group - A flat `FormGroup` of `FormControl`s.
|
|
204
|
+
* @param options - `injector` is **required**, not optional: S 3.7's argument
|
|
205
|
+
* is that every `EvalSignal` here has no auto-teardown, and
|
|
206
|
+
* an optional injector would silently vary that for a call
|
|
207
|
+
* that happened to sit in an injection context. `onError`
|
|
208
|
+
* defaults to `'undefined'` - the opposite of
|
|
209
|
+
* `eval-signals`' default, and resolved *here* rather than
|
|
210
|
+
* forwarded absent, since `createEvalSignal` would otherwise
|
|
211
|
+
* supply `'throw'` (S 3.4.4).
|
|
212
|
+
* @returns The bound properties keyed by field name, and the `destroy()` that
|
|
213
|
+
* ends them.
|
|
214
|
+
*/
|
|
215
|
+
declare const bindFieldProperties: (schema: readonly FieldSchema[], group: FormGroup, options: {
|
|
216
|
+
injector: Injector;
|
|
217
|
+
onError?: ExpressionErrorPolicy;
|
|
218
|
+
}) => FormBinding;
|
|
219
|
+
|
|
220
|
+
export { bindFieldProperties, createControlSource };
|
|
221
|
+
export type { FieldProperties, FieldSchema, FormBinding };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { EvalOptions, EvalContext } from '@zvenigora/ng-eval-core';
|
|
2
|
+
import { SignalContextSource } from '@zvenigora/ng-eval-signals';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Coerces an expression's result to the boolean a `visible` rule needs.
|
|
6
|
+
*
|
|
7
|
+
* **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author
|
|
8
|
+
* writing `visible: "country === 'US'"` gets a boolean already; the coercion
|
|
9
|
+
* exists for the ones who write `visible: "country"`.
|
|
10
|
+
*
|
|
11
|
+
* `undefined` is therefore the load-bearing input rather than an edge case.
|
|
12
|
+
* An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:
|
|
13
|
+
* a field key bound to `undefined` is treated as absent, and an empty
|
|
14
|
+
* `FormControl` is the common case - so both arrive as `undefined`, and both
|
|
15
|
+
* mean "not visible".
|
|
16
|
+
*
|
|
17
|
+
* The string `'false'` is **visible**, because it is a non-empty string. That
|
|
18
|
+
* is truthiness rather than parsing, and it is the answer a form-builder UI
|
|
19
|
+
* storing every value as a string will hit. Documented in the README rather
|
|
20
|
+
* than special-cased: a coercion that parsed `'false'` would then owe an
|
|
21
|
+
* answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.
|
|
22
|
+
*
|
|
23
|
+
* Both adapters call this, which is why it is in the core: `/reactive` and
|
|
24
|
+
* `/signals` differ in how they schedule the recompute and not in what the
|
|
25
|
+
* result means (S 3.4.5).
|
|
26
|
+
*
|
|
27
|
+
* @param value - Whatever the expression evaluated to.
|
|
28
|
+
* @returns `true` when the value is truthy.
|
|
29
|
+
*/
|
|
30
|
+
declare const toVisible: (value: unknown) => boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Coerces an expression's result to the string a `text` rule needs.
|
|
33
|
+
*
|
|
34
|
+
* `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).
|
|
35
|
+
* Those two are the carve-out and the only one: rendering the literal text
|
|
36
|
+
* `undefined` into a label is the failure this rule exists to prevent, and it
|
|
37
|
+
* is what a bare `String(value)` would do for the most ordinary input a form
|
|
38
|
+
* has - the empty control of S 3.4.3.
|
|
39
|
+
*
|
|
40
|
+
* Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is
|
|
41
|
+
* `'false'`. Mapping all falsy values to `''` would be the obvious way to
|
|
42
|
+
* write this rule wrongly, and it would blank a field whose value is
|
|
43
|
+
* legitimately zero.
|
|
44
|
+
*
|
|
45
|
+
* @param value - Whatever the expression evaluated to.
|
|
46
|
+
* @returns The value as a string, or `''` when it is `null` or `undefined`.
|
|
47
|
+
*/
|
|
48
|
+
declare const toText: (value: unknown) => string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a field property does when its expression throws at runtime.
|
|
52
|
+
*
|
|
53
|
+
* - `'throw'` - rethrow, matching `eval-signals`' own default.
|
|
54
|
+
* - `'undefined'` - the property resolves `undefined`, which `toVisible`
|
|
55
|
+
* reads as not visible and `toText` reads as `''`.
|
|
56
|
+
* - a function - called with the error; its return value becomes the
|
|
57
|
+
* property's value.
|
|
58
|
+
*
|
|
59
|
+
* **The default here is `'undefined'`, the opposite of `eval-signals`'**, and
|
|
60
|
+
* the consumer is the reason (plan S 3.4.4). An expression that fails in
|
|
61
|
+
* `eval-signals` was written by the developer reading the stack trace. An
|
|
62
|
+
* expression that fails here may have been typed into a form builder by an
|
|
63
|
+
* end user, and the right response to "the admin wrote a bad rule" is a field
|
|
64
|
+
* that does not render, not an application that throws on every change
|
|
65
|
+
* detection pass. Consumers who want the strict behaviour pass `'throw'`.
|
|
66
|
+
*
|
|
67
|
+
* Resolving that default is a real step and not a formality: `createEvalSignal`
|
|
68
|
+
* does `options?.onError ?? 'throw'`, so forwarding an *absent* policy verbatim
|
|
69
|
+
* inherits `'throw'` - the opposite of this default. The `/reactive` binding
|
|
70
|
+
* substitutes its own before the call.
|
|
71
|
+
*
|
|
72
|
+
* The union is structurally identical to `EvalSignalOptions['onError']`
|
|
73
|
+
* **deliberately**: `/reactive` resolves the default here and forwards the
|
|
74
|
+
* value to `createEvalSignal` with no mapping between the two unions, which is
|
|
75
|
+
* where two near-identical types would drift apart.
|
|
76
|
+
*
|
|
77
|
+
* `SignalContextWriteError` is **not** routed through this, in either adapter,
|
|
78
|
+
* matching `phase-3-plan.md` S 3.6.3. A write violation is static - illegal on
|
|
79
|
+
* every recompute with every dataset - and swallowing it under a default of
|
|
80
|
+
* `'undefined'` would hand every consumer a silent blank for a bug in the
|
|
81
|
+
* rule's own syntax. `createEvalSignal` already bypasses `onError` for it.
|
|
82
|
+
*
|
|
83
|
+
* **Only the type ships in this phase.** A matching `applyErrorPolicy` helper
|
|
84
|
+
* would have no caller: on the `/reactive` path - the only path Phase 4 ships
|
|
85
|
+
* - `createEvalSignal` applies the three cases internally. Its sole consumer
|
|
86
|
+
* is the `/signals` adapter, which is on paper only (S 9), so the helper is
|
|
87
|
+
* deferred to that phase and will be written against this type. Shipping an
|
|
88
|
+
* unexercised code path is what S 9.1 declines to do for the scope-leak
|
|
89
|
+
* containment, and the rule applies to both or to neither.
|
|
90
|
+
*/
|
|
91
|
+
type ExpressionErrorPolicy = 'throw' | 'undefined' | ((error: unknown) => unknown);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Composes one `EvalContext` for one field, out of two live sources.
|
|
95
|
+
*
|
|
96
|
+
* The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The
|
|
97
|
+
* field half is a `createSignalContext` over `fieldSource`; the form half is a
|
|
98
|
+
* second resolver pushed onto the same context's `lookups`. Both close over
|
|
99
|
+
* their source and read it at resolve time, so a key added to either after
|
|
100
|
+
* construction resolves - there is no third object to keep in sync, and no
|
|
101
|
+
* write-through to N contexts.
|
|
102
|
+
*
|
|
103
|
+
* `EvalContext.get` walks `lookups` in order and takes the first
|
|
104
|
+
* non-`undefined`, so the field half is consulted first.
|
|
105
|
+
*
|
|
106
|
+
* One context per field, never one shared across fields (plan S 3.4.1):
|
|
107
|
+
* `get` resolves `scopes` and `original` *before* `lookups`, so anything left
|
|
108
|
+
* on a shared context - an arrow function's leaked scope, say - would be read
|
|
109
|
+
* ahead of every other field's source key of the same name, for the life of
|
|
110
|
+
* the form.
|
|
111
|
+
*
|
|
112
|
+
* **Resolution is live; appearance is not reactive.** An expression that read
|
|
113
|
+
* a then-missing key subscribed to nothing, so the owner of the source calls
|
|
114
|
+
* `invalidate()` when the *key set* changes. A change to a *value* is
|
|
115
|
+
* Angular's job and needs nothing.
|
|
116
|
+
*
|
|
117
|
+
* **Precedence is value-dependent, not permanent.** A field key wins while
|
|
118
|
+
* its value is *present*: `EvalContext.get` treats `undefined` as absent at
|
|
119
|
+
* every step, and the field resolver returns `undefined` both for "no such
|
|
120
|
+
* key" and for "key bound to `undefined`" - so an empty `FormControl`, which
|
|
121
|
+
* is the common case rather than an exotic one, falls through to the form
|
|
122
|
+
* value. Documented as a limitation (plan S 3.4.3): distinguishing the two
|
|
123
|
+
* would need a sentinel threaded through `EvalContext.get`, which is
|
|
124
|
+
* `eval-core`'s and out of this phase's scope.
|
|
125
|
+
*
|
|
126
|
+
* @param formSource - The form-wide keys, shared by every field of the form.
|
|
127
|
+
* @param fieldSource - The field-local keys. These win on a name collision,
|
|
128
|
+
* while their value is not `undefined`.
|
|
129
|
+
* @param options - Passed to `createSignalContext` for **both** halves;
|
|
130
|
+
* configures this context and its resolvers, not the walk.
|
|
131
|
+
* As upstream, `caseInsensitive` corrects identifier keys
|
|
132
|
+
* here but not *property* names - the member visitor reads
|
|
133
|
+
* those off the state's options, so the same options must
|
|
134
|
+
* also reach `simpleEval` / `createState`.
|
|
135
|
+
*/
|
|
136
|
+
declare const createFieldContext: (formSource: SignalContextSource, fieldSource: SignalContextSource, options?: EvalOptions) => EvalContext;
|
|
137
|
+
|
|
138
|
+
export { createFieldContext, toText, toVisible };
|
|
139
|
+
export type { ExpressionErrorPolicy };
|