@lucas-barake/effect-form-react 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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/cjs/FormReact.js +477 -0
- package/dist/cjs/FormReact.js.map +1 -0
- package/dist/cjs/index.js +6 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/use-debounced.js +67 -0
- package/dist/cjs/internal/use-debounced.js.map +1 -0
- package/dist/dts/FormReact.d.ts +177 -0
- package/dist/dts/FormReact.d.ts.map +1 -0
- package/dist/dts/index.d.ts +2 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/internal/use-debounced.d.ts +2 -0
- package/dist/dts/internal/use-debounced.d.ts.map +1 -0
- package/dist/esm/FormReact.js +449 -0
- package/dist/esm/FormReact.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/use-debounced.js +39 -0
- package/dist/esm/internal/use-debounced.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/package.json +33 -0
- package/src/FormReact.tsx +743 -0
- package/src/index.ts +1 -0
- package/src/internal/use-debounced.ts +49 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @since 1.0.0
|
|
3
|
+
*/
|
|
4
|
+
import { RegistryContext, useAtom, useAtomSet, useAtomSubscribe, useAtomValue } from "@effect-atom/atom-react"
|
|
5
|
+
import * as Atom from "@effect-atom/atom/Atom"
|
|
6
|
+
import type * as Result from "@effect-atom/atom/Result"
|
|
7
|
+
import { Form, FormAtoms, Mode, Validation } from "@lucas-barake/effect-form"
|
|
8
|
+
import { getNestedValue, isPathOrParentDirty, schemaPathToFieldPath } from "@lucas-barake/effect-form/internal/path"
|
|
9
|
+
import * as Cause from "effect/Cause"
|
|
10
|
+
import type * as Effect from "effect/Effect"
|
|
11
|
+
import * as Option from "effect/Option"
|
|
12
|
+
import * as ParseResult from "effect/ParseResult"
|
|
13
|
+
import type * as Schema from "effect/Schema"
|
|
14
|
+
import * as AST from "effect/SchemaAST"
|
|
15
|
+
import * as React from "react"
|
|
16
|
+
import { createContext, useContext } from "react"
|
|
17
|
+
import { useDebounced } from "./internal/use-debounced.js"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Props passed to field components.
|
|
21
|
+
*
|
|
22
|
+
* @since 1.0.0
|
|
23
|
+
* @category Models
|
|
24
|
+
*/
|
|
25
|
+
export interface FieldComponentProps<S extends Schema.Schema.Any> {
|
|
26
|
+
readonly value: Schema.Schema.Encoded<S>
|
|
27
|
+
readonly onChange: (value: Schema.Schema.Encoded<S>) => void
|
|
28
|
+
readonly onBlur: () => void
|
|
29
|
+
readonly error: Option.Option<string>
|
|
30
|
+
readonly isTouched: boolean
|
|
31
|
+
readonly isValidating: boolean
|
|
32
|
+
readonly isDirty: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extracts field component map for array item schemas.
|
|
37
|
+
* - For Struct schemas: returns a map of field names to components
|
|
38
|
+
* - For primitive schemas: returns a single component
|
|
39
|
+
*
|
|
40
|
+
* @since 1.0.0
|
|
41
|
+
* @category Models
|
|
42
|
+
*/
|
|
43
|
+
export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schema.Struct<infer Fields> ? {
|
|
44
|
+
readonly [K in keyof Fields]: Fields[K] extends Schema.Schema.Any ? React.FC<FieldComponentProps<Fields[K]>> : never
|
|
45
|
+
}
|
|
46
|
+
: React.FC<FieldComponentProps<S>>
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Maps field names to their React components.
|
|
50
|
+
*
|
|
51
|
+
* @since 1.0.0
|
|
52
|
+
* @category Models
|
|
53
|
+
*/
|
|
54
|
+
export type FieldComponentMap<TFields extends Form.FieldsRecord> = {
|
|
55
|
+
readonly [K in keyof TFields]: TFields[K] extends Form.FieldDef<any, infer S> ? React.FC<FieldComponentProps<S>>
|
|
56
|
+
: TFields[K] extends Form.ArrayFieldDef<any, infer S> ? ArrayItemComponentMap<S>
|
|
57
|
+
: never
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Maps field names to their type-safe Field references for setValue operations.
|
|
62
|
+
*
|
|
63
|
+
* @since 1.0.0
|
|
64
|
+
* @category Models
|
|
65
|
+
*/
|
|
66
|
+
export type FieldRefs<TFields extends Form.FieldsRecord> = FormAtoms.FieldRefs<TFields>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Operations available for array fields.
|
|
70
|
+
*
|
|
71
|
+
* @since 1.0.0
|
|
72
|
+
* @category Models
|
|
73
|
+
*/
|
|
74
|
+
export interface ArrayFieldOperations<TItem> {
|
|
75
|
+
readonly items: ReadonlyArray<TItem>
|
|
76
|
+
readonly append: (value?: TItem) => void
|
|
77
|
+
readonly remove: (index: number) => void
|
|
78
|
+
readonly swap: (indexA: number, indexB: number) => void
|
|
79
|
+
readonly move: (from: number, to: number) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* State exposed to form.Subscribe render prop.
|
|
84
|
+
*
|
|
85
|
+
* @since 1.0.0
|
|
86
|
+
* @category Models
|
|
87
|
+
*/
|
|
88
|
+
export interface SubscribeState<TFields extends Form.FieldsRecord> {
|
|
89
|
+
readonly values: Form.EncodedFromFields<TFields>
|
|
90
|
+
readonly isDirty: boolean
|
|
91
|
+
readonly submitResult: Result.Result<unknown, unknown>
|
|
92
|
+
readonly submit: () => void
|
|
93
|
+
readonly reset: () => void
|
|
94
|
+
readonly setValue: <S>(field: Form.Field<S>, update: S | ((prev: S) => S)) => void
|
|
95
|
+
readonly setValues: (values: Form.EncodedFromFields<TFields>) => void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The result of building a form, containing all components and utilities needed
|
|
100
|
+
* for form rendering and submission.
|
|
101
|
+
*
|
|
102
|
+
* @since 1.0.0
|
|
103
|
+
* @category Models
|
|
104
|
+
*/
|
|
105
|
+
export type BuiltForm<TFields extends Form.FieldsRecord, R> = {
|
|
106
|
+
readonly atom: Atom.Writable<Option.Option<Form.FormState<TFields>>, Option.Option<Form.FormState<TFields>>>
|
|
107
|
+
readonly schema: Schema.Schema<Form.DecodedFromFields<TFields>, Form.EncodedFromFields<TFields>, R>
|
|
108
|
+
readonly fields: FieldRefs<TFields>
|
|
109
|
+
|
|
110
|
+
readonly Form: React.FC<{
|
|
111
|
+
readonly defaultValues: Form.EncodedFromFields<TFields>
|
|
112
|
+
readonly onSubmit: Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown>
|
|
113
|
+
readonly children: React.ReactNode
|
|
114
|
+
}>
|
|
115
|
+
|
|
116
|
+
readonly Subscribe: React.FC<{
|
|
117
|
+
readonly children: (state: SubscribeState<TFields>) => React.ReactNode
|
|
118
|
+
}>
|
|
119
|
+
|
|
120
|
+
readonly useForm: () => {
|
|
121
|
+
readonly submit: () => void
|
|
122
|
+
readonly reset: () => void
|
|
123
|
+
readonly isDirty: boolean
|
|
124
|
+
readonly submitResult: Result.Result<unknown, unknown>
|
|
125
|
+
readonly values: Form.EncodedFromFields<TFields>
|
|
126
|
+
readonly setValue: <S>(field: Form.Field<S>, update: S | ((prev: S) => S)) => void
|
|
127
|
+
readonly setValues: (values: Form.EncodedFromFields<TFields>) => void
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
readonly submit: <A, E>(
|
|
131
|
+
fn: (values: Form.DecodedFromFields<TFields>, get: Atom.FnContext) => Effect.Effect<A, E, R>,
|
|
132
|
+
) => Atom.AtomResultFn<Form.DecodedFromFields<TFields>, A, E>
|
|
133
|
+
} & FieldComponents<TFields>
|
|
134
|
+
|
|
135
|
+
type FieldComponents<TFields extends Form.FieldsRecord> = {
|
|
136
|
+
readonly [K in keyof TFields]: TFields[K] extends Form.FieldDef<any, any> ? React.FC
|
|
137
|
+
: TFields[K] extends Form.ArrayFieldDef<any, infer S> ? ArrayFieldComponent<S>
|
|
138
|
+
: never
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type ArrayFieldComponent<S extends Schema.Schema.Any> =
|
|
142
|
+
& React.FC<{
|
|
143
|
+
readonly children: (ops: ArrayFieldOperations<Schema.Schema.Encoded<S>>) => React.ReactNode
|
|
144
|
+
}>
|
|
145
|
+
& {
|
|
146
|
+
readonly Item: React.FC<{
|
|
147
|
+
readonly index: number
|
|
148
|
+
readonly children: React.ReactNode | ((props: { readonly remove: () => void }) => React.ReactNode)
|
|
149
|
+
}>
|
|
150
|
+
}
|
|
151
|
+
& (S extends Schema.Struct<infer Fields> ? { readonly [K in keyof Fields]: React.FC }
|
|
152
|
+
: unknown)
|
|
153
|
+
|
|
154
|
+
interface ArrayItemContextValue {
|
|
155
|
+
readonly index: number
|
|
156
|
+
readonly parentPath: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ArrayItemContext = createContext<ArrayItemContextValue | null>(null)
|
|
160
|
+
const AutoSubmitContext = createContext<(() => void) | null>(null)
|
|
161
|
+
|
|
162
|
+
const makeFieldComponent = <S extends Schema.Schema.Any>(
|
|
163
|
+
fieldKey: string,
|
|
164
|
+
fieldDef: Form.FieldDef<string, S>,
|
|
165
|
+
crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
|
|
166
|
+
submitCountAtom: Atom.Atom<number>,
|
|
167
|
+
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
168
|
+
parsedMode: Mode.ParsedMode,
|
|
169
|
+
getOrCreateValidationAtom: (
|
|
170
|
+
fieldPath: string,
|
|
171
|
+
schema: Schema.Schema.Any,
|
|
172
|
+
) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
|
|
173
|
+
getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
|
|
174
|
+
Component: React.FC<FieldComponentProps<S>>,
|
|
175
|
+
): React.FC => {
|
|
176
|
+
const FieldComponent: React.FC = React.memo(() => {
|
|
177
|
+
const arrayCtx = useContext(ArrayItemContext)
|
|
178
|
+
const autoSubmitOnBlur = useContext(AutoSubmitContext)
|
|
179
|
+
const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
|
|
180
|
+
|
|
181
|
+
const { crossFieldErrorAtom, touchedAtom, valueAtom } = React.useMemo(
|
|
182
|
+
() => getOrCreateFieldAtoms(fieldPath),
|
|
183
|
+
[fieldPath],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const [value, setValue] = useAtom(valueAtom) as [Schema.Schema.Encoded<S>, (v: unknown) => void]
|
|
187
|
+
const [isTouched, setTouched] = useAtom(touchedAtom)
|
|
188
|
+
const crossFieldError = useAtomValue(crossFieldErrorAtom)
|
|
189
|
+
const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
|
|
190
|
+
const submitCount = useAtomValue(submitCountAtom)
|
|
191
|
+
|
|
192
|
+
const validationAtom = React.useMemo(
|
|
193
|
+
() => getOrCreateValidationAtom(fieldPath, fieldDef.schema),
|
|
194
|
+
[fieldPath],
|
|
195
|
+
)
|
|
196
|
+
const validationResult = useAtomValue(validationAtom)
|
|
197
|
+
const validateImmediate = useAtomSet(validationAtom)
|
|
198
|
+
|
|
199
|
+
const shouldDebounceValidation = parsedMode.validation === "onChange"
|
|
200
|
+
&& parsedMode.debounce !== null
|
|
201
|
+
&& !parsedMode.autoSubmit
|
|
202
|
+
const validate = useDebounced(validateImmediate, shouldDebounceValidation ? parsedMode.debounce : null)
|
|
203
|
+
|
|
204
|
+
const prevValueRef = React.useRef(value)
|
|
205
|
+
React.useEffect(() => {
|
|
206
|
+
if (prevValueRef.current === value) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
prevValueRef.current = value
|
|
210
|
+
|
|
211
|
+
const shouldValidate = parsedMode.validation === "onChange"
|
|
212
|
+
|| (parsedMode.validation === "onBlur" && isTouched)
|
|
213
|
+
|
|
214
|
+
if (shouldValidate) {
|
|
215
|
+
validate(value)
|
|
216
|
+
}
|
|
217
|
+
}, [value, isTouched, validate])
|
|
218
|
+
|
|
219
|
+
const perFieldError: Option.Option<string> = React.useMemo(() => {
|
|
220
|
+
if (validationResult._tag === "Failure") {
|
|
221
|
+
const parseError = Cause.failureOption(validationResult.cause)
|
|
222
|
+
if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
|
|
223
|
+
return Validation.extractFirstError(parseError.value)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return Option.none()
|
|
227
|
+
}, [validationResult])
|
|
228
|
+
|
|
229
|
+
const validationError = Option.isSome(perFieldError) ? perFieldError : crossFieldError
|
|
230
|
+
|
|
231
|
+
const onChange = React.useCallback(
|
|
232
|
+
(newValue: Schema.Schema.Encoded<S>) => {
|
|
233
|
+
setValue(newValue)
|
|
234
|
+
setCrossFieldErrors((prev) => {
|
|
235
|
+
if (prev.has(fieldPath)) {
|
|
236
|
+
const next = new Map(prev)
|
|
237
|
+
next.delete(fieldPath)
|
|
238
|
+
return next
|
|
239
|
+
}
|
|
240
|
+
return prev
|
|
241
|
+
})
|
|
242
|
+
if (parsedMode.validation === "onChange") {
|
|
243
|
+
validate(newValue)
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
[fieldPath, setValue, setCrossFieldErrors, validate],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const onBlur = React.useCallback(() => {
|
|
250
|
+
setTouched(true)
|
|
251
|
+
if (parsedMode.validation === "onBlur") {
|
|
252
|
+
validate(value)
|
|
253
|
+
}
|
|
254
|
+
autoSubmitOnBlur?.()
|
|
255
|
+
}, [setTouched, validate, value, autoSubmitOnBlur])
|
|
256
|
+
|
|
257
|
+
const dirtyFields = useAtomValue(dirtyFieldsAtom)
|
|
258
|
+
const isDirty = React.useMemo(
|
|
259
|
+
() => isPathOrParentDirty(dirtyFields, fieldPath),
|
|
260
|
+
[dirtyFields, fieldPath],
|
|
261
|
+
)
|
|
262
|
+
const isValidating = validationResult.waiting
|
|
263
|
+
const shouldShowError = isTouched || submitCount > 0
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<Component
|
|
267
|
+
value={value}
|
|
268
|
+
onChange={onChange}
|
|
269
|
+
onBlur={onBlur}
|
|
270
|
+
error={shouldShowError ? validationError : Option.none<string>()}
|
|
271
|
+
isTouched={isTouched}
|
|
272
|
+
isValidating={isValidating}
|
|
273
|
+
isDirty={isDirty}
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
return FieldComponent
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
282
|
+
fieldKey: string,
|
|
283
|
+
def: Form.ArrayFieldDef<string, S>,
|
|
284
|
+
stateAtom: Atom.Writable<Option.Option<Form.FormState<any>>, Option.Option<Form.FormState<any>>>,
|
|
285
|
+
crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
|
|
286
|
+
submitCountAtom: Atom.Atom<number>,
|
|
287
|
+
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
288
|
+
parsedMode: Mode.ParsedMode,
|
|
289
|
+
getOrCreateValidationAtom: (
|
|
290
|
+
fieldPath: string,
|
|
291
|
+
schema: Schema.Schema.Any,
|
|
292
|
+
) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
|
|
293
|
+
getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
|
|
294
|
+
operations: FormAtoms.FormOperations<any>,
|
|
295
|
+
componentMap: ArrayItemComponentMap<S>,
|
|
296
|
+
): ArrayFieldComponent<S> => {
|
|
297
|
+
const isStructSchema = AST.isTypeLiteral(def.itemSchema.ast)
|
|
298
|
+
|
|
299
|
+
const ArrayWrapper: React.FC<{
|
|
300
|
+
readonly children: (ops: ArrayFieldOperations<Schema.Schema.Encoded<S>>) => React.ReactNode
|
|
301
|
+
}> = ({ children }) => {
|
|
302
|
+
const arrayCtx = useContext(ArrayItemContext)
|
|
303
|
+
const [formStateOption, setFormState] = useAtom(stateAtom)
|
|
304
|
+
const formState = Option.getOrThrow(formStateOption)
|
|
305
|
+
|
|
306
|
+
const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
|
|
307
|
+
const items = React.useMemo(
|
|
308
|
+
() => (getNestedValue(formState.values, fieldPath) ?? []) as ReadonlyArray<Schema.Schema.Encoded<S>>,
|
|
309
|
+
[formState.values, fieldPath],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
const append = React.useCallback(
|
|
313
|
+
(value?: Schema.Schema.Encoded<S>) => {
|
|
314
|
+
setFormState((prev) => {
|
|
315
|
+
if (Option.isNone(prev)) return prev
|
|
316
|
+
return Option.some(operations.appendArrayItem(prev.value, fieldPath, def.itemSchema, value))
|
|
317
|
+
})
|
|
318
|
+
},
|
|
319
|
+
[fieldPath, setFormState],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
const remove = React.useCallback(
|
|
323
|
+
(index: number) => {
|
|
324
|
+
setFormState((prev) => {
|
|
325
|
+
if (Option.isNone(prev)) return prev
|
|
326
|
+
return Option.some(operations.removeArrayItem(prev.value, fieldPath, index))
|
|
327
|
+
})
|
|
328
|
+
},
|
|
329
|
+
[fieldPath, setFormState],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
const swap = React.useCallback(
|
|
333
|
+
(indexA: number, indexB: number) => {
|
|
334
|
+
setFormState((prev) => {
|
|
335
|
+
if (Option.isNone(prev)) return prev
|
|
336
|
+
return Option.some(operations.swapArrayItems(prev.value, fieldPath, indexA, indexB))
|
|
337
|
+
})
|
|
338
|
+
},
|
|
339
|
+
[fieldPath, setFormState],
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const move = React.useCallback(
|
|
343
|
+
(from: number, to: number) => {
|
|
344
|
+
setFormState((prev) => {
|
|
345
|
+
if (Option.isNone(prev)) return prev
|
|
346
|
+
return Option.some(operations.moveArrayItem(prev.value, fieldPath, from, to))
|
|
347
|
+
})
|
|
348
|
+
},
|
|
349
|
+
[fieldPath, setFormState],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return <>{children({ items, append, remove, swap, move })}</>
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const ItemWrapper: React.FC<{
|
|
356
|
+
readonly index: number
|
|
357
|
+
readonly children: React.ReactNode | ((props: { readonly remove: () => void }) => React.ReactNode)
|
|
358
|
+
}> = ({ children, index }) => {
|
|
359
|
+
const arrayCtx = useContext(ArrayItemContext)
|
|
360
|
+
const setFormState = useAtomSet(stateAtom)
|
|
361
|
+
|
|
362
|
+
const parentPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
|
|
363
|
+
const itemPath = `${parentPath}[${index}]`
|
|
364
|
+
|
|
365
|
+
const remove = React.useCallback(() => {
|
|
366
|
+
setFormState((prev) => {
|
|
367
|
+
if (Option.isNone(prev)) return prev
|
|
368
|
+
return Option.some(operations.removeArrayItem(prev.value, parentPath, index))
|
|
369
|
+
})
|
|
370
|
+
}, [parentPath, index, setFormState])
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
<ArrayItemContext.Provider value={{ index, parentPath: itemPath }}>
|
|
374
|
+
{typeof children === "function" ? children({ remove }) : children}
|
|
375
|
+
</ArrayItemContext.Provider>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const itemFieldComponents: Record<string, React.FC> = {}
|
|
380
|
+
|
|
381
|
+
if (isStructSchema) {
|
|
382
|
+
const ast = def.itemSchema.ast as AST.TypeLiteral
|
|
383
|
+
for (const prop of ast.propertySignatures) {
|
|
384
|
+
const itemKey = prop.name as string
|
|
385
|
+
const itemSchema = { ast: prop.type } as Schema.Schema.Any
|
|
386
|
+
const itemDef = Form.makeField(itemKey, itemSchema)
|
|
387
|
+
const itemComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[itemKey]
|
|
388
|
+
itemFieldComponents[itemKey] = makeFieldComponent(
|
|
389
|
+
itemKey,
|
|
390
|
+
itemDef,
|
|
391
|
+
crossFieldErrorsAtom,
|
|
392
|
+
submitCountAtom,
|
|
393
|
+
dirtyFieldsAtom,
|
|
394
|
+
parsedMode,
|
|
395
|
+
getOrCreateValidationAtom,
|
|
396
|
+
getOrCreateFieldAtoms,
|
|
397
|
+
itemComponent,
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const properties: Record<string, unknown> = {
|
|
403
|
+
Item: ItemWrapper,
|
|
404
|
+
...itemFieldComponents,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return new Proxy(ArrayWrapper, {
|
|
408
|
+
get(target, prop) {
|
|
409
|
+
if (prop in properties) {
|
|
410
|
+
return properties[prop as string]
|
|
411
|
+
}
|
|
412
|
+
return Reflect.get(target, prop)
|
|
413
|
+
},
|
|
414
|
+
}) as ArrayFieldComponent<S>
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const makeFieldComponents = <TFields extends Form.FieldsRecord>(
|
|
418
|
+
fields: TFields,
|
|
419
|
+
stateAtom: Atom.Writable<Option.Option<Form.FormState<TFields>>, Option.Option<Form.FormState<TFields>>>,
|
|
420
|
+
crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
|
|
421
|
+
submitCountAtom: Atom.Atom<number>,
|
|
422
|
+
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
423
|
+
parsedMode: Mode.ParsedMode,
|
|
424
|
+
getOrCreateValidationAtom: (
|
|
425
|
+
fieldPath: string,
|
|
426
|
+
schema: Schema.Schema.Any,
|
|
427
|
+
) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
|
|
428
|
+
getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
|
|
429
|
+
operations: FormAtoms.FormOperations<TFields>,
|
|
430
|
+
componentMap: FieldComponentMap<TFields>,
|
|
431
|
+
): FieldComponents<TFields> => {
|
|
432
|
+
const components: Record<string, any> = {}
|
|
433
|
+
|
|
434
|
+
for (const [key, def] of Object.entries(fields)) {
|
|
435
|
+
if (Form.isArrayFieldDef(def)) {
|
|
436
|
+
const arrayComponentMap = (componentMap as Record<string, any>)[key]
|
|
437
|
+
components[key] = makeArrayFieldComponent(
|
|
438
|
+
key,
|
|
439
|
+
def as Form.ArrayFieldDef<string, Schema.Schema.Any>,
|
|
440
|
+
stateAtom,
|
|
441
|
+
crossFieldErrorsAtom,
|
|
442
|
+
submitCountAtom,
|
|
443
|
+
dirtyFieldsAtom,
|
|
444
|
+
parsedMode,
|
|
445
|
+
getOrCreateValidationAtom,
|
|
446
|
+
getOrCreateFieldAtoms,
|
|
447
|
+
operations,
|
|
448
|
+
arrayComponentMap,
|
|
449
|
+
)
|
|
450
|
+
} else if (Form.isFieldDef(def)) {
|
|
451
|
+
const fieldComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[key]
|
|
452
|
+
components[key] = makeFieldComponent(
|
|
453
|
+
key,
|
|
454
|
+
def,
|
|
455
|
+
crossFieldErrorsAtom,
|
|
456
|
+
submitCountAtom,
|
|
457
|
+
dirtyFieldsAtom,
|
|
458
|
+
parsedMode,
|
|
459
|
+
getOrCreateValidationAtom,
|
|
460
|
+
getOrCreateFieldAtoms,
|
|
461
|
+
fieldComponent,
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return components as FieldComponents<TFields>
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Builds a React form from a FormBuilder.
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* ```tsx
|
|
474
|
+
* import { Form } from "@lucas-barake/effect-form"
|
|
475
|
+
* import { FormReact } from "@lucas-barake/effect-form-react"
|
|
476
|
+
* import * as Atom from "@effect-atom/atom/Atom"
|
|
477
|
+
* import * as Schema from "effect/Schema"
|
|
478
|
+
* import * as Effect from "effect/Effect"
|
|
479
|
+
* import * as Layer from "effect/Layer"
|
|
480
|
+
*
|
|
481
|
+
* const runtime = Atom.runtime(Layer.empty)
|
|
482
|
+
*
|
|
483
|
+
* const loginForm = Form.empty
|
|
484
|
+
* .addField("email", Schema.String)
|
|
485
|
+
* .addField("password", Schema.String)
|
|
486
|
+
*
|
|
487
|
+
* const form = FormReact.build(loginForm, {
|
|
488
|
+
* runtime,
|
|
489
|
+
* fields: { email: TextInput, password: PasswordInput },
|
|
490
|
+
* })
|
|
491
|
+
*
|
|
492
|
+
* function LoginDialog({ onClose }) {
|
|
493
|
+
* const handleSubmit = form.submit((values) =>
|
|
494
|
+
* Effect.gen(function* () {
|
|
495
|
+
* yield* saveUser(values)
|
|
496
|
+
* onClose()
|
|
497
|
+
* })
|
|
498
|
+
* )
|
|
499
|
+
*
|
|
500
|
+
* return (
|
|
501
|
+
* <form.Form defaultValues={{ email: "", password: "" }} onSubmit={handleSubmit}>
|
|
502
|
+
* <form.email />
|
|
503
|
+
* <form.password />
|
|
504
|
+
* <form.Subscribe>
|
|
505
|
+
* {({ isDirty, submit }) => (
|
|
506
|
+
* <button onClick={submit} disabled={!isDirty}>Login</button>
|
|
507
|
+
* )}
|
|
508
|
+
* </form.Subscribe>
|
|
509
|
+
* </form.Form>
|
|
510
|
+
* )
|
|
511
|
+
* }
|
|
512
|
+
* ```
|
|
513
|
+
*
|
|
514
|
+
* @since 1.0.0
|
|
515
|
+
* @category Constructors
|
|
516
|
+
*/
|
|
517
|
+
export const build = <TFields extends Form.FieldsRecord, R, ER = never>(
|
|
518
|
+
self: Form.FormBuilder<TFields, R>,
|
|
519
|
+
options: {
|
|
520
|
+
readonly runtime: Atom.AtomRuntime<R, ER>
|
|
521
|
+
readonly fields: FieldComponentMap<TFields>
|
|
522
|
+
readonly mode?: Mode.FormMode
|
|
523
|
+
},
|
|
524
|
+
): BuiltForm<TFields, R> => {
|
|
525
|
+
const { fields: components, mode, runtime } = options
|
|
526
|
+
const parsedMode = Mode.parse(mode)
|
|
527
|
+
const { fields } = self
|
|
528
|
+
|
|
529
|
+
const formAtoms: FormAtoms.FormAtoms<TFields, R> = FormAtoms.make({
|
|
530
|
+
formBuilder: self,
|
|
531
|
+
runtime,
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const {
|
|
535
|
+
combinedSchema,
|
|
536
|
+
crossFieldErrorsAtom,
|
|
537
|
+
decodeAndSubmit,
|
|
538
|
+
dirtyFieldsAtom,
|
|
539
|
+
fieldRefs,
|
|
540
|
+
getOrCreateFieldAtoms,
|
|
541
|
+
getOrCreateValidationAtom,
|
|
542
|
+
isDirtyAtom,
|
|
543
|
+
onSubmitAtom,
|
|
544
|
+
operations,
|
|
545
|
+
resetValidationAtoms,
|
|
546
|
+
stateAtom,
|
|
547
|
+
submitCountAtom,
|
|
548
|
+
} = formAtoms
|
|
549
|
+
|
|
550
|
+
const FormComponent: React.FC<{
|
|
551
|
+
readonly defaultValues: Form.EncodedFromFields<TFields>
|
|
552
|
+
readonly onSubmit: Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown>
|
|
553
|
+
readonly children: React.ReactNode
|
|
554
|
+
}> = ({ children, defaultValues, onSubmit }) => {
|
|
555
|
+
const registry = React.useContext(RegistryContext)
|
|
556
|
+
const state = useAtomValue(stateAtom)
|
|
557
|
+
const setFormState = useAtomSet(stateAtom)
|
|
558
|
+
const setOnSubmit = useAtomSet(onSubmitAtom)
|
|
559
|
+
const callDecodeAndSubmit = useAtomSet(decodeAndSubmit)
|
|
560
|
+
|
|
561
|
+
React.useEffect(() => {
|
|
562
|
+
setOnSubmit(onSubmit)
|
|
563
|
+
}, [onSubmit, setOnSubmit])
|
|
564
|
+
|
|
565
|
+
React.useEffect(() => {
|
|
566
|
+
setFormState(Option.some(operations.createInitialState(defaultValues)))
|
|
567
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
|
|
568
|
+
}, [])
|
|
569
|
+
|
|
570
|
+
const debouncedAutoSubmit = useDebounced(() => {
|
|
571
|
+
const stateOption = registry.get(stateAtom)
|
|
572
|
+
if (Option.isNone(stateOption)) return
|
|
573
|
+
callDecodeAndSubmit(stateOption.value.values)
|
|
574
|
+
}, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
|
|
575
|
+
|
|
576
|
+
useAtomSubscribe(
|
|
577
|
+
stateAtom,
|
|
578
|
+
React.useCallback(() => {
|
|
579
|
+
if (parsedMode.autoSubmit && parsedMode.validation === "onChange") {
|
|
580
|
+
debouncedAutoSubmit()
|
|
581
|
+
}
|
|
582
|
+
}, [debouncedAutoSubmit]),
|
|
583
|
+
{ immediate: false },
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
const onBlurAutoSubmit = React.useCallback(() => {
|
|
587
|
+
if (parsedMode.autoSubmit && parsedMode.validation === "onBlur") {
|
|
588
|
+
const stateOption = registry.get(stateAtom)
|
|
589
|
+
if (Option.isNone(stateOption)) return
|
|
590
|
+
callDecodeAndSubmit(stateOption.value.values)
|
|
591
|
+
}
|
|
592
|
+
}, [registry, callDecodeAndSubmit])
|
|
593
|
+
|
|
594
|
+
if (Option.isNone(state)) return null
|
|
595
|
+
|
|
596
|
+
return (
|
|
597
|
+
<AutoSubmitContext.Provider value={onBlurAutoSubmit}>
|
|
598
|
+
<form
|
|
599
|
+
onSubmit={(e) => {
|
|
600
|
+
e.preventDefault()
|
|
601
|
+
e.stopPropagation()
|
|
602
|
+
}}
|
|
603
|
+
>
|
|
604
|
+
{children}
|
|
605
|
+
</form>
|
|
606
|
+
</AutoSubmitContext.Provider>
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const useFormHook = () => {
|
|
611
|
+
const registry = React.useContext(RegistryContext)
|
|
612
|
+
const formValues = Option.getOrThrow(useAtomValue(stateAtom)).values
|
|
613
|
+
const setFormState = useAtomSet(stateAtom)
|
|
614
|
+
const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
|
|
615
|
+
const [decodeAndSubmitResult, callDecodeAndSubmit] = useAtom(decodeAndSubmit)
|
|
616
|
+
const isDirty = useAtomValue(isDirtyAtom)
|
|
617
|
+
|
|
618
|
+
React.useEffect(() => {
|
|
619
|
+
if (decodeAndSubmitResult._tag === "Failure") {
|
|
620
|
+
const parseError = Cause.failureOption(decodeAndSubmitResult.cause)
|
|
621
|
+
if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
|
|
622
|
+
const issues = ParseResult.ArrayFormatter.formatErrorSync(parseError.value)
|
|
623
|
+
|
|
624
|
+
const fieldErrors = new Map<string, string>()
|
|
625
|
+
for (const issue of issues) {
|
|
626
|
+
if (issue.path.length > 0) {
|
|
627
|
+
const fieldPath = schemaPathToFieldPath(issue.path)
|
|
628
|
+
if (!fieldErrors.has(fieldPath)) {
|
|
629
|
+
fieldErrors.set(fieldPath, issue.message)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (fieldErrors.size > 0) {
|
|
635
|
+
setCrossFieldErrors(fieldErrors)
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}, [decodeAndSubmitResult, setCrossFieldErrors])
|
|
640
|
+
|
|
641
|
+
const submit = React.useCallback(() => {
|
|
642
|
+
const stateOption = registry.get(stateAtom)
|
|
643
|
+
if (Option.isNone(stateOption)) return
|
|
644
|
+
|
|
645
|
+
setCrossFieldErrors(new Map())
|
|
646
|
+
|
|
647
|
+
setFormState((prev) => {
|
|
648
|
+
if (Option.isNone(prev)) return prev
|
|
649
|
+
return Option.some(operations.createSubmitState(prev.value))
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
callDecodeAndSubmit(stateOption.value.values)
|
|
653
|
+
}, [setFormState, callDecodeAndSubmit, setCrossFieldErrors, registry])
|
|
654
|
+
|
|
655
|
+
const reset = React.useCallback(() => {
|
|
656
|
+
setFormState((prev) => {
|
|
657
|
+
if (Option.isNone(prev)) return prev
|
|
658
|
+
return Option.some(operations.createResetState(prev.value))
|
|
659
|
+
})
|
|
660
|
+
setCrossFieldErrors(new Map())
|
|
661
|
+
resetValidationAtoms(registry)
|
|
662
|
+
callDecodeAndSubmit(Atom.Reset)
|
|
663
|
+
}, [setFormState, setCrossFieldErrors, callDecodeAndSubmit, registry])
|
|
664
|
+
|
|
665
|
+
const setValue = React.useCallback(<S,>(
|
|
666
|
+
field: Form.Field<S>,
|
|
667
|
+
update: S | ((prev: S) => S),
|
|
668
|
+
) => {
|
|
669
|
+
const path = field.key
|
|
670
|
+
|
|
671
|
+
setFormState((prev) => {
|
|
672
|
+
if (Option.isNone(prev)) return prev
|
|
673
|
+
const state = prev.value
|
|
674
|
+
|
|
675
|
+
const currentValue = getNestedValue(state.values, path) as S
|
|
676
|
+
const newValue = typeof update === "function"
|
|
677
|
+
? (update as (prev: S) => S)(currentValue)
|
|
678
|
+
: update
|
|
679
|
+
|
|
680
|
+
return Option.some(operations.setFieldValue(state, path, newValue))
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
setCrossFieldErrors((prev) => {
|
|
684
|
+
let changed = false
|
|
685
|
+
const next = new Map(prev)
|
|
686
|
+
for (const errorPath of prev.keys()) {
|
|
687
|
+
if (errorPath === path || errorPath.startsWith(path + ".") || errorPath.startsWith(path + "[")) {
|
|
688
|
+
next.delete(errorPath)
|
|
689
|
+
changed = true
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return changed ? next : prev
|
|
693
|
+
})
|
|
694
|
+
}, [setFormState, setCrossFieldErrors])
|
|
695
|
+
|
|
696
|
+
const setValues = React.useCallback((values: Form.EncodedFromFields<TFields>) => {
|
|
697
|
+
setFormState((prev) => {
|
|
698
|
+
if (Option.isNone(prev)) return prev
|
|
699
|
+
return Option.some(operations.setFormValues(prev.value, values))
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
setCrossFieldErrors(new Map())
|
|
703
|
+
}, [setFormState, setCrossFieldErrors])
|
|
704
|
+
|
|
705
|
+
return { submit, reset, isDirty, submitResult: decodeAndSubmitResult, values: formValues, setValue, setValues }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const SubscribeComponent: React.FC<{
|
|
709
|
+
readonly children: (state: SubscribeState<TFields>) => React.ReactNode
|
|
710
|
+
}> = ({ children }) => {
|
|
711
|
+
const { isDirty, reset, setValue, setValues, submit, submitResult, values } = useFormHook()
|
|
712
|
+
|
|
713
|
+
return <>{children({ values, isDirty, submitResult, submit, reset, setValue, setValues })}</>
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const submitHelper = <A, E>(
|
|
717
|
+
fn: (values: Form.DecodedFromFields<TFields>, get: Atom.FnContext) => Effect.Effect<A, E, R>,
|
|
718
|
+
) => runtime.fn<Form.DecodedFromFields<TFields>>()(fn) as Atom.AtomResultFn<Form.DecodedFromFields<TFields>, A, E>
|
|
719
|
+
|
|
720
|
+
const fieldComponents = makeFieldComponents(
|
|
721
|
+
fields,
|
|
722
|
+
stateAtom,
|
|
723
|
+
crossFieldErrorsAtom,
|
|
724
|
+
submitCountAtom,
|
|
725
|
+
dirtyFieldsAtom,
|
|
726
|
+
parsedMode,
|
|
727
|
+
getOrCreateValidationAtom,
|
|
728
|
+
getOrCreateFieldAtoms,
|
|
729
|
+
operations,
|
|
730
|
+
components,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
atom: stateAtom,
|
|
735
|
+
schema: combinedSchema,
|
|
736
|
+
fields: fieldRefs,
|
|
737
|
+
Form: FormComponent,
|
|
738
|
+
Subscribe: SubscribeComponent,
|
|
739
|
+
useForm: useFormHook,
|
|
740
|
+
submit: submitHelper,
|
|
741
|
+
...fieldComponents,
|
|
742
|
+
} as BuiltForm<TFields, R>
|
|
743
|
+
}
|