@lucas-barake/effect-form 0.4.0 → 0.6.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/src/FormAtoms.ts CHANGED
@@ -1,13 +1,4 @@
1
- /**
2
- * Atom infrastructure for form state management.
3
- *
4
- * This module provides the core atom infrastructure that framework adapters
5
- * (React, Vue, Svelte, Solid) can use to build reactive form components.
6
- *
7
- * @since 1.0.0
8
- */
9
1
  import * as Atom from "@effect-atom/atom/Atom"
10
- import type * as Registry from "@effect-atom/atom/Registry"
11
2
  import * as Effect from "effect/Effect"
12
3
  import { pipe } from "effect/Function"
13
4
  import * as Option from "effect/Option"
@@ -16,8 +7,9 @@ import * as Schema from "effect/Schema"
16
7
  import * as Field from "./Field.js"
17
8
  import * as FormBuilder from "./FormBuilder.js"
18
9
  import { recalculateDirtyFieldsForArray, recalculateDirtySubtree } from "./internal/dirty.js"
19
- import { getNestedValue, setNestedValue } from "./internal/path.js"
10
+ import { getNestedValue, isPathUnderRoot, setNestedValue } from "./internal/path.js"
20
11
  import { createWeakRegistry, type WeakRegistry } from "./internal/weak-registry.js"
12
+ import * as Validation from "./Validation.js"
21
13
 
22
14
  /**
23
15
  * Atoms for a single field.
@@ -38,9 +30,10 @@ export interface FieldAtoms {
38
30
  * @since 1.0.0
39
31
  * @category Models
40
32
  */
41
- export interface FormAtomsConfig<TFields extends Field.FieldsRecord, R> {
33
+ export interface FormAtomsConfig<TFields extends Field.FieldsRecord, R, A, E> {
42
34
  readonly runtime: Atom.AtomRuntime<R, any>
43
35
  readonly formBuilder: FormBuilder.FormBuilder<TFields, R>
36
+ readonly onSubmit: (decoded: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A | Effect.Effect<A, E, R>
44
37
  }
45
38
 
46
39
  /**
@@ -63,7 +56,7 @@ export type FieldRefs<TFields extends Field.FieldsRecord> = {
63
56
  * @since 1.0.0
64
57
  * @category Models
65
58
  */
66
- export interface FormAtoms<TFields extends Field.FieldsRecord, R> {
59
+ export interface FormAtoms<TFields extends Field.FieldsRecord, R, A = void, E = never> {
67
60
  readonly stateAtom: Atom.Writable<
68
61
  Option.Option<FormBuilder.FormState<TFields>>,
69
62
  Option.Option<FormBuilder.FormState<TFields>>
@@ -72,15 +65,11 @@ export interface FormAtoms<TFields extends Field.FieldsRecord, R> {
72
65
  readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>
73
66
  readonly isDirtyAtom: Atom.Atom<boolean>
74
67
  readonly submitCountAtom: Atom.Atom<number>
75
- readonly lastSubmittedValuesAtom: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
68
+ readonly lastSubmittedValuesAtom: Atom.Atom<Option.Option<FormBuilder.SubmittedValues<TFields>>>
76
69
  readonly changedSinceSubmitFieldsAtom: Atom.Atom<ReadonlySet<string>>
77
70
  readonly hasChangedSinceSubmitAtom: Atom.Atom<boolean>
78
- readonly onSubmitAtom: Atom.Writable<
79
- Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown> | null,
80
- Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown> | null
81
- >
82
71
 
83
- readonly decodeAndSubmit: Atom.AtomResultFn<Field.EncodedFromFields<TFields>, unknown, unknown>
72
+ readonly submitAtom: Atom.AtomResultFn<void, A, E | ParseResult.ParseError>
84
73
 
85
74
  readonly combinedSchema: Schema.Schema<Field.DecodedFromFields<TFields>, Field.EncodedFromFields<TFields>, R>
86
75
 
@@ -96,9 +85,14 @@ export interface FormAtoms<TFields extends Field.FieldsRecord, R> {
96
85
 
97
86
  readonly getOrCreateFieldAtoms: (fieldPath: string) => FieldAtoms
98
87
 
99
- readonly resetValidationAtoms: (registry: Registry.Registry) => void
88
+ readonly resetValidationAtoms: (ctx: { set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void }) => void
100
89
 
101
90
  readonly operations: FormOperations<TFields>
91
+
92
+ readonly resetAtom: Atom.Writable<void, void>
93
+ readonly revertToLastSubmitAtom: Atom.Writable<void, void>
94
+ readonly setValuesAtom: Atom.Writable<void, Field.EncodedFromFields<TFields>>
95
+ readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>
102
96
  }
103
97
 
104
98
  /**
@@ -191,9 +185,9 @@ export interface FormOperations<TFields extends Field.FieldsRecord> {
191
185
  * @since 1.0.0
192
186
  * @category Constructors
193
187
  */
194
- export const make = <TFields extends Field.FieldsRecord, R>(
195
- config: FormAtomsConfig<TFields, R>,
196
- ): FormAtoms<TFields, R> => {
188
+ export const make = <TFields extends Field.FieldsRecord, R, A, E>(
189
+ config: FormAtomsConfig<TFields, R, A, E>,
190
+ ): FormAtoms<TFields, R, A, E> => {
197
191
  const { formBuilder, runtime } = config
198
192
  const { fields } = formBuilder
199
193
 
@@ -222,21 +216,17 @@ export const make = <TFields extends Field.FieldsRecord, R>(
222
216
  const state = Option.getOrThrow(get(stateAtom))
223
217
  return Option.match(state.lastSubmittedValues, {
224
218
  onNone: () => new Set<string>(),
225
- onSome: (lastSubmitted) => recalculateDirtySubtree(new Set(), lastSubmitted, state.values, ""),
219
+ onSome: (lastSubmitted) => recalculateDirtySubtree(new Set(), lastSubmitted.encoded, state.values, ""),
226
220
  })
227
221
  }).pipe(Atom.setIdleTTL(0))
228
222
 
229
223
  const hasChangedSinceSubmitAtom = Atom.readable((get) => {
230
224
  const state = Option.getOrThrow(get(stateAtom))
231
225
  if (Option.isNone(state.lastSubmittedValues)) return false
232
- if (state.values === state.lastSubmittedValues.value) return false
226
+ if (state.values === state.lastSubmittedValues.value.encoded) return false
233
227
  return get(changedSinceSubmitFieldsAtom).size > 0
234
228
  }).pipe(Atom.setIdleTTL(0))
235
229
 
236
- const onSubmitAtom = Atom.make<Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown> | null>(
237
- null,
238
- ).pipe(Atom.setIdleTTL(0))
239
-
240
230
  const validationAtomsRegistry = createWeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>()
241
231
  const fieldAtomsRegistry = createWeakRegistry<FieldAtoms>()
242
232
 
@@ -266,20 +256,7 @@ export const make = <TFields extends Field.FieldsRecord, R>(
266
256
  (get) => getNestedValue(Option.getOrThrow(get(stateAtom)).values, fieldPath),
267
257
  (ctx, value) => {
268
258
  const currentState = Option.getOrThrow(ctx.get(stateAtom))
269
- const newValues = setNestedValue(currentState.values, fieldPath, value)
270
- ctx.set(
271
- stateAtom,
272
- Option.some({
273
- ...currentState,
274
- values: newValues,
275
- dirtyFields: recalculateDirtySubtree(
276
- currentState.dirtyFields,
277
- currentState.initialValues,
278
- newValues,
279
- fieldPath,
280
- ),
281
- }),
282
- )
259
+ ctx.set(stateAtom, Option.some(operations.setFieldValue(currentState, fieldPath, value)))
283
260
  },
284
261
  ).pipe(Atom.setIdleTTL(0))
285
262
 
@@ -312,26 +289,49 @@ export const make = <TFields extends Field.FieldsRecord, R>(
312
289
  return atoms
313
290
  }
314
291
 
315
- const resetValidationAtoms = (registry: Registry.Registry) => {
292
+ const resetValidationAtoms = (ctx: { set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void }) => {
316
293
  for (const validationAtom of validationAtomsRegistry.values()) {
317
- registry.set(validationAtom, Atom.Reset)
294
+ ctx.set(validationAtom, Atom.Reset)
318
295
  }
319
296
  validationAtomsRegistry.clear()
320
297
  fieldAtomsRegistry.clear()
321
298
  }
322
299
 
323
- const decodeAndSubmit = runtime.fn<Field.EncodedFromFields<TFields>>()((values, get) =>
300
+ const submitAtom = runtime.fn<void>()((_void, get) =>
324
301
  Effect.gen(function*() {
325
- const decoded = yield* Schema.decodeUnknown(combinedSchema)(values) as Effect.Effect<
326
- Field.DecodedFromFields<TFields>,
327
- ParseResult.ParseError,
328
- R
329
- >
330
- const onSubmit = get(onSubmitAtom)!
331
- get.set(onSubmit, decoded)
332
- return yield* get.result(onSubmit, { suspendOnWaiting: true })
302
+ const state = get(stateAtom)
303
+ if (Option.isNone(state)) return yield* Effect.die("Form not initialized")
304
+ const values = state.value.values
305
+ get.set(crossFieldErrorsAtom, new Map())
306
+ const decoded = yield* pipe(
307
+ Schema.decodeUnknown(combinedSchema)(values) as Effect.Effect<
308
+ Field.DecodedFromFields<TFields>,
309
+ ParseResult.ParseError,
310
+ R
311
+ >,
312
+ Effect.tapError((parseError) =>
313
+ Effect.sync(() => {
314
+ const routedErrors = Validation.routeErrors(parseError)
315
+ get.set(crossFieldErrorsAtom, routedErrors)
316
+ get.set(stateAtom, Option.some(operations.createSubmitState(state.value)))
317
+ })
318
+ ),
319
+ )
320
+ const submitState = operations.createSubmitState(state.value)
321
+ get.set(
322
+ stateAtom,
323
+ Option.some({
324
+ ...submitState,
325
+ lastSubmittedValues: Option.some({ encoded: values, decoded }),
326
+ }),
327
+ )
328
+ const result = config.onSubmit(decoded, get)
329
+ if (Effect.isEffect(result)) {
330
+ return yield* (result as Effect.Effect<A, E, R>)
331
+ }
332
+ return result as A
333
333
  })
334
- ).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<Field.EncodedFromFields<TFields>, unknown, unknown>
334
+ ).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<void, A, E | ParseResult.ParseError>
335
335
 
336
336
  const fieldRefs = Object.fromEntries(
337
337
  Object.keys(fields).map((key) => [key, FormBuilder.makeFieldRef(key)]),
@@ -358,7 +358,6 @@ export const make = <TFields extends Field.FieldsRecord, R>(
358
358
 
359
359
  createSubmitState: (state) => ({
360
360
  ...state,
361
- lastSubmittedValues: Option.some(state.values),
362
361
  touched: Field.createTouchedRecord(fields, true) as { readonly [K in keyof TFields]: boolean },
363
362
  submitCount: state.submitCount + 1,
364
363
  }),
@@ -462,25 +461,82 @@ export const make = <TFields extends Field.FieldsRecord, R>(
462
461
  return state
463
462
  }
464
463
 
465
- if (state.values === state.lastSubmittedValues.value) {
464
+ const lastEncoded = state.lastSubmittedValues.value.encoded
465
+ if (state.values === lastEncoded) {
466
466
  return state
467
467
  }
468
468
 
469
469
  const newDirtyFields = recalculateDirtySubtree(
470
470
  state.dirtyFields,
471
471
  state.initialValues,
472
- state.lastSubmittedValues.value,
472
+ lastEncoded,
473
473
  "",
474
474
  )
475
475
 
476
476
  return {
477
477
  ...state,
478
- values: state.lastSubmittedValues.value,
478
+ values: lastEncoded,
479
479
  dirtyFields: newDirtyFields,
480
480
  }
481
481
  },
482
482
  }
483
483
 
484
+ const resetAtom = Atom.fnSync<void>()((_: void, get) => {
485
+ const state = get(stateAtom)
486
+ if (Option.isNone(state)) return
487
+ get.set(stateAtom, Option.some(operations.createResetState(state.value)))
488
+ get.set(crossFieldErrorsAtom, new Map())
489
+ resetValidationAtoms(get)
490
+ get.set(submitAtom, Atom.Reset)
491
+ }, { initialValue: undefined as void }).pipe(Atom.setIdleTTL(0))
492
+
493
+ const revertToLastSubmitAtom = Atom.fnSync<void>()((_: void, get) => {
494
+ const state = get(stateAtom)
495
+ if (Option.isNone(state)) return
496
+ get.set(stateAtom, Option.some(operations.revertToLastSubmit(state.value)))
497
+ get.set(crossFieldErrorsAtom, new Map())
498
+ }, { initialValue: undefined as void }).pipe(Atom.setIdleTTL(0))
499
+
500
+ const setValuesAtom = Atom.fnSync<Field.EncodedFromFields<TFields>>()((_values, get) => {
501
+ const state = get(stateAtom)
502
+ if (Option.isNone(state)) return
503
+ get.set(stateAtom, Option.some(operations.setFormValues(state.value, _values)))
504
+ get.set(crossFieldErrorsAtom, new Map())
505
+ }, { initialValue: undefined as void }).pipe(Atom.setIdleTTL(0))
506
+
507
+ const setValueAtomsRegistry = createWeakRegistry<Atom.Writable<void, any>>()
508
+
509
+ const setValue = <S>(field: FormBuilder.FieldRef<S>): Atom.Writable<void, S | ((prev: S) => S)> => {
510
+ const cached = setValueAtomsRegistry.get(field.key)
511
+ if (cached) return cached
512
+
513
+ const atom = Atom.fnSync<S | ((prev: S) => S)>()((update, get) => {
514
+ const state = get(stateAtom)
515
+ if (Option.isNone(state)) return
516
+
517
+ const currentValue = getNestedValue(state.value.values, field.key) as S
518
+ const newValue = typeof update === "function"
519
+ ? (update as (prev: S) => S)(currentValue)
520
+ : update
521
+
522
+ get.set(stateAtom, Option.some(operations.setFieldValue(state.value, field.key, newValue)))
523
+
524
+ const currentErrors = get(crossFieldErrorsAtom)
525
+ const nextErrors = new Map<string, string>()
526
+ for (const [errorPath, message] of currentErrors) {
527
+ if (!isPathUnderRoot(errorPath, field.key)) {
528
+ nextErrors.set(errorPath, message)
529
+ }
530
+ }
531
+ if (nextErrors.size !== currentErrors.size) {
532
+ get.set(crossFieldErrorsAtom, nextErrors)
533
+ }
534
+ }, { initialValue: undefined as void }).pipe(Atom.setIdleTTL(0))
535
+
536
+ setValueAtomsRegistry.set(field.key, atom)
537
+ return atom
538
+ }
539
+
484
540
  return {
485
541
  stateAtom,
486
542
  crossFieldErrorsAtom,
@@ -490,8 +546,7 @@ export const make = <TFields extends Field.FieldsRecord, R>(
490
546
  lastSubmittedValuesAtom,
491
547
  changedSinceSubmitFieldsAtom,
492
548
  hasChangedSinceSubmitAtom,
493
- onSubmitAtom,
494
- decodeAndSubmit,
549
+ submitAtom,
495
550
  combinedSchema,
496
551
  fieldRefs,
497
552
  validationAtomsRegistry,
@@ -500,5 +555,9 @@ export const make = <TFields extends Field.FieldsRecord, R>(
500
555
  getOrCreateFieldAtoms,
501
556
  resetValidationAtoms,
502
557
  operations,
503
- } as FormAtoms<TFields, R>
558
+ resetAtom,
559
+ revertToLastSubmitAtom,
560
+ setValuesAtom,
561
+ setValue,
562
+ } as FormAtoms<TFields, R, A, E>
504
563
  }
@@ -16,6 +16,15 @@ import type {
16
16
  } from "./Field.js"
17
17
  import { isArrayFieldDef, isFieldDef } from "./Field.js"
18
18
 
19
+ /**
20
+ * @since 1.0.0
21
+ * @category Models
22
+ */
23
+ export interface SubmittedValues<TFields extends FieldsRecord> {
24
+ readonly encoded: EncodedFromFields<TFields>
25
+ readonly decoded: DecodedFromFields<TFields>
26
+ }
27
+
19
28
  /**
20
29
  * Unique identifier for Field references.
21
30
  *
@@ -84,7 +93,7 @@ export type TypeId = typeof TypeId
84
93
  export interface FormState<TFields extends FieldsRecord> {
85
94
  readonly values: EncodedFromFields<TFields>
86
95
  readonly initialValues: EncodedFromFields<TFields>
87
- readonly lastSubmittedValues: Option.Option<EncodedFromFields<TFields>>
96
+ readonly lastSubmittedValues: Option.Option<SubmittedValues<TFields>>
88
97
  readonly touched: { readonly [K in keyof TFields]: boolean }
89
98
  readonly submitCount: number
90
99
  readonly dirtyFields: ReadonlySet<string>
package/src/index.ts CHANGED
@@ -6,12 +6,10 @@
6
6
  export * as Field from "./Field.js"
7
7
 
8
8
  /**
9
- * Atom infrastructure for form state management.
10
- *
11
- * This module provides the core atom infrastructure that framework adapters
12
- * (React, Vue, Svelte, Solid) can use to build reactive form components.
9
+ * Atoms for a single field.
13
10
  *
14
11
  * @since 1.0.0
12
+ * @category Models
15
13
  */
16
14
  export * as FormAtoms from "./FormAtoms.js"
17
15
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import * as Equal from "effect/Equal"
7
7
  import * as Utils from "effect/Utils"
8
- import { getNestedValue } from "./path.js"
8
+ import { getNestedValue, isPathUnderRoot } from "./path.js"
9
9
 
10
10
  /**
11
11
  * Recalculates dirty fields for an array after mutation.
@@ -17,20 +17,24 @@ export const recalculateDirtyFieldsForArray = (
17
17
  arrayPath: string,
18
18
  newItems: ReadonlyArray<unknown>,
19
19
  ): ReadonlySet<string> => {
20
+ const initialItems = (getNestedValue(initialValues, arrayPath) ?? []) as ReadonlyArray<unknown>
21
+
22
+ if (newItems === initialItems) {
23
+ return dirtyFields
24
+ }
25
+
20
26
  const nextDirty = new Set(
21
- Array.from(dirtyFields).filter(
22
- (path) => path !== arrayPath && !path.startsWith(arrayPath + ".") && !path.startsWith(arrayPath + "["),
23
- ),
27
+ Array.from(dirtyFields).filter((path) => !isPathUnderRoot(path, arrayPath)),
24
28
  )
25
29
 
26
- const initialItems = (getNestedValue(initialValues, arrayPath) ?? []) as ReadonlyArray<unknown>
27
-
28
30
  const loopLength = Math.max(newItems.length, initialItems.length)
29
31
  for (let i = 0; i < loopLength; i++) {
30
32
  const itemPath = `${arrayPath}[${i}]`
31
33
  const newItem = newItems[i]
32
34
  const initialItem = initialItems[i]
33
35
 
36
+ if (newItem === initialItem) continue
37
+
34
38
  const isEqual = Utils.structuralRegion(() => Equal.equals(newItem, initialItem))
35
39
  if (!isEqual) {
36
40
  nextDirty.add(itemPath)
@@ -58,22 +62,40 @@ export const recalculateDirtySubtree = (
58
62
  allValues: unknown,
59
63
  rootPath: string = "",
60
64
  ): ReadonlySet<string> => {
65
+ const targetValue = rootPath ? getNestedValue(allValues, rootPath) : allValues
66
+ const targetInitial = rootPath ? getNestedValue(allInitial, rootPath) : allInitial
67
+
68
+ if (targetValue === targetInitial) {
69
+ if (rootPath === "") {
70
+ return new Set()
71
+ }
72
+
73
+ let changed = false
74
+ const nextDirty = new Set(currentDirty)
75
+ for (const path of currentDirty) {
76
+ if (isPathUnderRoot(path, rootPath)) {
77
+ nextDirty.delete(path)
78
+ changed = true
79
+ }
80
+ }
81
+ return changed ? nextDirty : currentDirty
82
+ }
83
+
61
84
  const nextDirty = new Set(currentDirty)
62
85
 
63
86
  if (rootPath === "") {
64
87
  nextDirty.clear()
65
88
  } else {
66
89
  for (const path of nextDirty) {
67
- if (path === rootPath || path.startsWith(rootPath + ".") || path.startsWith(rootPath + "[")) {
90
+ if (isPathUnderRoot(path, rootPath)) {
68
91
  nextDirty.delete(path)
69
92
  }
70
93
  }
71
94
  }
72
95
 
73
- const targetValue = rootPath ? getNestedValue(allValues, rootPath) : allValues
74
- const targetInitial = rootPath ? getNestedValue(allInitial, rootPath) : allInitial
75
-
76
96
  const recurse = (current: unknown, initial: unknown, path: string): void => {
97
+ if (current === initial) return
98
+
77
99
  if (Array.isArray(current)) {
78
100
  const initialArr = (initial ?? []) as ReadonlyArray<unknown>
79
101
  for (let i = 0; i < Math.max(current.length, initialArr.length); i++) {
@@ -81,10 +103,14 @@ export const recalculateDirtySubtree = (
81
103
  }
82
104
  } else if (current !== null && typeof current === "object") {
83
105
  const initialObj = (initial ?? {}) as Record<string, unknown>
84
- const allKeys = new Set([...Object.keys(current as object), ...Object.keys(initialObj)])
85
- for (const key of allKeys) {
106
+ for (const key in current as object) {
86
107
  recurse((current as Record<string, unknown>)[key], initialObj[key], path ? `${path}.${key}` : key)
87
108
  }
109
+ for (const key in initialObj) {
110
+ if (!(key in (current as object))) {
111
+ recurse(undefined, initialObj[key], path ? `${path}.${key}` : key)
112
+ }
113
+ }
88
114
  } else {
89
115
  const isEqual = Utils.structuralRegion(() => Equal.equals(current, initial))
90
116
  if (!isEqual && path) nextDirty.add(path)
@@ -27,6 +27,18 @@ export const schemaPathToFieldPath = (path: ReadonlyArray<PropertyKey>): string
27
27
  return result
28
28
  }
29
29
 
30
+ /**
31
+ * Checks if a path matches a root path or is a descendant of it.
32
+ * Handles both dot notation (root.child) and bracket notation (root[0]).
33
+ *
34
+ * @example
35
+ * isPathUnderRoot("items[0].name", "items[0]") // true
36
+ * isPathUnderRoot("items[0].name", "items") // true
37
+ * isPathUnderRoot("other", "items") // false
38
+ */
39
+ export const isPathUnderRoot = (path: string, rootPath: string): boolean =>
40
+ path === rootPath || path.startsWith(rootPath + ".") || path.startsWith(rootPath + "[")
41
+
30
42
  /**
31
43
  * Checks if a field path or any of its parent paths are in the dirty set.
32
44
  */