react-luna-form 0.0.82 → 0.0.84
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 +140 -0
- package/dist/client/cjs/index.js +1 -1
- package/dist/client/esm/index.js +1 -1
- package/dist/config/cjs/index.js +1 -1
- package/dist/config/esm/index.js +1 -1
- package/dist/schema/cjs/index.js +1 -1
- package/dist/schema/esm/index.js +1 -1
- package/dist/server/cjs/index.js +1 -1
- package/dist/server/esm/index.js +1 -1
- package/dist/types/luna-core/type.d.ts +1 -1
- package/dist/types/luna-core/util/build.d.ts +1 -1
- package/dist/types/luna-core/util/extract.d.ts +4 -2
- package/dist/types/luna-core/util/is-type.d.ts +2 -1
- package/dist/types/luna-core/util/schema.d.ts +4 -4
- package/dist/types/luna-core/util/style.d.ts +1 -5
- package/dist/types/luna-react/src/client/component/input-selectable.d.ts +2 -2
- package/dist/types/luna-react/src/client/component/input-strategies.d.ts +19 -4
- package/dist/types/luna-react/src/client/hook/use-input-core.d.ts +3 -3
- package/docs/events/change.md +69 -2
- package/docs/fields/data-source.md +113 -0
- package/docs/fields/input.md +1 -1
- package/docs/fields/list.md +5 -2
- package/docs/fields/select.md +49 -1
- package/docs/forms/submit.md +129 -0
- package/docs/index.md +9 -3
- package/docs/interpolation/format-filters.md +4 -0
- package/docs/interpolation/overview.md +179 -0
- package/docs/setup.md +65 -14
- package/docs/structure/definition.md +99 -0
- package/docs/structure/layout.md +189 -0
- package/docs/structure/sections.md +187 -0
- package/docs/validation/overview.md +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Data Sources
|
|
2
|
+
|
|
3
|
+
The options of a `select`, `radio` or `chips` can be written in the schema or
|
|
4
|
+
fetched. This page is the fetched half: the shape of a `DataSource`, when a
|
|
5
|
+
request is actually made, and what happens when one is not.
|
|
6
|
+
|
|
7
|
+
Declaring options inline is covered in [select.md](select.md#1-static-options),
|
|
8
|
+
and mapping a fetched row onto `{ label, value }` in
|
|
9
|
+
[Property Mapping Reference](select.md#property-mapping-reference).
|
|
10
|
+
|
|
11
|
+
## The shape
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"name": "country",
|
|
16
|
+
"type": "select",
|
|
17
|
+
"label": "Country",
|
|
18
|
+
"source": {
|
|
19
|
+
"url": "/api/countries",
|
|
20
|
+
"method": "GET",
|
|
21
|
+
"namespace": "data.results",
|
|
22
|
+
"headers": { "X-Tenant": "acme" },
|
|
23
|
+
"cache": "no-store"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Key | Type | Does |
|
|
29
|
+
| ----------- | -------------------- | -------------------------------------------------- |
|
|
30
|
+
| `url` | `string`, required | relative or absolute |
|
|
31
|
+
| `method` | `string` | defaults to `GET` |
|
|
32
|
+
| `namespace` | `string` | dot path to the array inside the response |
|
|
33
|
+
| `headers` | `HeadersInit` | merged over `Accept: application/json` |
|
|
34
|
+
| `body` | `object \| BodyInit` | query parameters on a `GET`, a JSON body otherwise |
|
|
35
|
+
| `cache` | `RequestCache` | passed through to `fetch` |
|
|
36
|
+
|
|
37
|
+
## `body` changes meaning with the method
|
|
38
|
+
|
|
39
|
+
On a `GET`, each key of `body` is appended to the URL as a query parameter, and
|
|
40
|
+
keys holding `undefined` are dropped rather than sent as the string
|
|
41
|
+
`"undefined"`. On anything else the body is serialized as JSON and
|
|
42
|
+
`Content-Type: application/json` is added for you.
|
|
43
|
+
|
|
44
|
+
## `namespace` reaches into the response
|
|
45
|
+
|
|
46
|
+
An API that answers with the array itself needs nothing. One that wraps it needs
|
|
47
|
+
the dot path to it:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "url": "/api/countries", "namespace": "data.results" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If the response is already an array, `namespace` is ignored. If the path does
|
|
54
|
+
not lead to an array, the field renders with no options — the same outcome as an
|
|
55
|
+
empty response.
|
|
56
|
+
|
|
57
|
+
## When a request is made, and when it is not
|
|
58
|
+
|
|
59
|
+
Four ways to end up with an empty field and no request:
|
|
60
|
+
|
|
61
|
+
- **The source is an array.** Inline options are used as they are.
|
|
62
|
+
- **The URL still holds a placeholder.** A cascading source keyed on another
|
|
63
|
+
field is not fetched until that placeholder resolves. This is deliberate and
|
|
64
|
+
it is also what a typo produces — see
|
|
65
|
+
[Interpolation](../interpolation/overview.md#an-unresolved-placeholder-cancels-a-request).
|
|
66
|
+
- **The URL is blocked** by [`fetcher.remotePatterns`](../setup.md#fetcherremotepatterns).
|
|
67
|
+
This one is logged; the others are silent. Relative URLs are internal and
|
|
68
|
+
always allowed.
|
|
69
|
+
- **The field is disabled.** Nothing is fetched, and a disabled `select` whose
|
|
70
|
+
value is an object renders that object as its only option — so a saved
|
|
71
|
+
selection is still readable on a form that cannot be edited.
|
|
72
|
+
|
|
73
|
+
Requests are keyed by the source itself, so two fields declaring the same one
|
|
74
|
+
share a single request and its cache entry.
|
|
75
|
+
|
|
76
|
+
## Failures
|
|
77
|
+
|
|
78
|
+
A non-2xx response throws with the parsed body, and the error is logged rather
|
|
79
|
+
than rendered: the field shows an empty list. A response that is not JSON fails
|
|
80
|
+
the same way, since the body is always parsed as JSON.
|
|
81
|
+
|
|
82
|
+
A URL containing the literal `null` or `undefined` in a path or query segment is
|
|
83
|
+
rejected before the request goes out — the usual sign that a placeholder
|
|
84
|
+
resolved to nothing.
|
|
85
|
+
|
|
86
|
+
## Changing a source at run time
|
|
87
|
+
|
|
88
|
+
A [`source` change event](../events/change.md#3-source-action-sourceevent)
|
|
89
|
+
installs a new `DataSource` on another field, with `{value}` and the rest of the
|
|
90
|
+
selected option interpolated into its `url` and `body`.
|
|
91
|
+
|
|
92
|
+
Several events aimed at the same target **merge** rather than replace: query
|
|
93
|
+
parameters from each URL are combined, `body` and `headers` are merged key by
|
|
94
|
+
key, and for anything else the most recent event wins. Two fields can each
|
|
95
|
+
contribute a parameter to one dependent source that way.
|
|
96
|
+
|
|
97
|
+
Options that arrive from a source are data, not authored copy, so they are not
|
|
98
|
+
resolved against the dictionary — see
|
|
99
|
+
[Translating Options](select.md#translating-options).
|
|
100
|
+
|
|
101
|
+
## Sharing one source between fields
|
|
102
|
+
|
|
103
|
+
A source used in more than one place belongs in a
|
|
104
|
+
[definition](../structure/definition.md), named once and referenced:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"source": { "$ref": "#/definition/sources.countries" }
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A reference that does not resolve is left as written, and a `source` that is
|
|
113
|
+
still a `$ref` is never fetched.
|
package/docs/fields/input.md
CHANGED
|
@@ -44,7 +44,7 @@ The `advanced` property dictates finer HTML details, interactive structures, and
|
|
|
44
44
|
- **`autocomplete`** _(string)_: Standard HTML autocomplete attribute (e.g., `"username"`, `"off"`).
|
|
45
45
|
- **`cols`** _(number)_: Layout column span for CSS grid alignment.
|
|
46
46
|
- **`horizontal`** _(boolean)_: Overrides configuration layout to render label and input horizontally.
|
|
47
|
-
- **`reverse`** _(boolean)_:
|
|
47
|
+
- **`reverse`** _(boolean)_: `checkbox` only, and it exists to turn a default **off**. A checkbox renders its box first and its label after it; `reverse: false` swaps them, `true` changes nothing, and on any other field type the key is not read. See [Layout](../structure/layout.md#advancedreverse-checkbox-only).
|
|
48
48
|
- **`aria`** _(object)_: Map of specific ARIA attributes for screen readers (e.g., `{ "aria-label": "Custom label" }`).
|
|
49
49
|
- **`data`** _(object)_: Map of specific HTML data attributes (e.g., `{ "data-test-id": "my-field" }`).
|
|
50
50
|
- **`transient`** _(boolean)_: If `true`, the field fires its change events and keeps nothing of its own — it is neither stored nor reported to the consumer. For a control whose whole job is to write to _another_ field. See [Transient controls](../events/change.md#transient-controls).
|
package/docs/fields/list.md
CHANGED
|
@@ -18,6 +18,9 @@ The `list` field type in Luna Form acts as an iterative array structure, enablin
|
|
|
18
18
|
|
|
19
19
|
The `advanced` property customizes structural interaction rules and layout rendering for the collection container.
|
|
20
20
|
|
|
21
|
+
- **`title`** _(string)_: Names one item of the collection. The header of the second row then reads `Contact 2` and its controls announce `Collapse Contact 2` and `Remove Contact 2`.
|
|
22
|
+
It falls back to the list's `label`, and then to its `name`, so a list called `contacts` with neither captions its rows `contacts 1`. A [`preview.label`](#collapsed-previews-preview-object) replaces the caption where it resolves; this stays the fallback for the rows where it does not.
|
|
23
|
+
Like `action`, it is copy the form declares, so it is translated through the dictionary — see [Localization](#localization).
|
|
21
24
|
- **`action`** _(string)_: A custom label applied strictly to the generic "Add item" button rendering at the end of the collection (e.g. `"Add email address"`).
|
|
22
25
|
A custom label has to be translated by the form; the built-in `Add item` default is already translated by the library — see [Localization](#localization).
|
|
23
26
|
- **`length`** _({ min?: number, max?: number })_: Configures restrictions on how many items can be generated. Enforces boundary conditions where minimum instances ensure permanent default items and maximum instances automatically disable insertion toggles. The matching error messages are declared under the list's `validation.length` — see the [Validation reference](../validation/overview.md#list-length).
|
|
@@ -65,7 +68,7 @@ For example, if you want a field inside the list to update another field **in th
|
|
|
65
68
|
{
|
|
66
69
|
"name": "role",
|
|
67
70
|
"type": "select",
|
|
68
|
-
"
|
|
71
|
+
"source": [
|
|
69
72
|
{ "value": "admin", "label": "Admin" },
|
|
70
73
|
{ "value": "user", "label": "User" }
|
|
71
74
|
],
|
|
@@ -215,7 +218,7 @@ Left untranslated, the template fills in to the same English text it replaced.
|
|
|
215
218
|
{
|
|
216
219
|
"name": "status",
|
|
217
220
|
"type": "select",
|
|
218
|
-
"
|
|
221
|
+
"source": [
|
|
219
222
|
{ "value": "active", "label": "Currently Employed" },
|
|
220
223
|
{ "value": "past", "label": "Past Job" }
|
|
221
224
|
]
|
package/docs/fields/select.md
CHANGED
|
@@ -36,7 +36,7 @@ Each option consists of:
|
|
|
36
36
|
|
|
37
37
|
### 2. Remote Data Source
|
|
38
38
|
|
|
39
|
-
You can fetch options from a remote API by providing a `DataSource` configuration.
|
|
39
|
+
You can fetch options from a remote API by providing a `DataSource` configuration. Its full shape, and when a request is and is not made, are in [data-source.md](data-source.md).
|
|
40
40
|
|
|
41
41
|
```json
|
|
42
42
|
"source": {
|
|
@@ -79,6 +79,52 @@ Inside `advanced.options`, you can map:
|
|
|
79
79
|
|
|
80
80
|
---
|
|
81
81
|
|
|
82
|
+
### `advanced.entity`
|
|
83
|
+
|
|
84
|
+
`advanced.options` decides what the user sees and what the field submits.
|
|
85
|
+
`advanced.entity` decides how a value is matched **back** to the row it came
|
|
86
|
+
from, and it defaults to `value`.
|
|
87
|
+
|
|
88
|
+
That default is right for options shaped `{ label, value }` and wrong for
|
|
89
|
+
everything else, which is why it belongs next to `options`: whenever
|
|
90
|
+
`options.value` names a key other than `value`, `entity` has to name the same
|
|
91
|
+
key.
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"name": "user_selection",
|
|
96
|
+
"type": "select",
|
|
97
|
+
"source": { "url": "/api/users" },
|
|
98
|
+
"advanced": {
|
|
99
|
+
"entity": "id",
|
|
100
|
+
"options": { "value": "id", "label": "name" }
|
|
101
|
+
},
|
|
102
|
+
"event": {
|
|
103
|
+
"change": [{ "action": "value", "value": { "contact_email": "{email}" } }]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
It does two things, both of them a lookup by that key:
|
|
109
|
+
|
|
110
|
+
- **A change event gets the whole row.** The library finds the source row whose
|
|
111
|
+
`entity` key equals the current selection and hands that object to the
|
|
112
|
+
event, which is what makes `{email}` — a key that is neither the label nor
|
|
113
|
+
the value — resolvable. Without `entity` the lookup matches nothing, the
|
|
114
|
+
event falls back to `{ value }`, and every other placeholder is left literal.
|
|
115
|
+
See [Event payloads](../interpolation/overview.md#event-payloads).
|
|
116
|
+
- **An object value is read through it.** A form whose value for this field is
|
|
117
|
+
the whole record rather than the id — `{ "id": 101, "name": "John" }` — shows
|
|
118
|
+
the right option, because `entity` says which key of that object is the
|
|
119
|
+
value. Without it the field looks for a `value` key, finds none, and mounts
|
|
120
|
+
empty.
|
|
121
|
+
|
|
122
|
+
The comparison is made on the text of both sides, so a numeric `id` in the
|
|
123
|
+
source matches the string the select reports.
|
|
124
|
+
|
|
125
|
+
`entity` is not read for `chips`: an array selection answers to no single row,
|
|
126
|
+
so a `chips` change carries the array itself.
|
|
127
|
+
|
|
82
128
|
## Translating Options
|
|
83
129
|
|
|
84
130
|
When the options are declared inline as an array, `label` and `description` are resolved against the form dictionary, exactly like a field's own `label`. Missing keys fall back to the key itself.
|
|
@@ -139,6 +185,8 @@ Specialized selectors carry their own built-in labels and are not resolved again
|
|
|
139
185
|
- **`multiple`** _(boolean, optional)_: (Available for `chips`) Allows selecting more than one option. Defaults to `true`.
|
|
140
186
|
- **`preselected`** _(boolean, optional)_: For specialized selectors like `select/month`, determines if the current value (e.g., current month) should be selected by default.
|
|
141
187
|
- **`horizontal`** _(boolean, optional)_: Renders the options or selector horizontally.
|
|
188
|
+
- **`entity`** _(string, optional)_: The key that identifies an option row, defaulting to `value`. Set it whenever `options.value` names a different key — see [`advanced.entity`](#advancedentity).
|
|
189
|
+
- **`options`** _({ label, value, description }, optional)_: Maps the keys of a remote row onto the option shape — see [Property Mapping Reference](#property-mapping-reference).
|
|
142
190
|
|
|
143
191
|
### A chips value is always an array
|
|
144
192
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Submitting
|
|
2
|
+
|
|
3
|
+
What happens between the user pressing the button and your code receiving an
|
|
4
|
+
object. This page is about the client `Form` — the server one renders fields and
|
|
5
|
+
has no submit path of its own.
|
|
6
|
+
|
|
7
|
+
## Nothing submits without a control
|
|
8
|
+
|
|
9
|
+
The library renders no button. Whatever you pass as `children` becomes the
|
|
10
|
+
form's control area:
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
<Form sections={sections} config={config} action={save}>
|
|
14
|
+
{({ isPending }) => (
|
|
15
|
+
<button type="submit" disabled={isPending}>
|
|
16
|
+
{isPending ? 'Saving…' : 'Save'}
|
|
17
|
+
</button>
|
|
18
|
+
)}
|
|
19
|
+
</Form>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A plain node works as well; the function form is what gets `isPending`, which is
|
|
23
|
+
true from the moment the action starts until it settles.
|
|
24
|
+
|
|
25
|
+
## The pipeline
|
|
26
|
+
|
|
27
|
+
On submit, in this order:
|
|
28
|
+
|
|
29
|
+
1. **The form data becomes an object.** A name that appears more than once
|
|
30
|
+
arrives as an array; everything else as a single value.
|
|
31
|
+
2. **Date fields are normalized.** A date input holds its value in the display
|
|
32
|
+
format the field declares, and it is converted back to a native date before
|
|
33
|
+
validation, so the schema and your action see one shape whatever
|
|
34
|
+
`advanced.format` says.
|
|
35
|
+
3. **It is validated** against the schema built from the fields that are
|
|
36
|
+
currently mounted.
|
|
37
|
+
4. **Dotted names are un-flattened.** `basicAuth.username` becomes
|
|
38
|
+
`{ basicAuth: { username } }`, and a numeric segment becomes an array index,
|
|
39
|
+
so `rules.0.value` rebuilds the list — with the gaps of removed rows closed
|
|
40
|
+
up.
|
|
41
|
+
5. **Your `action` is awaited** with that object and the schema.
|
|
42
|
+
6. **`onSuccess` is called** with what the action returned, and the form clears
|
|
43
|
+
itself.
|
|
44
|
+
|
|
45
|
+
## Only mounted fields are validated
|
|
46
|
+
|
|
47
|
+
The client schema is assembled from the fields on screen. A field hidden by a
|
|
48
|
+
[`state` action](../events/change.md#2-state-action-stateevent) is unmounted, so
|
|
49
|
+
it is absent from the schema and from the submitted object — which is the point:
|
|
50
|
+
a rule that does not apply cannot block a submit.
|
|
51
|
+
|
|
52
|
+
The consequence is that the browser check is narrower than the
|
|
53
|
+
[headless one](../validation/overview.md#headless-validation).
|
|
54
|
+
`buildFormSchema` walks the whole tree, item-scoped rules and list lengths
|
|
55
|
+
included. A server that re-validates with it is not repeating the client's work;
|
|
56
|
+
it is doing the part the client structurally cannot.
|
|
57
|
+
|
|
58
|
+
## What `action` receives and returns
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
type FormState<T> = {
|
|
62
|
+
data: T | null
|
|
63
|
+
error: {
|
|
64
|
+
title: string
|
|
65
|
+
description?: string
|
|
66
|
+
details?: string[] | Record<string, string[]>
|
|
67
|
+
} | null
|
|
68
|
+
success: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function save(
|
|
72
|
+
data: Payload,
|
|
73
|
+
schema?: ZodSchema
|
|
74
|
+
): Promise<FormState<Saved>> {
|
|
75
|
+
const stored = await api.save(data)
|
|
76
|
+
return { data: stored, error: null, success: true }
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The schema is handed over with the data so a server action can re-parse the same
|
|
81
|
+
rules rather than reconstructing them.
|
|
82
|
+
|
|
83
|
+
Three ways it can end:
|
|
84
|
+
|
|
85
|
+
- **`success: true`** — `onSuccess` receives `data`, and the form clears.
|
|
86
|
+
- **`success: false`** — the `error` you return is rendered by the form's
|
|
87
|
+
[alert](../setup.md#style-alert-env), untouched. This is where a server-side
|
|
88
|
+
failure ("that email is already registered") belongs.
|
|
89
|
+
- **It throws** — the error is caught and logged, and the alert shows a generic
|
|
90
|
+
title with the thrown message as its detail. A rejection is a bug report, not
|
|
91
|
+
a message for the user; return `success: false` for anything you want them to
|
|
92
|
+
read.
|
|
93
|
+
|
|
94
|
+
## The form empties on a successful submit
|
|
95
|
+
|
|
96
|
+
Once the action succeeds the values are cleared and the inputs come back blank.
|
|
97
|
+
For the usual "submit and move on" this is what you want, and for a form that
|
|
98
|
+
should keep what was submitted it is a surprise.
|
|
99
|
+
|
|
100
|
+
If the values have to survive, hold them outside the form: mirror
|
|
101
|
+
`onValueChange` into your own state and feed it back through `value` — see
|
|
102
|
+
[`withDeclaredFields`](../setup.md#withdeclaredfields) for the part that trips
|
|
103
|
+
people, which is that a key you stop sending is not the same as a key you send
|
|
104
|
+
empty.
|
|
105
|
+
|
|
106
|
+
A **failed** validation is the opposite: the submitted values are handed back to
|
|
107
|
+
the form, so the user's typing is still on screen next to the errors.
|
|
108
|
+
|
|
109
|
+
## Turning submit validation off
|
|
110
|
+
|
|
111
|
+
`defineConfig({ validation: { submit: false, ... } })` skips the parse
|
|
112
|
+
entirely. The action is called with the raw `FormData` and the schema, and
|
|
113
|
+
nothing is checked, reported or cleared on the way — for a form whose action
|
|
114
|
+
does its own parsing. With no action at all, submitting does nothing.
|
|
115
|
+
|
|
116
|
+
Remember that this object [replaces the defaults rather than merging](../setup.md#validation),
|
|
117
|
+
so pass the keys you still want.
|
|
118
|
+
|
|
119
|
+
## The error summary
|
|
120
|
+
|
|
121
|
+
When a validation fails, two things happen at once: each field is told about its
|
|
122
|
+
own errors, and the form reports a summary. The summary renders only when
|
|
123
|
+
`validation.showError` is on and a component is registered as `alert`; without
|
|
124
|
+
one, the per-field messages are all the user sees.
|
|
125
|
+
|
|
126
|
+
Its three strings — the title, the "correct the errors" description, and the
|
|
127
|
+
title used when an action throws — are
|
|
128
|
+
[shipped translations](../fields/list.md#built-in-translations), so `lang` alone
|
|
129
|
+
localizes them.
|
package/docs/index.md
CHANGED
|
@@ -9,21 +9,27 @@ field descriptors is the single source for what renders **and** for the Zod
|
|
|
9
9
|
schema that validates it. Most mistakes come from assuming otherwise.
|
|
10
10
|
|
|
11
11
|
Start at [setup.md](setup.md) if you are wiring the form up for the first
|
|
12
|
-
time: entry points, `defineConfig`, and the difference
|
|
13
|
-
server `Form`.
|
|
12
|
+
time: installing, styling, entry points, `defineConfig`, and the difference
|
|
13
|
+
between the client and server `Form`.
|
|
14
14
|
|
|
15
15
|
## Where to look
|
|
16
16
|
|
|
17
17
|
| You are | Read |
|
|
18
18
|
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
19
|
+
| Grouping fields into a titled, collapsible or numbered section | [structure/sections.md](structure/sections.md) |
|
|
20
|
+
| Putting fields side by side, or a label beside its control | [structure/layout.md](structure/layout.md) |
|
|
21
|
+
| Reusing one piece of JSON across the form | [structure/definition.md](structure/definition.md) |
|
|
19
22
|
| Declaring a text, number, date or password field | [fields/input.md](fields/input.md) |
|
|
20
23
|
| Declaring a `select`, `radio` or `chips` field, or filling one from a source | [fields/select.md](fields/select.md) |
|
|
21
24
|
| Declaring a repeatable group of fields | [fields/list.md](fields/list.md) |
|
|
22
25
|
| Looking for a date range, a color or another ready-made picker | [fields/specialized-selectors.md](fields/specialized-selectors.md) |
|
|
23
26
|
| Implementing a field type this library does not ship | [fields/custom-inputs.md](fields/custom-inputs.md) |
|
|
27
|
+
| Filling a `select` from an API | [fields/data-source.md](fields/data-source.md) |
|
|
24
28
|
| Making a field required, or validating against another field | [validation/overview.md](validation/overview.md) |
|
|
29
|
+
| Handling the submit, a server action or the error summary | [forms/submit.md](forms/submit.md) |
|
|
25
30
|
| Showing, hiding, clearing or filling a field when another one changes | [events/change.md](events/change.md) |
|
|
26
|
-
|
|
|
31
|
+
| Writing a `{placeholder}` in a label, a description or an event | [interpolation/overview.md](interpolation/overview.md) |
|
|
32
|
+
| Formatting an interpolated value as currency, a date or a duration | [interpolation/format-filters.md](interpolation/format-filters.md) |
|
|
27
33
|
|
|
28
34
|
## Three things that surprise people
|
|
29
35
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Format filters transform interpolated values into locale-aware strings using a pipe syntax inspired by Vue and Angular.
|
|
4
4
|
|
|
5
|
+
This page is the filter reference. What a placeholder resolves against in the first place — and it is not the same set everywhere — is [Interpolation](overview.md).
|
|
6
|
+
|
|
5
7
|
## Where filters can be used
|
|
6
8
|
|
|
7
9
|
Filters work in any string passed through Luna Form's interpolation engine, including:
|
|
@@ -27,6 +29,8 @@ Multiple filters can be chained; the output of the previous filter becomes the i
|
|
|
27
29
|
|
|
28
30
|
Locale-aware filters read the locale from `config.env.locale`. If not set, filters fall back to the runtime default (typically the browser's locale).
|
|
29
31
|
|
|
32
|
+
It applies wherever a filter is written — a label, a description, a `value` payload — so one setting keeps them consistent. See [Interpolation](overview.md#the-locale-filters-use).
|
|
33
|
+
|
|
30
34
|
```ts
|
|
31
35
|
const config = {
|
|
32
36
|
env: {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Interpolation
|
|
2
|
+
|
|
3
|
+
A `{placeholder}` in a form is resolved against a set of values that depends on
|
|
4
|
+
**where the placeholder is written**. There are two of them, and most confusion
|
|
5
|
+
about interpolation is one being read as the other.
|
|
6
|
+
|
|
7
|
+
- **In rendered text** — a field's `label`, a field or column `description` —
|
|
8
|
+
the values are `context`, `env` and, in a description, that field's own
|
|
9
|
+
`value`.
|
|
10
|
+
- **In an event payload** — what a [`value` action](../events/change.md#1-value-action-valueevent)
|
|
11
|
+
writes, and the `url` and `body` of a [`source` action](../events/change.md#3-source-action-sourceevent) —
|
|
12
|
+
the values are **the selected option itself**: `{value}`, `{label}` and every
|
|
13
|
+
other key the option carries.
|
|
14
|
+
|
|
15
|
+
[Format filters](format-filters.md) work in both.
|
|
16
|
+
|
|
17
|
+
## Where each namespace applies
|
|
18
|
+
|
|
19
|
+
| Written in | Resolves against |
|
|
20
|
+
| --------------------------------- | --------------------------------------- |
|
|
21
|
+
| a field `label` | `context`, `env` |
|
|
22
|
+
| a field `description` | `context`, `env`, `value` |
|
|
23
|
+
| a column `description` | `context`, `env` |
|
|
24
|
+
| a `state` action's `description` | `context`, `env`, `value` of the target |
|
|
25
|
+
| a `value` action's payload | the selected option |
|
|
26
|
+
| a `source` action's `url`, `body` | the selected option |
|
|
27
|
+
| a section `title`, `description` | nothing — see below |
|
|
28
|
+
|
|
29
|
+
## Rendered text
|
|
30
|
+
|
|
31
|
+
`context` is a prop of the form and `env` is a key of
|
|
32
|
+
[`defineConfig`](../setup.md#style-alert-env). Both are addressed by name, with
|
|
33
|
+
dot notation for anything nested:
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
<Form
|
|
37
|
+
sections={sections}
|
|
38
|
+
config={config}
|
|
39
|
+
context={{ user: { firstName: 'Jane' }, plan: 'pro' }}
|
|
40
|
+
/>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"name": "greeting",
|
|
46
|
+
"type": "input/text",
|
|
47
|
+
"label": "Welcome {context.user.firstName}",
|
|
48
|
+
"description": "You are on the {context.plan} plan, billed in {env.currency}"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Three details decide most of what you will hit:
|
|
53
|
+
|
|
54
|
+
- **`{value}` is a description's, not a label's.** A description is rendered
|
|
55
|
+
with the field's current value in scope, so `{value}` there tracks what the
|
|
56
|
+
user typed. A label is not: the same placeholder in a `label` resolves to
|
|
57
|
+
nothing and stays on screen as the literal `{value}`.
|
|
58
|
+
- **Interpolation runs first, translation second.** The resolved string is what
|
|
59
|
+
gets looked up in the [`translations`](../fields/list.md#localization)
|
|
60
|
+
dictionary, so a label written as `Welcome {context.user.firstName}` is
|
|
61
|
+
looked up as `Welcome Jane` — which is not a key anybody wrote. Translate the
|
|
62
|
+
whole sentence with the placeholder inside it (`"welcome_user": "Hola
|
|
63
|
+
{context.user.firstName}"` keyed on the source text) rather than expecting the
|
|
64
|
+
key to survive.
|
|
65
|
+
- **A section heading is not interpolated at all.** `title` and `description` on
|
|
66
|
+
a [section](../structure/sections.md#title-and-description-are-translated-not-interpolated)
|
|
67
|
+
are translated and take markdown links, but a placeholder in one is printed
|
|
68
|
+
as written. A column description is interpolated, so that is the place to put
|
|
69
|
+
one.
|
|
70
|
+
|
|
71
|
+
## Event payloads
|
|
72
|
+
|
|
73
|
+
What a change event carries is the **selected option**, not the form's values.
|
|
74
|
+
For a field whose options are declared or fetched, that is the whole row behind
|
|
75
|
+
the selection:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"name": "country",
|
|
80
|
+
"type": "select",
|
|
81
|
+
"source": [
|
|
82
|
+
{ "value": "cl", "label": "Chile", "dial": "+56" },
|
|
83
|
+
{ "value": "mx", "label": "Mexico", "dial": "+52" }
|
|
84
|
+
],
|
|
85
|
+
"event": {
|
|
86
|
+
"change": [
|
|
87
|
+
{
|
|
88
|
+
"action": "value",
|
|
89
|
+
"value": { "phone_prefix": "{dial}", "country_name": "{label}" }
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Selecting Chile writes `+56` and `Chile`. The keys available are the keys of
|
|
97
|
+
that option: there is no `{context.*}` or `{env.*}` here, and no way to read
|
|
98
|
+
another field's value.
|
|
99
|
+
|
|
100
|
+
Two shapes to keep in mind:
|
|
101
|
+
|
|
102
|
+
- **A text, textarea or date field carries `{ value }` and nothing else.** There
|
|
103
|
+
is no option row behind it, so `{value}` is the only placeholder that resolves
|
|
104
|
+
in its events.
|
|
105
|
+
- **A `chips` field carries its array** — see
|
|
106
|
+
[change.md](../events/change.md#when-a-change-event-runs-at-mount). `{value}`
|
|
107
|
+
is the array itself, and there is no `{label}` to read.
|
|
108
|
+
|
|
109
|
+
When the option rows do not use `value` as their identifying key — a remote
|
|
110
|
+
source mapped with `advanced.options` — the row is found through
|
|
111
|
+
[`advanced.entity`](../fields/select.md#advancedentity). Without it the event
|
|
112
|
+
sees only `{ value }` and every other placeholder stays literal.
|
|
113
|
+
|
|
114
|
+
### A `state` description is interpolated by the field that shows it
|
|
115
|
+
|
|
116
|
+
Unlike `value` and `source`, a [`state` action](../events/change.md#2-state-action-stateevent)
|
|
117
|
+
stores its `description` as written and the **target** interpolates it when it
|
|
118
|
+
renders. So `{value}` in that description is the target field's own value, not
|
|
119
|
+
the value of the field whose change set it.
|
|
120
|
+
|
|
121
|
+
Aimed at the field that raised the event — the common case — the two are the
|
|
122
|
+
same thing, which is why this rarely comes up:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"action": "state",
|
|
127
|
+
"target": "price",
|
|
128
|
+
"state": { "description": "Total to pay: {value | currency:USD}" }
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Aimed at a different field, `{value}` reads that other field, and it is empty
|
|
133
|
+
until somebody fills it.
|
|
134
|
+
|
|
135
|
+
## Resolution rules
|
|
136
|
+
|
|
137
|
+
- **Dot notation walks objects.** `{context.user.firstName}`, `{env.branding.name}`.
|
|
138
|
+
- **A key that resolves to nothing is left alone.** The placeholder stays in the
|
|
139
|
+
text exactly as written — `{context.missing}` renders as `{context.missing}`.
|
|
140
|
+
This is deliberate: a half-rendered sentence is easier to spot than a blank.
|
|
141
|
+
- **So is a value that is not a scalar.** A placeholder standing for an object or
|
|
142
|
+
an array is left literal in text. The one exception is a `value` action, where
|
|
143
|
+
a template that is _nothing but_ the placeholder hands the structure over
|
|
144
|
+
untouched — that is what fills a
|
|
145
|
+
[list](../events/change.md#targeting-a-list).
|
|
146
|
+
- **An unknown filter leaves the whole placeholder.** `{total | nope}` renders as
|
|
147
|
+
`{total | nope}` rather than dropping the value.
|
|
148
|
+
- **Spaces are trimmed.** `{ value }` and `{value}` are the same placeholder, as
|
|
149
|
+
are `{total|currency:USD}` and `{total | currency:USD}`.
|
|
150
|
+
|
|
151
|
+
## The locale filters use
|
|
152
|
+
|
|
153
|
+
Locale-aware filters read `config.env.locale`:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
defineConfig({ inputs: [...], env: { locale: 'es-ES', currency: 'EUR' } })
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
It reaches every filter the form renders — a label and a description format the
|
|
160
|
+
same value the same way. With no `locale` set, filters fall back to the locale of
|
|
161
|
+
whatever is running the code: the browser in the client form, the process in the
|
|
162
|
+
server one, which are not always the same machine. Set it once in `env` and both
|
|
163
|
+
renders agree.
|
|
164
|
+
|
|
165
|
+
## An unresolved placeholder cancels a request
|
|
166
|
+
|
|
167
|
+
A `source.url` still holding a `{placeholder}` after interpolation is **not
|
|
168
|
+
fetched**. The field renders with no options, no request appears in the network
|
|
169
|
+
panel, and nothing is logged: from the outside it looks exactly like an empty
|
|
170
|
+
response.
|
|
171
|
+
|
|
172
|
+
That is the intended behaviour — a cascading select should not call
|
|
173
|
+
`/api/states?country={value}` before a country is picked — but it is also what a
|
|
174
|
+
typo produces. A field that never fetches is worth checking for a placeholder
|
|
175
|
+
that nothing resolves, before checking the network.
|
|
176
|
+
|
|
177
|
+
A URL that _is_ fully resolved and still comes back empty is a different
|
|
178
|
+
problem: see [`fetcher.remotePatterns`](../setup.md#fetcherremotepatterns),
|
|
179
|
+
which does log when it blocks one.
|