@lucas-barake/effect-form 0.1.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/Form/package.json +6 -0
- package/FormAtoms/package.json +6 -0
- package/LICENSE +21 -0
- package/Mode/package.json +6 -0
- package/README.md +5 -0
- package/Validation/package.json +6 -0
- package/dist/cjs/Form.js +299 -0
- package/dist/cjs/Form.js.map +1 -0
- package/dist/cjs/FormAtoms.js +266 -0
- package/dist/cjs/FormAtoms.js.map +1 -0
- package/dist/cjs/Mode.js +64 -0
- package/dist/cjs/Mode.js.map +1 -0
- package/dist/cjs/Validation.js +69 -0
- package/dist/cjs/Validation.js.map +1 -0
- package/dist/cjs/index.js +35 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/dirty.js +101 -0
- package/dist/cjs/internal/dirty.js.map +1 -0
- package/dist/cjs/internal/path.js +96 -0
- package/dist/cjs/internal/path.js.map +1 -0
- package/dist/cjs/internal/weak-registry.js +52 -0
- package/dist/cjs/internal/weak-registry.js.map +1 -0
- package/dist/dts/Form.d.ts +317 -0
- package/dist/dts/Form.d.ts.map +1 -0
- package/dist/dts/FormAtoms.d.ts +145 -0
- package/dist/dts/FormAtoms.d.ts.map +1 -0
- package/dist/dts/Mode.d.ts +55 -0
- package/dist/dts/Mode.d.ts.map +1 -0
- package/dist/dts/Validation.d.ts +23 -0
- package/dist/dts/Validation.d.ts.map +1 -0
- package/dist/dts/index.d.ts +26 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/internal/dirty.d.ts +13 -0
- package/dist/dts/internal/dirty.d.ts.map +1 -0
- package/dist/dts/internal/path.d.ts +32 -0
- package/dist/dts/internal/path.d.ts.map +1 -0
- package/dist/dts/internal/weak-registry.d.ts +7 -0
- package/dist/dts/internal/weak-registry.d.ts.map +1 -0
- package/dist/esm/Form.js +263 -0
- package/dist/esm/Form.js.map +1 -0
- package/dist/esm/FormAtoms.js +238 -0
- package/dist/esm/FormAtoms.js.map +1 -0
- package/dist/esm/Mode.js +36 -0
- package/dist/esm/Mode.js.map +1 -0
- package/dist/esm/Validation.js +40 -0
- package/dist/esm/Validation.js.map +1 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/dirty.js +72 -0
- package/dist/esm/internal/dirty.js.map +1 -0
- package/dist/esm/internal/path.js +86 -0
- package/dist/esm/internal/path.js.map +1 -0
- package/dist/esm/internal/weak-registry.js +45 -0
- package/dist/esm/internal/weak-registry.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/package.json +64 -0
- package/src/Form.ts +522 -0
- package/src/FormAtoms.ts +485 -0
- package/src/Mode.ts +59 -0
- package/src/Validation.ts +43 -0
- package/src/index.ts +28 -0
- package/src/internal/dirty.ts +96 -0
- package/src/internal/path.ts +93 -0
- package/src/internal/weak-registry.ts +60 -0
package/src/FormAtoms.ts
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
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
|
+
import * as Atom from "@effect-atom/atom/Atom"
|
|
10
|
+
import type * as Registry from "@effect-atom/atom/Registry"
|
|
11
|
+
import * as Effect from "effect/Effect"
|
|
12
|
+
import * as Equal from "effect/Equal"
|
|
13
|
+
import { pipe } from "effect/Function"
|
|
14
|
+
import * as Option from "effect/Option"
|
|
15
|
+
import type * as ParseResult from "effect/ParseResult"
|
|
16
|
+
import * as Schema from "effect/Schema"
|
|
17
|
+
import * as Utils from "effect/Utils"
|
|
18
|
+
import type * as Form from "./Form.js"
|
|
19
|
+
import { buildSchema, createTouchedRecord, getDefaultFromSchema, makeFieldRef } from "./Form.js"
|
|
20
|
+
import { recalculateDirtyFieldsForArray, recalculateDirtySubtree } from "./internal/dirty.js"
|
|
21
|
+
import { getNestedValue, setNestedValue } from "./internal/path.js"
|
|
22
|
+
import { createWeakRegistry, type WeakRegistry } from "./internal/weak-registry.js"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Atoms for a single field.
|
|
26
|
+
*
|
|
27
|
+
* @since 1.0.0
|
|
28
|
+
* @category Models
|
|
29
|
+
*/
|
|
30
|
+
export interface FieldAtoms {
|
|
31
|
+
readonly valueAtom: Atom.Writable<unknown, unknown>
|
|
32
|
+
readonly initialValueAtom: Atom.Atom<unknown>
|
|
33
|
+
readonly touchedAtom: Atom.Writable<boolean, boolean>
|
|
34
|
+
readonly crossFieldErrorAtom: Atom.Atom<Option.Option<string>>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Configuration for creating form atoms.
|
|
39
|
+
*
|
|
40
|
+
* @since 1.0.0
|
|
41
|
+
* @category Models
|
|
42
|
+
*/
|
|
43
|
+
export interface FormAtomsConfig<TFields extends Form.FieldsRecord, R> {
|
|
44
|
+
readonly runtime: Atom.AtomRuntime<R, any>
|
|
45
|
+
readonly formBuilder: Form.FormBuilder<TFields, R>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Maps field names to their type-safe Field references for setValue operations.
|
|
50
|
+
*
|
|
51
|
+
* @since 1.0.0
|
|
52
|
+
* @category Models
|
|
53
|
+
*/
|
|
54
|
+
export type FieldRefs<TFields extends Form.FieldsRecord> = {
|
|
55
|
+
readonly [K in keyof TFields]: TFields[K] extends Form.FieldDef<any, infer S> ? Form.Field<Schema.Schema.Encoded<S>>
|
|
56
|
+
: TFields[K] extends Form.ArrayFieldDef<any, infer S> ? Form.Field<ReadonlyArray<Schema.Schema.Encoded<S>>>
|
|
57
|
+
: never
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The complete form atoms infrastructure.
|
|
62
|
+
*
|
|
63
|
+
* @since 1.0.0
|
|
64
|
+
* @category Models
|
|
65
|
+
*/
|
|
66
|
+
export interface FormAtoms<TFields extends Form.FieldsRecord, R> {
|
|
67
|
+
readonly stateAtom: Atom.Writable<Option.Option<Form.FormState<TFields>>, Option.Option<Form.FormState<TFields>>>
|
|
68
|
+
readonly crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>
|
|
69
|
+
readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>
|
|
70
|
+
readonly isDirtyAtom: Atom.Atom<boolean>
|
|
71
|
+
readonly submitCountAtom: Atom.Atom<number>
|
|
72
|
+
readonly onSubmitAtom: Atom.Writable<
|
|
73
|
+
Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null,
|
|
74
|
+
Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null
|
|
75
|
+
>
|
|
76
|
+
|
|
77
|
+
readonly decodeAndSubmit: Atom.AtomResultFn<Form.EncodedFromFields<TFields>, unknown, unknown>
|
|
78
|
+
|
|
79
|
+
readonly combinedSchema: Schema.Schema<Form.DecodedFromFields<TFields>, Form.EncodedFromFields<TFields>, R>
|
|
80
|
+
|
|
81
|
+
readonly fieldRefs: FieldRefs<TFields>
|
|
82
|
+
|
|
83
|
+
readonly validationAtomsRegistry: WeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>
|
|
84
|
+
readonly fieldAtomsRegistry: WeakRegistry<FieldAtoms>
|
|
85
|
+
|
|
86
|
+
readonly getOrCreateValidationAtom: (
|
|
87
|
+
fieldPath: string,
|
|
88
|
+
schema: Schema.Schema.Any,
|
|
89
|
+
) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>
|
|
90
|
+
|
|
91
|
+
readonly getOrCreateFieldAtoms: (fieldPath: string) => FieldAtoms
|
|
92
|
+
|
|
93
|
+
readonly resetValidationAtoms: (registry: Registry.Registry) => void
|
|
94
|
+
|
|
95
|
+
readonly operations: FormOperations<TFields>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pure state operations for form manipulation.
|
|
100
|
+
*
|
|
101
|
+
* @since 1.0.0
|
|
102
|
+
* @category Models
|
|
103
|
+
*/
|
|
104
|
+
export interface FormOperations<TFields extends Form.FieldsRecord> {
|
|
105
|
+
/**
|
|
106
|
+
* Creates the initial form state from default values.
|
|
107
|
+
*/
|
|
108
|
+
readonly createInitialState: (defaultValues: Form.EncodedFromFields<TFields>) => Form.FormState<TFields>
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Creates a reset state (back to initial values).
|
|
112
|
+
*/
|
|
113
|
+
readonly createResetState: (state: Form.FormState<TFields>) => Form.FormState<TFields>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates state with all fields marked as touched and submit count incremented.
|
|
117
|
+
*/
|
|
118
|
+
readonly createSubmitState: (state: Form.FormState<TFields>) => Form.FormState<TFields>
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Updates a single field value in the state.
|
|
122
|
+
*/
|
|
123
|
+
readonly setFieldValue: (
|
|
124
|
+
state: Form.FormState<TFields>,
|
|
125
|
+
fieldPath: string,
|
|
126
|
+
value: unknown,
|
|
127
|
+
) => Form.FormState<TFields>
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sets all form values, recalculating dirty fields.
|
|
131
|
+
*/
|
|
132
|
+
readonly setFormValues: (
|
|
133
|
+
state: Form.FormState<TFields>,
|
|
134
|
+
values: Form.EncodedFromFields<TFields>,
|
|
135
|
+
) => Form.FormState<TFields>
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Sets a field as touched.
|
|
139
|
+
*/
|
|
140
|
+
readonly setFieldTouched: (
|
|
141
|
+
state: Form.FormState<TFields>,
|
|
142
|
+
fieldPath: string,
|
|
143
|
+
touched: boolean,
|
|
144
|
+
) => Form.FormState<TFields>
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Appends an item to an array field.
|
|
148
|
+
*/
|
|
149
|
+
readonly appendArrayItem: (
|
|
150
|
+
state: Form.FormState<TFields>,
|
|
151
|
+
arrayPath: string,
|
|
152
|
+
itemSchema: Schema.Schema.Any,
|
|
153
|
+
value?: unknown,
|
|
154
|
+
) => Form.FormState<TFields>
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Removes an item from an array field.
|
|
158
|
+
*/
|
|
159
|
+
readonly removeArrayItem: (
|
|
160
|
+
state: Form.FormState<TFields>,
|
|
161
|
+
arrayPath: string,
|
|
162
|
+
index: number,
|
|
163
|
+
) => Form.FormState<TFields>
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Swaps two items in an array field.
|
|
167
|
+
*/
|
|
168
|
+
readonly swapArrayItems: (
|
|
169
|
+
state: Form.FormState<TFields>,
|
|
170
|
+
arrayPath: string,
|
|
171
|
+
indexA: number,
|
|
172
|
+
indexB: number,
|
|
173
|
+
) => Form.FormState<TFields>
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Moves an item in an array field.
|
|
177
|
+
*/
|
|
178
|
+
readonly moveArrayItem: (
|
|
179
|
+
state: Form.FormState<TFields>,
|
|
180
|
+
arrayPath: string,
|
|
181
|
+
fromIndex: number,
|
|
182
|
+
toIndex: number,
|
|
183
|
+
) => Form.FormState<TFields>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Creates the complete form atoms infrastructure.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* import * as FormAtoms from "@lucas-barake/effect-form/FormAtoms"
|
|
192
|
+
* import * as Form from "@lucas-barake/effect-form"
|
|
193
|
+
* import * as Atom from "@effect-atom/atom/Atom"
|
|
194
|
+
* import * as Layer from "effect/Layer"
|
|
195
|
+
*
|
|
196
|
+
* const runtime = Atom.runtime(Layer.empty)
|
|
197
|
+
*
|
|
198
|
+
* const loginForm = Form.empty
|
|
199
|
+
* .addField(Form.makeField("email", Schema.String))
|
|
200
|
+
* .addField(Form.makeField("password", Schema.String))
|
|
201
|
+
*
|
|
202
|
+
* const atoms = FormAtoms.make({
|
|
203
|
+
* runtime,
|
|
204
|
+
* formBuilder: loginForm,
|
|
205
|
+
* parsedMode: { validation: "onChange", debounce: 300, autoSubmit: false }
|
|
206
|
+
* })
|
|
207
|
+
* ```
|
|
208
|
+
*
|
|
209
|
+
* @since 1.0.0
|
|
210
|
+
* @category Constructors
|
|
211
|
+
*/
|
|
212
|
+
export const make = <TFields extends Form.FieldsRecord, R>(
|
|
213
|
+
config: FormAtomsConfig<TFields, R>,
|
|
214
|
+
): FormAtoms<TFields, R> => {
|
|
215
|
+
const { formBuilder, runtime } = config
|
|
216
|
+
const { fields } = formBuilder
|
|
217
|
+
|
|
218
|
+
const combinedSchema = buildSchema(formBuilder)
|
|
219
|
+
|
|
220
|
+
const stateAtom = Atom.make(Option.none<Form.FormState<TFields>>()).pipe(Atom.setIdleTTL(0))
|
|
221
|
+
const crossFieldErrorsAtom = Atom.make<Map<string, string>>(new Map()).pipe(Atom.setIdleTTL(0))
|
|
222
|
+
|
|
223
|
+
const dirtyFieldsAtom = Atom.readable((get) => Option.getOrThrow(get(stateAtom)).dirtyFields).pipe(
|
|
224
|
+
Atom.setIdleTTL(0),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
const isDirtyAtom = Atom.readable((get) => Option.getOrThrow(get(stateAtom)).dirtyFields.size > 0).pipe(
|
|
228
|
+
Atom.setIdleTTL(0),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
const submitCountAtom = Atom.readable((get) => Option.getOrThrow(get(stateAtom)).submitCount).pipe(
|
|
232
|
+
Atom.setIdleTTL(0),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const onSubmitAtom = Atom.make<Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null>(
|
|
236
|
+
null,
|
|
237
|
+
).pipe(Atom.setIdleTTL(0))
|
|
238
|
+
|
|
239
|
+
const updateDirtyFields = (
|
|
240
|
+
state: Form.FormState<TFields>,
|
|
241
|
+
fieldPath: string,
|
|
242
|
+
newValue: unknown,
|
|
243
|
+
): ReadonlySet<string> => {
|
|
244
|
+
const initialValue = getNestedValue(state.initialValues, fieldPath)
|
|
245
|
+
const isEqual = Utils.structuralRegion(() => Equal.equals(newValue, initialValue))
|
|
246
|
+
|
|
247
|
+
const newDirtyFields = new Set(state.dirtyFields)
|
|
248
|
+
if (!isEqual) {
|
|
249
|
+
newDirtyFields.add(fieldPath)
|
|
250
|
+
} else {
|
|
251
|
+
newDirtyFields.delete(fieldPath)
|
|
252
|
+
}
|
|
253
|
+
return newDirtyFields
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const validationAtomsRegistry = createWeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>()
|
|
257
|
+
const fieldAtomsRegistry = createWeakRegistry<FieldAtoms>()
|
|
258
|
+
|
|
259
|
+
const getOrCreateValidationAtom = (
|
|
260
|
+
fieldPath: string,
|
|
261
|
+
schema: Schema.Schema.Any,
|
|
262
|
+
): Atom.AtomResultFn<unknown, void, ParseResult.ParseError> => {
|
|
263
|
+
const existing = validationAtomsRegistry.get(fieldPath)
|
|
264
|
+
if (existing) return existing
|
|
265
|
+
|
|
266
|
+
const validationAtom = runtime.fn<unknown>()((value: unknown) =>
|
|
267
|
+
pipe(
|
|
268
|
+
Schema.decodeUnknown(schema)(value) as Effect.Effect<unknown, ParseResult.ParseError, R>,
|
|
269
|
+
Effect.asVoid,
|
|
270
|
+
)
|
|
271
|
+
).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<unknown, void, ParseResult.ParseError>
|
|
272
|
+
|
|
273
|
+
validationAtomsRegistry.set(fieldPath, validationAtom)
|
|
274
|
+
return validationAtom
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const getOrCreateFieldAtoms = (fieldPath: string): FieldAtoms => {
|
|
278
|
+
const existing = fieldAtomsRegistry.get(fieldPath)
|
|
279
|
+
if (existing) return existing
|
|
280
|
+
|
|
281
|
+
const valueAtom = Atom.writable(
|
|
282
|
+
(get) => getNestedValue(Option.getOrThrow(get(stateAtom)).values, fieldPath),
|
|
283
|
+
(ctx, value) => {
|
|
284
|
+
const currentState = Option.getOrThrow(ctx.get(stateAtom))
|
|
285
|
+
ctx.set(
|
|
286
|
+
stateAtom,
|
|
287
|
+
Option.some({
|
|
288
|
+
...currentState,
|
|
289
|
+
values: setNestedValue(currentState.values, fieldPath, value),
|
|
290
|
+
dirtyFields: updateDirtyFields(currentState, fieldPath, value),
|
|
291
|
+
}),
|
|
292
|
+
)
|
|
293
|
+
},
|
|
294
|
+
).pipe(Atom.setIdleTTL(0))
|
|
295
|
+
|
|
296
|
+
const initialValueAtom = Atom.readable(
|
|
297
|
+
(get) => getNestedValue(Option.getOrThrow(get(stateAtom)).initialValues, fieldPath),
|
|
298
|
+
).pipe(Atom.setIdleTTL(0))
|
|
299
|
+
|
|
300
|
+
const touchedAtom = Atom.writable(
|
|
301
|
+
(get) => (getNestedValue(Option.getOrThrow(get(stateAtom)).touched, fieldPath) ?? false) as boolean,
|
|
302
|
+
(ctx, value) => {
|
|
303
|
+
const currentState = Option.getOrThrow(ctx.get(stateAtom))
|
|
304
|
+
ctx.set(
|
|
305
|
+
stateAtom,
|
|
306
|
+
Option.some({
|
|
307
|
+
...currentState,
|
|
308
|
+
touched: setNestedValue(currentState.touched, fieldPath, value),
|
|
309
|
+
}),
|
|
310
|
+
)
|
|
311
|
+
},
|
|
312
|
+
).pipe(Atom.setIdleTTL(0))
|
|
313
|
+
|
|
314
|
+
const crossFieldErrorAtom = Atom.readable((get) => {
|
|
315
|
+
const errors = get(crossFieldErrorsAtom)
|
|
316
|
+
const error = errors.get(fieldPath)
|
|
317
|
+
return error !== undefined ? Option.some(error) : Option.none<string>()
|
|
318
|
+
}).pipe(Atom.setIdleTTL(0))
|
|
319
|
+
|
|
320
|
+
const atoms: FieldAtoms = { valueAtom, initialValueAtom, touchedAtom, crossFieldErrorAtom }
|
|
321
|
+
fieldAtomsRegistry.set(fieldPath, atoms)
|
|
322
|
+
return atoms
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const resetValidationAtoms = (registry: Registry.Registry) => {
|
|
326
|
+
for (const validationAtom of validationAtomsRegistry.values()) {
|
|
327
|
+
registry.set(validationAtom, Atom.Reset)
|
|
328
|
+
}
|
|
329
|
+
validationAtomsRegistry.clear()
|
|
330
|
+
fieldAtomsRegistry.clear()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const decodeAndSubmit = runtime.fn<Form.EncodedFromFields<TFields>>()((values, get) =>
|
|
334
|
+
Effect.gen(function*() {
|
|
335
|
+
const decoded = yield* Schema.decodeUnknown(combinedSchema)(values) as Effect.Effect<
|
|
336
|
+
Form.DecodedFromFields<TFields>,
|
|
337
|
+
ParseResult.ParseError,
|
|
338
|
+
R
|
|
339
|
+
>
|
|
340
|
+
const onSubmit = get(onSubmitAtom)!
|
|
341
|
+
get.set(onSubmit, decoded)
|
|
342
|
+
return yield* get.result(onSubmit, { suspendOnWaiting: true })
|
|
343
|
+
})
|
|
344
|
+
).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<Form.EncodedFromFields<TFields>, unknown, unknown>
|
|
345
|
+
|
|
346
|
+
const fieldRefs = Object.fromEntries(
|
|
347
|
+
Object.keys(fields).map((key) => [key, makeFieldRef(key)]),
|
|
348
|
+
) as FieldRefs<TFields>
|
|
349
|
+
|
|
350
|
+
const operations: FormOperations<TFields> = {
|
|
351
|
+
createInitialState: (defaultValues) => ({
|
|
352
|
+
values: defaultValues,
|
|
353
|
+
initialValues: defaultValues,
|
|
354
|
+
touched: createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
|
|
355
|
+
submitCount: 0,
|
|
356
|
+
dirtyFields: new Set(),
|
|
357
|
+
}),
|
|
358
|
+
|
|
359
|
+
createResetState: (state) => ({
|
|
360
|
+
values: state.initialValues,
|
|
361
|
+
initialValues: state.initialValues,
|
|
362
|
+
touched: createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
|
|
363
|
+
submitCount: 0,
|
|
364
|
+
dirtyFields: new Set(),
|
|
365
|
+
}),
|
|
366
|
+
|
|
367
|
+
createSubmitState: (state) => ({
|
|
368
|
+
...state,
|
|
369
|
+
touched: createTouchedRecord(fields, true) as { readonly [K in keyof TFields]: boolean },
|
|
370
|
+
submitCount: state.submitCount + 1,
|
|
371
|
+
}),
|
|
372
|
+
|
|
373
|
+
setFieldValue: (state, fieldPath, value) => {
|
|
374
|
+
const newValues = setNestedValue(state.values, fieldPath, value)
|
|
375
|
+
const newDirtyFields = recalculateDirtySubtree(
|
|
376
|
+
state.dirtyFields,
|
|
377
|
+
state.initialValues,
|
|
378
|
+
newValues,
|
|
379
|
+
fieldPath,
|
|
380
|
+
)
|
|
381
|
+
return {
|
|
382
|
+
...state,
|
|
383
|
+
values: newValues as Form.EncodedFromFields<TFields>,
|
|
384
|
+
dirtyFields: newDirtyFields,
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
setFormValues: (state, values) => {
|
|
389
|
+
const newDirtyFields = recalculateDirtySubtree(
|
|
390
|
+
state.dirtyFields,
|
|
391
|
+
state.initialValues,
|
|
392
|
+
values,
|
|
393
|
+
"",
|
|
394
|
+
)
|
|
395
|
+
return {
|
|
396
|
+
...state,
|
|
397
|
+
values,
|
|
398
|
+
dirtyFields: newDirtyFields,
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
setFieldTouched: (state, fieldPath, touched) => ({
|
|
403
|
+
...state,
|
|
404
|
+
touched: setNestedValue(state.touched, fieldPath, touched) as { readonly [K in keyof TFields]: boolean },
|
|
405
|
+
}),
|
|
406
|
+
|
|
407
|
+
appendArrayItem: (state, arrayPath, itemSchema, value) => {
|
|
408
|
+
const newItem = value ?? getDefaultFromSchema(itemSchema)
|
|
409
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
|
|
410
|
+
const newItems = [...currentItems, newItem]
|
|
411
|
+
return {
|
|
412
|
+
...state,
|
|
413
|
+
values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
|
|
414
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
removeArrayItem: (state, arrayPath, index) => {
|
|
419
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
|
|
420
|
+
const newItems = currentItems.filter((_, i) => i !== index)
|
|
421
|
+
return {
|
|
422
|
+
...state,
|
|
423
|
+
values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
|
|
424
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
swapArrayItems: (state, arrayPath, indexA, indexB) => {
|
|
429
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
|
|
430
|
+
if (
|
|
431
|
+
indexA < 0 || indexA >= currentItems.length ||
|
|
432
|
+
indexB < 0 || indexB >= currentItems.length ||
|
|
433
|
+
indexA === indexB
|
|
434
|
+
) {
|
|
435
|
+
return state
|
|
436
|
+
}
|
|
437
|
+
const newItems = [...currentItems]
|
|
438
|
+
const temp = newItems[indexA]
|
|
439
|
+
newItems[indexA] = newItems[indexB]
|
|
440
|
+
newItems[indexB] = temp
|
|
441
|
+
return {
|
|
442
|
+
...state,
|
|
443
|
+
values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
|
|
444
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
moveArrayItem: (state, arrayPath, fromIndex, toIndex) => {
|
|
449
|
+
const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
|
|
450
|
+
if (
|
|
451
|
+
fromIndex < 0 || fromIndex >= currentItems.length ||
|
|
452
|
+
toIndex < 0 || toIndex > currentItems.length ||
|
|
453
|
+
fromIndex === toIndex
|
|
454
|
+
) {
|
|
455
|
+
return state
|
|
456
|
+
}
|
|
457
|
+
const newItems = [...currentItems]
|
|
458
|
+
const [item] = newItems.splice(fromIndex, 1)
|
|
459
|
+
newItems.splice(toIndex, 0, item)
|
|
460
|
+
return {
|
|
461
|
+
...state,
|
|
462
|
+
values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
|
|
463
|
+
dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
stateAtom,
|
|
470
|
+
crossFieldErrorsAtom,
|
|
471
|
+
dirtyFieldsAtom,
|
|
472
|
+
isDirtyAtom,
|
|
473
|
+
submitCountAtom,
|
|
474
|
+
onSubmitAtom,
|
|
475
|
+
decodeAndSubmit,
|
|
476
|
+
combinedSchema,
|
|
477
|
+
fieldRefs,
|
|
478
|
+
validationAtomsRegistry,
|
|
479
|
+
fieldAtomsRegistry,
|
|
480
|
+
getOrCreateValidationAtom,
|
|
481
|
+
getOrCreateFieldAtoms,
|
|
482
|
+
resetValidationAtoms,
|
|
483
|
+
operations,
|
|
484
|
+
} as FormAtoms<TFields, R>
|
|
485
|
+
}
|
package/src/Mode.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form validation mode configuration.
|
|
3
|
+
*
|
|
4
|
+
* @since 1.0.0
|
|
5
|
+
*/
|
|
6
|
+
import * as Duration from "effect/Duration"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Controls when field validation is triggered and whether form auto-submits.
|
|
10
|
+
*
|
|
11
|
+
* Simple modes (string):
|
|
12
|
+
* - `"onSubmit"`: Validation only runs when the form is submitted (default)
|
|
13
|
+
* - `"onBlur"`: Validation runs when a field loses focus
|
|
14
|
+
* - `"onChange"`: Validation runs on every value change (sync)
|
|
15
|
+
*
|
|
16
|
+
* Object modes (with options):
|
|
17
|
+
* - `{ onChange: { debounce, autoSubmit? } }`: Debounced validation, optional auto-submit
|
|
18
|
+
* - `{ onBlur: { autoSubmit: true } }`: Validate on blur, auto-submit when valid
|
|
19
|
+
*
|
|
20
|
+
* @since 1.0.0
|
|
21
|
+
* @category Models
|
|
22
|
+
*/
|
|
23
|
+
export type FormMode =
|
|
24
|
+
| "onSubmit"
|
|
25
|
+
| "onBlur"
|
|
26
|
+
| "onChange"
|
|
27
|
+
| { readonly onChange: { readonly debounce: Duration.DurationInput; readonly autoSubmit?: false } }
|
|
28
|
+
| { readonly onBlur: { readonly autoSubmit: true } }
|
|
29
|
+
| { readonly onChange: { readonly debounce: Duration.DurationInput; readonly autoSubmit: true } }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parsed form mode with resolved values.
|
|
33
|
+
*
|
|
34
|
+
* @since 1.0.0
|
|
35
|
+
* @category Models
|
|
36
|
+
*/
|
|
37
|
+
export interface ParsedMode {
|
|
38
|
+
readonly validation: "onSubmit" | "onBlur" | "onChange"
|
|
39
|
+
readonly debounce: number | null
|
|
40
|
+
readonly autoSubmit: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parses a FormMode into a normalized ParsedMode.
|
|
45
|
+
*
|
|
46
|
+
* @since 1.0.0
|
|
47
|
+
* @category Parsing
|
|
48
|
+
*/
|
|
49
|
+
export const parse = (mode: FormMode = "onSubmit"): ParsedMode => {
|
|
50
|
+
if (typeof mode === "string") {
|
|
51
|
+
return { validation: mode, debounce: null, autoSubmit: false }
|
|
52
|
+
}
|
|
53
|
+
if ("onBlur" in mode) {
|
|
54
|
+
return { validation: "onBlur", debounce: null, autoSubmit: true }
|
|
55
|
+
}
|
|
56
|
+
const debounceMs = Duration.toMillis(mode.onChange.debounce)
|
|
57
|
+
const autoSubmit = mode.onChange.autoSubmit === true
|
|
58
|
+
return { validation: "onChange", debounce: debounceMs, autoSubmit }
|
|
59
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation utilities for form error handling.
|
|
3
|
+
*
|
|
4
|
+
* @since 1.0.0
|
|
5
|
+
*/
|
|
6
|
+
import * as Option from "effect/Option"
|
|
7
|
+
import * as ParseResult from "effect/ParseResult"
|
|
8
|
+
import { schemaPathToFieldPath } from "./internal/path.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extracts the first error message from a ParseError.
|
|
12
|
+
*
|
|
13
|
+
* @since 1.0.0
|
|
14
|
+
* @category Error Handling
|
|
15
|
+
*/
|
|
16
|
+
export const extractFirstError = (error: ParseResult.ParseError): Option.Option<string> => {
|
|
17
|
+
const issues = ParseResult.ArrayFormatter.formatErrorSync(error)
|
|
18
|
+
if (issues.length === 0) {
|
|
19
|
+
return Option.none()
|
|
20
|
+
}
|
|
21
|
+
return Option.some(issues[0].message)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Routes validation errors from a ParseError to a map of field paths to error messages.
|
|
26
|
+
* Used for cross-field validation where schema errors need to be displayed on specific fields.
|
|
27
|
+
*
|
|
28
|
+
* @since 1.0.0
|
|
29
|
+
* @category Error Handling
|
|
30
|
+
*/
|
|
31
|
+
export const routeErrors = (error: ParseResult.ParseError): Map<string, string> => {
|
|
32
|
+
const result = new Map<string, string>()
|
|
33
|
+
const issues = ParseResult.ArrayFormatter.formatErrorSync(error)
|
|
34
|
+
|
|
35
|
+
for (const issue of issues) {
|
|
36
|
+
const fieldPath = schemaPathToFieldPath(issue.path)
|
|
37
|
+
if (fieldPath && !result.has(fieldPath)) {
|
|
38
|
+
result.set(fieldPath, issue.message)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @since 1.0.0
|
|
3
|
+
*/
|
|
4
|
+
export * as Form from "./Form.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Atom infrastructure for form state management.
|
|
8
|
+
*
|
|
9
|
+
* This module provides the core atom infrastructure that framework adapters
|
|
10
|
+
* (React, Vue, Svelte, Solid) can use to build reactive form components.
|
|
11
|
+
*
|
|
12
|
+
* @since 1.0.0
|
|
13
|
+
*/
|
|
14
|
+
export * as FormAtoms from "./FormAtoms.js"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Form validation mode configuration.
|
|
18
|
+
*
|
|
19
|
+
* @since 1.0.0
|
|
20
|
+
*/
|
|
21
|
+
export * as Mode from "./Mode.js"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validation utilities for form error handling.
|
|
25
|
+
*
|
|
26
|
+
* @since 1.0.0
|
|
27
|
+
*/
|
|
28
|
+
export * as Validation from "./Validation.js"
|