sygnal 6.5.0 → 7.0.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/CHANGELOG.md +156 -0
- package/README.md +2 -0
- package/dist/astro/index.cjs.js +423 -27
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +423 -27
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +141 -3
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +141 -4
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +169 -14
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +168 -15
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +2 -0
- package/dist/guide/ai-chat.md +6 -6
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/element-commands.md +4 -2
- package/dist/guide/forms-reference.md +89 -3
- package/dist/guide/forms.md +8 -8
- package/dist/guide/http.md +77 -7
- package/dist/guide/migrating-to-7.md +314 -0
- package/dist/guide/recipes/ai-summarize.md +2 -1
- package/dist/guide/recipes/i18n.md +1 -1
- package/dist/guide/ui/tabs.md +5 -4
- package/dist/guide/ui/tooltip.md +24 -0
- package/dist/index.cjs.js +781 -320
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +319 -43
- package/dist/index.esm.js +781 -320
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui.cjs.js +96 -11
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +96 -12
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vike/onRenderClient.cjs.js +63 -43
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +63 -43
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +21 -25
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +21 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +410 -27
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +410 -27
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +17 -16
- package/package.json +1 -1
- package/src/astro/index.d.ts +8 -0
- package/src/astro/index.ts +18 -0
- package/src/core/actions.ts +28 -21
- package/src/core/define.ts +2 -0
- package/src/core/hooks.ts +8 -2
- package/src/core/hosts/collection.ts +4 -3
- package/src/core/instance.ts +60 -16
- package/src/core/runtime.ts +5 -1
- package/src/core/view.ts +2 -0
- package/src/cycle/dom/utils.ts +4 -2
- package/src/devtools.d.ts +13 -0
- package/src/devtools.ts +3 -0
- package/src/extra/ai/chat/behavior.ts +3 -3
- package/src/extra/ai/commandBar.ts +3 -3
- package/src/extra/ai/compose/behavior.ts +3 -3
- package/src/extra/ai/transports/shared.ts +8 -2
- package/src/extra/behaviors.ts +21 -0
- package/src/extra/devGraph.ts +111 -0
- package/src/extra/devMcp.ts +1 -1
- package/src/extra/devtools.ts +18 -0
- package/src/extra/diagnostics/checks/fetch.ts +21 -1
- package/src/extra/diagnostics/checks/forms.ts +4 -3
- package/src/extra/diagnostics/checks/index.ts +2 -1
- package/src/extra/diagnostics/checks/inspect.ts +68 -6
- package/src/extra/diagnostics/checks/next.ts +3 -0
- package/src/extra/diagnostics/checks/public.d.ts +133 -12
- package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
- package/src/extra/diagnostics/codes.ts +19 -0
- package/src/extra/fetchDriver.ts +112 -7
- package/src/extra/fetchEventSource.ts +113 -0
- package/src/extra/form.ts +68 -26
- package/src/extra/formHelpers.ts +3 -1
- package/src/extra/lens.ts +3 -2
- package/src/extra/queryCache.ts +4 -3
- package/src/extra/socketDriver.ts +10 -4
- package/src/extra/testing.ts +68 -11
- package/src/index.d.ts +199 -30
- package/src/index.ts +1 -0
- package/src/shared.ts +3 -0
- package/src/ui/dialog.ts +3 -2
- package/src/ui/tooltips.ts +92 -0
- package/src/ui.d.ts +35 -0
- package/src/ui.ts +1 -0
- package/src/vike/onRenderClient.ts +44 -46
- package/src/vike/onRenderHtml.ts +5 -26
- package/src/vike/shell.ts +33 -0
- package/src/vite/graph.ts +255 -0
- package/src/vite/mcp.ts +3 -3
- package/src/vite/plugin.d.ts +19 -0
- package/src/vite/plugin.ts +144 -25
|
@@ -266,6 +266,8 @@ Star.model = { STAR: (state) => ({ ...state, starred: !state.starred }) }
|
|
|
266
266
|
|
|
267
267
|
Use `aria-selected` for a tab, `aria-expanded` for a disclosure, `aria-checked` with `role="switch"`, and `aria-current="page"` for the current link. Native controls that carry their own state (a checkbox, `select`, `details`) aren't reported, and neither is a busy class (`{ saving: state.saving }`) that the click sets rather than toggles.
|
|
268
268
|
|
|
269
|
+
A change announced another way isn't reported either: the element's text or `aria-label` follows a field of the class condition (a Copy button whose text becomes `{state.status === 'copied' ? 'Copied!' : 'Copy'}`), or it has `aria-live` or `role="status"` / `role="alert"`. `aria-pressed` is only for an on/off toggle; for any other change, let the text or label say it.
|
|
270
|
+
|
|
269
271
|
## ARIA values in JSX
|
|
270
272
|
|
|
271
273
|
An `aria-*` attribute set to `true` renders `"true"`. Set to `false`, it renders `"false"` on the ARIA states that have a false value (`aria-expanded`, `aria-hidden`, `aria-pressed`, `aria-checked`, `aria-selected`, `aria-disabled`, `aria-invalid`, `aria-current`, `aria-haspopup`, `aria-busy`, `aria-modal`, `aria-required`, `aria-readonly`, `aria-multiline`, `aria-multiselectable`, `aria-atomic`, `aria-grabbed`); on any other `aria-*` (a label, an id reference, a number) `false` removes the attribute, so `aria-describedby={hasError && uid('error')}` doesn't announce "false". `null` and `undefined` remove it. Passed to a component, `role`, `for`, `tabindex` and `aria-*` are ordinary props: the component gets the value as written.
|
package/dist/guide/ai-chat.md
CHANGED
|
@@ -92,8 +92,8 @@ Chat.model = {
|
|
|
92
92
|
STATE: (state) => (state.prompt.trim()
|
|
93
93
|
? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
|
|
94
94
|
: ABORT),
|
|
95
|
-
//
|
|
96
|
-
LLM: (state) => (
|
|
95
|
+
// STATE has stored the new message; the prompt from before it decides whether to send
|
|
96
|
+
LLM: (state, data, next, { previousState }) => (previousState.prompt.trim() ? ask(state.messages) : ABORT),
|
|
97
97
|
},
|
|
98
98
|
STOP: {
|
|
99
99
|
// keep the text that arrived; nothing arrives after the abort
|
|
@@ -114,7 +114,7 @@ Chat.model = {
|
|
|
114
114
|
What the component does:
|
|
115
115
|
|
|
116
116
|
- **The conversation is state.** `messages` holds the user's messages and the model's replies. Every request sends the whole conversation: models are stateless, so the history goes with each turn. A message you write yourself can be `{ role: 'user', content: text }`: the driver, `messageText()` and every transport accept that short form.
|
|
117
|
-
- **`SEND` has two sinks.** `STATE` adds the user's message, `LLM` sends the request.
|
|
117
|
+
- **`SEND` has two sinks.** `STATE` adds the user's message, `LLM` sends the request. STATE runs first, so the `LLM` sink sends the message list STATE just stored; STATE has cleared `prompt`, so the empty-submit guard reads `previousState.prompt` ([Model](https://sygnal.js.org/guide/model/#what-state-each-sink-sees)).
|
|
118
118
|
- **`DELTA` shows the reply so far.** Its `text` is all the text received, not just the new piece, so the reducer only replaces `draft`.
|
|
119
119
|
- **`DONE` gets the finished message** and appends it to the conversation as is.
|
|
120
120
|
- **`STOP` aborts the request** by its key. Nothing arrives after an abort, not even `FAILED`.
|
|
@@ -388,7 +388,7 @@ Shopping.model = {
|
|
|
388
388
|
STATE: (state) => (state.prompt.trim()
|
|
389
389
|
? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
|
|
390
390
|
: ABORT),
|
|
391
|
-
LLM: (state) => (
|
|
391
|
+
LLM: (state, data, next, { previousState }) => (previousState.prompt.trim() ? ask(state.messages) : ABORT),
|
|
392
392
|
},
|
|
393
393
|
DELTA: (state, { text }) => ({ ...state, draft: text }),
|
|
394
394
|
// runs the call as it arrives; the model's input is unchecked, so check it
|
|
@@ -402,8 +402,8 @@ Shopping.model = {
|
|
|
402
402
|
draft: '',
|
|
403
403
|
status: toolCalls.length ? 'streaming' : 'ready',
|
|
404
404
|
}),
|
|
405
|
-
// after tool calls, send the conversation again
|
|
406
|
-
LLM: (state, {
|
|
405
|
+
// after tool calls, send the conversation again: STATE has added the answered message
|
|
406
|
+
LLM: (state, { toolCalls }) => (toolCalls.length ? ask(state.messages) : ABORT),
|
|
407
407
|
},
|
|
408
408
|
FAILED: (state, { error }) => ({ ...state, draft: '', status: 'ready', error: error.message }),
|
|
409
409
|
}
|
package/dist/guide/behaviors.md
CHANGED
|
@@ -185,7 +185,7 @@ Two components can use the same behavior, and so can every item of a [Collection
|
|
|
185
185
|
|
|
186
186
|
### Options, the key, host state and timers
|
|
187
187
|
|
|
188
|
-
Every model handler of a behavior gets the use's options and its key after the usual arguments: `(slice, data, next, props, options, key)`, and the intent gets `(sources, options, key)`. `props.state` is the host's whole state.
|
|
188
|
+
Every model handler of a behavior gets the use's options and its key after the usual arguments: `(slice, data, next, props, options, key)`, and the intent gets `(sources, options, key)`. `props.state` is the host's whole state. As in a component, STATE runs first: an output handler gets the slice and `props.state` after it, and `props.previousState` is the host's state from before the action, so the slice from before it is `props.previousState[key]` ([Model](https://sygnal.js.org/guide/model/#what-state-each-sink-sees)).
|
|
189
189
|
|
|
190
190
|
Two more parts are for behaviors that reach past their slice:
|
|
191
191
|
|
|
@@ -68,6 +68,8 @@ declare module 'sygnal' {
|
|
|
68
68
|
}
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
A command built outside the model's return, in a variable or a helper, needs a type in TypeScript: in `const top = { scrollIntoView: '.page', block: 'start' }` the `block` widens to `string`, which isn't a `ScrollLogicalPosition`. Annotate it, `const top: ElementCommand = { … }` (or write `satisfies ElementCommand`); `ElementCommand` is exported from `sygnal`. A command written inside the return needs nothing.
|
|
72
|
+
|
|
71
73
|
Methods that change the DOM Sygnal renders (`remove`, `append`, `setAttribute` and the like) are reported as [SYG641](https://sygnal.js.org/reference/errors/#syg641): the next render would undo them or trip over them. Change the state instead.
|
|
72
74
|
|
|
73
75
|
## Which element
|
|
@@ -105,12 +107,12 @@ Checklist.model = {
|
|
|
105
107
|
ADD: {
|
|
106
108
|
STATE: (state) => ({ ...state, rows: [...state.rows, { id: state.next, title: '' }], next: state.next + 1 }),
|
|
107
109
|
// the new row renders in the same patch, before the command runs
|
|
108
|
-
ELEMENT: (state) => ({ focus: focusWithin(`[data-id="${
|
|
110
|
+
ELEMENT: (state, data, next, { previousState }) => ({ focus: focusWithin(`[data-id="${previousState.next}"] .title`) }),
|
|
109
111
|
},
|
|
110
112
|
}
|
|
111
113
|
```
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
STATE has already incremented `next` when `ELEMENT` runs, so the new row's id is `previousState.next`. Prefer the child's own model when the child decides; `focusWithin` is for a parent that does (adding a row, submitting a form whose fields are child components). If nothing matches, nothing happens; `renderComponent()` reports [SYG640](https://sygnal.js.org/reference/errors/#syg640) when nothing in the view, children included, matches.
|
|
114
116
|
|
|
115
117
|
## When it runs
|
|
116
118
|
|
|
@@ -13,6 +13,7 @@ The details of the [`form` behavior](./forms.md). Start with the recipe on [Form
|
|
|
13
13
|
| `errors` | Every current schema error by field name, shown or not |
|
|
14
14
|
| `touched`, `server`, `remote`, `pending` | Blurred fields; [server errors](./forms.md#server-errors-and-failed-submits); [check](#async-checks) results; checks running |
|
|
15
15
|
| `submitting`, `submitted`, `submitCount`, `queued`, `validating`, `validated` | Submit state: the request sent and not answered yet; `form.DONE` arrived (or a submit without a request was dispatched); attempts; a submit waits for a check or an async schema; an async schema runs; the schema has answered since the start or the last reset |
|
|
16
|
+
| `progress`, `resets` | The submit request's last [progress](#file-fields) step (`{ phase, loaded, total, fraction }`, or `null`); how many times the form started over (`form.RESET`, `resetOnShow`), for keying a [file input](#file-fields) |
|
|
16
17
|
| `fields`, `valid`, `dirty`, `error` | Calculated: per-field view data; no errors as of the schema's last answer (`false` before the first); values differ from `initial`; the form-level message |
|
|
17
18
|
|
|
18
19
|
The options are on [Forms](./forms.md#options).
|
|
@@ -23,7 +24,7 @@ Named after the `uses` key (`form.CHANGE` for `uses = { form: … }`):
|
|
|
23
24
|
|
|
24
25
|
| Action | Data |
|
|
25
26
|
|---|---|
|
|
26
|
-
| `form.CHANGE` | `{ name, value }`: from the form element's `input` events ([field types](./forms.md#field-types); a checkbox gives `checked`, and its `value` as `item`) |
|
|
27
|
+
| `form.CHANGE` | `{ name, value }`: from the form element's `input` events ([field types](./forms.md#field-types); a checkbox gives `checked`, and its `value` as `item`; a [file input](#file-fields) its `File`) |
|
|
27
28
|
| `form.SET` | `{ values }`: several fields at once, by field name (`{ name: 'Dana', 'addresses.7.city': 'Hilo' }`), as if the user had typed them: one validation, the fields count as changed, `touched` as for `form.CHANGE` (so errors show by the [`show`](./forms.md#options) rule), their server errors and check results cleared. Fields not named keep their values; a name that isn't a field is skipped ([SYG230](https://sygnal.js.org/reference/errors/#syg230) in development). For a fill from elsewhere: [an AI extraction](./recipes/ai-form-fill.md), a saved draft, an address lookup |
|
|
28
29
|
| `form.BLUR` | The field name, from `focusout` |
|
|
29
30
|
| `form.SUBMIT` | From the form element's `submit` (default prevented) |
|
|
@@ -31,10 +32,50 @@ Named after the `uses` key (`form.CHANGE` for `uses = { form: … }`):
|
|
|
31
32
|
| `form.ERRORS` | Server errors: an error reply, a map or a list of issues |
|
|
32
33
|
| `form.DONE` | The submit's request was saved: `initial` becomes `values`, `submitted` turns on ([a submit without a request](./forms.md#submit-without-a-request) needs none) |
|
|
33
34
|
| `form.RESET` | Back to `initial`, or to the values given |
|
|
35
|
+
| `form.PROGRESS` | A progress step of the submit's request, sent with `progress: 'form.PROGRESS'`: kept as `progress` until `form.DONE` or `form.ERRORS` ([file fields](#file-fields)) |
|
|
34
36
|
| `form.VALIDATE` | Internal: the first validation when the host starts; with `resetOnShow`, the start over (also each time the form element appears again) |
|
|
35
37
|
|
|
36
38
|
As for any behavior, a host model entry with the same name runs after the form's: `'form.DONE': (state) => ({ ...state, done: true })` shows a confirmation. Trigger an action from elsewhere with an intent action of that name, or `t.simulateAction('form.RESET')` in a test.
|
|
37
39
|
|
|
40
|
+
## File fields
|
|
41
|
+
|
|
42
|
+
A file input whose `name` is in `values` is a field like any other: its value is the `File` the user picked (an array of them with `multiple`, `null` when none), the schema validates it, its error shows on `fields.avatar.error`, and the submit's data carries it, so `request` sends it as [`multipart`](./http.md#upload-and-download-progress):
|
|
43
|
+
|
|
44
|
+
```jsx
|
|
45
|
+
import { form } from 'sygnal'
|
|
46
|
+
import { z } from 'zod'
|
|
47
|
+
|
|
48
|
+
const profileSchema = z.object({
|
|
49
|
+
name: z.string().min(1, 'Enter your name'),
|
|
50
|
+
avatar: z.file('Choose a photo').max(5_000_000, 'At most 5 MB').mime(['image/png', 'image/jpeg'], 'A PNG or JPEG photo'),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
function Profile({ state }) {
|
|
54
|
+
const f = state.form.fields
|
|
55
|
+
const percent = Math.round((state.form.progress?.fraction ?? 0) * 100)
|
|
56
|
+
return (
|
|
57
|
+
<form>
|
|
58
|
+
<label>Name <input name="name" value={f.name.value} /></label>
|
|
59
|
+
<label>Photo <input type="file" name="avatar" accept="image/*" key={'avatar' + state.form.resets} /></label>
|
|
60
|
+
<p className="chosen">{f.avatar.value?.name}</p>
|
|
61
|
+
<p className="error">{f.avatar.error}</p>
|
|
62
|
+
{state.form.submitting && <progress max="100" value={percent} aria-label="Upload progress" />}
|
|
63
|
+
<button type="submit" disabled={state.form.submitting}>Save</button>
|
|
64
|
+
</form>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// the data has a File, so the form sends it as multipart, with progress in state.form.progress
|
|
69
|
+
Profile.uses = { form: form(profileSchema, { values: { name: '', avatar: null }, request: { url: '/api/profile' } }) }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **Progress**: the form's `request` asks for progress (`progress: 'form.PROGRESS'`; a `submit` entry's request can name it too), and the form keeps the latest step in `state.form.progress` (`{ phase, loaded, total, fraction }`) while it is submitting, and clears it on `form.DONE`, `form.ERRORS` and `form.RESET`.
|
|
73
|
+
- **Don't bind `value`**: the browser doesn't let a page set a file input's file. So a reset can't clear it through state; instead the form counts its start overs in `state.form.resets`, and `key={'avatar' + state.form.resets}` makes a new, empty input after each one. For the same reason, show the chosen file's name yourself (`f.avatar.value?.name`): after a reset to saved values (`form.DONE` makes the saved values the start values) the value is still the file while the new input is empty.
|
|
74
|
+
- **Validation** uses your schema's file checks: zod's `z.file()` (`.max()`, `.mime()`), valibot's `v.file()`, or `instanceof File` in a hand-written schema. Required means rejecting `null`.
|
|
75
|
+
- **Tests** pick a file with `t.simulateEvent('[name="avatar"]', 'input', { target: { files: [new File(['…'], 'me.png')] } })`, submit, then `t.progress('HTTP', { loaded: 1, total: 2 }, 'form.PROGRESS')` and `t.respond('HTTP', reply, 'form.DONE')`.
|
|
76
|
+
- A file input whose `name` isn't in `values` is left alone, as before 6.7, for an app that reads its files in its own intent.
|
|
77
|
+
- A `File` can't be serialized, so `persist()` and server-rendered state don't carry file fields.
|
|
78
|
+
|
|
38
79
|
## Any Standard Schema
|
|
39
80
|
|
|
40
81
|
`form()` takes any object with `~standard.validate`: zod (3.24 and later), valibot (1.0 and later), arktype, or one you write. A schema's issue paths become field names, and an issue without a path is a form-level message (`state.form.error`, shown after a submit):
|
|
@@ -263,11 +304,15 @@ Signup.model = {
|
|
|
263
304
|
|
|
264
305
|
`form.DONE` marks the submit saved: `submitting` turns off, `submitted` on, and the saved values become `initial` (so `dirty` is `false` again).
|
|
265
306
|
|
|
266
|
-
Whether a valid submit waits for `form.DONE` depends on
|
|
307
|
+
Whether a valid submit waits for `form.DONE` depends on how it is sent:
|
|
308
|
+
|
|
309
|
+
- **With `request`**: the form sends it on its `http` sink and waits. `request` is an object or `(data, state) => request`, called with the schema's output and the host's state (as committed: the outputs' state since 7.0); `ABORT` or a falsy result sends nothing and leaves the submit pending, as below. The form adds `json: data` (or `multipart: data` when a top-level value is a `File`, `Blob` or an array of them; a file nested deeper isn't sent as a file) unless the request sets `json`, `multipart` or `body`, adds `progress: 'form.PROGRESS'` unless it sets `progress`, and always sets `ok: 'form.DONE'` and `error: 'form.ERRORS'`. With `submit` as well, that host action is dispatched too (to set state); its entry shouldn't send another request.
|
|
310
|
+
|
|
311
|
+
Otherwise it depends on the host's entry for the `submit` action, checked when the host starts:
|
|
267
312
|
|
|
268
313
|
- **With the `http` sink** (`SIGN_UP: { HTTP: … }`, or the sink the `http` option names, such as `http: 'API'`): `submitting` is `true` until `form.DONE` or `form.ERRORS`, and a submit meanwhile is dropped ([SYG232](https://sygnal.js.org/reference/errors/#syg232)). Even when the entry returns `ABORT` this time, the form waits.
|
|
269
314
|
- **Anything else** (`STATE`, `PARENT`, `EVENTS`, `EFFECT`, `ROUTER`, a reducer function): no request, so the submit is done at once, as if `form.DONE` had arrived (no host `form.DONE` entry runs). An `EFFECT` that calls `next('form.DONE')` itself still works; the second `DONE` changes nothing. Work that answers later without the `http` sink (a promise in an `EFFECT`) gets no pending state: send it through a driver as a request instead.
|
|
270
|
-
- **A host that unmounts** keeps its slice in the parent's state when it has a `state` prop. When it mounts again, the form clears `submitting`, `queued` and running checks (their replies went to the old host); the values, errors and `submitted` stay. `form.RESET` goes back to `initial`, or to the values it is given (`t.simulateAction('form.RESET', values)`, or an intent action `'form.RESET': DOM.click('.reset')`); it clears errors, touched fields and the submit count.
|
|
315
|
+
- **A host that unmounts** keeps its slice in the parent's state when it has a `state` prop. When it mounts again, the form clears `submitting`, `queued` and running checks (their replies went to the old host); the values, errors and `submitted` stay. `form.RESET` goes back to `initial`, or to the values it is given (`t.simulateAction('form.RESET', values)`, or an intent action `'form.RESET': DOM.click('.reset')`: data that isn't a plain object, such as the click event, means `initial`); it clears errors, touched fields and the submit count.
|
|
271
316
|
- **Starting over when shown** (`resetOnShow: true`): the form goes back to its start values, as a new form (`initial` too; touched fields, errors, server errors, checks and the submit state cleared), when its host starts (mounted again, a Switchable page made again for another `instance`) and each time its form element appears in the host's DOM after being absent (a Switchable page shown again: hidden pages stay alive and keep their state; a form rendered conditionally). Re-renders while the form is shown keep what the user typed. With the mock DOM (and in SSR) only the host's start counts. A `values` function gets the host's whole state and is called at each start over; without `resetOnShow`, once, when the host starts and the slice is still untouched. Before its first call (the first render of a page that reads its parent's state) the fields come from calling it with a blank state, every value read from it `''`.
|
|
272
317
|
|
|
273
318
|
## Without the behavior: the helpers
|
|
@@ -344,6 +389,47 @@ Signup.uses = {
|
|
|
344
389
|
- A user's own submit is unchanged.
|
|
345
390
|
- `formTool` comes from `sygnal/ai` so that an app using `form` without a tool doesn't carry the tool code. A plain object as `tool` does nothing but report [SYG245](https://sygnal.js.org/reference/errors/#syg245) in development.
|
|
346
391
|
|
|
392
|
+
## TypeScript
|
|
393
|
+
|
|
394
|
+
With a schema that declares its [Standard Schema](https://standardschema.dev) types (zod, valibot and arktype do), `form()` types the values from the schema's input and checks `values` against it. `UsesState<typeof uses>` gives the component's state the slice: `state.form.values.email` and `state.form.fields.email.value` are strings, and a top-level field name that isn't in the values is a type error (nested paths such as `` f[`addresses.${id}.city`] `` stay open). `FormOutput<typeof uses.form>` (or `SchemaOutput<typeof signupSchema>`) is the schema's output, the data of the host's submit action:
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
import { form } from 'sygnal'
|
|
398
|
+
import type { Component, UsesState, UsesActions, FormOutput } from 'sygnal'
|
|
399
|
+
import { z } from 'zod'
|
|
400
|
+
|
|
401
|
+
const signupSchema = z.object({
|
|
402
|
+
email: z.string().trim().email('Enter a valid email address'),
|
|
403
|
+
age: z.coerce.number().int().min(18, 'You must be 18 or older'),
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const uses = {
|
|
407
|
+
form: form(signupSchema, { values: { email: '', age: '' }, submit: 'SIGN_UP' }),
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
type State = UsesState<typeof uses>
|
|
411
|
+
type Actions = UsesActions<typeof uses> & { SIGN_UP: FormOutput<typeof uses.form> }
|
|
412
|
+
|
|
413
|
+
const Signup: Component<State, {}, {}, Actions> = ({ state }) => {
|
|
414
|
+
const f = state.form.fields
|
|
415
|
+
return (
|
|
416
|
+
<form noValidate>
|
|
417
|
+
<label>Email <input name="email" value={f.email.value} aria-invalid={f.email.invalid} /></label>
|
|
418
|
+
<label>Age <input name="age" inputMode="numeric" value={f.age.value} aria-invalid={f.age.invalid} /></label>
|
|
419
|
+
<button type="submit" disabled={state.form.submitting}>Sign up</button>
|
|
420
|
+
</form>
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
Signup.uses = uses
|
|
425
|
+
Signup.model = {
|
|
426
|
+
// values: { email: string; age: number }, the schema's output
|
|
427
|
+
SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
A schema without `types` (a hand-written one) types the values from `values` and the output as `any`. The reply actions are typed too: `form.ERRORS` takes an error reply, a map `{ name: message }` or a list of issues (`FormServerErrors`), and `form.RESET` the whole values (or nothing, for `initial`).
|
|
432
|
+
|
|
347
433
|
## Diagnostics
|
|
348
434
|
|
|
349
435
|
With the dev checks on (the Vite plugin in dev, `renderComponent` in tests): [SYG230](https://sygnal.js.org/reference/errors/#syg230) a field inside the form whose name isn't in `values`, [SYG231](https://sygnal.js.org/reference/errors/#syg231) a schema that isn't a Standard Schema, [SYG232](https://sygnal.js.org/reference/errors/#syg232) a submit dropped while one is in progress (info), [SYG233](https://sygnal.js.org/reference/errors/#syg233) a value the schema strips from its output, [SYG234](https://sygnal.js.org/reference/errors/#syg234) a `submit` action the model doesn't have, [SYG235](https://sygnal.js.org/reference/errors/#syg235) a `check` for an unknown field, or a check request that sets `ok`/`error`/`latest`, [SYG236](https://sygnal.js.org/reference/errors/#syg236) array rows without an `id`, and [SYG237](https://sygnal.js.org/reference/errors/#syg237) two forms in one component on the same selector. `sygnal-check` knows `form`: an option typo is [SYG127](https://sygnal.js.org/reference/errors/#syg127), and fields inside the form element are not reported as uncontrolled ([SYG111](https://sygnal.js.org/reference/errors/#syg111)).
|
package/dist/guide/forms.md
CHANGED
|
@@ -69,7 +69,7 @@ function Signup({ state, uid }) {
|
|
|
69
69
|
Signup.initialState = { accountId: null }
|
|
70
70
|
|
|
71
71
|
Signup.uses = {
|
|
72
|
-
form: form(signupSchema, { values: { name: '', email: '', addresses: [{ id: 1, city: '' }] },
|
|
72
|
+
form: form(signupSchema, { values: { name: '', email: '', addresses: [{ id: 1, city: '' }] }, request: { url: '/api/signup' } }),
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
Signup.intent = ({ DOM }) => ({
|
|
@@ -78,7 +78,6 @@ Signup.intent = ({ DOM }) => ({
|
|
|
78
78
|
})
|
|
79
79
|
|
|
80
80
|
Signup.model = {
|
|
81
|
-
SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
|
|
82
81
|
'form.DONE': (state, account) => ({ ...state, accountId: account.id }),
|
|
83
82
|
}
|
|
84
83
|
```
|
|
@@ -91,8 +90,8 @@ Signup.model = {
|
|
|
91
90
|
- **`state.form.fields[name]`** is what the view needs for each field: `value`, `error` (the message to show, `''` for none), `invalid` (`!!error`, for `aria-invalid`), `touched`, `dirty` and `pending` (an [async check](./forms-reference.md#async-checks) is running), plus `name`.
|
|
92
91
|
- **When errors show**: every change is validated, but a field's schema error shows once the field has lost focus (`show: 'blur'`, the default), or while typing (`show: 'input'`), or only after a submit (`show: 'submit'`). After the first submit every error shows, and each follows the typing. Server errors show at once.
|
|
93
92
|
- **An invalid submit** shows every error, focuses the first invalid field in page order (rows included) and sends nothing.
|
|
94
|
-
- **A valid submit**
|
|
95
|
-
- **Pending**:
|
|
93
|
+
- **A valid submit** sends the `request` with the schema's output as its body: the trimmed, transformed values (`email` lower-cased here; the rows without their `id`, because `z.object` strips keys it doesn't declare), not the raw ones. The form adds `json: data` (`multipart` when a value is a [file](./forms-reference.md#file-fields)), `progress: 'form.PROGRESS'` and its reply actions `ok: 'form.DONE'`, `error: 'form.ERRORS'`. Other request fields (`method`, `headers`) are sent as written.
|
|
94
|
+
- **Pending**: `state.form.submitting` is `true` from the valid submit until `form.DONE` or `form.ERRORS` arrives. Use it for the button's text and `disabled`. A second submit meanwhile is dropped, so a double click sends once.
|
|
96
95
|
- **`form.DONE`** marks the submit saved: `submitting` turns off, `submitted` on, and the saved values become the new start values (`dirty` is `false` again). A host model entry with the same name runs after the form's and gets the reply body: `'form.DONE': (state, account) => …` above shows the new account's id.
|
|
97
96
|
- **Labels and errors**: each field has a label and its error text is linked with `aria-describedby`, with ids from [`uid()`](./inputs.md#labels-and-ids-uid) (a row's ids include its `id`, so they stay unique), so the form passes the [accessibility checks](./accessibility.md). `aria-invalid={f.email.invalid}` renders `"true"` or `"false"`.
|
|
98
97
|
|
|
@@ -104,7 +103,7 @@ Reserve the height of the error lines in your CSS (`min-height`). A field's erro
|
|
|
104
103
|
|
|
105
104
|
## Submit without a request
|
|
106
105
|
|
|
107
|
-
A submit that sends nothing (a wizard step's "Next", a dialog's "OK")
|
|
106
|
+
A submit that sends nothing (a wizard step's "Next", a dialog's "OK") names a host action with `submit` instead of `request`: an entry with `STATE`, `PARENT` or `EVENTS` and no `HTTP`. It is done at once: `submitting` stays `false`, `submitted` turns on, and you send no `form.DONE`. (One with an `HTTP` sink is pending like a `request`.)
|
|
108
107
|
|
|
109
108
|
```jsx
|
|
110
109
|
AccountStep.uses = { form: form(accountSchema, { values: { email: '', password: '' }, submit: 'NEXT' }) }
|
|
@@ -185,18 +184,19 @@ What `state.form.values[name]` gets from each kind of field, and how to bind it:
|
|
|
185
184
|
| `<select multiple>` | The array of selected `value`s | `selected={f.colors.value.includes('red')}` on each `<option>` |
|
|
186
185
|
| A form-associated custom element (`<wa-input>`, `<wa-select>`) | Its `value` | `value={f.nick.value}` |
|
|
187
186
|
| A form-associated custom checkbox or switch (`<wa-checkbox>`, `<wa-switch>`: a hyphenated tag with a boolean `checked`) | `checked` (as a group on an array, like checkboxes) | `checked={f.news.value}` |
|
|
188
|
-
| `<input type="file">` |
|
|
187
|
+
| `<input type="file">` | Its `File` (`multiple`: an array), or `null` | Don't bind `value`: [file fields](./forms-reference.md#file-fields) |
|
|
189
188
|
|
|
190
189
|
## Options
|
|
191
190
|
|
|
192
191
|
| Option | |
|
|
193
192
|
|---|---|
|
|
194
193
|
| `values` | The start values (or a function of the host's state). Field names are paths in it: `email`, `address.city`, and `addresses.7.city` for the row with `id` 7 |
|
|
195
|
-
| `
|
|
194
|
+
| `request` | What a valid submit sends: `{ url, … }`, or `(data, state) => request` (an id from state) |
|
|
195
|
+
| `submit` | Instead of `request`: the host action a valid submit dispatches with the schema's output ([SYG234](https://sygnal.js.org/reference/errors/#syg234) when the model has no such entry; [submit without a request](#submit-without-a-request)) |
|
|
196
196
|
| `show` | `'blur'` (default), `'input'` or `'submit'`: when a schema error shows |
|
|
197
197
|
| `form` | The form element's selector, default `'form'`. Give each form its own when a component has two: `form: '.login'` and `form: '.news'` ([two forms](./forms-reference.md#two-forms-in-one-component), [SYG237](https://sygnal.js.org/reference/errors/#syg237)) |
|
|
198
198
|
| `check` | [Async checks](./forms-reference.md#async-checks) by field name |
|
|
199
|
-
| `http` | The driver sink of the requests, default `'HTTP'
|
|
199
|
+
| `http` | The driver sink of the requests, default `'HTTP'` |
|
|
200
200
|
| `resetOnShow` | `true`: start over each time the form is shown ([start empty on each visit](#start-empty-on-each-visit)) |
|
|
201
201
|
| `tool` | Experimental: `formTool({ name, description, autosubmit? })` from `sygnal/ai` offers the form to the browser's agent ([WebMCP](./forms-reference.md#offer-the-form-to-the-browsers-agent)) |
|
|
202
202
|
|
package/dist/guide/http.md
CHANGED
|
@@ -45,7 +45,7 @@ Quote.intent = ({ DOM }) => ({
|
|
|
45
45
|
Quote.model = {
|
|
46
46
|
SHOW: {
|
|
47
47
|
STATE: (state, id) => ({ ...state, id, status: 'loading', error: '' }),
|
|
48
|
-
HTTP: (state, id) => ({ url: `/api/quotes/${id}`, ok: 'LOADED', error: 'FAILED', latest: true }), //
|
|
48
|
+
HTTP: (state, id) => ({ url: `/api/quotes/${id}`, ok: 'LOADED', error: 'FAILED', latest: true }), // or state.id: STATE has stored it
|
|
49
49
|
},
|
|
50
50
|
REFRESH: {
|
|
51
51
|
STATE: (state) => (state.id === null ? ABORT : { ...state, status: 'loading', error: '' }),
|
|
@@ -66,7 +66,7 @@ Click First, then Second before quote 1 arrives: with `latest: true` the first r
|
|
|
66
66
|
|
|
67
67
|
### Build the request from (state, data)
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
STATE runs first, so the HTTP sink of `SHOW` sees the `id` STATE just stored; the action's data has it too. `REFRESH` doesn't change `id` and reads it from `state`. A request that needs a value STATE replaced (a cleared field, a guard on a flag STATE sets) reads it from the fourth argument's `previousState` ([Model](https://sygnal.js.org/guide/model/#what-state-each-sink-sees)). Sygnal 6.x gave every sink the state from before the action; see [Migrating to 7.0](./migrating-to-7.md).
|
|
70
70
|
|
|
71
71
|
## Only the Latest Response
|
|
72
72
|
|
|
@@ -123,10 +123,12 @@ A request is an object (or a URL string, for a plain GET with no reply actions):
|
|
|
123
123
|
| `ok` | — | Action that receives the parsed body of a 2xx response |
|
|
124
124
|
| `error` | — | Action that receives `{ error, status, body, request }` |
|
|
125
125
|
| `key` | the `ok` action, else `error` | The group for `latest` and `abort` |
|
|
126
|
-
| `method` | `'POST'` with `json`/`body`, else `'GET'` | HTTP method |
|
|
126
|
+
| `method` | `'POST'` with `json`/`multipart`/`body`, else `'GET'` | HTTP method |
|
|
127
127
|
| `query` | — | Object appended as a query string, before any `#fragment`: `{ q: 'dune' }` → `?q=dune` (URL-encoded; `null`/`undefined` values skipped; an array repeats the key: `{ tag: ['a', 'b'] }` → `?tag=a&tag=b`) |
|
|
128
128
|
| `json` | — | Body sent as `JSON.stringify(json)` with `Content-Type: application/json` |
|
|
129
|
+
| `multipart` | — | A `multipart/form-data` body written as data: `{ file: state.file, title: state.title }` ([uploads](#upload-and-download-progress)) |
|
|
129
130
|
| `body` | — | Raw body (string, `FormData`, `Blob`, …) |
|
|
131
|
+
| `progress` | — | Report upload and download progress: an action name (`'UPLOADING'`) or `true` for `HTTP.progress()` ([Upload and download progress](#upload-and-download-progress)) |
|
|
130
132
|
| `headers` | — | Object or `Headers`, merged over the driver's `headers` case-insensitively (sent with lowercase names) |
|
|
131
133
|
| `latest` | driver's `latest` (false) | Abort this instance's requests with the same key still in flight |
|
|
132
134
|
| `timeoutMs` | driver's `timeoutMs` (none) | Fail with a `TimeoutError` after this many ms |
|
|
@@ -143,6 +145,72 @@ Any other field is yours (an id, the query text): it isn't sent, and it comes ba
|
|
|
143
145
|
|
|
144
146
|
A request that names only `ok` sends its failures to `HTTP.errors()` (logged when nothing listens), and one that names only `error` sends its successes to `HTTP.select()`. Name both.
|
|
145
147
|
|
|
148
|
+
## Upload and Download Progress
|
|
149
|
+
|
|
150
|
+
Add `progress` to a request to hear how far it has got. With an action name, each step arrives as that action of the component that sent it, like `ok` and `error`:
|
|
151
|
+
|
|
152
|
+
```js live-server
|
|
153
|
+
export default {
|
|
154
|
+
'POST /api/files': ({ body }) => {
|
|
155
|
+
const file = body.get('file')
|
|
156
|
+
return { status: 201, json: { name: file.name, size: file.size }, delayMs: 300 }
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```jsx live title="Upload.jsx"
|
|
162
|
+
import { ABORT } from 'sygnal'
|
|
163
|
+
|
|
164
|
+
function Upload({ state }) {
|
|
165
|
+
const percent = Math.round(state.fraction * 100)
|
|
166
|
+
return (
|
|
167
|
+
<form className="upload">
|
|
168
|
+
<input type="file" className="file" aria-label="File to upload" />
|
|
169
|
+
<button type="button" className="sample">Use a 3 MB sample</button>
|
|
170
|
+
<button type="submit" disabled={!state.file || state.status === 'uploading'}>Upload</button>
|
|
171
|
+
<progress max="100" value={percent} aria-label="Upload progress">{percent}%</progress>
|
|
172
|
+
<p className="status" role="status">{state.message}</p>
|
|
173
|
+
</form>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
Upload.initialState = { file: null, fraction: 0, status: 'idle', message: '' }
|
|
177
|
+
Upload.intent = ({ DOM }) => ({
|
|
178
|
+
PICK: DOM.select('.file').events('change').map((e) => e.target.files[0] ?? null),
|
|
179
|
+
SAMPLE: DOM.click('.sample'),
|
|
180
|
+
SEND: DOM.select('.upload').events('submit', { preventDefault: true }),
|
|
181
|
+
})
|
|
182
|
+
const busy = (state) => !state.file || state.status === 'uploading'
|
|
183
|
+
Upload.model = {
|
|
184
|
+
PICK: (state, file) => ({ ...state, file, fraction: 0, status: 'idle', message: file ? file.name : '' }),
|
|
185
|
+
SAMPLE: (state) => ({ ...state, file: new File([new Uint8Array(3 * 1024 * 1024)], 'sample.bin'), fraction: 0, status: 'idle', message: 'sample.bin' }),
|
|
186
|
+
SEND: {
|
|
187
|
+
STATE: (state) => (busy(state) ? ABORT : { ...state, status: 'uploading', fraction: 0, message: 'Uploading…' }),
|
|
188
|
+
// STATE has already set status 'uploading': admit the upload by the state before this SEND
|
|
189
|
+
HTTP: (state, data, next, { previousState }) => (busy(previousState) ? ABORT : { url: '/api/files', multipart: { file: state.file }, progress: 'UPLOADING', ok: 'UPLOADED', error: 'FAILED' }),
|
|
190
|
+
},
|
|
191
|
+
UPLOADING: (state, { phase, fraction }) => (phase === 'upload' ? { ...state, fraction } : ABORT),
|
|
192
|
+
UPLOADED: (state, saved) => ({ ...state, status: 'done', fraction: 1, message: `Uploaded ${saved.name} (${saved.size} bytes).` }),
|
|
193
|
+
FAILED: (state) => ({ ...state, status: 'failed', message: 'Upload failed.' }),
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- **Each step** is `{ phase, loaded, total, fraction, category, request }`. `phase` is `'upload'` while the body is sent, then `'download'` while the reply arrives (a GET reports only `'download'`). `total` and `fraction` (0 to 1) are `null` when the size isn't known, such as a download without a `Content-Length`.
|
|
198
|
+
- **How often:** at most one step every 50 ms per phase, always including the first and the last (`fraction: 1`), so a fast connection doesn't render hundreds of times. A retry starts again at 0.
|
|
199
|
+
- **`progress: true`** sends the steps to the source instead: `HTTP.progress('upload')` in the intent, filtered by category like `select()`.
|
|
200
|
+
- **The file** comes from the input's `change` event (`e.target.files`) and is kept in state. `multipart` turns the request's parts into a `multipart/form-data` body, the encoding upload endpoints expect: a `File` or `Blob` is a file part, an array repeats the name (`files: [a, b]`), `null` and `undefined` are left out, an object is sent as JSON text and anything else as a string. The browser writes the `Content-Type` with its boundary, so one set in `headers` is dropped. In a test, `t.requests('HTTP')` shows the parts as you wrote them (`multipart.file.name`). An endpoint that takes the raw bytes needs no encoding: `body: state.file` (with `method: 'PUT'`). With the [`form` behavior](./forms.md), a file input is a field like any other, and `request: { url }` sends the submit as multipart with progress in `state.form.progress` ([file fields](./forms-reference.md#file-fields)).
|
|
201
|
+
- **XMLHttpRequest:** a request with `progress` goes over `XMLHttpRequest`, because it is the only way to see upload progress in every browser (streamed `fetch` uploads work only in Chromium over HTTP/2). The reply is the same as with `fetch`: parsing, statuses, `latest`, `abort`, `timeoutMs` and `retry` all apply. Options `fetch` has and `XMLHttpRequest` doesn't (`init.keepalive`, `priority`, `cache`, `redirect`, `integrity`, `mode`) are ignored ([SYG636](https://sygnal.js.org/reference/errors/#syg636)), and the driver's `fetch` option isn't used: put auth in `headers`, which apply to both. `progress` does nothing on resources and `batch` requests. Where there is no `XMLHttpRequest` (Node, a service worker), the request is sent with `fetch` and reports no steps.
|
|
202
|
+
|
|
203
|
+
In a test, `t.progress` sends a step of a pending request, which stays pending until `t.respond` or `t.fail`:
|
|
204
|
+
|
|
205
|
+
```js
|
|
206
|
+
t.simulateEvent('.sample', 'click')
|
|
207
|
+
t.simulateEvent('.upload', 'submit')
|
|
208
|
+
await t.progress('HTTP', { loaded: 50, total: 100 }, 'UPLOADING') // phase: 'upload' for a request with a body
|
|
209
|
+
expect(t.state.fraction).toBe(0.5)
|
|
210
|
+
await t.respond('HTTP', { name: 'sample.bin', size: 3145728 }, 'UPLOADED')
|
|
211
|
+
expect(t.state.status).toBe('done')
|
|
212
|
+
```
|
|
213
|
+
|
|
146
214
|
## Driver Options
|
|
147
215
|
|
|
148
216
|
```javascript
|
|
@@ -156,6 +224,7 @@ makeFetchDriver({
|
|
|
156
224
|
fetch: myFetch, // default: globalThis.fetch, read at each request
|
|
157
225
|
cache: queryCache(), // the query cache (default: none), see Resources and Caching
|
|
158
226
|
retry: 2, // default for GET/HEAD requests (default: 0)
|
|
227
|
+
XMLHttpRequest: MyXhr, // for progress requests (default: the global one)
|
|
159
228
|
})
|
|
160
229
|
```
|
|
161
230
|
|
|
@@ -234,9 +303,10 @@ Todos.intent = ({ DOM }) => ({ TOGGLE: DOM.click('.toggle').data('id', Number) }
|
|
|
234
303
|
Todos.model = {
|
|
235
304
|
TOGGLE: {
|
|
236
305
|
STATE: (state, id) => ({ ...state, error: '', todos: state.todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)) }),
|
|
237
|
-
HTTP: (state, id) => {
|
|
238
|
-
const before =
|
|
239
|
-
|
|
306
|
+
HTTP: (state, id, next, { previousState }) => {
|
|
307
|
+
const before = previousState.todos.find(t => t.id === id) // rollback value
|
|
308
|
+
const updated = state.todos.find(t => t.id === id)
|
|
309
|
+
return { url: `/api/todos/${id}`, method: 'PATCH', json: { done: updated.done }, ok: 'SAVED', error: 'SAVE_FAILED', before }
|
|
240
310
|
},
|
|
241
311
|
},
|
|
242
312
|
SAVED: (state, saved) => ({ ...state, todos: state.todos.map(t => (t.id === saved.id ? saved : t)) }),
|
|
@@ -281,7 +351,7 @@ Profile.model = {
|
|
|
281
351
|
NAME: (state, name) => ({ ...state, name, save: 'idle' }),
|
|
282
352
|
SAVE: {
|
|
283
353
|
STATE: (state) => (state.save === 'saving' ? ABORT : { ...state, save: 'saving' }),
|
|
284
|
-
HTTP: (state) => (
|
|
354
|
+
HTTP: (state, data, next, { previousState }) => (previousState.save === 'saving' ? ABORT : { url: '/api/profile', method: 'PUT', json: { name: state.name }, ok: 'SAVED', error: 'SAVE_FAILED' }),
|
|
285
355
|
},
|
|
286
356
|
SAVED: (state) => ({ ...state, save: 'saved' }),
|
|
287
357
|
SAVE_FAILED: (state) => ({ ...state, save: 'failed' }),
|