react-luna-form 0.0.81 → 0.0.83

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.
@@ -0,0 +1,318 @@
1
+ # Validation
2
+
3
+ Luna Form derives a [Zod](https://zod.dev) schema directly from the form JSON, so the **form definition is the single source of truth for validation**. Each field carries a `validation` object that maps a constraint to its error message; the builder turns those declarations into a runnable schema.
4
+
5
+ The same schema powers two paths:
6
+
7
+ - **In the browser**, the rendered `<Form>` validates on blur/submit.
8
+ - **On the server (or any headless caller)**, `react-luna-form/schema` exposes `buildFormSchema(sections)` so you can run the _exact same_ validation without React — see [Headless validation](#headless-validation).
9
+
10
+ This page documents every key of the `validation` object plus the shared operator vocabulary. Per-field basics also appear in each field's own page (e.g. [Input](../fields/input.md), [Select](../fields/select.md), [List](../fields/list.md)).
11
+
12
+ ---
13
+
14
+ ## The `validation` object
15
+
16
+ `validation` lives on a field and maps a constraint name to a message (or a rule declaration):
17
+
18
+ - **`required`** _(string)_: Message shown when a field marked `required: true` is empty.
19
+ - **`email`** _(string)_: Message for an invalid email format (`input/email`).
20
+ - **`length`** _({ min?: string, max?: string })_: Messages when the value's length breaches `advanced.length`.
21
+ - **`custom`** _(CustomValidation | CustomValidation[])_: Cross-field comparison. See [Custom (cross-field)](#custom-cross-field).
22
+ - **`requiredWhen`** _(WhenRule | WhenRule[])_: Conditionally required. See [requiredWhen](#requiredwhen).
23
+ - **`pattern`** _(PatternRule)_: Regex/format check. See [pattern](#pattern).
24
+ - **`rules`** _(AssertRule[])_: General gated assertions. See [rules](#rules).
25
+
26
+ > Emptiness is normalized for validation: an empty string, a whitespace-only string, `null`/`undefined`, and an empty array all count as "no value". So `pattern` and format rules never fire on an empty field — that is `required`/`requiredWhen`'s job.
27
+
28
+ ---
29
+
30
+ ## `requiredWhen`
31
+
32
+ Makes a field required **only when a condition over sibling data holds**. This is the declarative replacement for wiring "required" to visibility by hand.
33
+
34
+ A `WhenRule` is:
35
+
36
+ - **`field`** _(string, required)_: The sibling field whose value is tested.
37
+ - **`operator`** _(Operator, optional)_: How to compare. Defaults to `eq`. See [Operators](#operators).
38
+ - **`value`** _(string | number | boolean | Array, optional)_: The value to compare against (ignored by the unary operators `exists` / `empty` / `truthy`).
39
+ - **`message`** _(string, optional)_: The error message. Falls back to `validation.required` when omitted.
40
+
41
+ Provide a single rule or an array. **An array is OR** — the field is required if _any_ rule's condition holds.
42
+
43
+ ```json
44
+ {
45
+ "name": "token",
46
+ "type": "input/text",
47
+ "validation": {
48
+ "requiredWhen": {
49
+ "field": "authType",
50
+ "operator": "eq",
51
+ "value": "bearer",
52
+ "message": "Provide the access token"
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ### Item scope inside lists
59
+
60
+ When a `requiredWhen` field lives inside a [`list`](../fields/list.md), its `field` reference resolves **within the same item**, not the form root. This lets each row validate against its own siblings:
61
+
62
+ ```json
63
+ {
64
+ "name": "rules",
65
+ "type": "list",
66
+ "fields": [
67
+ { "name": "operator", "type": "select" },
68
+ {
69
+ "name": "value",
70
+ "type": "input/text",
71
+ "validation": {
72
+ "requiredWhen": {
73
+ "field": "operator",
74
+ "operator": "nin",
75
+ "value": ["truthy", "exists"],
76
+ "message": "Enter the value to compare against"
77
+ }
78
+ }
79
+ }
80
+ ]
81
+ }
82
+ ```
83
+
84
+ Here `value` is required unless this row's `operator` is `truthy` or `exists`. Item-scoped `requiredWhen` is resolved by the [headless builder](#headless-validation).
85
+
86
+ ---
87
+
88
+ ## `pattern`
89
+
90
+ Validates a string value against a regular expression.
91
+
92
+ - **`regex`** _(string, required)_: The pattern source (compiled with `new RegExp`).
93
+ - **`flags`** _(string, optional)_: Regex flags, e.g. `"i"`.
94
+ - **`message`** _(string, optional)_: The error message.
95
+ - **`allowInterpolation`** _(boolean, optional)_: When `true`, the check is **skipped** if the value is dynamic and only resolved at run time — either an interpolation template (contains `{...}`) or a reference beginning with `@` (e.g. `@Trigger.url`), the convention applications built on this library use for a value resolved elsewhere. Use this for fields that accept dynamic values, e.g. a URL like `{step.url}` or `@step.url`.
96
+
97
+ ```json
98
+ {
99
+ "name": "url",
100
+ "type": "input/text",
101
+ "validation": {
102
+ "pattern": {
103
+ "regex": "^https?://",
104
+ "allowInterpolation": true,
105
+ "message": "The web address must start with http:// or https://"
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ `pattern` never fires on an empty value — pair it with `required`/`requiredWhen` when the field is mandatory.
112
+
113
+ ---
114
+
115
+ ## `rules`
116
+
117
+ An escape hatch that keeps complex validation **in JSON**: an array of gated assertions evaluated in order.
118
+
119
+ Each `AssertRule` is:
120
+
121
+ - **`assert`** _(string, required)_: What to check — one of `required`, `minItems`, `maxItems`, `min`, `max`, `oneOf`, `pattern`.
122
+ - **`value`** _(depends on `assert`)_: The bound the assertion checks against (a number, an array for `oneOf`, or a `PatternRule` for `pattern`).
123
+ - **`message`** _(string, required)_: The error shown when the assertion fails.
124
+ - **`when`** _(WhenClause, optional)_: Only run the assertion when this condition holds. Omit to always run it.
125
+
126
+ ### Assertions
127
+
128
+ | `assert` | Passes when | `value` |
129
+ | ---------- | --------------------------------------------------- | ------------- |
130
+ | `required` | The field has a value | — |
131
+ | `minItems` | Array length ≥ value | number |
132
+ | `maxItems` | Array length ≤ value | number |
133
+ | `min` | Size ≥ value (number value, or string/array length) | number |
134
+ | `max` | Size ≤ value | number |
135
+ | `oneOf` | Value is one of the listed values | array |
136
+ | `pattern` | Value matches the pattern | `PatternRule` |
137
+
138
+ ### `when` clause
139
+
140
+ A `when` gate can be a single rule, an array, or an all/any object:
141
+
142
+ - A single **`WhenRule`** — runs when that condition holds.
143
+ - An **array of `WhenRule`** — **AND**: every condition must hold.
144
+ - **`{ all?: WhenRule[]; any?: WhenRule[] }`** — `all` requires every rule, `any` requires at least one; both must be satisfied when present.
145
+
146
+ ```json
147
+ {
148
+ "name": "port",
149
+ "type": "input/number",
150
+ "validation": {
151
+ "rules": [
152
+ {
153
+ "when": { "field": "kind", "operator": "eq", "value": "custom" },
154
+ "assert": "required",
155
+ "message": "Port is required for custom connections"
156
+ },
157
+ {
158
+ "assert": "oneOf",
159
+ "value": ["eq", "neq", "gt"],
160
+ "message": "Choose a valid operator"
161
+ }
162
+ ]
163
+ }
164
+ }
165
+ ```
166
+
167
+ ---
168
+
169
+ ## `custom` (cross-field)
170
+
171
+ Compares this field's value against **another field's value** using an operator. Provide one rule or an array (all are checked).
172
+
173
+ - **`field`** _(string)_: The other field to compare against.
174
+ - **`operator`** _(Operator, optional)_: Defaults to `eq`.
175
+ - **`message`** _(string)_: The error shown when the comparison fails.
176
+
177
+ ```json
178
+ {
179
+ "name": "confirmPassword",
180
+ "type": "input/text",
181
+ "required": true,
182
+ "validation": {
183
+ "custom": {
184
+ "field": "password",
185
+ "operator": "eq",
186
+ "message": "Passwords must match"
187
+ }
188
+ }
189
+ }
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Operators
195
+
196
+ Operators are shared across `custom`, `requiredWhen`, `rules` gates, and [change-event conditions](../events/change.md). Each compares a `current` value against a `value`:
197
+
198
+ | Operator | True when | Notes |
199
+ | ------------ | ---------------------------------------------- | --------------------------- |
200
+ | `eq` | `current === value` | default |
201
+ | `neq` | `current !== value` | |
202
+ | `in` | `value` (array) contains `current` | |
203
+ | `nin` | `value` (array) does **not** contain `current` | |
204
+ | `gt` / `gte` | `current > value` / `>=` | numeric |
205
+ | `lt` / `lte` | `current < value` / `<=` | numeric |
206
+ | `contains` | `current` (string/array) contains `value` | |
207
+ | `exists` | `current` has a value | **unary** — `value` ignored |
208
+ | `empty` | `current` has no value (inverse of `exists`) | **unary** — `value` ignored |
209
+ | `truthy` | `current` is truthy | **unary** — `value` ignored |
210
+
211
+ `contains`, `exists`, `empty`, and `truthy` are the newest additions; the rest have always been available.
212
+
213
+ ---
214
+
215
+ ## List length
216
+
217
+ A [`list`](../fields/list.md) can require a minimum/maximum number of items. The bounds live on `advanced.length`, and the messages on `validation.length`:
218
+
219
+ ```json
220
+ {
221
+ "name": "rules",
222
+ "type": "list",
223
+ "advanced": { "length": { "min": 1, "max": 5 } },
224
+ "validation": {
225
+ "length": {
226
+ "min": "Add at least one condition",
227
+ "max": "You can add up to five conditions"
228
+ }
229
+ },
230
+ "fields": [{ "name": "label", "type": "input/text" }]
231
+ }
232
+ ```
233
+
234
+ A missing list key is treated as an empty list, so `length.min` still fails on an absent value rather than silently passing. List length is enforced by the [headless builder](#headless-validation).
235
+
236
+ ---
237
+
238
+ ## Headless validation
239
+
240
+ The server-safe entry `react-luna-form/schema` exposes the builder with **no React dependency**, so a backend can validate a stored configuration with the same rules the form uses:
241
+
242
+ ```ts
243
+ import { buildFormSchema, collectIssues } from 'react-luna-form/schema'
244
+
245
+ const schema = buildFormSchema(sections) // sections: the form JSON
246
+ const result = schema.safeParse(config)
247
+
248
+ if (!result.success) {
249
+ const issues = collectIssues(result.error)
250
+ // [{ path: 'rules.0.value', message: 'Enter the value to compare against' }]
251
+ }
252
+ ```
253
+
254
+ - **`buildFormSchema(sections, translations?, definition?)`** — walks the form tree (sections → columns → lists → fields) and returns a Zod schema. Lists become arrays of objects, so nested `requiredWhen`/`rules` resolve in **item scope**. `translations` maps messages to localized strings; `definition` resolves `$ref` sources.
255
+ - **`collectIssues(error)`** — flattens a `ZodError` into `{ path, message }` entries with **dotted paths** (e.g. `rules.0.value`), including nested list issues.
256
+
257
+ ### What each path validates
258
+
259
+ | Rule | Rendered `<Form>` submit | `buildFormSchema` (headless) |
260
+ | --------------------------------------------- | ------------------------ | ---------------------------- |
261
+ | `required`, `email`, `length` (field) | ✅ | ✅ |
262
+ | `custom` | ✅ | ✅ |
263
+ | `requiredWhen` (top-level) | ✅ | ✅ |
264
+ | `pattern` | ✅ | ✅ |
265
+ | `rules` (top-level) | ✅ | ✅ |
266
+ | `requiredWhen` **inside a list** (item scope) | — | ✅ |
267
+ | List `length` | — | ✅ |
268
+
269
+ The headless builder is the superset: item-scoped conditions and list length require the nested tree it produces.
270
+
271
+ ### Nested (dotted) field names
272
+
273
+ A field whose `name` contains a dot — e.g. `basicAuth.username` — is grouped into a **nested object** by `buildFormSchema`, matching the nested config the runtime persists (the `<Form>` submits flat keys and then un-flattens them). So `{ "name": "basicAuth.username" }` validates against `{ basicAuth: { username } }`, and its issue path is `basicAuth.username`. The nested group is optional as a whole: a config may omit it entirely (e.g. no `basicAuth` when auth is off) and rely on `requiredWhen` for the conditional requirement.
274
+
275
+ ### Hidden fields
276
+
277
+ `buildFormSchema` treats a `hidden: true` field as **not structurally required**, even if it declares `required: true`. A hidden field is only shown once an event reveals it, so its requirement is conditional by nature — express it with [`requiredWhen`](#requiredwhen) (typically keyed on the same condition that reveals it). This keeps the headless schema from over-requiring fields the rendered form never mounts. `pattern`/format rules on a hidden field still fire when it holds a value.
278
+
279
+ ---
280
+
281
+ ## Complete example
282
+
283
+ ```json
284
+ {
285
+ "sections": [
286
+ {
287
+ "fields": [
288
+ { "name": "authType", "type": "select" },
289
+ {
290
+ "name": "token",
291
+ "type": "input/text",
292
+ "validation": {
293
+ "requiredWhen": {
294
+ "field": "authType",
295
+ "operator": "eq",
296
+ "value": "bearer",
297
+ "message": "Provide the access token"
298
+ }
299
+ }
300
+ },
301
+ {
302
+ "name": "url",
303
+ "type": "input/text",
304
+ "required": true,
305
+ "validation": {
306
+ "required": "The web address is required",
307
+ "pattern": {
308
+ "regex": "^https?://",
309
+ "allowInterpolation": true,
310
+ "message": "Use a valid http(s) address"
311
+ }
312
+ }
313
+ }
314
+ ]
315
+ }
316
+ ]
317
+ }
318
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-luna-form",
3
- "version": "0.0.81",
3
+ "version": "0.0.83",
4
4
  "description": "A React library for building forms based on JSON",
5
5
  "main": "./dist/client/cjs/index.js",
6
6
  "module": "./dist/client/esm/index.js",
@@ -15,7 +15,8 @@
15
15
  "validation"
16
16
  ],
17
17
  "files": [
18
- "dist"
18
+ "dist",
19
+ "docs"
19
20
  ],
20
21
  "peerDependencies": {
21
22
  "date-fns": "^4.0.0",
@@ -34,7 +35,7 @@
34
35
  "eslint-plugin-react": "7.37.5",
35
36
  "eslint-plugin-react-hooks": "7.1.1",
36
37
  "eslint-plugin-react-refresh": "0.5.4",
37
- "@luna-form/core": "0.0.81"
38
+ "@luna-form/core": "0.0.83"
38
39
  },
39
40
  "license": "Apache-2.0",
40
41
  "repository": {