@thomasfosterau/effect-forms-svelte 0.1.0 → 0.3.1

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.
Files changed (44) hide show
  1. package/README.md +137 -23
  2. package/dist/ClientValidate.d.ts +3 -3
  3. package/dist/ClientValidate.js +1 -2
  4. package/dist/ClientValidate.js.map +1 -1
  5. package/dist/EnhanceCore.d.ts +41 -10
  6. package/dist/EnhanceCore.js +107 -40
  7. package/dist/EnhanceCore.js.map +1 -1
  8. package/dist/EntityForm.d.ts +17 -18
  9. package/dist/EntityForm.js +28 -28
  10. package/dist/EntityForm.js.map +1 -1
  11. package/dist/FormStore.svelte.d.ts +25 -18
  12. package/dist/FormStore.svelte.js +95 -62
  13. package/dist/FormStore.svelte.js.map +1 -1
  14. package/dist/Listeners.d.ts +6 -6
  15. package/dist/Listeners.js +3 -2
  16. package/dist/Listeners.js.map +1 -1
  17. package/dist/SvelteKit.d.ts +6 -4
  18. package/dist/SvelteKit.js +5 -2
  19. package/dist/SvelteKit.js.map +1 -1
  20. package/dist/Tainted.d.ts +7 -7
  21. package/dist/Tainted.js +27 -39
  22. package/dist/Tainted.js.map +1 -1
  23. package/dist/_virtual/_rolldown/runtime.js +13 -0
  24. package/dist/kit/ActionRedirect.d.ts +22 -0
  25. package/dist/kit/ActionRedirect.js +54 -0
  26. package/dist/kit/ActionRedirect.js.map +1 -0
  27. package/dist/kit/CommandFailure.d.ts +52 -0
  28. package/dist/kit/CommandFailure.js +81 -0
  29. package/dist/kit/CommandFailure.js.map +1 -0
  30. package/dist/kit/Enhance.d.ts +30 -0
  31. package/dist/kit/Enhance.js +57 -0
  32. package/dist/kit/Enhance.js.map +1 -0
  33. package/dist/kit/FormAction.d.ts +132 -0
  34. package/dist/kit/FormAction.js +170 -0
  35. package/dist/kit/FormAction.js.map +1 -0
  36. package/dist/kit/FormStateReaders.d.ts +58 -0
  37. package/dist/kit/FormStateReaders.js +64 -0
  38. package/dist/kit/FormStateReaders.js.map +1 -0
  39. package/dist/kit/Pointer.d.ts +23 -0
  40. package/dist/kit/Pointer.js +38 -0
  41. package/dist/kit/Pointer.js.map +1 -0
  42. package/dist/kit/index.d.ts +7 -0
  43. package/dist/kit/index.js +7 -0
  44. package/package.json +23 -4
@@ -3,20 +3,23 @@ import { computeDataApplication, objectToFormData, parseActionResult, resultToUp
3
3
  import { computeTainted, isAnyTainted, isTaintedAt, taintedLeafPaths } from "./Tainted.js";
4
4
  import { dispatchChange } from "./Listeners.js";
5
5
  import { Constraints, Errors, Field, Form, FormBuilder, Mode, Path, Validate } from "@thomasfosterau/effect-forms";
6
+ import * as Arr from "effect/Array";
7
+ import * as Predicate from "effect/Predicate";
8
+ import * as R from "effect/Record";
9
+ import * as Option from "effect/Option";
6
10
  import * as Duration from "effect/Duration";
7
11
  import * as Effect from "effect/Effect";
8
12
  import * as Fiber from "effect/Fiber";
9
13
  import * as Layer from "effect/Layer";
10
14
  import * as ManagedRuntime from "effect/ManagedRuntime";
15
+ import * as SchemaParser from "effect/SchemaParser";
11
16
  import { createAttachmentKey } from "svelte/attachments";
12
17
  //#region src/FormStore.svelte.ts
13
18
  /**
14
- * @copyright Thomas Foster
15
- *
16
19
  * The reactive Svelte 5 form store.
17
20
  *
18
21
  * {@link createForm} binds a schema to a reactive, rune-backed {@link FormStore}.
19
- * Give it either `{ schema, defaults }` (it wraps the core's `makeForm` to seed
22
+ * Give it either `{ schema, defaults }` (it wraps the core's `Form.make` to seed
20
23
  * an empty form) or a server-produced {@link FormState} to hydrate from. The
21
24
  * store exposes mutable `data` (bind inputs to it), typed `errors`
22
25
  * (`form.errors.name`), `submitting` / `delayed` / `message` / `valid`, tainted
@@ -52,11 +55,20 @@ import { createAttachmentKey } from "svelte/attachments";
52
55
  * governs what happens *after* a failed post in `onSubmit` mode: fixing a
53
56
  * field re-validates it (clearing its error) without a resubmit.
54
57
  *
58
+ * @copyright Thomas Foster
55
59
  * @module effect-forms-svelte/FormStore
56
60
  */
57
61
  const { clearSourceAt, emptySourced, hasErrors, mergeSourced, noErrors, replaceAt, setSourceAt } = Errors;
58
62
  const { getAt, setAt } = Path;
59
63
  const makeForm = Form.make;
64
+ /** View a runtime {@link FormErrors} tree through the data-shaped {@link TypedFormErrors} lens. */
65
+ const typedErrors = (errors) => {
66
+ return errors;
67
+ };
68
+ /** Forget the data-shaped typing of a {@link TypedFormErrors} tree, back to the runtime {@link FormErrors}. */
69
+ const untypedErrors = (errors) => {
70
+ return errors;
71
+ };
60
72
  /**
61
73
  * A typed, reactive handle to one field, returned by {@link FormStore.field}.
62
74
  * Bundles everything a single input needs — `bind:value={field.value}`,
@@ -116,17 +128,18 @@ const defaultSubmit = async (body, action) => {
116
128
  body,
117
129
  headers: { accept: "application/json" }
118
130
  });
119
- let parsed;
120
- try {
121
- parsed = await response.json();
122
- } catch {
123
- return {
124
- _tag: "Error",
125
- status: response.status,
126
- message: response.statusText || "Request failed"
127
- };
128
- }
129
- return parseActionResult(parsed, response.status);
131
+ return response.json().then((parsed) => parseActionResult(parsed, response.status), () => ({
132
+ _tag: "Error",
133
+ status: response.status,
134
+ message: response.statusText || "Request failed"
135
+ }));
136
+ };
137
+ /**
138
+ * Erase a store's type parameters for {@link FieldHandle}, which only calls
139
+ * its path-addressed methods.
140
+ */
141
+ const eraseStore = (store) => {
142
+ return store;
130
143
  };
131
144
  /** Whether `index` is a valid position in an array of `length`. */
132
145
  const inRange = (index, length) => index >= 0 && index < length;
@@ -186,23 +199,29 @@ var FormStore = class {
186
199
  #autoSubmitNode;
187
200
  #validateTimer;
188
201
  /** Per-field-listener debounce timers, keyed by `"change:<path>"` / `"blur:<path>"`. */
189
- #listenerTimers = /* @__PURE__ */ new Map();
202
+ #listenerTimers = {};
190
203
  /** Per-field validators, keyed by {@link Path.toString}; seeded from `options.validators`. */
191
204
  #fieldValidators;
192
205
  /** In-flight async-validator fibers, keyed by `"<path>:<trigger>"` (fields) or `""` (whole-form `refineEffect`). */
193
206
  #fibers = {};
194
207
  /** The error paths the last `refineEffect` run set, so the next run can clear exactly those before merging fresh ones. */
195
208
  #lastRefinementPaths = [];
209
+ /**
210
+ * The decoder that turns the form data into the builder's decoded values
211
+ * before its `refineEffect` refinements see them — `None` unless the store
212
+ * was seeded from a {@link FormBuilder}.
213
+ */
214
+ #decodeForRefinements;
196
215
  /** The data snapshot the field-tracking watcher last diffed against. */
197
216
  #previousData;
198
217
  /** The current nested error tree (server errors merged with client errors), typed to the form data's shape. */
199
- errors = $derived.by(() => mergeSourced(this.#sourced));
218
+ errors = $derived.by(() => typedErrors(mergeSourced(this.#sourced)));
200
219
  /** Client-side-sourced errors only (schema, per-field validators, `refineEffect`). */
201
- clientErrors = $derived.by(() => this.#sourced.client);
220
+ clientErrors = $derived.by(() => typedErrors(this.#sourced.client));
202
221
  /** Server-sourced errors only (the last submission's response). */
203
- serverErrors = $derived.by(() => this.#sourced.server);
222
+ serverErrors = $derived.by(() => typedErrors(this.#sourced.server));
204
223
  /** Whether any async field validator or `refineEffect` run is currently in flight. */
205
- isValidating = $derived(Object.values(this.#validating).some(Boolean));
224
+ isValidating = $derived(Arr.some(R.values(this.#validating), Boolean));
206
225
  /** Whether the form has ever been submitted (attempted). */
207
226
  isSubmitted = $derived(this.submissionAttempts > 0);
208
227
  /** Whether the most recently *completed* submission succeeded. `false` before any submit completes. */
@@ -230,6 +249,7 @@ var FormStore = class {
230
249
  this.#mode = Mode.parse(options.mode);
231
250
  this.#runtime = ManagedRuntime.make(options.layer ?? Layer.empty);
232
251
  this.#fieldValidators = { ...options.validators };
252
+ this.#decodeForRefinements = Option.map(Option.fromUndefinedOr(options.builder), (builder) => SchemaParser.decodeUnknownEffect(FormBuilder.buildSchema(builder), { errors: "all" }));
233
253
  this.data = structuredClone(initial.data);
234
254
  this.#sourced = {
235
255
  client: noErrors,
@@ -247,9 +267,13 @@ var FormStore = class {
247
267
  * progressive-enhancement submit handler via a Svelte attachment.
248
268
  */
249
269
  get props() {
250
- return {
270
+ const action = this.#options.action;
271
+ return action === void 0 ? {
251
272
  method: "post",
252
- ...this.#options.action !== void 0 ? { action: this.#options.action } : {},
273
+ [this.#attachmentKey]: this.#enhanceAttachment
274
+ } : {
275
+ method: "post",
276
+ action,
253
277
  [this.#attachmentKey]: this.#enhanceAttachment
254
278
  };
255
279
  }
@@ -259,7 +283,7 @@ var FormStore = class {
259
283
  }
260
284
  /** The messages currently attached to `path` (for dynamic / nested paths). */
261
285
  errorsAt(path) {
262
- return Errors.getError(this.errors, path) ?? [];
286
+ return Errors.getError(untypedErrors(this.errors), path) ?? [];
263
287
  }
264
288
  /** Whether the async validator(s) at `path` are currently running. */
265
289
  isValidatingAt(path) {
@@ -351,7 +375,7 @@ var FormStore = class {
351
375
  * Path.FieldPath}); a typo'd segment or wrong-shaped path is a compile error.
352
376
  */
353
377
  field(path) {
354
- return new FieldHandle(this, Path.fromString(path));
378
+ return new FieldHandle(eraseStore(this), Path.fromString(path));
355
379
  }
356
380
  /**
357
381
  * Handle a field losing focus: mark it touched, run its `onBlur` /
@@ -384,11 +408,11 @@ var FormStore = class {
384
408
  run();
385
409
  return;
386
410
  }
387
- clearTimeout(this.#listenerTimers.get(key));
388
- this.#listenerTimers.set(key, setTimeout(() => {
389
- this.#listenerTimers.delete(key);
411
+ clearTimeout(this.#listenerTimers[key]);
412
+ this.#listenerTimers[key] = setTimeout(() => {
413
+ delete this.#listenerTimers[key];
390
414
  run();
391
- }, Duration.toMillis(debounce)));
415
+ }, Duration.toMillis(debounce));
392
416
  }
393
417
  /**
394
418
  * The listeners table to dispatch change events against, with each field's
@@ -402,11 +426,10 @@ var FormStore = class {
402
426
  const listeners = this.#options.listeners;
403
427
  if (!listeners || listeners.onChange === void 0 && !listeners.fields) return void 0;
404
428
  if (!listeners.fields) return listeners;
405
- const fields = {};
406
- for (const [key, field] of Object.entries(listeners.fields)) fields[key] = field.onChange ? {
429
+ const fields = R.fromEntries(Arr.map(R.toEntries(listeners.fields), ([key, field]) => [key, field.onChange ? {
407
430
  ...field,
408
431
  onChange: (input) => this.#scheduleListener(`change:${key}`, field.debounce, () => field.onChange?.(input))
409
- } : field;
432
+ } : field]));
410
433
  return {
411
434
  ...listeners,
412
435
  fields
@@ -554,7 +577,7 @@ var FormStore = class {
554
577
  #defaultItem(path) {
555
578
  if (this.#options.schema) return Field.defaultItemAt(this.#options.schema, path);
556
579
  const fields = this.#options.fields;
557
- if (fields && path.length === 1 && typeof path[0] === "string") {
580
+ if (fields && path.length === 1 && Predicate.isString(path[0])) {
558
581
  const def = fields[path[0]];
559
582
  if (def && Field.isArrayFieldDef(def)) return Field.defaultValue(def.itemSchema);
560
583
  }
@@ -703,11 +726,15 @@ var FormStore = class {
703
726
  */
704
727
  #runRefineEffect() {
705
728
  const builder = this.#options.builder;
706
- if (!builder || FormBuilder.asyncRefinements(builder).length === 0) return;
707
- this.#runValidatorFiber("", FormBuilder.runAsyncRefinements(builder, this.data).pipe(Effect.tap((issues) => Effect.sync(() => {
708
- let client = this.#sourcedSnapshot().client;
709
- for (const path of this.#lastRefinementPaths) client = replaceAt(client, path, []);
710
- client = Errors.mergeErrors(client, Errors.issuesToErrors(issues));
729
+ if (!builder || Arr.isReadonlyArrayEmpty(FormBuilder.asyncRefinements(builder))) return;
730
+ if (Option.isNone(this.#decodeForRefinements)) return;
731
+ const decoded = this.#decodeForRefinements.value(this.#detachedData());
732
+ this.#runValidatorFiber("", decoded.pipe(Effect.matchEffect({
733
+ onFailure: () => Effect.succeed([]),
734
+ onSuccess: (values) => FormBuilder.runAsyncRefinements(builder, values)
735
+ }), Effect.tap((issues) => Effect.sync(() => {
736
+ const cleared = Arr.reduce(this.#lastRefinementPaths, this.#sourcedSnapshot().client, (client, path) => replaceAt(client, path, []));
737
+ const client = Errors.mergeErrors(cleared, Errors.issuesToErrors(issues));
711
738
  this.#lastRefinementPaths = issues.map((issue) => issue.path);
712
739
  this.#sourced = {
713
740
  ...this.#sourced,
@@ -723,7 +750,7 @@ var FormStore = class {
723
750
  */
724
751
  reset(values) {
725
752
  if (values !== void 0) this.#initial = structuredClone(values);
726
- for (const key of Object.keys(this.#fibers)) this.#interruptFiber(key);
753
+ Arr.forEach(R.keys(this.#fibers), (key) => this.#interruptFiber(key));
727
754
  this.data = structuredClone(this.#initial);
728
755
  this.#previousData = this.#detachedData();
729
756
  this.#sourced = emptySourced;
@@ -746,7 +773,7 @@ var FormStore = class {
746
773
  valid: this.valid,
747
774
  posted: this.posted,
748
775
  data: this.#detachedData(),
749
- errors: this.errors,
776
+ errors: untypedErrors(this.errors),
750
777
  message: this.message,
751
778
  constraints: this.constraints
752
779
  };
@@ -813,14 +840,14 @@ var FormStore = class {
813
840
  this.posted = true;
814
841
  this.#options.onUpdated?.({ form: {
815
842
  data: this.data,
816
- errors: this.errors,
843
+ errors: untypedErrors(this.errors),
817
844
  valid: this.valid
818
845
  } });
819
846
  }
820
847
  #applyRedirect(location) {
821
848
  this.#lastSubmitOutcome = "success";
822
849
  if (this.#options.onRedirect) this.#options.onRedirect({ location });
823
- else if (typeof window !== "undefined") window.location.assign(location);
850
+ else if (!Predicate.isUndefined(globalThis.window)) window.location.assign(location);
824
851
  }
825
852
  #applyError(error) {
826
853
  this.#lastSubmitOutcome = "failure";
@@ -839,11 +866,12 @@ var FormStore = class {
839
866
  * actually just saved, not the original seed.
840
867
  */
841
868
  #applySubmittedData(data, result) {
869
+ const submittedData = $state.snapshot(this.#lastSubmitted ?? this.data);
842
870
  const applied = computeDataApplication({
843
- isSuccess: result._tag === "Success",
871
+ isSuccess: Predicate.isTagged(result, "Success"),
844
872
  appliedData: data,
845
873
  initial: this.#initial,
846
- submittedData: $state.snapshot(this.#lastSubmitted ?? this.data),
874
+ submittedData,
847
875
  resetForm: this.#options.resetForm ?? false,
848
876
  autoSubmit: this.#mode.autoSubmit
849
877
  });
@@ -880,7 +908,7 @@ var FormStore = class {
880
908
  }, delayMs);
881
909
  try {
882
910
  const submit = this.#options.submit ?? defaultSubmit;
883
- const action = node.action || (typeof location !== "undefined" ? location.href : "");
911
+ const action = node.action || (Predicate.isUndefined(globalThis.location) ? "" : location.href);
884
912
  let result;
885
913
  if (resolved !== void 0) result = resolved;
886
914
  else if (this.#options.handler) result = await this.#options.handler(this.data);
@@ -888,7 +916,7 @@ var FormStore = class {
888
916
  this.#options.onResult?.({ result });
889
917
  this.#applyUpdate(resultToUpdate(result), result);
890
918
  } catch (cause) {
891
- const message = cause instanceof Error ? cause.message : "Submission failed";
919
+ const message = Predicate.isError(cause) ? cause.message : "Submission failed";
892
920
  this.#lastSubmitOutcome = "failure";
893
921
  this.#options.onError?.({ error: message });
894
922
  } finally {
@@ -912,7 +940,7 @@ var FormStore = class {
912
940
  const beforeUnload = (event) => {
913
941
  if (this.#options.taintedMessage && this.isTainted) event.preventDefault();
914
942
  };
915
- if (this.#options.taintedMessage && typeof window !== "undefined") window.addEventListener("beforeunload", beforeUnload);
943
+ if (this.#options.taintedMessage && !Predicate.isUndefined(globalThis.window)) window.addEventListener("beforeunload", beforeUnload);
916
944
  this.#autoSubmitNode = node;
917
945
  this.#options.listeners?.onMount?.({ data: $state.snapshot(this.data) });
918
946
  const dispatchListeners = this.#buildDispatchListeners();
@@ -933,24 +961,31 @@ var FormStore = class {
933
961
  const snapshot = $state.snapshot(this.data);
934
962
  const changed = taintedLeafPaths(computeTainted(this.#previousData, snapshot));
935
963
  this.#previousData = snapshot;
936
- for (const path of changed) this.#onFieldChanged(path);
964
+ Arr.forEach(changed, (path) => this.#onFieldChanged(path));
937
965
  });
938
966
  });
939
967
  return { destroy: () => {
940
968
  node.removeEventListener("submit", handleSubmit);
941
- if (typeof window !== "undefined") window.removeEventListener("beforeunload", beforeUnload);
969
+ if (!Predicate.isUndefined(globalThis.window)) window.removeEventListener("beforeunload", beforeUnload);
942
970
  stopWatching?.();
943
971
  stopFieldWatching?.();
944
972
  clearTimeout(this.#validateTimer);
945
- for (const timer of this.#listenerTimers.values()) clearTimeout(timer);
946
- this.#listenerTimers.clear();
947
- for (const key of Object.keys(this.#fibers)) this.#interruptFiber(key);
973
+ Arr.forEach(R.values(this.#listenerTimers), (timer) => clearTimeout(timer));
974
+ this.#listenerTimers = {};
975
+ Arr.forEach(R.keys(this.#fibers), (key) => this.#interruptFiber(key));
948
976
  if (this.#autoSubmitNode === node) this.#autoSubmitNode = void 0;
949
977
  } };
950
978
  };
951
979
  /** Attachment form of {@link FormStore.enhance} for {@link FormStore.props}. */
952
- #enhanceAttachment = (node) => this.enhance(node).destroy;
953
- /** Build a {@link FormData} body from the current data (SPA / manual submit). */
980
+ #enhanceAttachment = (node) => {
981
+ return this.enhance(node).destroy;
982
+ };
983
+ /**
984
+ * Build a {@link FormData} body from the current data (SPA / manual submit).
985
+ *
986
+ * Each value is encoded as `objectToFormData` encodes it: a `File` or
987
+ * `Blob` is appended as a file and a `Date` as its UTC `YYYY-MM-DD`.
988
+ */
954
989
  toFormData() {
955
990
  return objectToFormData(this.data);
956
991
  }
@@ -958,10 +993,10 @@ var FormStore = class {
958
993
  function createForm(source, options = {}) {
959
994
  if (FormBuilder.isFormBuilder(source)) {
960
995
  const schema = FormBuilder.buildSchema(source);
961
- return new FormStore(Form.fromBuilder(source, {
962
- ...options.defaults !== void 0 ? { defaults: options.defaults } : {},
963
- ...options.id !== void 0 ? { id: options.id } : {}
964
- }).empty(), {
996
+ const builderOptions = {};
997
+ if (options.defaults !== void 0) builderOptions.defaults = options.defaults;
998
+ if (options.id !== void 0) builderOptions.id = options.id;
999
+ return new FormStore(Form.fromBuilder(source, builderOptions).empty(), {
965
1000
  ...options,
966
1001
  schema,
967
1002
  fields: source.fields,
@@ -969,13 +1004,11 @@ function createForm(source, options = {}) {
969
1004
  });
970
1005
  }
971
1006
  if ("defaults" in source && "schema" in source) {
972
- const seed = source;
973
- return new FormStore(makeForm(seed.schema, {
974
- defaults: seed.defaults,
975
- ...seed.id !== void 0 ? { id: seed.id } : {}
976
- }).empty(), {
1007
+ const makeOptions = { defaults: source.defaults };
1008
+ if (source.id !== void 0) makeOptions.id = source.id;
1009
+ return new FormStore(makeForm(source.schema, makeOptions).empty(), {
977
1010
  ...options,
978
- schema: seed.schema
1011
+ schema: source.schema
979
1012
  });
980
1013
  }
981
1014
  return new FormStore(source, options);