@jarenjs/forms 0.9.2
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/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/types/data.d.ts +67 -0
- package/dist/types/formats.d.ts +22 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/model.d.ts +125 -0
- package/dist/types/rules.d.ts +131 -0
- package/dist/types/validate.d.ts +29 -0
- package/package.json +54 -0
- package/src/data.js +223 -0
- package/src/formats.js +120 -0
- package/src/index.js +49 -0
- package/src/model.js +318 -0
- package/src/rules.js +404 -0
- package/src/validate.js +244 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Joham
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# @jarenjs/forms
|
|
2
|
+
|
|
3
|
+
Framework-agnostic form generation for JSON Schema. Turns a schema into a renderable field tree and validates it in **three layers, one stack**:
|
|
4
|
+
|
|
5
|
+
1. **Per field, every keystroke** — cheap, synchronous checks powered directly by [`@jarenjs/core`](../core) primitives (grapheme-aware string lengths, unicode patterns, deep equality) and the canonical format-tester registry of [`@jarenjs/formats`](../formats): everything one field can know about itself.
|
|
6
|
+
2. **Cross field, every keystroke** — visibility, enablement, computed values, and preemptive assertions expressed as [Jaren JSON Query](../json/docs/QUERY-FORMAT.md) documents in an `x-form` annotation, compiled once per model by [`@jarenjs/json`](../json) and evaluated per keystroke as cheap closures.
|
|
7
|
+
3. **Authoritative, on submit** — the complete compiled schema validation with [`@jarenjs/validate`](../validate), which owns `required` combinations, `dependentSchemas`, `if/then/else`, `unevaluatedProperties`, and (via the `$query` keyword) the very same cross-field rules.
|
|
8
|
+
|
|
9
|
+
No DOM, no framework: render the model with React, Vue, vanilla JS or anything else. Forms never imports the validator — apps wire the authoritative layer themselves. See it in action in the [Jaren playground](https://jklarenbeek.github.io/jarenjs/#/playground).
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
import {
|
|
15
|
+
buildFormModel,
|
|
16
|
+
createInitialData,
|
|
17
|
+
validateField,
|
|
18
|
+
parseFieldInput,
|
|
19
|
+
setValueAtPointer,
|
|
20
|
+
} from '@jarenjs/forms';
|
|
21
|
+
|
|
22
|
+
const schema = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
title: 'Sign up',
|
|
25
|
+
properties: {
|
|
26
|
+
username: { type: 'string', minLength: 3, pattern: '^[a-z0-9_]+$' },
|
|
27
|
+
email: { type: 'string', format: 'email' },
|
|
28
|
+
age: { type: 'integer', minimum: 13 },
|
|
29
|
+
},
|
|
30
|
+
required: ['username', 'email'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// 1. Build the field tree once
|
|
34
|
+
const model = buildFormModel(schema);
|
|
35
|
+
// model.children -> [{ pointer: '/username', label: 'Username', control: 'text',
|
|
36
|
+
// required: true, constraints: {...} }, ...]
|
|
37
|
+
|
|
38
|
+
// 2. Start with the schema's defaults (untouched fields stay absent)
|
|
39
|
+
let data = createInitialData(model);
|
|
40
|
+
|
|
41
|
+
// 3. On every keystroke: coerce the raw input and validate the field
|
|
42
|
+
const field = model.children.find((f) => f.key === 'email');
|
|
43
|
+
const value = parseFieldInput(field, 'not-an-email'); // '' -> undefined, numbers -> Number, ...
|
|
44
|
+
const errors = validateField(field, value);
|
|
45
|
+
// [{ keyword: 'format', message: 'Must be a valid email' }]
|
|
46
|
+
|
|
47
|
+
data = setValueAtPointer(data, field.pointer, value); // immutable update
|
|
48
|
+
|
|
49
|
+
// 4. On submit (or continuously): the authoritative validation
|
|
50
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
51
|
+
const validate = new JarenValidator({ skipErrors: false, collectErrors: true }).compile(schema);
|
|
52
|
+
const result = validate(data); // { valid, errors: [{ instancePath, keyword, message, ... }] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## The form model
|
|
56
|
+
|
|
57
|
+
`buildFormModel(schema)` resolves local `$ref`s (`#/$defs/...`), merges `allOf` branches, and returns a tree of field descriptors:
|
|
58
|
+
|
|
59
|
+
| Property | Meaning |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `pointer` | JSON pointer into the data (`/user/name`) |
|
|
62
|
+
| `label` | `title` or a humanized property name (`firstName` → "First Name") |
|
|
63
|
+
| `kind` | `string` `number` `integer` `boolean` `enum` `const` `object` `array` |
|
|
64
|
+
| `control` | Rendering hint: `text` `email` `url` `password` `textarea` `number` `checkbox` `select` `date` `color` `json` |
|
|
65
|
+
| `required` | Whether the parent object requires this property |
|
|
66
|
+
| `constraints` | `minLength`/`maxLength`/`pattern`/`format`/`minimum`/`maximum`/`multipleOf`/`minItems`/... |
|
|
67
|
+
| `rules` | The raw `x-form` rules annotation, if any (see below) |
|
|
68
|
+
| `enumValues` / `constValue` / `defaultValue` / `placeholder` | Values for the UI |
|
|
69
|
+
| `children` | Child fields (object kinds) |
|
|
70
|
+
| `item` / `tuple` | Item template and tuple prefix fields (array kinds) |
|
|
71
|
+
|
|
72
|
+
Field kinds are inferred from structural keywords when `type` is absent, and `format` maps to input controls and placeholders through the same registry the preemptive validation uses (`getFormatInfo`).
|
|
73
|
+
|
|
74
|
+
## Layer 1 — preemptive per-field validation
|
|
75
|
+
|
|
76
|
+
`validateField(field, value)` returns `[{ keyword, message }]` using `@jarenjs/core` directly:
|
|
77
|
+
|
|
78
|
+
- **strings**: grapheme-aware `minLength`/`maxLength` (`getStringLength`), unicode `pattern` (`createRegExp`, cached), and 50+ `format` testers from the [`@jarenjs/formats`](../formats) `formatTesters` registry — the same name → predicate table the authoritative validator's format compilers wrap, so both layers accept exactly the same strings
|
|
79
|
+
- **numbers**: type/integer checks, bounds, `multipleOf`
|
|
80
|
+
- **enum/const**: deep equality (`equalsDeep`)
|
|
81
|
+
- **arrays**: `minItems`/`maxItems`/`uniqueItems` (`isUniqueDeepArray`)
|
|
82
|
+
|
|
83
|
+
`validateAllFields(model, data)` walks the whole tree and returns a `{ '/pointer': errors }` map — ideal for rendering inline errors next to every field.
|
|
84
|
+
|
|
85
|
+
## Layer 2 — `x-form` rules: cross-field behavior per keystroke
|
|
86
|
+
|
|
87
|
+
One namespaced annotation keyword — safe under every metaschema, invisible to validators — on any subschema. Its members are [Jaren JSON Query](../json/docs/QUERY-FORMAT.md) documents (a bare RFC 9535 JSONPath string is the degenerate query):
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{ "type": "object",
|
|
91
|
+
"properties": {
|
|
92
|
+
"company": { "type": "string" },
|
|
93
|
+
"vatId": { "type": "string",
|
|
94
|
+
"x-form": { "visible": { "$ne": ["$.company", ""] },
|
|
95
|
+
"assert": { "$or": [ { "$eq": ["$.company", ""] },
|
|
96
|
+
{ "$ne": ["$.vatId", ""] } ] },
|
|
97
|
+
"message": "VAT id is required for companies" } },
|
|
98
|
+
"total": { "type": "number",
|
|
99
|
+
"x-form": { "computed": { "$sum": "$.lines[*].amount" } } }
|
|
100
|
+
} }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Recognized members — unknown members are ignored for forward compatibility:
|
|
104
|
+
|
|
105
|
+
| Member | Kind | Meaning |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `visible` | EBV query | Should the field be shown? |
|
|
108
|
+
| `enabled` | EBV query | Should the field accept input? |
|
|
109
|
+
| `assert` | EBV query | Cross-field preemptive validation |
|
|
110
|
+
| `computed` | query | The field's derived value, mapped to plain JSON |
|
|
111
|
+
| `message` | string | Shown when `assert` fails |
|
|
112
|
+
|
|
113
|
+
Rules compile **once per model** and evaluate per keystroke:
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
import { buildFormModel, compileFormRules, evaluateFormRules } from '@jarenjs/forms';
|
|
117
|
+
|
|
118
|
+
const model = buildFormModel(schema);
|
|
119
|
+
const rules = compileFormRules(model); // throws on malformed rules, with the field pointer
|
|
120
|
+
|
|
121
|
+
// per keystroke, after updating `data`:
|
|
122
|
+
const state = evaluateFormRules(rules, data);
|
|
123
|
+
// { '/vatId': { visible: true, errors: [{ keyword: 'x-form/assert',
|
|
124
|
+
// message: 'VAT id is required for companies' }] },
|
|
125
|
+
// '/total': { computed: 20 } }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### The rule query context
|
|
129
|
+
|
|
130
|
+
Every rule kind shares one context:
|
|
131
|
+
|
|
132
|
+
- **`$`** — the input document is the **whole form data root**: cross-field is the point.
|
|
133
|
+
- **`$value`** — the field's current value, bound as an external per evaluation. An absent field binds `null` (`undefined` is not a JSON value).
|
|
134
|
+
- **`$pointer`** — the field's data pointer string (`'/vatId'`).
|
|
135
|
+
|
|
136
|
+
These two externals are the whole vocabulary: any other free name in a rule is a **compile-time** error naming it.
|
|
137
|
+
|
|
138
|
+
`visible`/`enabled`/`assert` are asserted by **effective boolean value** (EBV, [QUERY-FORMAT.md §2.2](../json/docs/QUERY-FORMAT.md)): the empty sequence is `false`, a singleton counts per its type, a multi-item sequence is runtime error `JQ2003`. Runtime errors follow a fixed policy: `visible`/`enabled` **fail open** (evaluate to `true` — a broken rule must never hide data or lock a control), `assert` **fails closed** (an assertion that cannot be computed has not been satisfied), and `computed` leaves the value absent.
|
|
139
|
+
|
|
140
|
+
### Array item templates
|
|
141
|
+
|
|
142
|
+
A rule on an array item template (`/lines/-/amount`) compiles **once** and evaluates **per element** of the actual array, binding `$value`/`$pointer` per index — results are keyed by the expanded pointer (`/lines/2/amount`). That compiled-once/dispatch-per-node generalization now exists as the [`@jarenjs/json/jslt`](../json/docs/JSLT-FORMAT.md) `$apply` engine: a future forms computed-view layer can generalize `x-form.computed` into schema-dispatched view-model stylesheets without changing forms' validator-independent boundary.
|
|
143
|
+
|
|
144
|
+
### Schema literals in rules
|
|
145
|
+
|
|
146
|
+
Rules may use the query engine's schema operators (`$valid`/`$as`) by passing the same `compileTypeTest` hook the engine defines ([QUERY-FORMAT.md §8.11](../json/docs/QUERY-FORMAT.md)) — this is the only door through which a validator reaches forms, and the app holds the key:
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
import { createTypeTestCompiler } from '@jarenjs/validate/query'; // app-side, not a forms dependency
|
|
150
|
+
const rules = compileFormRules(model, { compileTypeTest: createTypeTestCompiler() });
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Composing rule errors with field errors
|
|
154
|
+
|
|
155
|
+
`validateAllFields` and `evaluateFormRules` stay separate on purpose (a render loop usually wants them at different times). Both speak the same error shape, so merging is one spread per pointer:
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
const fieldErrors = validateAllFields(model, data); // layer 1
|
|
159
|
+
const ruleState = evaluateFormRules(rules, data); // layer 2
|
|
160
|
+
const errorsAt = (pointer) => [
|
|
161
|
+
...(fieldErrors[pointer] ?? []),
|
|
162
|
+
...(ruleState[pointer]?.errors ?? []),
|
|
163
|
+
];
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Layer 3 — write the rule once, enforce it on submit
|
|
167
|
+
|
|
168
|
+
The same constraint can be spelled twice — `x-form.assert` for keystroke feedback, the [`$query` keyword](../validate) for authoritative submit validation — or written **once** and copied:
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
import { formRulesToQueryAssertions } from '@jarenjs/forms';
|
|
172
|
+
|
|
173
|
+
// pure schema-to-schema transform: every x-form.assert is copied into a
|
|
174
|
+
// root-level $query (joined to an existing one through allOf), with
|
|
175
|
+
// value/pointer rebound to the field's location
|
|
176
|
+
const submitSchema = formRulesToQueryAssertions(schema);
|
|
177
|
+
|
|
178
|
+
import { JarenValidator } from '@jarenjs/validate'; // app-side
|
|
179
|
+
const validate = new JarenValidator().compile(submitSchema);
|
|
180
|
+
validate({ company: 'ACME', vatId: '' }); // false - the vatId assert, now authoritative
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Item-template asserts quantify with `$every` over the actual elements. Two divergences from the keystroke path are inherent to the copy: on submit an absent field binds `$value` to the empty sequence (not `null`), and `$pointer` for template elements stays the template pointer (element indexes are a render-time notion).
|
|
184
|
+
|
|
185
|
+
## Data helpers
|
|
186
|
+
|
|
187
|
+
Form data keeps plain JSON semantics — an untouched field is *absent*, not an empty string. Pointers parse and read through the [`@jarenjs/json`](../json) compiled pointer engine (RFC 6901, one implementation repo-wide); reads hit a compiled-getter cache and allocate nothing:
|
|
188
|
+
|
|
189
|
+
- `createInitialData(model)` — defaults and `const` values filled in, everything else absent
|
|
190
|
+
- `parseFieldInput(field, raw)` — input coercion (`''` → undefined, numeric strings → numbers, enum options → typed values)
|
|
191
|
+
- `getValueAtPointer` / `setValueAtPointer` / `appendItem` / `removeItemAt` — immutable updates addressed by JSON pointer
|
|
192
|
+
- `createItemValue(field.item)` — starter value for a new array item
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
Unit tests live in `test/forms/` at the repository root. See the repository [README](../../README.md) for the full Jaren documentation, and the [ROADMAP](../../ROADMAP.md) for planned forms work (rule dependency memoization, hidden-field pruning on submit, computed views through JSLT).
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a JSON pointer into decoded segments per RFC 6901. '' -> [].
|
|
3
|
+
* @param {string} pointer
|
|
4
|
+
* @returns {string[]}
|
|
5
|
+
* @throws {import('@jarenjs/json/pointer').JSONPointerSyntaxError}
|
|
6
|
+
* When the pointer violates the RFC 6901 grammar
|
|
7
|
+
*/
|
|
8
|
+
export declare function parsePointer(pointer: string): string[];
|
|
9
|
+
/**
|
|
10
|
+
* Read the value at a JSON pointer.
|
|
11
|
+
* @param {any} data
|
|
12
|
+
* @param {string} pointer - e.g. '/user/address/0/street'
|
|
13
|
+
* @returns {any} The value, or undefined when the path does not exist
|
|
14
|
+
*/
|
|
15
|
+
export declare function getValueAtPointer(data: any, pointer: string): any;
|
|
16
|
+
/**
|
|
17
|
+
* Return a copy of `data` with the value at `pointer` replaced.
|
|
18
|
+
* Setting `undefined` REMOVES the property (array items become undefined
|
|
19
|
+
* holes only when explicitly set; use removeItemAt to delete them).
|
|
20
|
+
* Missing intermediate containers are created (objects for name segments,
|
|
21
|
+
* arrays for numeric segments).
|
|
22
|
+
* @param {any} data
|
|
23
|
+
* @param {string} pointer
|
|
24
|
+
* @param {any} value
|
|
25
|
+
* @returns {any} The new root value
|
|
26
|
+
*/
|
|
27
|
+
export declare function setValueAtPointer(data: any, pointer: string, value: any): any;
|
|
28
|
+
/**
|
|
29
|
+
* Return a copy of `data` with the item at `index` removed from the array
|
|
30
|
+
* at `pointer`.
|
|
31
|
+
* @param {any} data
|
|
32
|
+
* @param {string} pointer - Pointer to the ARRAY
|
|
33
|
+
* @param {number} index
|
|
34
|
+
* @returns {any}
|
|
35
|
+
*/
|
|
36
|
+
export declare function removeItemAt(data: any, pointer: string, index: number): any;
|
|
37
|
+
/**
|
|
38
|
+
* Return a copy of `data` with `value` appended to the array at `pointer`
|
|
39
|
+
* (the array is created when absent).
|
|
40
|
+
* @param {any} data
|
|
41
|
+
* @param {string} pointer - Pointer to the ARRAY
|
|
42
|
+
* @param {any} value
|
|
43
|
+
* @returns {any}
|
|
44
|
+
*/
|
|
45
|
+
export declare function appendItem(data: any, pointer: string, value: any): any;
|
|
46
|
+
/**
|
|
47
|
+
* Create initial data for a form model: schema defaults and const values
|
|
48
|
+
* are filled in, everything else stays absent.
|
|
49
|
+
* @param {import('./model.js').FormField} field - A field from buildFormModel
|
|
50
|
+
* @returns {any}
|
|
51
|
+
*/
|
|
52
|
+
export declare function createInitialData(field: import('./model.js').FormField): any;
|
|
53
|
+
/**
|
|
54
|
+
* Create a sensible starter value for one array item of the given field.
|
|
55
|
+
* @param {import('./model.js').FormField} itemField
|
|
56
|
+
* @returns {any}
|
|
57
|
+
*/
|
|
58
|
+
export declare function createItemValue(itemField: import('./model.js').FormField): any;
|
|
59
|
+
/**
|
|
60
|
+
* Coerce a raw input string (what an HTML input yields) into the typed
|
|
61
|
+
* value for a field. An empty string means "absent" (undefined) so that
|
|
62
|
+
* required/optional semantics stay correct.
|
|
63
|
+
* @param {import('./model.js').FormField} field
|
|
64
|
+
* @param {any} raw - Raw input value (string, or boolean for checkboxes)
|
|
65
|
+
* @returns {any}
|
|
66
|
+
*/
|
|
67
|
+
export declare function parseFieldInput(field: import('./model.js').FormField, raw: any): any;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type FormatInfo = {
|
|
2
|
+
/**
|
|
3
|
+
* - Synchronous validity test
|
|
4
|
+
*/
|
|
5
|
+
test: (value: string) => boolean;
|
|
6
|
+
/**
|
|
7
|
+
* - Suggested HTML input control
|
|
8
|
+
*/
|
|
9
|
+
control: string;
|
|
10
|
+
/**
|
|
11
|
+
* - Suggested placeholder text
|
|
12
|
+
*/
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
};
|
|
15
|
+
/** @type {Record<string, FormatInfo>} */
|
|
16
|
+
export declare const FORM_FORMATS: Record<string, FormatInfo>;
|
|
17
|
+
/**
|
|
18
|
+
* Look up the format info for a JSON Schema format name.
|
|
19
|
+
* @param {string|undefined} format - The format name
|
|
20
|
+
* @returns {FormatInfo|null} The format info, or null when unknown
|
|
21
|
+
*/
|
|
22
|
+
export declare function getFormatInfo(format: string | undefined): FormatInfo | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jarenjs/forms - framework-agnostic form model generation for JSON Schema.
|
|
3
|
+
*
|
|
4
|
+
* buildFormModel(schema) turns a schema into a renderable field tree;
|
|
5
|
+
* validateField gives immediate per-field feedback powered by @jarenjs/core
|
|
6
|
+
* primitives, and the `x-form` rules (rules.js) add cross-field behavior -
|
|
7
|
+
* visibility, enablement, computed values, preemptive assertions - as
|
|
8
|
+
* compiled Jaren JSON Queries, before the complete compiled schema
|
|
9
|
+
* validation runs (app-wired; forms never imports the validator). The
|
|
10
|
+
* data helpers keep form values in plain JSON semantics, addressed by
|
|
11
|
+
* JSON pointer through the @jarenjs/json compiled pointer engine.
|
|
12
|
+
*/
|
|
13
|
+
export { buildFormModel, resolveSchema, getFieldKind, humanizeKey, escapePointerKey, } from './model.js';
|
|
14
|
+
export { compileFormRules, evaluateFormRules, formRulesToQueryAssertions, } from './rules.js';
|
|
15
|
+
export { validateField, validateAllFields, } from './validate.js';
|
|
16
|
+
export { createInitialData, createItemValue, parseFieldInput, parsePointer, getValueAtPointer, setValueAtPointer, appendItem, removeItemAt, } from './data.js';
|
|
17
|
+
export { FORM_FORMATS, getFormatInfo, } from './formats.js';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export type FormField = {
|
|
2
|
+
/**
|
|
3
|
+
* - JSON pointer into the DATA (e.g. '/user/name')
|
|
4
|
+
*/
|
|
5
|
+
pointer: string;
|
|
6
|
+
/**
|
|
7
|
+
* - Property name (or '-' for an array item template)
|
|
8
|
+
*/
|
|
9
|
+
key: string;
|
|
10
|
+
/**
|
|
11
|
+
* - Human friendly label (schema title or humanized key)
|
|
12
|
+
*/
|
|
13
|
+
label: string;
|
|
14
|
+
description: string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* - The resolved subschema for this field
|
|
17
|
+
*/
|
|
18
|
+
schema: object;
|
|
19
|
+
/**
|
|
20
|
+
* - 'string'|'number'|'integer'|'boolean'|'enum'|'const'|'object'|'array'|'unknown'
|
|
21
|
+
*/
|
|
22
|
+
kind: string;
|
|
23
|
+
/**
|
|
24
|
+
* - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'color'|'json'
|
|
25
|
+
*/
|
|
26
|
+
control: string;
|
|
27
|
+
/**
|
|
28
|
+
* - Whether the parent object requires this property
|
|
29
|
+
*/
|
|
30
|
+
required: boolean;
|
|
31
|
+
readOnly: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* - Options for a select control
|
|
34
|
+
*/
|
|
35
|
+
enumValues: Array<any> | null;
|
|
36
|
+
/**
|
|
37
|
+
* - Fixed value when the schema is a const
|
|
38
|
+
*/
|
|
39
|
+
constValue: any;
|
|
40
|
+
defaultValue: any;
|
|
41
|
+
placeholder: string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* - minLength/maxLength/pattern/minimum/... extracted for the UI
|
|
44
|
+
*/
|
|
45
|
+
constraints: object;
|
|
46
|
+
/**
|
|
47
|
+
* - The raw `x-form` rules annotation, if any (see rules.js)
|
|
48
|
+
*/
|
|
49
|
+
rules: object | null;
|
|
50
|
+
/**
|
|
51
|
+
* - Child fields for object kinds
|
|
52
|
+
*/
|
|
53
|
+
children: Array<FormField> | null;
|
|
54
|
+
/**
|
|
55
|
+
* - Template field for array items
|
|
56
|
+
*/
|
|
57
|
+
item: FormField | null;
|
|
58
|
+
/**
|
|
59
|
+
* - Fixed prefix fields for tuple arrays
|
|
60
|
+
*/
|
|
61
|
+
tuple: Array<FormField> | null;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} FormField
|
|
65
|
+
* @property {string} pointer - JSON pointer into the DATA (e.g. '/user/name')
|
|
66
|
+
* @property {string} key - Property name (or '-' for an array item template)
|
|
67
|
+
* @property {string} label - Human friendly label (schema title or humanized key)
|
|
68
|
+
* @property {string|undefined} description
|
|
69
|
+
* @property {object} schema - The resolved subschema for this field
|
|
70
|
+
* @property {string} kind - 'string'|'number'|'integer'|'boolean'|'enum'|'const'|'object'|'array'|'unknown'
|
|
71
|
+
* @property {string} control - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'color'|'json'
|
|
72
|
+
* @property {boolean} required - Whether the parent object requires this property
|
|
73
|
+
* @property {boolean} readOnly
|
|
74
|
+
* @property {Array<any>|null} enumValues - Options for a select control
|
|
75
|
+
* @property {any} constValue - Fixed value when the schema is a const
|
|
76
|
+
* @property {any} defaultValue
|
|
77
|
+
* @property {string|undefined} placeholder
|
|
78
|
+
* @property {object} constraints - minLength/maxLength/pattern/minimum/... extracted for the UI
|
|
79
|
+
* @property {object|null} rules - The raw `x-form` rules annotation, if any (see rules.js)
|
|
80
|
+
* @property {Array<FormField>|null} children - Child fields for object kinds
|
|
81
|
+
* @property {FormField|null} item - Template field for array items
|
|
82
|
+
* @property {Array<FormField>|null} tuple - Fixed prefix fields for tuple arrays
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* Convert 'firstName' / 'first_name' / 'first-name' to 'First Name'.
|
|
86
|
+
* @param {string} key
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export declare function humanizeKey(key: string): string;
|
|
90
|
+
/**
|
|
91
|
+
* Resolve local $refs and shallowly merge allOf branches into a single
|
|
92
|
+
* effective schema object for form purposes.
|
|
93
|
+
* @param {object|boolean} schema
|
|
94
|
+
* @param {object} rootSchema
|
|
95
|
+
* @param {number} depth
|
|
96
|
+
* @returns {object|boolean}
|
|
97
|
+
*/
|
|
98
|
+
export declare function resolveSchema(schema: object | boolean, rootSchema: object, depth?: number): object | boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Derive the field kind from a resolved schema.
|
|
101
|
+
* @param {object|boolean} schema
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export declare function getFieldKind(schema: object | boolean): string;
|
|
105
|
+
/**
|
|
106
|
+
* Encode a property name as an RFC 6901 reference token (`~` -> `~0`,
|
|
107
|
+
* `/` -> `~1`), the write-side inverse of the shared parse.
|
|
108
|
+
* @param {string} key
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export declare function escapePointerKey(key: string): string;
|
|
112
|
+
/**
|
|
113
|
+
* Build the form model for a JSON schema.
|
|
114
|
+
*
|
|
115
|
+
* @param {object|boolean} schema - The root JSON schema
|
|
116
|
+
* @returns {FormField} The root field descriptor (kind 'object' for object schemas)
|
|
117
|
+
* @example
|
|
118
|
+
* const model = buildFormModel({
|
|
119
|
+
* type: 'object',
|
|
120
|
+
* properties: { email: { type: 'string', format: 'email' } },
|
|
121
|
+
* required: ['email'],
|
|
122
|
+
* });
|
|
123
|
+
* model.children[0].control; // 'email'
|
|
124
|
+
*/
|
|
125
|
+
export declare function buildFormModel(schema: object | boolean): FormField;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export type RuleResult = {
|
|
2
|
+
/**
|
|
3
|
+
* - EBV of the field's `visible` rule
|
|
4
|
+
*/
|
|
5
|
+
visible?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* - EBV of the field's `enabled` rule
|
|
8
|
+
*/
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* - Plain-JSON result of the `computed` rule
|
|
12
|
+
*/
|
|
13
|
+
computed?: any;
|
|
14
|
+
/**
|
|
15
|
+
* `[{ keyword: 'x-form/assert', message }]` when the `assert` rule fails
|
|
16
|
+
* (the validateField error shape, so error rendering works unchanged)
|
|
17
|
+
*/
|
|
18
|
+
errors?: Array<import('./validate.js').FieldError>;
|
|
19
|
+
};
|
|
20
|
+
export type CompiledFieldRules = {
|
|
21
|
+
/**
|
|
22
|
+
* - The field's data pointer (template pointers keep `-`)
|
|
23
|
+
*/
|
|
24
|
+
pointer: string;
|
|
25
|
+
/**
|
|
26
|
+
* - Decoded segments; ITEM marks an array-item slot
|
|
27
|
+
*/
|
|
28
|
+
parts: Array<string | symbol>;
|
|
29
|
+
/**
|
|
30
|
+
* - Whether `parts` contains an ITEM slot
|
|
31
|
+
*/
|
|
32
|
+
templated: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* - Compiled getter (non-template fields)
|
|
35
|
+
*/
|
|
36
|
+
getValue: ((root: any) => any) | null;
|
|
37
|
+
visible: Function | null;
|
|
38
|
+
enabled: Function | null;
|
|
39
|
+
assert: Function | null;
|
|
40
|
+
computed: Function | null;
|
|
41
|
+
message: string | null;
|
|
42
|
+
};
|
|
43
|
+
export type CompiledRules = {
|
|
44
|
+
rules: Array<CompiledFieldRules>;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} CompiledFieldRules
|
|
48
|
+
* @property {string} pointer - The field's data pointer (template pointers keep `-`)
|
|
49
|
+
* @property {Array<string|symbol>} parts - Decoded segments; ITEM marks an array-item slot
|
|
50
|
+
* @property {boolean} templated - Whether `parts` contains an ITEM slot
|
|
51
|
+
* @property {((root: any) => any)|null} getValue - Compiled getter (non-template fields)
|
|
52
|
+
* @property {function|null} visible
|
|
53
|
+
* @property {function|null} enabled
|
|
54
|
+
* @property {function|null} assert
|
|
55
|
+
* @property {function|null} computed
|
|
56
|
+
* @property {string|null} message
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} CompiledRules
|
|
60
|
+
* @property {Array<CompiledFieldRules>} rules
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* Compile every `x-form` rule of a form model into reusable closures.
|
|
64
|
+
*
|
|
65
|
+
* Walks the field tree once and runs `compileJsonQuery` per rule
|
|
66
|
+
* document. `options.compileTypeTest` passes through to the query
|
|
67
|
+
* compiler, so rules may use `$valid`/`$assert`/`$as` with a
|
|
68
|
+
* caller-supplied type-test compiler (the validator package's `query`
|
|
69
|
+
* module exports `createTypeTestCompiler()`; forms itself never imports
|
|
70
|
+
* the validator).
|
|
71
|
+
* Without the hook, a schema-using rule surfaces the engine's JQ0008.
|
|
72
|
+
*
|
|
73
|
+
* @param {import('./model.js').FormField} model - Root field from buildFormModel
|
|
74
|
+
* @param {object} [options]
|
|
75
|
+
* @param {(schemaJson: any, docPath: string) => ((value: any) => boolean)}
|
|
76
|
+
* [options.compileTypeTest] - hook for schema literals inside rules
|
|
77
|
+
* @returns {CompiledRules}
|
|
78
|
+
* @throws {Error} On a malformed rule document (field pointer prepended)
|
|
79
|
+
* or a rule referencing an external other than `value`/`pointer`
|
|
80
|
+
* @example
|
|
81
|
+
* const compiled = compileFormRules(model);
|
|
82
|
+
* const results = evaluateFormRules(compiled, data);
|
|
83
|
+
* results['/vatId']; // { visible: true, errors: [{ keyword: 'x-form/assert', ... }] }
|
|
84
|
+
*/
|
|
85
|
+
export declare function compileFormRules(model: import('./model.js').FormField, options?: {
|
|
86
|
+
compileTypeTest?: (schemaJson: any, docPath: string) => ((value: any) => boolean);
|
|
87
|
+
}): CompiledRules;
|
|
88
|
+
/**
|
|
89
|
+
* Evaluate compiled form rules against the current data root.
|
|
90
|
+
*
|
|
91
|
+
* Returns a map of data pointer -> RuleResult holding only the rules each
|
|
92
|
+
* field declares. Rules on array item templates are evaluated once per
|
|
93
|
+
* element of the actual array, keyed by the expanded pointer.
|
|
94
|
+
*
|
|
95
|
+
* @param {CompiledRules} compiled - From compileFormRules
|
|
96
|
+
* @param {any} data - The form data root (the query input `$`)
|
|
97
|
+
* @returns {Record<string, RuleResult>}
|
|
98
|
+
* @example
|
|
99
|
+
* const results = evaluateFormRules(compiled, { company: 'ACME', vatId: '' });
|
|
100
|
+
* results['/vatId'].errors; // [{ keyword: 'x-form/assert', message: '...' }]
|
|
101
|
+
*/
|
|
102
|
+
export declare function evaluateFormRules(compiled: CompiledRules, data: any): Record<string, RuleResult>;
|
|
103
|
+
/**
|
|
104
|
+
* Copy every `x-form.assert` of a schema into a `$query` assertion, so a
|
|
105
|
+
* rule authored once for per-keystroke feedback is also enforced by the
|
|
106
|
+
* authoritative submit validation (the validator's `$query` keyword).
|
|
107
|
+
* Pure schema-to-schema transform - no validator import; the output only
|
|
108
|
+
* spells the keyword.
|
|
109
|
+
*
|
|
110
|
+
* The `$query` lands on the ROOT schema (where the query input `$` is the
|
|
111
|
+
* instance root, matching the rule context), with each assert wrapped to
|
|
112
|
+
* rebuild its bindings: `value` binds to the field's location, `pointer`
|
|
113
|
+
* to its pointer string. An assert on an array item template quantifies
|
|
114
|
+
* with `$every` over the actual elements (`pointer` then stays the
|
|
115
|
+
* template pointer - element indexes are a render-time notion). Multiple
|
|
116
|
+
* asserts conjoin under `$and`; an existing root `$query` is preserved by
|
|
117
|
+
* wrapping the new one in an `allOf` branch.
|
|
118
|
+
*
|
|
119
|
+
* The transform follows the same structural spine as buildFormModel
|
|
120
|
+
* (`properties`, `items`, `prefixItems`, `allOf`) but does not resolve
|
|
121
|
+
* `$ref`s - a `$def`'s data location depends on its use site.
|
|
122
|
+
*
|
|
123
|
+
* @param {object|boolean} schema - The root JSON schema
|
|
124
|
+
* @returns {object|boolean} A new root schema (input is not mutated;
|
|
125
|
+
* untouched subtrees are shared) with the collected `$query`, or the
|
|
126
|
+
* input itself when there is nothing to copy
|
|
127
|
+
* @example
|
|
128
|
+
* const submitSchema = formRulesToQueryAssertions(schema);
|
|
129
|
+
* const validate = new JarenValidator().compile(submitSchema); // caller-side
|
|
130
|
+
*/
|
|
131
|
+
export declare function formRulesToQueryAssertions(schema: object | boolean): object | boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type FieldError = {
|
|
2
|
+
/**
|
|
3
|
+
* - The JSON Schema keyword that failed
|
|
4
|
+
*/
|
|
5
|
+
keyword: string;
|
|
6
|
+
/**
|
|
7
|
+
* - Human readable message
|
|
8
|
+
*/
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Validate a single field value against its own constraints.
|
|
13
|
+
*
|
|
14
|
+
* @param {import('./model.js').FormField} field - Field from buildFormModel
|
|
15
|
+
* @param {any} value - The TYPED value (see parseFieldInput); undefined = absent
|
|
16
|
+
* @returns {FieldError[]} Empty when the value passes every per-field check
|
|
17
|
+
* @example
|
|
18
|
+
* const errors = validateField(emailField, 'not-an-email');
|
|
19
|
+
* // [{ keyword: 'format', message: 'Must be a valid email' }]
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateField(field: import('./model.js').FormField, value: any): FieldError[];
|
|
22
|
+
/**
|
|
23
|
+
* Validate every leaf field of a model against the current data.
|
|
24
|
+
* Returns a map of data-pointer -> FieldError[] for fields that fail.
|
|
25
|
+
* @param {import('./model.js').FormField} model - Root field from buildFormModel
|
|
26
|
+
* @param {any} data - Current form data
|
|
27
|
+
* @returns {Record<string, FieldError[]>}
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateAllFields(model: import('./model.js').FormField, data: any): Record<string, FieldError[]>;
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jarenjs/forms",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.9.2",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist/types/",
|
|
18
|
+
"src/"
|
|
19
|
+
],
|
|
20
|
+
"description": "Framework-agnostic form model generator for JSON Schema with preemptive per-field validation powered by @jarenjs/core",
|
|
21
|
+
"author": "joham",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/jklarenbeek/jarenjs.git",
|
|
25
|
+
"directory": "packages/forms"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org/"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"jaren",
|
|
37
|
+
"json",
|
|
38
|
+
"schema",
|
|
39
|
+
"form",
|
|
40
|
+
"forms",
|
|
41
|
+
"generator",
|
|
42
|
+
"validation"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "npm run build:types",
|
|
46
|
+
"build:types": "tsc -p tsconfig.json",
|
|
47
|
+
"prepack": "npm run build:types"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@jarenjs/core": "^0.9.2",
|
|
51
|
+
"@jarenjs/formats": "^0.9.2",
|
|
52
|
+
"@jarenjs/json": "^0.9.2"
|
|
53
|
+
}
|
|
54
|
+
}
|