@thomasfosterau/effect-forms-svelte 0.2.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.
@@ -108,7 +108,9 @@ export interface FormOptions<S extends Schema.Top, A, Message, R = never> {
108
108
  /**
109
109
  * The builder itself, set automatically when {@link createForm} is given a
110
110
  * {@link FormBuilder}. Its `refineEffect` refinements run client-side as a
111
- * whole-form async validator whenever {@link FormStore.validate} runs.
111
+ * whole-form async validator whenever {@link FormStore.validate} runs, on the
112
+ * form data decoded through the builder's schema (as on the server); while
113
+ * the data doesn't decode they don't run, and the field errors say why.
112
114
  */
113
115
  readonly builder?: FormBuilder.FormBuilder<Field.FieldsRecord, R>;
114
116
  /** The action URL the form posts to (defaults to the current page's action). */
@@ -346,7 +348,12 @@ export declare class FormStore<S extends Schema.Top, A = S["Type"], Message = un
346
348
  enhance: (node: HTMLFormElement) => {
347
349
  destroy: () => void;
348
350
  };
349
- /** Build a {@link FormData} body from the current data (SPA / manual submit). */
351
+ /**
352
+ * Build a {@link FormData} body from the current data (SPA / manual submit).
353
+ *
354
+ * Each value is encoded as `objectToFormData` encodes it: a `File` or
355
+ * `Blob` is appended as a file and a `Date` as its UTC `YYYY-MM-DD`.
356
+ */
350
357
  toFormData(): FormData;
351
358
  }
352
359
  /** A schema plus its blank-form defaults — what {@link createForm} wraps `Form.make` over. */
@@ -377,4 +384,5 @@ export declare function createForm<TFields extends Field.FieldsRecord, R, Messag
377
384
  export declare function createForm<S extends Schema.Top, Message = unknown, R = never>(seed: FormSeed<S>, options?: FormOptions<S, S["Type"], Message, R>): FormStore<S, S["Type"], Message, R>;
378
385
  export declare function createForm<S extends Schema.Top, A = S["Type"], Message = unknown, R = never>(initial: FormState<A, Message>, options: FormOptions<S, A, Message, R>): FormStore<S, A, Message, R>;
379
386
  //#endregion
387
+ export {};
380
388
  //# sourceMappingURL=FormStore.svelte.d.ts.map
@@ -3,11 +3,16 @@ 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
  /**
@@ -56,6 +61,14 @@ import { createAttachmentKey } from "svelte/attachments";
56
61
  const { clearSourceAt, emptySourced, hasErrors, mergeSourced, noErrors, replaceAt, setSourceAt } = Errors;
57
62
  const { getAt, setAt } = Path;
58
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
+ };
59
72
  /**
60
73
  * A typed, reactive handle to one field, returned by {@link FormStore.field}.
61
74
  * Bundles everything a single input needs — `bind:value={field.value}`,
@@ -115,17 +128,18 @@ const defaultSubmit = async (body, action) => {
115
128
  body,
116
129
  headers: { accept: "application/json" }
117
130
  });
118
- let parsed;
119
- try {
120
- parsed = await response.json();
121
- } catch {
122
- return {
123
- _tag: "Error",
124
- status: response.status,
125
- message: response.statusText || "Request failed"
126
- };
127
- }
128
- 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;
129
143
  };
130
144
  /** Whether `index` is a valid position in an array of `length`. */
131
145
  const inRange = (index, length) => index >= 0 && index < length;
@@ -185,23 +199,29 @@ var FormStore = class {
185
199
  #autoSubmitNode;
186
200
  #validateTimer;
187
201
  /** Per-field-listener debounce timers, keyed by `"change:<path>"` / `"blur:<path>"`. */
188
- #listenerTimers = /* @__PURE__ */ new Map();
202
+ #listenerTimers = {};
189
203
  /** Per-field validators, keyed by {@link Path.toString}; seeded from `options.validators`. */
190
204
  #fieldValidators;
191
205
  /** In-flight async-validator fibers, keyed by `"<path>:<trigger>"` (fields) or `""` (whole-form `refineEffect`). */
192
206
  #fibers = {};
193
207
  /** The error paths the last `refineEffect` run set, so the next run can clear exactly those before merging fresh ones. */
194
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;
195
215
  /** The data snapshot the field-tracking watcher last diffed against. */
196
216
  #previousData;
197
217
  /** The current nested error tree (server errors merged with client errors), typed to the form data's shape. */
198
- errors = $derived.by(() => mergeSourced(this.#sourced));
218
+ errors = $derived.by(() => typedErrors(mergeSourced(this.#sourced)));
199
219
  /** Client-side-sourced errors only (schema, per-field validators, `refineEffect`). */
200
- clientErrors = $derived.by(() => this.#sourced.client);
220
+ clientErrors = $derived.by(() => typedErrors(this.#sourced.client));
201
221
  /** Server-sourced errors only (the last submission's response). */
202
- serverErrors = $derived.by(() => this.#sourced.server);
222
+ serverErrors = $derived.by(() => typedErrors(this.#sourced.server));
203
223
  /** Whether any async field validator or `refineEffect` run is currently in flight. */
204
- isValidating = $derived(Object.values(this.#validating).some(Boolean));
224
+ isValidating = $derived(Arr.some(R.values(this.#validating), Boolean));
205
225
  /** Whether the form has ever been submitted (attempted). */
206
226
  isSubmitted = $derived(this.submissionAttempts > 0);
207
227
  /** Whether the most recently *completed* submission succeeded. `false` before any submit completes. */
@@ -229,6 +249,7 @@ var FormStore = class {
229
249
  this.#mode = Mode.parse(options.mode);
230
250
  this.#runtime = ManagedRuntime.make(options.layer ?? Layer.empty);
231
251
  this.#fieldValidators = { ...options.validators };
252
+ this.#decodeForRefinements = Option.map(Option.fromUndefinedOr(options.builder), (builder) => SchemaParser.decodeUnknownEffect(FormBuilder.buildSchema(builder), { errors: "all" }));
232
253
  this.data = structuredClone(initial.data);
233
254
  this.#sourced = {
234
255
  client: noErrors,
@@ -246,9 +267,13 @@ var FormStore = class {
246
267
  * progressive-enhancement submit handler via a Svelte attachment.
247
268
  */
248
269
  get props() {
249
- return {
270
+ const action = this.#options.action;
271
+ return action === void 0 ? {
272
+ method: "post",
273
+ [this.#attachmentKey]: this.#enhanceAttachment
274
+ } : {
250
275
  method: "post",
251
- ...this.#options.action !== void 0 ? { action: this.#options.action } : {},
276
+ action,
252
277
  [this.#attachmentKey]: this.#enhanceAttachment
253
278
  };
254
279
  }
@@ -258,7 +283,7 @@ var FormStore = class {
258
283
  }
259
284
  /** The messages currently attached to `path` (for dynamic / nested paths). */
260
285
  errorsAt(path) {
261
- return Errors.getError(this.errors, path) ?? [];
286
+ return Errors.getError(untypedErrors(this.errors), path) ?? [];
262
287
  }
263
288
  /** Whether the async validator(s) at `path` are currently running. */
264
289
  isValidatingAt(path) {
@@ -350,7 +375,7 @@ var FormStore = class {
350
375
  * Path.FieldPath}); a typo'd segment or wrong-shaped path is a compile error.
351
376
  */
352
377
  field(path) {
353
- return new FieldHandle(this, Path.fromString(path));
378
+ return new FieldHandle(eraseStore(this), Path.fromString(path));
354
379
  }
355
380
  /**
356
381
  * Handle a field losing focus: mark it touched, run its `onBlur` /
@@ -383,11 +408,11 @@ var FormStore = class {
383
408
  run();
384
409
  return;
385
410
  }
386
- clearTimeout(this.#listenerTimers.get(key));
387
- this.#listenerTimers.set(key, setTimeout(() => {
388
- this.#listenerTimers.delete(key);
411
+ clearTimeout(this.#listenerTimers[key]);
412
+ this.#listenerTimers[key] = setTimeout(() => {
413
+ delete this.#listenerTimers[key];
389
414
  run();
390
- }, Duration.toMillis(debounce)));
415
+ }, Duration.toMillis(debounce));
391
416
  }
392
417
  /**
393
418
  * The listeners table to dispatch change events against, with each field's
@@ -401,11 +426,10 @@ var FormStore = class {
401
426
  const listeners = this.#options.listeners;
402
427
  if (!listeners || listeners.onChange === void 0 && !listeners.fields) return void 0;
403
428
  if (!listeners.fields) return listeners;
404
- const fields = {};
405
- 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 ? {
406
430
  ...field,
407
431
  onChange: (input) => this.#scheduleListener(`change:${key}`, field.debounce, () => field.onChange?.(input))
408
- } : field;
432
+ } : field]));
409
433
  return {
410
434
  ...listeners,
411
435
  fields
@@ -553,7 +577,7 @@ var FormStore = class {
553
577
  #defaultItem(path) {
554
578
  if (this.#options.schema) return Field.defaultItemAt(this.#options.schema, path);
555
579
  const fields = this.#options.fields;
556
- if (fields && path.length === 1 && typeof path[0] === "string") {
580
+ if (fields && path.length === 1 && Predicate.isString(path[0])) {
557
581
  const def = fields[path[0]];
558
582
  if (def && Field.isArrayFieldDef(def)) return Field.defaultValue(def.itemSchema);
559
583
  }
@@ -702,11 +726,15 @@ var FormStore = class {
702
726
  */
703
727
  #runRefineEffect() {
704
728
  const builder = this.#options.builder;
705
- if (!builder || FormBuilder.asyncRefinements(builder).length === 0) return;
706
- this.#runValidatorFiber("", FormBuilder.runAsyncRefinements(builder, this.data).pipe(Effect.tap((issues) => Effect.sync(() => {
707
- let client = this.#sourcedSnapshot().client;
708
- for (const path of this.#lastRefinementPaths) client = replaceAt(client, path, []);
709
- 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));
710
738
  this.#lastRefinementPaths = issues.map((issue) => issue.path);
711
739
  this.#sourced = {
712
740
  ...this.#sourced,
@@ -722,7 +750,7 @@ var FormStore = class {
722
750
  */
723
751
  reset(values) {
724
752
  if (values !== void 0) this.#initial = structuredClone(values);
725
- for (const key of Object.keys(this.#fibers)) this.#interruptFiber(key);
753
+ Arr.forEach(R.keys(this.#fibers), (key) => this.#interruptFiber(key));
726
754
  this.data = structuredClone(this.#initial);
727
755
  this.#previousData = this.#detachedData();
728
756
  this.#sourced = emptySourced;
@@ -745,7 +773,7 @@ var FormStore = class {
745
773
  valid: this.valid,
746
774
  posted: this.posted,
747
775
  data: this.#detachedData(),
748
- errors: this.errors,
776
+ errors: untypedErrors(this.errors),
749
777
  message: this.message,
750
778
  constraints: this.constraints
751
779
  };
@@ -812,14 +840,14 @@ var FormStore = class {
812
840
  this.posted = true;
813
841
  this.#options.onUpdated?.({ form: {
814
842
  data: this.data,
815
- errors: this.errors,
843
+ errors: untypedErrors(this.errors),
816
844
  valid: this.valid
817
845
  } });
818
846
  }
819
847
  #applyRedirect(location) {
820
848
  this.#lastSubmitOutcome = "success";
821
849
  if (this.#options.onRedirect) this.#options.onRedirect({ location });
822
- else if (typeof window !== "undefined") window.location.assign(location);
850
+ else if (!Predicate.isUndefined(globalThis.window)) window.location.assign(location);
823
851
  }
824
852
  #applyError(error) {
825
853
  this.#lastSubmitOutcome = "failure";
@@ -838,11 +866,12 @@ var FormStore = class {
838
866
  * actually just saved, not the original seed.
839
867
  */
840
868
  #applySubmittedData(data, result) {
869
+ const submittedData = $state.snapshot(this.#lastSubmitted ?? this.data);
841
870
  const applied = computeDataApplication({
842
- isSuccess: result._tag === "Success",
871
+ isSuccess: Predicate.isTagged(result, "Success"),
843
872
  appliedData: data,
844
873
  initial: this.#initial,
845
- submittedData: $state.snapshot(this.#lastSubmitted ?? this.data),
874
+ submittedData,
846
875
  resetForm: this.#options.resetForm ?? false,
847
876
  autoSubmit: this.#mode.autoSubmit
848
877
  });
@@ -879,7 +908,7 @@ var FormStore = class {
879
908
  }, delayMs);
880
909
  try {
881
910
  const submit = this.#options.submit ?? defaultSubmit;
882
- const action = node.action || (typeof location !== "undefined" ? location.href : "");
911
+ const action = node.action || (Predicate.isUndefined(globalThis.location) ? "" : location.href);
883
912
  let result;
884
913
  if (resolved !== void 0) result = resolved;
885
914
  else if (this.#options.handler) result = await this.#options.handler(this.data);
@@ -887,7 +916,7 @@ var FormStore = class {
887
916
  this.#options.onResult?.({ result });
888
917
  this.#applyUpdate(resultToUpdate(result), result);
889
918
  } catch (cause) {
890
- const message = cause instanceof Error ? cause.message : "Submission failed";
919
+ const message = Predicate.isError(cause) ? cause.message : "Submission failed";
891
920
  this.#lastSubmitOutcome = "failure";
892
921
  this.#options.onError?.({ error: message });
893
922
  } finally {
@@ -911,7 +940,7 @@ var FormStore = class {
911
940
  const beforeUnload = (event) => {
912
941
  if (this.#options.taintedMessage && this.isTainted) event.preventDefault();
913
942
  };
914
- if (this.#options.taintedMessage && typeof window !== "undefined") window.addEventListener("beforeunload", beforeUnload);
943
+ if (this.#options.taintedMessage && !Predicate.isUndefined(globalThis.window)) window.addEventListener("beforeunload", beforeUnload);
915
944
  this.#autoSubmitNode = node;
916
945
  this.#options.listeners?.onMount?.({ data: $state.snapshot(this.data) });
917
946
  const dispatchListeners = this.#buildDispatchListeners();
@@ -932,24 +961,31 @@ var FormStore = class {
932
961
  const snapshot = $state.snapshot(this.data);
933
962
  const changed = taintedLeafPaths(computeTainted(this.#previousData, snapshot));
934
963
  this.#previousData = snapshot;
935
- for (const path of changed) this.#onFieldChanged(path);
964
+ Arr.forEach(changed, (path) => this.#onFieldChanged(path));
936
965
  });
937
966
  });
938
967
  return { destroy: () => {
939
968
  node.removeEventListener("submit", handleSubmit);
940
- if (typeof window !== "undefined") window.removeEventListener("beforeunload", beforeUnload);
969
+ if (!Predicate.isUndefined(globalThis.window)) window.removeEventListener("beforeunload", beforeUnload);
941
970
  stopWatching?.();
942
971
  stopFieldWatching?.();
943
972
  clearTimeout(this.#validateTimer);
944
- for (const timer of this.#listenerTimers.values()) clearTimeout(timer);
945
- this.#listenerTimers.clear();
946
- 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));
947
976
  if (this.#autoSubmitNode === node) this.#autoSubmitNode = void 0;
948
977
  } };
949
978
  };
950
979
  /** Attachment form of {@link FormStore.enhance} for {@link FormStore.props}. */
951
- #enhanceAttachment = (node) => this.enhance(node).destroy;
952
- /** 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
+ */
953
989
  toFormData() {
954
990
  return objectToFormData(this.data);
955
991
  }
@@ -957,10 +993,10 @@ var FormStore = class {
957
993
  function createForm(source, options = {}) {
958
994
  if (FormBuilder.isFormBuilder(source)) {
959
995
  const schema = FormBuilder.buildSchema(source);
960
- return new FormStore(Form.fromBuilder(source, {
961
- ...options.defaults !== void 0 ? { defaults: options.defaults } : {},
962
- ...options.id !== void 0 ? { id: options.id } : {}
963
- }).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(), {
964
1000
  ...options,
965
1001
  schema,
966
1002
  fields: source.fields,
@@ -968,13 +1004,11 @@ function createForm(source, options = {}) {
968
1004
  });
969
1005
  }
970
1006
  if ("defaults" in source && "schema" in source) {
971
- const seed = source;
972
- return new FormStore(makeForm(seed.schema, {
973
- defaults: seed.defaults,
974
- ...seed.id !== void 0 ? { id: seed.id } : {}
975
- }).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(), {
976
1010
  ...options,
977
- schema: seed.schema
1011
+ schema: source.schema
978
1012
  });
979
1013
  }
980
1014
  return new FormStore(source, options);