@thomasfosterau/effect-forms-svelte 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Foster
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @thomasfosterau/effect-forms-svelte
2
+
3
+ Svelte 5 (runes) bindings for [`@thomasfosterau/effect-forms`](../effect-forms). A reactive form store with progressive-enhancement submission, tainted tracking, lifecycle events, and optional client-side validation against the **same Effect Schema the server uses**.
4
+
5
+ This package depends only on `svelte` (a peer) and the framework-neutral core — not SvelteKit — so it works in any Svelte 5 app.
6
+
7
+ ## Usage
8
+
9
+ Server (`+page.server.ts` or any action) produces a `FormState` with the core's `makeForm`, and returns it to the page. The component hydrates it:
10
+
11
+ ```svelte
12
+ <script lang="ts">
13
+ import { createForm } from "@thomasfosterau/effect-forms-svelte";
14
+ import { Signup } from "$lib/schemas";
15
+
16
+ let { data } = $props(); // data.form is the server FormState
17
+
18
+ const form = createForm(data.form, {
19
+ schema: Signup, // enables client-side validation
20
+ resetForm: true,
21
+ taintedMessage: "You have unsaved changes.",
22
+ onUpdated: ({ form }) => form.valid && toast("Saved"),
23
+ });
24
+ </script>
25
+
26
+ <form method="post" use:form.enhance>
27
+ <input name="name" bind:value={form.data.name} />
28
+ {#each form.errorsAt(["name"]) as message}<small>{message}</small>{/each}
29
+
30
+ <button disabled={form.submitting}>
31
+ {form.delayed ? "Saving…" : "Save"}
32
+ </button>
33
+ </form>
34
+ ```
35
+
36
+ ## The store
37
+
38
+ `createForm(source, options)` returns a [`FormStore`](src/FormStore.svelte.ts). `source` is a server-produced `FormState`, a `{ schema, defaults }` seed, or a [`FormBuilder`](../effect-forms/src/FormBuilder.ts) — the builder derives its schema, blank defaults, and array-item defaults for you:
39
+
40
+ ```ts
41
+ import { createForm } from "@thomasfosterau/effect-forms-svelte";
42
+ import { signup } from "$lib/forms"; // a FormBuilder
43
+
44
+ const form = createForm(signup, { mode: { validation: "onChange", debounce: 300 } });
45
+ ```
46
+
47
+ **Reactive state** — `data` (mutable, bind inputs to it), `errors` (merged client + server), `clientErrors` / `serverErrors` (per-source views), `message`, `constraints`, `submitting`, `delayed`, `posted`, `valid`, `canSubmit`, `isValidating`, `isSubmitted`, `isSubmitSuccessful`, `submissionAttempts`, `tainted`, `isTainted`, and (against the last submission) `changedSinceSubmit` / `hasChangedSinceSubmit`.
48
+
49
+ **Methods** — `enhance(node)` (the form action), `validate()` / `validateField(path)` (client-side, needs `schema`; also runs a builder's `refineEffect` refinements), `errorsAt(path)` / `taintedAt(path)` / `isValidatingAt(path)`, `setValue(path, v)` / `valueAt(path)`, `handleBlur(path)` / `setTouched(path)` / `isTouchedAt(path)`, `isDirtyAt(path)` / `isDefaultValueAt(path)` (persistent per-field dirty tracking — see [Field handles](#field-handles)), `setFieldValidators(path, validators)` (register a field's validators dynamically), `shouldShowError(path)` / `visibleErrorsAt(path)` (mode-aware display gating), `constraintAt(path)`, `revertToLastSubmit()`, `reset()`, `capture()` / `restore()` (SvelteKit snapshots), `setServerState(data, errors)` (apply a result received some other way), `toFormData()`.
50
+
51
+ **Field arrays** — `append(path, value?)`, `insertItem(path, i, value?)`, `replaceItem(path, i, value)`, `removeItem(path, i)`, `clearItems(path)`, `swap(path, a, b)`, `move(path, from, to)` mutate the array at `path` immutably. `append` / `insertItem` derive a blank item by walking the bound schema's AST at `path` (any nesting depth, e.g. `authors[0].emails`), falling back to the top-level `FormBuilder` field when no schema is bound. Every index-shifting mutation (`insertItem` / `removeItem` / `swap` / `move` / `clearItems`) remaps per-index touched flags and errors so they keep following their row — `replaceItem` drops only the replaced index's own touched flag / errors.
52
+
53
+ ## Field handles
54
+
55
+ `form.field(path)` (modelled on TanStack Form's `FieldApi`) returns a typed, reactive [`FieldHandle`](src/FormStore.svelte.ts) bundling everything one input needs, instead of wiring `bind:value` / `name` / `handleBlur` / `visibleErrorsAt` / `constraints` against the store separately:
56
+
57
+ ```svelte
58
+ <script lang="ts">
59
+ const name = form.field("author.name"); // deep paths are type-checked
60
+ </script>
61
+
62
+ <input
63
+ name={name.name}
64
+ bind:value={name.value}
65
+ onblur={name.handleBlur}
66
+ {...name.constraints}
67
+ />
68
+ {#each name.visibleErrors as message}<small>{message}</small>{/each}
69
+ ```
70
+
71
+ `path` is checked against the form data's shape via `Path.FieldPath<A>` — a typo'd segment (`"authr.name"`) or a path into a field that doesn't exist is a compile error, and `field.value` is the leaf's actual type, not `unknown`. This replaced the core's unconsumed `FieldRef` seam.
72
+
73
+ `field.meta` exposes TanStack-style field state: `isBlurred` (lost focus at least once), `isTouched` (`isBlurred || isDirty`), `isDirty` (changed away from the default at some point — **persistent**, unlike `taintedAt` it does not clear on reverting to the default; only `reset()` clears it), and `isDefaultValue` (current value equals the default — flips back on revert). `isDirty` only tracks writes made through `field.value = …` / `setValue` / the array mutators (`append` / `removeItem` / `swap` / `move`) — a form that still binds `bind:value={form.data.x}` directly, bypassing `field()`, never marks that field dirty.
74
+
75
+ **Validation modes** — the `mode` option (see [`Mode`](../effect-forms/src/Mode.ts)) drives when the form validates: `onSubmit` (default), `onBlur` (call `handleBlur` from an input's blur), or `onChange` (debounced). `onBlur` / `onChange` may `autoSubmit`, turning the form into a live-saving surface. `revalidate` (`"onChange"` default / `"onBlur"` / `"never"`) governs what happens _after_ a failed post in `onSubmit` mode: fixing a field re-validates just that field, clearing its error without a resubmit. Errors are computed for the whole form; gate their display per field with `visibleErrorsAt(path)` (shown only once the field is tainted / touched, or after a submit).
76
+
77
+ **Per-field validators** — the `validators` option (or `setFieldValidators`) attaches TanStack-Form-style `{ onChange?, onBlur?, onChangeAsync?, onBlurAsync?, asyncDebounceMs? }` per field, keyed by `Path.toString` (e.g. `"author.name"`, `"tags[0]"`). Sync validators return a `Schema.FilterOutput`; async ones are `Effect`s, run as a fiber per field — a new value interrupts the field's in-flight fiber before forking the next run, which is what gives the debounce + "latest wins" cancellation (no timer bookkeeping needed). Pass `layer` when a validator (or a builder's `refineEffect` refinement) needs services on its `R` channel:
78
+
79
+ ```ts
80
+ const form = createForm(
81
+ { schema: Signup, defaults: { username: "" } },
82
+ {
83
+ validators: {
84
+ username: {
85
+ onChangeAsync: (value) => Api.checkUsernameAvailable(value as string),
86
+ asyncDebounceMs: 300,
87
+ },
88
+ },
89
+ layer: ApiClient.layer,
90
+ },
91
+ );
92
+ ```
93
+
94
+ **Error sources** — a field's server-reported error (from the last submission) survives a client-side revalidation of some _other_ field; it clears once _that_ field's own value changes (or the server clears it on the next submit). `errors` is the merged view; `clientErrors` / `serverErrors` expose the two trees separately for a UI that wants to style them differently.
95
+
96
+ **Lifecycle options** — `onSubmit` (with `cancel()`), `onResult`, `onUpdate`, `onUpdated`, `onError`, `onRedirect`, plus `resetForm`, `taintedMessage`, `delayMs`, and a `submit` transport override (defaults to `fetch` to the form's action, parsing a JSON [`FormActionResult`](../effect-forms/src/Result.ts) or a bare `FormState`).
97
+
98
+ ## Layout
99
+
100
+ The reactive store lives in `FormStore.svelte.ts` (rune-compiled, checked by `svelte-check`). Its logic is delegated to pure, unit-tested modules:
101
+
102
+ | Module | Purpose |
103
+ | ----------------------------------------- | ------------------------------------------------------------------------- |
104
+ | [`Tainted`](src/Tainted.ts) | Deep-diff the data to compute the tainted tree. |
105
+ | [`ClientValidate`](src/ClientValidate.ts) | Validate the form / one field against the schema. |
106
+ | [`EnhanceCore`](src/EnhanceCore.ts) | Serialise data → form body, parse the response, compute the store update. |
107
+
108
+ The store itself is mostly exercised only by `svelte-check`, but the submitter-aware enhanced submit path (`enhance` → `#runSubmit` → the posted `FormData`) is directly unit-tested in [`FormStore.svelte.test.ts`](tests/FormStore.svelte.test.ts) — a `.svelte.ts` rune file needs the Svelte compiler to run at all, hence the `.svelte.test.ts` extension and the `svelte()` plugin in `vite.config.ts` (scoped to a `happy-dom` DOM via a per-file `@vitest-environment` docblock, not the package default).
109
+
110
+ ## Development
111
+
112
+ ```sh
113
+ vp exec svelte-check --tsconfig ./tsconfig.json # typecheck (incl. runes)
114
+ vp test run tests # unit tests (pure modules + the submit path)
115
+ ```
@@ -0,0 +1,18 @@
1
+ import { Errors, Issues } from "@thomasfosterau/effect-forms";
2
+ import * as Schema from "effect/Schema";
3
+ //#region src/ClientValidate.d.ts
4
+ type FormErrors = Errors.FormErrors;
5
+ type FormPath = Issues.FormPath;
6
+ type LeafFormatter = Issues.LeafFormatter;
7
+ /** Validate the whole form's data, returning the nested error tree (empty when valid). */
8
+ declare function validateData<S extends Schema.Top>(schema: S, data: unknown, format?: LeafFormatter): FormErrors;
9
+ /**
10
+ * Validate the whole form and return just the messages at `path`. Validating
11
+ * the whole object (rather than the leaf in isolation) keeps cross-field rules
12
+ * working; the caller merges the result into its existing error tree for that
13
+ * field only.
14
+ */
15
+ declare function validateField<S extends Schema.Top>(schema: S, data: unknown, path: FormPath, format?: LeafFormatter): readonly string[];
16
+ //#endregion
17
+ export { validateData, validateField };
18
+ //# sourceMappingURL=ClientValidate.d.ts.map
@@ -0,0 +1,37 @@
1
+ import { Errors, Validate } from "@thomasfosterau/effect-forms";
2
+ //#region src/ClientValidate.ts
3
+ /**
4
+ * @copyright Thomas Foster
5
+ *
6
+ * Client-side validation helpers — validate the whole form or a single field
7
+ * against the same Effect Schema the server uses, returning the nested
8
+ * {@link FormErrors} tree (or one field's messages).
9
+ *
10
+ * Re-running the schema in the browser gives instant feedback; this is
11
+ * the framework-neutral plumbing the Svelte store calls. Schemas used here must
12
+ * have no decoding-service requirements (the common case for form schemas) so
13
+ * validation can run synchronously in an event handler.
14
+ *
15
+ * @module effect-forms-svelte/ClientValidate
16
+ */
17
+ const { getError, noErrors } = Errors;
18
+ const { validateValueSync } = Validate;
19
+ /** Validate the whole form's data, returning the nested error tree (empty when valid). */
20
+ function validateData(schema, data, format) {
21
+ const result = validateValueSync(schema, data, format);
22
+ return result.valid ? noErrors : result.errors;
23
+ }
24
+ /**
25
+ * Validate the whole form and return just the messages at `path`. Validating
26
+ * the whole object (rather than the leaf in isolation) keeps cross-field rules
27
+ * working; the caller merges the result into its existing error tree for that
28
+ * field only.
29
+ */
30
+ function validateField(schema, data, path, format) {
31
+ const errors = validateData(schema, data, format);
32
+ return getError(errors, path) ?? [];
33
+ }
34
+ //#endregion
35
+ export { validateData, validateField };
36
+
37
+ //# sourceMappingURL=ClientValidate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClientValidate.js","names":[],"sources":["../src/ClientValidate.ts"],"sourcesContent":["/**\n * @copyright Thomas Foster\n *\n * Client-side validation helpers — validate the whole form or a single field\n * against the same Effect Schema the server uses, returning the nested\n * {@link FormErrors} tree (or one field's messages).\n *\n * Re-running the schema in the browser gives instant feedback; this is\n * the framework-neutral plumbing the Svelte store calls. Schemas used here must\n * have no decoding-service requirements (the common case for form schemas) so\n * validation can run synchronously in an event handler.\n *\n * @module effect-forms-svelte/ClientValidate\n */\n\nimport { Errors, Issues, Validate } from \"@thomasfosterau/effect-forms\";\n\n// Terse local aliases onto the namespaced core exports.\ntype FormErrors = Errors.FormErrors;\ntype FormPath = Issues.FormPath;\ntype LeafFormatter = Issues.LeafFormatter;\nconst { getError, noErrors } = Errors;\nconst { validateValueSync } = Validate;\nimport type * as Schema from \"effect/Schema\";\n\n/** Validate the whole form's data, returning the nested error tree (empty when valid). */\nexport function validateData<S extends Schema.Top>(\n schema: S,\n data: unknown,\n format?: LeafFormatter,\n): FormErrors {\n const result = validateValueSync(schema, data, format);\n return result.valid ? noErrors : result.errors;\n}\n\n/**\n * Validate the whole form and return just the messages at `path`. Validating\n * the whole object (rather than the leaf in isolation) keeps cross-field rules\n * working; the caller merges the result into its existing error tree for that\n * field only.\n */\nexport function validateField<S extends Schema.Top>(\n schema: S,\n data: unknown,\n path: FormPath,\n format?: LeafFormatter,\n): readonly string[] {\n const errors = validateData(schema, data, format);\n return getError(errors, path) ?? [];\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAqBA,MAAM,EAAE,UAAU,aAAa;AAC/B,MAAM,EAAE,sBAAsB;;AAI9B,SAAgB,aACd,QACA,MACA,QACY;CACZ,MAAM,SAAS,kBAAkB,QAAQ,MAAM,MAAM;CACrD,OAAO,OAAO,QAAQ,WAAW,OAAO;AAC1C;;;;;;;AAQA,SAAgB,cACd,QACA,MACA,MACA,QACmB;CACnB,MAAM,SAAS,aAAa,QAAQ,MAAM,MAAM;CAChD,OAAO,SAAS,QAAQ,IAAI,KAAK,CAAC;AACpC"}
@@ -0,0 +1,61 @@
1
+ import { Errors, Result } from "@thomasfosterau/effect-forms";
2
+ //#region src/EnhanceCore.d.ts
3
+ type FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;
4
+ type FormErrors = Errors.FormErrors;
5
+ /** Flatten a nested data object into `[name, value]` entries using path notation. */
6
+ declare function objectToEntries(data: unknown, prefix?: string): Array<[string, string]>;
7
+ /** Serialise the store's data into a {@link FormData} body for submission. */
8
+ declare function objectToFormData(data: unknown): FormData;
9
+ /**
10
+ * Normalise a parsed JSON response body into a {@link FormActionResult}. The
11
+ * server may return a full action result (`{ _tag, … }`) or a bare
12
+ * {@link FormState} — a bare state maps to `Success` / `Failure` by its `valid`
13
+ * flag, using `status` for the failure code.
14
+ */
15
+ declare function parseActionResult<A, Message>(body: unknown, status?: number): FormActionResult<A, Message>;
16
+ /** The mutation a {@link FormActionResult} implies for the store. */
17
+ interface FormUpdate<A, Message> {
18
+ readonly data?: A | undefined;
19
+ readonly errors?: FormErrors | undefined;
20
+ readonly message?: Message | undefined;
21
+ readonly redirect?: string | undefined;
22
+ readonly error?: string | undefined;
23
+ readonly valid?: boolean | undefined;
24
+ }
25
+ /**
26
+ * Compute the store update implied by a {@link FormActionResult}. Pure: the
27
+ * caller applies the returned fields to its reactive state and acts on
28
+ * `redirect` / `error`.
29
+ */
30
+ declare function resultToUpdate<A, Message>(result: FormActionResult<A, Message>): FormUpdate<A, Message>;
31
+ /** How a successful/failed submit's data should land on `data` and the tainted baseline. */
32
+ interface DataApplication<A> {
33
+ /** The next value for `data`, or `undefined` to leave it untouched (an auto-submit keeps local edits). */
34
+ readonly data: A | undefined;
35
+ /** The next tainted baseline (`#initial`), or `undefined` when this isn't a successful submit. */
36
+ readonly rebaseTo: A | undefined;
37
+ }
38
+ /**
39
+ * Decide how a submit result's data applies to the store, given a defined
40
+ * `update.data` (redirects/errors never reach this — see {@link resultToUpdate}).
41
+ * Pure so the save-then-navigate baseline rebase is unit-testable without
42
+ * compiling the rune store: on a successful submit the tainted baseline
43
+ * rebases to whatever was actually persisted, so `isTainted` goes back to
44
+ * `false` for values that were just saved, not just the original seed. An
45
+ * auto-submitting (live-save) form keeps its local `data` as the source of
46
+ * truth while a request is in flight — adopting the server's echo would
47
+ * discard keystrokes made during the round-trip — but on success rebases to
48
+ * `submittedData` (the snapshot actually sent), not the live value, since the
49
+ * user may have typed further edits that haven't been submitted yet.
50
+ */
51
+ declare function computeDataApplication<A>(params: {
52
+ readonly isSuccess: boolean;
53
+ readonly appliedData: A;
54
+ readonly initial: A;
55
+ readonly submittedData: A;
56
+ readonly resetForm: boolean;
57
+ readonly autoSubmit: boolean;
58
+ }): DataApplication<A>;
59
+ //#endregion
60
+ export { DataApplication, FormUpdate, computeDataApplication, objectToEntries, objectToFormData, parseActionResult, resultToUpdate };
61
+ //# sourceMappingURL=EnhanceCore.d.ts.map
@@ -0,0 +1,123 @@
1
+ import { Result } from "@thomasfosterau/effect-forms";
2
+ //#region src/EnhanceCore.ts
3
+ /**
4
+ * @copyright Thomas Foster
5
+ *
6
+ * Pure plumbing for the `enhance` action: serialise the store's data back into
7
+ * a form body, interpret a server response into a {@link FormActionResult}, and
8
+ * compute the store update a result implies. Kept framework-free and side-
9
+ * effect-free so it is unit-testable in isolation; the rune store
10
+ * (`superForm.svelte.ts`) wires these to the DOM.
11
+ *
12
+ * @module effect-forms-svelte/EnhanceCore
13
+ */
14
+ const { failure, success } = Result;
15
+ /** Flatten a nested data object into `[name, value]` entries using path notation. */
16
+ function objectToEntries(data, prefix = "") {
17
+ const out = [];
18
+ const push = (key, value) => {
19
+ if (value === void 0) return;
20
+ if (value === null) out.push([key, ""]);
21
+ else if (Array.isArray(value)) value.forEach((item, i) => out.push(...objectToEntries(item, `${key}[${i}]`)));
22
+ else if (typeof value === "object") out.push(...objectToEntries(value, key));
23
+ else out.push([key, String(value)]);
24
+ };
25
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) for (const [key, value] of Object.entries(data)) push(prefix ? `${prefix}.${key}` : key, value);
26
+ else if (prefix) push(prefix, data);
27
+ return out;
28
+ }
29
+ /** Serialise the store's data into a {@link FormData} body for submission. */
30
+ function objectToFormData(data) {
31
+ const formData = new FormData();
32
+ for (const [key, value] of objectToEntries(data)) formData.append(key, value);
33
+ return formData;
34
+ }
35
+ /** Whether a value looks like a {@link FormActionResult} (carries a known `_tag`). */
36
+ function isActionResult(value) {
37
+ return typeof value === "object" && value !== null && typeof value._tag === "string" && [
38
+ "Success",
39
+ "Failure",
40
+ "Redirect",
41
+ "Error"
42
+ ].includes(value._tag);
43
+ }
44
+ /** Whether a value looks like a bare {@link FormState}. */
45
+ function isFormState(value) {
46
+ return typeof value === "object" && value !== null && "valid" in value && "data" in value && "errors" in value;
47
+ }
48
+ /**
49
+ * Normalise a parsed JSON response body into a {@link FormActionResult}. The
50
+ * server may return a full action result (`{ _tag, … }`) or a bare
51
+ * {@link FormState} — a bare state maps to `Success` / `Failure` by its `valid`
52
+ * flag, using `status` for the failure code.
53
+ */
54
+ function parseActionResult(body, status = 200) {
55
+ if (isActionResult(body)) return body;
56
+ if (isFormState(body)) {
57
+ const state = body;
58
+ return state.valid ? success(state, status) : failure(state, status);
59
+ }
60
+ return {
61
+ _tag: "Error",
62
+ status,
63
+ message: "Unrecognised form response"
64
+ };
65
+ }
66
+ /**
67
+ * Compute the store update implied by a {@link FormActionResult}. Pure: the
68
+ * caller applies the returned fields to its reactive state and acts on
69
+ * `redirect` / `error`.
70
+ */
71
+ function resultToUpdate(result) {
72
+ switch (result._tag) {
73
+ case "Success": return {
74
+ data: result.state.data,
75
+ errors: result.state.errors,
76
+ message: result.state.message,
77
+ valid: true
78
+ };
79
+ case "Failure": return {
80
+ data: result.state.data,
81
+ errors: result.state.errors,
82
+ message: result.state.message,
83
+ valid: false
84
+ };
85
+ case "Redirect": return { redirect: result.location };
86
+ case "Error": return {
87
+ error: result.message,
88
+ valid: false
89
+ };
90
+ }
91
+ }
92
+ /**
93
+ * Decide how a submit result's data applies to the store, given a defined
94
+ * `update.data` (redirects/errors never reach this — see {@link resultToUpdate}).
95
+ * Pure so the save-then-navigate baseline rebase is unit-testable without
96
+ * compiling the rune store: on a successful submit the tainted baseline
97
+ * rebases to whatever was actually persisted, so `isTainted` goes back to
98
+ * `false` for values that were just saved, not just the original seed. An
99
+ * auto-submitting (live-save) form keeps its local `data` as the source of
100
+ * truth while a request is in flight — adopting the server's echo would
101
+ * discard keystrokes made during the round-trip — but on success rebases to
102
+ * `submittedData` (the snapshot actually sent), not the live value, since the
103
+ * user may have typed further edits that haven't been submitted yet.
104
+ */
105
+ function computeDataApplication(params) {
106
+ const { isSuccess, appliedData, initial, submittedData, resetForm, autoSubmit } = params;
107
+ if (resetForm && isSuccess) return {
108
+ data: initial,
109
+ rebaseTo: initial
110
+ };
111
+ if (autoSubmit) return {
112
+ data: void 0,
113
+ rebaseTo: isSuccess ? submittedData : void 0
114
+ };
115
+ return {
116
+ data: appliedData,
117
+ rebaseTo: isSuccess ? appliedData : void 0
118
+ };
119
+ }
120
+ //#endregion
121
+ export { computeDataApplication, objectToEntries, objectToFormData, parseActionResult, resultToUpdate };
122
+
123
+ //# sourceMappingURL=EnhanceCore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EnhanceCore.js","names":[],"sources":["../src/EnhanceCore.ts"],"sourcesContent":["/**\n * @copyright Thomas Foster\n *\n * Pure plumbing for the `enhance` action: serialise the store's data back into\n * a form body, interpret a server response into a {@link FormActionResult}, and\n * compute the store update a result implies. Kept framework-free and side-\n * effect-free so it is unit-testable in isolation; the rune store\n * (`superForm.svelte.ts`) wires these to the DOM.\n *\n * @module effect-forms-svelte/EnhanceCore\n */\n\nimport { Errors, Result, State } from \"@thomasfosterau/effect-forms\";\n\n// Terse local aliases onto the namespaced core exports.\ntype FormActionResult<A, Message = unknown> = Result.FormActionResult<A, Message>;\ntype FormErrors = Errors.FormErrors;\ntype FormState<A, Message = unknown> = State.FormState<A, Message>;\nconst { failure, success } = Result;\n\n/** Flatten a nested data object into `[name, value]` entries using path notation. */\nexport function objectToEntries(data: unknown, prefix = \"\"): Array<[string, string]> {\n const out: Array<[string, string]> = [];\n const push = (key: string, value: unknown): void => {\n if (value === undefined) return;\n if (value === null) {\n out.push([key, \"\"]);\n } else if (Array.isArray(value)) {\n value.forEach((item, i) => out.push(...objectToEntries(item, `${key}[${i}]`)));\n } else if (typeof value === \"object\") {\n out.push(...objectToEntries(value, key));\n } else {\n out.push([key, String(value)]);\n }\n };\n\n if (data !== null && typeof data === \"object\" && !Array.isArray(data)) {\n for (const [key, value] of Object.entries(data as Record<string, unknown>)) {\n push(prefix ? `${prefix}.${key}` : key, value);\n }\n } else if (prefix) {\n push(prefix, data);\n }\n return out;\n}\n\n/** Serialise the store's data into a {@link FormData} body for submission. */\nexport function objectToFormData(data: unknown): FormData {\n const formData = new FormData();\n for (const [key, value] of objectToEntries(data)) formData.append(key, value);\n return formData;\n}\n\n/** Whether a value looks like a {@link FormActionResult} (carries a known `_tag`). */\nfunction isActionResult(value: unknown): value is FormActionResult<unknown, unknown> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { _tag?: unknown })._tag === \"string\" &&\n [\"Success\", \"Failure\", \"Redirect\", \"Error\"].includes((value as { _tag: string })._tag)\n );\n}\n\n/** Whether a value looks like a bare {@link FormState}. */\nfunction isFormState(value: unknown): value is FormState<unknown, unknown> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"valid\" in value &&\n \"data\" in value &&\n \"errors\" in value\n );\n}\n\n/**\n * Normalise a parsed JSON response body into a {@link FormActionResult}. The\n * server may return a full action result (`{ _tag, … }`) or a bare\n * {@link FormState} — a bare state maps to `Success` / `Failure` by its `valid`\n * flag, using `status` for the failure code.\n */\nexport function parseActionResult<A, Message>(\n body: unknown,\n status = 200,\n): FormActionResult<A, Message> {\n if (isActionResult(body)) return body as FormActionResult<A, Message>;\n if (isFormState(body)) {\n const state = body as FormState<A, Message>;\n return state.valid ? success(state, status) : failure(state, status);\n }\n return { _tag: \"Error\", status, message: \"Unrecognised form response\" };\n}\n\n/** The mutation a {@link FormActionResult} implies for the store. */\nexport interface FormUpdate<A, Message> {\n readonly data?: A | undefined;\n readonly errors?: FormErrors | undefined;\n readonly message?: Message | undefined;\n readonly redirect?: string | undefined;\n readonly error?: string | undefined;\n readonly valid?: boolean | undefined;\n}\n\n/**\n * Compute the store update implied by a {@link FormActionResult}. Pure: the\n * caller applies the returned fields to its reactive state and acts on\n * `redirect` / `error`.\n */\nexport function resultToUpdate<A, Message>(\n result: FormActionResult<A, Message>,\n): FormUpdate<A, Message> {\n switch (result._tag) {\n case \"Success\":\n return {\n data: result.state.data,\n errors: result.state.errors,\n message: result.state.message,\n valid: true,\n };\n case \"Failure\":\n return {\n data: result.state.data,\n errors: result.state.errors,\n message: result.state.message,\n valid: false,\n };\n case \"Redirect\":\n return { redirect: result.location };\n case \"Error\":\n return { error: result.message, valid: false };\n }\n}\n\n/** How a successful/failed submit's data should land on `data` and the tainted baseline. */\nexport interface DataApplication<A> {\n /** The next value for `data`, or `undefined` to leave it untouched (an auto-submit keeps local edits). */\n readonly data: A | undefined;\n /** The next tainted baseline (`#initial`), or `undefined` when this isn't a successful submit. */\n readonly rebaseTo: A | undefined;\n}\n\n/**\n * Decide how a submit result's data applies to the store, given a defined\n * `update.data` (redirects/errors never reach this — see {@link resultToUpdate}).\n * Pure so the save-then-navigate baseline rebase is unit-testable without\n * compiling the rune store: on a successful submit the tainted baseline\n * rebases to whatever was actually persisted, so `isTainted` goes back to\n * `false` for values that were just saved, not just the original seed. An\n * auto-submitting (live-save) form keeps its local `data` as the source of\n * truth while a request is in flight — adopting the server's echo would\n * discard keystrokes made during the round-trip — but on success rebases to\n * `submittedData` (the snapshot actually sent), not the live value, since the\n * user may have typed further edits that haven't been submitted yet.\n */\nexport function computeDataApplication<A>(params: {\n readonly isSuccess: boolean;\n readonly appliedData: A;\n readonly initial: A;\n readonly submittedData: A;\n readonly resetForm: boolean;\n readonly autoSubmit: boolean;\n}): DataApplication<A> {\n const { isSuccess, appliedData, initial, submittedData, resetForm, autoSubmit } = params;\n if (resetForm && isSuccess) return { data: initial, rebaseTo: initial };\n if (autoSubmit) return { data: undefined, rebaseTo: isSuccess ? submittedData : undefined };\n return { data: appliedData, rebaseTo: isSuccess ? appliedData : undefined };\n}\n"],"mappings":";;;;;;;;;;;;;AAkBA,MAAM,EAAE,SAAS,YAAY;;AAG7B,SAAgB,gBAAgB,MAAe,SAAS,IAA6B;CACnF,MAAM,MAA+B,CAAC;CACtC,MAAM,QAAQ,KAAa,UAAyB;EAClD,IAAI,UAAU,KAAA,GAAW;EACzB,IAAI,UAAU,MACZ,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;OACb,IAAI,MAAM,QAAQ,KAAK,GAC5B,MAAM,SAAS,MAAM,MAAM,IAAI,KAAK,GAAG,gBAAgB,MAAM,GAAG,IAAI,GAAG,EAAE,EAAE,CAAC,CAAC;OACxE,IAAI,OAAO,UAAU,UAC1B,IAAI,KAAK,GAAG,gBAAgB,OAAO,GAAG,CAAC;OAEvC,IAAI,KAAK,CAAC,KAAK,OAAO,KAAK,CAAC,CAAC;CAEjC;CAEA,IAAI,SAAS,QAAQ,OAAO,SAAS,YAAY,CAAC,MAAM,QAAQ,IAAI,GAClE,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAA+B,GACvE,KAAK,SAAS,GAAG,OAAO,GAAG,QAAQ,KAAK,KAAK;MAE1C,IAAI,QACT,KAAK,QAAQ,IAAI;CAEnB,OAAO;AACT;;AAGA,SAAgB,iBAAiB,MAAyB;CACxD,MAAM,WAAW,IAAI,SAAS;CAC9B,KAAK,MAAM,CAAC,KAAK,UAAU,gBAAgB,IAAI,GAAG,SAAS,OAAO,KAAK,KAAK;CAC5E,OAAO;AACT;;AAGA,SAAS,eAAe,OAA6D;CACnF,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS,YAC9C;EAAC;EAAW;EAAW;EAAY;CAAO,CAAC,CAAC,SAAU,MAA2B,IAAI;AAEzF;;AAGA,SAAS,YAAY,OAAsD;CACzE,OACE,OAAO,UAAU,YACjB,UAAU,QACV,WAAW,SACX,UAAU,SACV,YAAY;AAEhB;;;;;;;AAQA,SAAgB,kBACd,MACA,SAAS,KACqB;CAC9B,IAAI,eAAe,IAAI,GAAG,OAAO;CACjC,IAAI,YAAY,IAAI,GAAG;EACrB,MAAM,QAAQ;EACd,OAAO,MAAM,QAAQ,QAAQ,OAAO,MAAM,IAAI,QAAQ,OAAO,MAAM;CACrE;CACA,OAAO;EAAE,MAAM;EAAS;EAAQ,SAAS;CAA6B;AACxE;;;;;;AAiBA,SAAgB,eACd,QACwB;CACxB,QAAQ,OAAO,MAAf;EACE,KAAK,WACH,OAAO;GACL,MAAM,OAAO,MAAM;GACnB,QAAQ,OAAO,MAAM;GACrB,SAAS,OAAO,MAAM;GACtB,OAAO;EACT;EACF,KAAK,WACH,OAAO;GACL,MAAM,OAAO,MAAM;GACnB,QAAQ,OAAO,MAAM;GACrB,SAAS,OAAO,MAAM;GACtB,OAAO;EACT;EACF,KAAK,YACH,OAAO,EAAE,UAAU,OAAO,SAAS;EACrC,KAAK,SACH,OAAO;GAAE,OAAO,OAAO;GAAS,OAAO;EAAM;CACjD;AACF;;;;;;;;;;;;;;AAuBA,SAAgB,uBAA0B,QAOnB;CACrB,MAAM,EAAE,WAAW,aAAa,SAAS,eAAe,WAAW,eAAe;CAClF,IAAI,aAAa,WAAW,OAAO;EAAE,MAAM;EAAS,UAAU;CAAQ;CACtE,IAAI,YAAY,OAAO;EAAE,MAAM,KAAA;EAAW,UAAU,YAAY,gBAAgB,KAAA;CAAU;CAC1F,OAAO;EAAE,MAAM;EAAa,UAAU,YAAY,cAAc,KAAA;CAAU;AAC5E"}
@@ -0,0 +1,158 @@
1
+ import { Command } from "@thomasfosterau/effect-forms";
2
+ import * as Schema from "effect/Schema";
3
+ import { Component } from "svelte";
4
+ //#region src/EntityForm.d.ts
5
+ /** A render-time message thunk — pass the Paraglide function itself, uncalled. */
6
+ type MessageThunk = () => string;
7
+ /** Presentation options for a scalar text entry. */
8
+ interface TextEntryOptions {
9
+ readonly label: MessageThunk;
10
+ /** Presentation `type` attribute (`url`, `date`). Omitted → text input. */
11
+ readonly type?: "text" | "url" | "date";
12
+ /** Keyboard hint for numeric text fields; defaults from the field's derived kind. */
13
+ readonly inputmode?: "numeric";
14
+ /** Force required-ness (defaults to the schema-derived constraint). */
15
+ readonly required?: boolean;
16
+ readonly placeholder?: MessageThunk;
17
+ }
18
+ /** A scalar field rendered by the shared text/textarea primitives. */
19
+ interface ScalarEntry {
20
+ readonly _tag: "scalar";
21
+ readonly key: string;
22
+ readonly multiline: boolean;
23
+ readonly options: TextEntryOptions;
24
+ }
25
+ /** Presentation options for a `<select>` entry over a literal-union (`enum` kind) field. */
26
+ interface SelectEntryOptions {
27
+ readonly label: MessageThunk;
28
+ readonly required?: boolean;
29
+ /**
30
+ * Per-option render-time label thunks, keyed by the field's derived option
31
+ * *value* (`Command.FieldPlan.options`, read via `fieldFor` — not declared
32
+ * here). `defineEntityForm` throws at definition time if a derived value has
33
+ * no matching thunk.
34
+ */
35
+ readonly optionLabels: Readonly<Record<string, MessageThunk>>;
36
+ }
37
+ /**
38
+ * A `<select>` field over a literal-union (`enum` kind) command field. Option
39
+ * *values* derive from the field's plan; this entry supplies only the
40
+ * per-option labels — `defineEntityForm` validates the field is `enum`-kind
41
+ * and that every derived value has a label.
42
+ */
43
+ interface SelectEntry {
44
+ readonly _tag: "select";
45
+ readonly key: string;
46
+ readonly options: SelectEntryOptions;
47
+ }
48
+ /** Options for a widget entry (picker / rows list). */
49
+ interface WidgetEntryOptions {
50
+ /** Gate the submit button while this field's value is empty (required pickers). */
51
+ readonly gate?: boolean;
52
+ }
53
+ /**
54
+ * A bespoke widget field. The renderer bridges the store to the widget through
55
+ * the stage-1 wrapper contract — `name`, `value`, `onChange`, `errors` — which
56
+ * are **reserved keys**: the bridge is spread last, so a `props()` thunk or a
57
+ * load-supplied field prop can never silently sever the store binding.
58
+ */
59
+ interface WidgetEntry {
60
+ readonly _tag: "widget";
61
+ readonly key: string;
62
+ /** `"value"` bridges a string FK; `"rows"` bridges a controlled row list. */
63
+ readonly bridge: "value" | "rows";
64
+ readonly component: Component<any>;
65
+ readonly props: () => Record<string, unknown>;
66
+ readonly options: WidgetEntryOptions;
67
+ }
68
+ /** One ordered entry in an entity form definition. */
69
+ type EntityFormEntry = ScalarEntry | WidgetEntry | SelectEntry;
70
+ /** The per-mode submit labels. */
71
+ interface SubmitMessages {
72
+ readonly create: MessageThunk;
73
+ readonly update: MessageThunk;
74
+ }
75
+ /** The runtime shape `defineEntityForm` accepts for a command struct. */
76
+ interface CommandStructLike {
77
+ readonly fields: Record<string, Schema.Top>;
78
+ }
79
+ /** The value `defineEntityForm` returns — everything one renderer needs. */
80
+ interface EntityFormDefinition {
81
+ readonly plan: Command.CommandPlan;
82
+ readonly entries: readonly EntityFormEntry[];
83
+ readonly createInput: Schema.Top;
84
+ readonly updateInput: Schema.Top;
85
+ readonly createDefaults: Readonly<Record<string, string>>;
86
+ readonly updateDefaults: Readonly<Record<string, string>>;
87
+ readonly adaptCreate: (input: Command.FormInputRecord) => Record<string, unknown>;
88
+ readonly adaptUpdate: (input: Command.FormInputRecord) => Record<string, unknown>;
89
+ readonly submit: SubmitMessages;
90
+ /** The create struct carries a client-generated id (idempotent retries). */
91
+ readonly clientId: boolean;
92
+ /** Entries visible in one mode, in declaration order. */
93
+ readonly entriesFor: (mode: "create" | "update") => readonly EntityFormEntry[];
94
+ /** The plan field behind an entry key. */
95
+ readonly fieldFor: (key: string) => Command.FieldPlan;
96
+ }
97
+ /** The writable command keys a definition may declare (the `id` key is renderer-owned). */
98
+ type CommandKeys<C extends CommandStructLike, U extends CommandStructLike> = Exclude<Extract<keyof C["fields"] | keyof U["fields"], string>, "id">;
99
+ /** The entry builders handed to the definition callback, key-typed to the command pair. */
100
+ interface EntryBuilders<K extends string> {
101
+ /** A scalar field rendered by the shared `TextInputField`. */
102
+ text(key: K, options: TextEntryOptions): EntityFormEntry;
103
+ /** A scalar field rendered by the shared `TextAreaInputField`. */
104
+ textarea(key: K, options: TextEntryOptions): EntityFormEntry;
105
+ /**
106
+ * A `<select>` field over a literal-union (`enum` kind) command field.
107
+ * Option values are derived from the field's plan; `options.optionLabels`
108
+ * supplies each derived value's render-time label thunk.
109
+ */
110
+ select(key: K, options: SelectEntryOptions): EntityFormEntry;
111
+ /**
112
+ * A bespoke string-valued widget (an FK picker). The renderer supplies
113
+ * `name` / `value` / `onChange` / `errors`; `props()` supplies the rest at
114
+ * render time (labels stay thunk-resolved).
115
+ */
116
+ custom<P extends Record<string, unknown>>(key: K, component: Component<never>, props: () => P, options?: WidgetEntryOptions): EntityFormEntry;
117
+ /**
118
+ * A bespoke row-list widget (a contributor list). The renderer owns the row
119
+ * state and supplies `name` / `value` / `onChange` / `errors`; the widget
120
+ * owns the wire serialisation (its hidden input).
121
+ */
122
+ rows<P extends Record<string, unknown>>(key: K, component: Component<never>, props: () => P, options?: WidgetEntryOptions): EntityFormEntry;
123
+ }
124
+ /** Everything `defineEntityForm` needs beyond the entries. */
125
+ interface DefineEntityFormOptions<C extends CommandStructLike, U extends CommandStructLike> {
126
+ readonly create: C;
127
+ readonly update: U;
128
+ readonly submit: SubmitMessages;
129
+ /** Per-field adapter overrides, per mode — the irregular-coercion escape hatch. */
130
+ readonly adaptOverrides?: {
131
+ readonly create?: Command.AdaptOverrides;
132
+ readonly update?: Command.AdaptOverrides;
133
+ };
134
+ /** Per-field Form-Input schema overrides, per mode (e.g. an optional create title with a server default). */
135
+ readonly inputOverrides?: {
136
+ readonly create?: Readonly<Record<string, Schema.Top>>;
137
+ readonly update?: Readonly<Record<string, Schema.Top>>;
138
+ };
139
+ /**
140
+ * Command fields deliberately absent from the standalone form (a carve-out,
141
+ * e.g. BookSeries' `editors` — managed through the Book form's inline-create
142
+ * path instead). Omitted fields are stripped from the create/update field
143
+ * records before `Command.plan` runs, so they need no entry, are never
144
+ * classified (a kind-less omitted field can't throw at definition time),
145
+ * and never appear in the derived input schema, defaults, or adapters.
146
+ */
147
+ readonly omit?: readonly CommandKeys<C, U>[];
148
+ }
149
+ /**
150
+ * Build an entity-form definition from a command-struct pair and an ordered
151
+ * entry list. Runs once at module load. Throws when the entries don't cover
152
+ * the derivable command fields exactly (exhaustiveness) or name an unknown
153
+ * key — a command-struct change must surface here, at definition time.
154
+ */
155
+ declare const defineEntityForm: <C extends CommandStructLike, U extends CommandStructLike>(options: DefineEntityFormOptions<C, U>, build: (f: EntryBuilders<CommandKeys<C, U>>) => readonly EntityFormEntry[]) => EntityFormDefinition;
156
+ //#endregion
157
+ export { CommandKeys, CommandStructLike, DefineEntityFormOptions, EntityFormDefinition, EntityFormEntry, EntryBuilders, MessageThunk, ScalarEntry, SelectEntry, SelectEntryOptions, SubmitMessages, TextEntryOptions, WidgetEntry, WidgetEntryOptions, defineEntityForm };
158
+ //# sourceMappingURL=EntityForm.d.ts.map