@lucas-barake/effect-form-react 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/dist/cjs/FormReact.js +95 -183
- package/dist/cjs/FormReact.js.map +1 -1
- package/dist/dts/FormReact.d.ts +93 -69
- package/dist/dts/FormReact.d.ts.map +1 -1
- package/dist/esm/FormReact.js +94 -183
- package/dist/esm/FormReact.js.map +1 -1
- package/package.json +2 -2
- package/src/FormReact.tsx +194 -306
package/src/FormReact.tsx
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
* @since 1.0.0
|
|
3
3
|
*/
|
|
4
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"
|
|
5
|
+
import type * as Atom from "@effect-atom/atom/Atom"
|
|
7
6
|
import { Field, FormAtoms, Mode, Validation } from "@lucas-barake/effect-form"
|
|
8
7
|
import type * as FormBuilder from "@lucas-barake/effect-form/FormBuilder"
|
|
9
|
-
import { getNestedValue, isPathOrParentDirty,
|
|
8
|
+
import { getNestedValue, isPathOrParentDirty, isPathUnderRoot } from "@lucas-barake/effect-form/internal/path"
|
|
10
9
|
import * as Cause from "effect/Cause"
|
|
11
|
-
import * as Effect from "effect/Effect"
|
|
10
|
+
import type * as Effect from "effect/Effect"
|
|
12
11
|
import * as Option from "effect/Option"
|
|
13
12
|
import * as ParseResult from "effect/ParseResult"
|
|
14
13
|
import type * as Schema from "effect/Schema"
|
|
@@ -18,12 +17,12 @@ import { createContext, useContext } from "react"
|
|
|
18
17
|
import { useDebounced } from "./internal/use-debounced.js"
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
|
-
*
|
|
20
|
+
* Form-controlled state passed to field components.
|
|
22
21
|
*
|
|
23
22
|
* @since 1.0.0
|
|
24
23
|
* @category Models
|
|
25
24
|
*/
|
|
26
|
-
export interface
|
|
25
|
+
export interface FieldState<S extends Schema.Schema.Any> {
|
|
27
26
|
readonly value: Schema.Schema.Encoded<S>
|
|
28
27
|
readonly onChange: (value: Schema.Schema.Encoded<S>) => void
|
|
29
28
|
readonly onBlur: () => void
|
|
@@ -33,6 +32,30 @@ export interface FieldComponentProps<S extends Schema.Schema.Any> {
|
|
|
33
32
|
readonly isDirty: boolean
|
|
34
33
|
}
|
|
35
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Props passed to field components.
|
|
37
|
+
* Contains form-controlled state in `field` and user-defined props in `props`.
|
|
38
|
+
*
|
|
39
|
+
* @since 1.0.0
|
|
40
|
+
* @category Models
|
|
41
|
+
*/
|
|
42
|
+
export interface FieldComponentProps<
|
|
43
|
+
S extends Schema.Schema.Any,
|
|
44
|
+
P extends Record<string, unknown> = Record<string, never>,
|
|
45
|
+
> {
|
|
46
|
+
readonly field: FieldState<S>
|
|
47
|
+
readonly props: P
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extracts the extra props type from a field component.
|
|
52
|
+
*
|
|
53
|
+
* @since 1.0.0
|
|
54
|
+
* @category Type-level utilities
|
|
55
|
+
*/
|
|
56
|
+
export type ExtractExtraProps<C> = C extends React.FC<FieldComponentProps<any, infer P>> ? P
|
|
57
|
+
: Record<string, never>
|
|
58
|
+
|
|
36
59
|
/**
|
|
37
60
|
* Extracts field component map for array item schemas.
|
|
38
61
|
* - For Struct schemas: returns a map of field names to components
|
|
@@ -42,9 +65,10 @@ export interface FieldComponentProps<S extends Schema.Schema.Any> {
|
|
|
42
65
|
* @category Models
|
|
43
66
|
*/
|
|
44
67
|
export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schema.Struct<infer Fields> ? {
|
|
45
|
-
readonly [K in keyof Fields]: Fields[K] extends Schema.Schema.Any ? React.FC<FieldComponentProps<Fields[K]>>
|
|
68
|
+
readonly [K in keyof Fields]: Fields[K] extends Schema.Schema.Any ? React.FC<FieldComponentProps<Fields[K], any>>
|
|
69
|
+
: never
|
|
46
70
|
}
|
|
47
|
-
: React.FC<FieldComponentProps<S>>
|
|
71
|
+
: React.FC<FieldComponentProps<S, any>>
|
|
48
72
|
|
|
49
73
|
/**
|
|
50
74
|
* Maps field names to their React components.
|
|
@@ -53,7 +77,7 @@ export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schem
|
|
|
53
77
|
* @category Models
|
|
54
78
|
*/
|
|
55
79
|
export type FieldComponentMap<TFields extends Field.FieldsRecord> = {
|
|
56
|
-
readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S> ? React.FC<FieldComponentProps<S>>
|
|
80
|
+
readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S> ? React.FC<FieldComponentProps<S, any>>
|
|
57
81
|
: TFields[K] extends Field.ArrayFieldDef<any, infer S> ? ArrayItemComponentMap<S>
|
|
58
82
|
: never
|
|
59
83
|
}
|
|
@@ -80,25 +104,6 @@ export interface ArrayFieldOperations<TItem> {
|
|
|
80
104
|
readonly move: (from: number, to: number) => void
|
|
81
105
|
}
|
|
82
106
|
|
|
83
|
-
/**
|
|
84
|
-
* State exposed to form.Subscribe render prop.
|
|
85
|
-
*
|
|
86
|
-
* @since 1.0.0
|
|
87
|
-
* @category Models
|
|
88
|
-
*/
|
|
89
|
-
export interface SubscribeState<TFields extends Field.FieldsRecord> {
|
|
90
|
-
readonly values: Field.EncodedFromFields<TFields>
|
|
91
|
-
readonly isDirty: boolean
|
|
92
|
-
readonly hasChangedSinceSubmit: boolean
|
|
93
|
-
readonly lastSubmittedValues: Option.Option<Field.EncodedFromFields<TFields>>
|
|
94
|
-
readonly submitResult: Result.Result<unknown, unknown>
|
|
95
|
-
readonly submit: () => void
|
|
96
|
-
readonly reset: () => void
|
|
97
|
-
readonly revertToLastSubmit: () => void
|
|
98
|
-
readonly setValue: <S>(field: FormBuilder.FieldRef<S>, update: S | ((prev: S) => S)) => void
|
|
99
|
-
readonly setValues: (values: Field.EncodedFromFields<TFields>) => void
|
|
100
|
-
}
|
|
101
|
-
|
|
102
107
|
/**
|
|
103
108
|
* The result of building a form, containing all components and utilities needed
|
|
104
109
|
* for form rendering and submission.
|
|
@@ -106,53 +111,47 @@ export interface SubscribeState<TFields extends Field.FieldsRecord> {
|
|
|
106
111
|
* @since 1.0.0
|
|
107
112
|
* @category Models
|
|
108
113
|
*/
|
|
109
|
-
export type BuiltForm<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
export type BuiltForm<
|
|
115
|
+
TFields extends Field.FieldsRecord,
|
|
116
|
+
R,
|
|
117
|
+
A = void,
|
|
118
|
+
E = never,
|
|
119
|
+
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
120
|
+
> = {
|
|
121
|
+
// Atoms for fine-grained subscriptions (use with useAtomValue)
|
|
122
|
+
readonly isDirty: Atom.Atom<boolean>
|
|
123
|
+
readonly hasChangedSinceSubmit: Atom.Atom<boolean>
|
|
124
|
+
readonly lastSubmittedValues: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
|
|
125
|
+
readonly submitCount: Atom.Atom<number>
|
|
126
|
+
|
|
114
127
|
readonly schema: Schema.Schema<Field.DecodedFromFields<TFields>, Field.EncodedFromFields<TFields>, R>
|
|
115
128
|
readonly fields: FieldRefs<TFields>
|
|
116
129
|
|
|
117
|
-
readonly
|
|
130
|
+
readonly Initialize: React.FC<{
|
|
118
131
|
readonly defaultValues: Field.EncodedFromFields<TFields>
|
|
119
|
-
readonly onSubmit: Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown>
|
|
120
132
|
readonly children: React.ReactNode
|
|
121
133
|
}>
|
|
122
134
|
|
|
123
|
-
readonly
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
readonly
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
readonly submitResult: Result.Result<unknown, unknown>
|
|
135
|
-
readonly values: Field.EncodedFromFields<TFields>
|
|
136
|
-
readonly setValue: <S>(field: FormBuilder.FieldRef<S>, update: S | ((prev: S) => S)) => void
|
|
137
|
-
readonly setValues: (values: Field.EncodedFromFields<TFields>) => void
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
readonly submit: <A>(
|
|
141
|
-
fn: (values: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A,
|
|
142
|
-
) => Atom.AtomResultFn<
|
|
143
|
-
Field.DecodedFromFields<TFields>,
|
|
144
|
-
A extends Effect.Effect<infer T, any, any> ? T : A,
|
|
145
|
-
A extends Effect.Effect<any, infer E, any> ? E : never
|
|
146
|
-
>
|
|
147
|
-
} & FieldComponents<TFields>
|
|
148
|
-
|
|
149
|
-
type FieldComponents<TFields extends Field.FieldsRecord> = {
|
|
150
|
-
readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, any> ? React.FC
|
|
151
|
-
: TFields[K] extends Field.ArrayFieldDef<any, infer S> ? ArrayFieldComponent<S>
|
|
135
|
+
readonly submit: Atom.AtomResultFn<void, A, E | ParseResult.ParseError>
|
|
136
|
+
readonly reset: Atom.Writable<void, void>
|
|
137
|
+
readonly revertToLastSubmit: Atom.Writable<void, void>
|
|
138
|
+
readonly setValues: Atom.Writable<void, Field.EncodedFromFields<TFields>>
|
|
139
|
+
readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>
|
|
140
|
+
} & FieldComponents<TFields, CM>
|
|
141
|
+
|
|
142
|
+
type FieldComponents<TFields extends Field.FieldsRecord, CM extends FieldComponentMap<TFields>> = {
|
|
143
|
+
readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, any> ? React.FC<ExtractExtraProps<CM[K]>>
|
|
144
|
+
: TFields[K] extends Field.ArrayFieldDef<any, infer S>
|
|
145
|
+
? ArrayFieldComponent<S, ExtractArrayItemExtraProps<CM[K], S>>
|
|
152
146
|
: never
|
|
153
147
|
}
|
|
154
148
|
|
|
155
|
-
type
|
|
149
|
+
type ExtractArrayItemExtraProps<CM, S extends Schema.Schema.Any> = S extends Schema.Struct<infer Fields>
|
|
150
|
+
? { readonly [K in keyof Fields]: CM extends { readonly [P in K]: infer C } ? ExtractExtraProps<C> : never }
|
|
151
|
+
: CM extends React.FC<FieldComponentProps<any, infer P>> ? P
|
|
152
|
+
: never
|
|
153
|
+
|
|
154
|
+
type ArrayFieldComponent<S extends Schema.Schema.Any, ExtraPropsMap> =
|
|
156
155
|
& React.FC<{
|
|
157
156
|
readonly children: (ops: ArrayFieldOperations<Schema.Schema.Encoded<S>>) => React.ReactNode
|
|
158
157
|
}>
|
|
@@ -162,7 +161,11 @@ type ArrayFieldComponent<S extends Schema.Schema.Any> =
|
|
|
162
161
|
readonly children: React.ReactNode | ((props: { readonly remove: () => void }) => React.ReactNode)
|
|
163
162
|
}>
|
|
164
163
|
}
|
|
165
|
-
& (S extends Schema.Struct<infer Fields> ? {
|
|
164
|
+
& (S extends Schema.Struct<infer Fields> ? {
|
|
165
|
+
readonly [K in keyof Fields]: React.FC<
|
|
166
|
+
ExtraPropsMap extends { readonly [P in K]: infer EP } ? EP : Record<string, never>
|
|
167
|
+
>
|
|
168
|
+
}
|
|
166
169
|
: unknown)
|
|
167
170
|
|
|
168
171
|
interface ArrayItemContextValue {
|
|
@@ -173,7 +176,7 @@ interface ArrayItemContextValue {
|
|
|
173
176
|
const ArrayItemContext = createContext<ArrayItemContextValue | null>(null)
|
|
174
177
|
const AutoSubmitContext = createContext<(() => void) | null>(null)
|
|
175
178
|
|
|
176
|
-
const makeFieldComponent = <S extends Schema.Schema.Any
|
|
179
|
+
const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string, unknown>>(
|
|
177
180
|
fieldKey: string,
|
|
178
181
|
fieldDef: Field.FieldDef<string, S>,
|
|
179
182
|
crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
|
|
@@ -185,9 +188,9 @@ const makeFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
185
188
|
schema: Schema.Schema.Any,
|
|
186
189
|
) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
|
|
187
190
|
getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
|
|
188
|
-
Component: React.FC<FieldComponentProps<S>>,
|
|
189
|
-
): React.FC => {
|
|
190
|
-
const FieldComponent: React.FC =
|
|
191
|
+
Component: React.FC<FieldComponentProps<S, P>>,
|
|
192
|
+
): React.FC<P> => {
|
|
193
|
+
const FieldComponent: React.FC<P> = (extraProps) => {
|
|
191
194
|
const arrayCtx = useContext(ArrayItemContext)
|
|
192
195
|
const autoSubmitOnBlur = useContext(AutoSubmitContext)
|
|
193
196
|
const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
|
|
@@ -246,12 +249,13 @@ const makeFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
246
249
|
(newValue: Schema.Schema.Encoded<S>) => {
|
|
247
250
|
setValue(newValue)
|
|
248
251
|
setCrossFieldErrors((prev) => {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
const next = new Map<string, string>()
|
|
253
|
+
for (const [errorPath, message] of prev) {
|
|
254
|
+
if (!isPathUnderRoot(errorPath, fieldPath)) {
|
|
255
|
+
next.set(errorPath, message)
|
|
256
|
+
}
|
|
253
257
|
}
|
|
254
|
-
return prev
|
|
258
|
+
return next.size !== prev.size ? next : prev
|
|
255
259
|
})
|
|
256
260
|
if (parsedMode.validation === "onChange") {
|
|
257
261
|
validate(newValue)
|
|
@@ -276,20 +280,20 @@ const makeFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
276
280
|
const isValidating = validationResult.waiting
|
|
277
281
|
const shouldShowError = isTouched || submitCount > 0
|
|
278
282
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
/>
|
|
289
|
-
)
|
|
290
|
-
})
|
|
283
|
+
const fieldState: FieldState<S> = React.useMemo(() => ({
|
|
284
|
+
value,
|
|
285
|
+
onChange,
|
|
286
|
+
onBlur,
|
|
287
|
+
error: shouldShowError ? validationError : Option.none<string>(),
|
|
288
|
+
isTouched,
|
|
289
|
+
isValidating,
|
|
290
|
+
isDirty,
|
|
291
|
+
}), [value, onChange, onBlur, shouldShowError, validationError, isTouched, isValidating, isDirty])
|
|
291
292
|
|
|
292
|
-
|
|
293
|
+
return <Component field={fieldState} props={extraProps} />
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return React.memo(FieldComponent) as React.FC<P>
|
|
293
297
|
}
|
|
294
298
|
|
|
295
299
|
const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
@@ -307,7 +311,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
307
311
|
getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
|
|
308
312
|
operations: FormAtoms.FormOperations<any>,
|
|
309
313
|
componentMap: ArrayItemComponentMap<S>,
|
|
310
|
-
): ArrayFieldComponent<S> => {
|
|
314
|
+
): ArrayFieldComponent<S, any> => {
|
|
311
315
|
const isStructSchema = AST.isTypeLiteral(def.itemSchema.ast)
|
|
312
316
|
|
|
313
317
|
const ArrayWrapper: React.FC<{
|
|
@@ -398,7 +402,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
398
402
|
const itemKey = prop.name as string
|
|
399
403
|
const itemSchema = { ast: prop.type } as Schema.Schema.Any
|
|
400
404
|
const itemDef = Field.makeField(itemKey, itemSchema)
|
|
401
|
-
const itemComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[itemKey]
|
|
405
|
+
const itemComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any, any>>>)[itemKey]
|
|
402
406
|
itemFieldComponents[itemKey] = makeFieldComponent(
|
|
403
407
|
itemKey,
|
|
404
408
|
itemDef,
|
|
@@ -418,6 +422,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
418
422
|
...itemFieldComponents,
|
|
419
423
|
}
|
|
420
424
|
|
|
425
|
+
// Proxy enables <Form.items.Item> and <Form.items.name> syntax
|
|
421
426
|
return new Proxy(ArrayWrapper, {
|
|
422
427
|
get(target, prop) {
|
|
423
428
|
if (prop in properties) {
|
|
@@ -425,10 +430,13 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
425
430
|
}
|
|
426
431
|
return Reflect.get(target, prop)
|
|
427
432
|
},
|
|
428
|
-
}) as ArrayFieldComponent<S>
|
|
433
|
+
}) as ArrayFieldComponent<S, any>
|
|
429
434
|
}
|
|
430
435
|
|
|
431
|
-
const makeFieldComponents = <
|
|
436
|
+
const makeFieldComponents = <
|
|
437
|
+
TFields extends Field.FieldsRecord,
|
|
438
|
+
CM extends FieldComponentMap<TFields>,
|
|
439
|
+
>(
|
|
432
440
|
fields: TFields,
|
|
433
441
|
stateAtom: Atom.Writable<
|
|
434
442
|
Option.Option<FormBuilder.FormState<TFields>>,
|
|
@@ -444,8 +452,8 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
|
|
|
444
452
|
) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
|
|
445
453
|
getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
|
|
446
454
|
operations: FormAtoms.FormOperations<TFields>,
|
|
447
|
-
componentMap:
|
|
448
|
-
): FieldComponents<TFields> => {
|
|
455
|
+
componentMap: CM,
|
|
456
|
+
): FieldComponents<TFields, CM> => {
|
|
449
457
|
const components: Record<string, any> = {}
|
|
450
458
|
|
|
451
459
|
for (const [key, def] of Object.entries(fields)) {
|
|
@@ -465,7 +473,7 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
|
|
|
465
473
|
arrayComponentMap,
|
|
466
474
|
)
|
|
467
475
|
} else if (Field.isFieldDef(def)) {
|
|
468
|
-
const fieldComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[key]
|
|
476
|
+
const fieldComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any, any>>>)[key]
|
|
469
477
|
components[key] = makeFieldComponent(
|
|
470
478
|
key,
|
|
471
479
|
def,
|
|
@@ -480,7 +488,7 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
|
|
|
480
488
|
}
|
|
481
489
|
}
|
|
482
490
|
|
|
483
|
-
return components as FieldComponents<TFields>
|
|
491
|
+
return components as FieldComponents<TFields, CM>
|
|
484
492
|
}
|
|
485
493
|
|
|
486
494
|
/**
|
|
@@ -488,11 +496,11 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
|
|
|
488
496
|
*
|
|
489
497
|
* @example
|
|
490
498
|
* ```tsx
|
|
491
|
-
* import {
|
|
499
|
+
* import { FormBuilder } from "@lucas-barake/effect-form"
|
|
492
500
|
* import { FormReact } from "@lucas-barake/effect-form-react"
|
|
501
|
+
* import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"
|
|
493
502
|
* import * as Atom from "@effect-atom/atom/Atom"
|
|
494
503
|
* import * as Schema from "effect/Schema"
|
|
495
|
-
* import * as Effect from "effect/Effect"
|
|
496
504
|
* import * as Layer from "effect/Layer"
|
|
497
505
|
*
|
|
498
506
|
* const runtime = Atom.runtime(Layer.empty)
|
|
@@ -504,26 +512,28 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
|
|
|
504
512
|
* const form = FormReact.build(loginForm, {
|
|
505
513
|
* runtime,
|
|
506
514
|
* fields: { email: TextInput, password: PasswordInput },
|
|
515
|
+
* onSubmit: (values) => Effect.log(`Login: ${values.email}`),
|
|
507
516
|
* })
|
|
508
517
|
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
518
|
+
* // Subscribe to atoms anywhere in the tree
|
|
519
|
+
* function SubmitButton() {
|
|
520
|
+
* const isDirty = useAtomValue(form.isDirty)
|
|
521
|
+
* const submit = useAtomValue(form.submit)
|
|
522
|
+
* const callSubmit = useAtomSet(form.submit)
|
|
523
|
+
* return (
|
|
524
|
+
* <button onClick={() => callSubmit()} disabled={!isDirty || submit.waiting}>
|
|
525
|
+
* {submit.waiting ? "Validating..." : "Login"}
|
|
526
|
+
* </button>
|
|
515
527
|
* )
|
|
528
|
+
* }
|
|
516
529
|
*
|
|
530
|
+
* function LoginDialog({ onClose }) {
|
|
517
531
|
* return (
|
|
518
|
-
* <form.
|
|
532
|
+
* <form.Initialize defaultValues={{ email: "", password: "" }}>
|
|
519
533
|
* <form.email />
|
|
520
534
|
* <form.password />
|
|
521
|
-
* <
|
|
522
|
-
*
|
|
523
|
-
* <button onClick={submit} disabled={!isDirty}>Login</button>
|
|
524
|
-
* )}
|
|
525
|
-
* </form.Subscribe>
|
|
526
|
-
* </form.Form>
|
|
535
|
+
* <SubmitButton />
|
|
536
|
+
* </form.Initialize>
|
|
527
537
|
* )
|
|
528
538
|
* }
|
|
529
539
|
* ```
|
|
@@ -531,27 +541,35 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
|
|
|
531
541
|
* @since 1.0.0
|
|
532
542
|
* @category Constructors
|
|
533
543
|
*/
|
|
534
|
-
export const build = <
|
|
544
|
+
export const build = <
|
|
545
|
+
TFields extends Field.FieldsRecord,
|
|
546
|
+
R,
|
|
547
|
+
A,
|
|
548
|
+
E,
|
|
549
|
+
ER = never,
|
|
550
|
+
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
551
|
+
>(
|
|
535
552
|
self: FormBuilder.FormBuilder<TFields, R>,
|
|
536
553
|
options: {
|
|
537
554
|
readonly runtime: Atom.AtomRuntime<R, ER>
|
|
538
|
-
readonly fields:
|
|
555
|
+
readonly fields: CM
|
|
539
556
|
readonly mode?: Mode.FormMode
|
|
557
|
+
readonly onSubmit: (decoded: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A | Effect.Effect<A, E, R>
|
|
540
558
|
},
|
|
541
|
-
): BuiltForm<TFields, R> => {
|
|
542
|
-
const { fields: components, mode, runtime } = options
|
|
559
|
+
): BuiltForm<TFields, R, A, E, CM> => {
|
|
560
|
+
const { fields: components, mode, onSubmit, runtime } = options
|
|
543
561
|
const parsedMode = Mode.parse(mode)
|
|
544
562
|
const { fields } = self
|
|
545
563
|
|
|
546
|
-
const formAtoms: FormAtoms.FormAtoms<TFields, R> = FormAtoms.make({
|
|
564
|
+
const formAtoms: FormAtoms.FormAtoms<TFields, R, A, E> = FormAtoms.make({
|
|
547
565
|
formBuilder: self,
|
|
548
566
|
runtime,
|
|
567
|
+
onSubmit,
|
|
549
568
|
})
|
|
550
569
|
|
|
551
570
|
const {
|
|
552
571
|
combinedSchema,
|
|
553
572
|
crossFieldErrorsAtom,
|
|
554
|
-
decodeAndSubmit,
|
|
555
573
|
dirtyFieldsAtom,
|
|
556
574
|
fieldRefs,
|
|
557
575
|
getOrCreateFieldAtoms,
|
|
@@ -559,42 +577,47 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
|
|
|
559
577
|
hasChangedSinceSubmitAtom,
|
|
560
578
|
isDirtyAtom,
|
|
561
579
|
lastSubmittedValuesAtom,
|
|
562
|
-
onSubmitAtom,
|
|
563
580
|
operations,
|
|
564
|
-
|
|
581
|
+
resetAtom,
|
|
582
|
+
revertToLastSubmitAtom,
|
|
583
|
+
setValue,
|
|
584
|
+
setValuesAtom,
|
|
565
585
|
stateAtom,
|
|
586
|
+
submitAtom,
|
|
566
587
|
submitCountAtom,
|
|
567
588
|
} = formAtoms
|
|
568
589
|
|
|
569
|
-
const
|
|
590
|
+
const InitializeComponent: React.FC<{
|
|
570
591
|
readonly defaultValues: Field.EncodedFromFields<TFields>
|
|
571
|
-
readonly onSubmit: Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown>
|
|
572
592
|
readonly children: React.ReactNode
|
|
573
|
-
}> = ({ children, defaultValues
|
|
593
|
+
}> = ({ children, defaultValues }) => {
|
|
574
594
|
const registry = React.useContext(RegistryContext)
|
|
575
595
|
const state = useAtomValue(stateAtom)
|
|
576
596
|
const setFormState = useAtomSet(stateAtom)
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
React.useEffect(() => {
|
|
581
|
-
setOnSubmit(onSubmit)
|
|
582
|
-
}, [onSubmit, setOnSubmit])
|
|
597
|
+
const callSubmit = useAtomSet(submitAtom)
|
|
598
|
+
// Prevents auto-submit from firing on mount when initial state is set
|
|
599
|
+
const isInitializedRef = React.useRef(false)
|
|
583
600
|
|
|
584
601
|
React.useEffect(() => {
|
|
585
602
|
setFormState(Option.some(operations.createInitialState(defaultValues)))
|
|
603
|
+
// Microtask ensures state update completes before enabling auto-submit
|
|
604
|
+
queueMicrotask(() => {
|
|
605
|
+
isInitializedRef.current = true
|
|
606
|
+
})
|
|
586
607
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
|
|
587
608
|
}, [])
|
|
588
609
|
|
|
589
610
|
const debouncedAutoSubmit = useDebounced(() => {
|
|
590
611
|
const stateOption = registry.get(stateAtom)
|
|
591
612
|
if (Option.isNone(stateOption)) return
|
|
592
|
-
|
|
613
|
+
callSubmit()
|
|
593
614
|
}, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
|
|
594
615
|
|
|
595
616
|
useAtomSubscribe(
|
|
596
617
|
stateAtom,
|
|
597
618
|
React.useCallback(() => {
|
|
619
|
+
// Skip auto-submit for initial state set
|
|
620
|
+
if (!isInitializedRef.current) return
|
|
598
621
|
if (parsedMode.autoSubmit && parsedMode.validation === "onChange") {
|
|
599
622
|
debouncedAutoSubmit()
|
|
600
623
|
}
|
|
@@ -606,9 +629,9 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
|
|
|
606
629
|
if (parsedMode.autoSubmit && parsedMode.validation === "onBlur") {
|
|
607
630
|
const stateOption = registry.get(stateAtom)
|
|
608
631
|
if (Option.isNone(stateOption)) return
|
|
609
|
-
|
|
632
|
+
callSubmit()
|
|
610
633
|
}
|
|
611
|
-
}, [registry,
|
|
634
|
+
}, [registry, callSubmit])
|
|
612
635
|
|
|
613
636
|
if (Option.isNone(state)) return null
|
|
614
637
|
|
|
@@ -626,175 +649,6 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
|
|
|
626
649
|
)
|
|
627
650
|
}
|
|
628
651
|
|
|
629
|
-
const useFormHook = () => {
|
|
630
|
-
const registry = React.useContext(RegistryContext)
|
|
631
|
-
const formValues = Option.getOrThrow(useAtomValue(stateAtom)).values
|
|
632
|
-
const setFormState = useAtomSet(stateAtom)
|
|
633
|
-
const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
|
|
634
|
-
const [decodeAndSubmitResult, callDecodeAndSubmit] = useAtom(decodeAndSubmit)
|
|
635
|
-
const isDirty = useAtomValue(isDirtyAtom)
|
|
636
|
-
const hasChangedSinceSubmit = useAtomValue(hasChangedSinceSubmitAtom)
|
|
637
|
-
const lastSubmittedValues = useAtomValue(lastSubmittedValuesAtom)
|
|
638
|
-
|
|
639
|
-
React.useEffect(() => {
|
|
640
|
-
if (decodeAndSubmitResult._tag === "Failure") {
|
|
641
|
-
const parseError = Cause.failureOption(decodeAndSubmitResult.cause)
|
|
642
|
-
if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
|
|
643
|
-
const issues = ParseResult.ArrayFormatter.formatErrorSync(parseError.value)
|
|
644
|
-
|
|
645
|
-
const fieldErrors = new Map<string, string>()
|
|
646
|
-
for (const issue of issues) {
|
|
647
|
-
if (issue.path.length > 0) {
|
|
648
|
-
const fieldPath = schemaPathToFieldPath(issue.path)
|
|
649
|
-
if (!fieldErrors.has(fieldPath)) {
|
|
650
|
-
fieldErrors.set(fieldPath, issue.message)
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
if (fieldErrors.size > 0) {
|
|
656
|
-
setCrossFieldErrors(fieldErrors)
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}, [decodeAndSubmitResult, setCrossFieldErrors])
|
|
661
|
-
|
|
662
|
-
const submit = React.useCallback(() => {
|
|
663
|
-
const stateOption = registry.get(stateAtom)
|
|
664
|
-
if (Option.isNone(stateOption)) return
|
|
665
|
-
|
|
666
|
-
setCrossFieldErrors(new Map())
|
|
667
|
-
|
|
668
|
-
setFormState((prev) => {
|
|
669
|
-
if (Option.isNone(prev)) return prev
|
|
670
|
-
return Option.some(operations.createSubmitState(prev.value))
|
|
671
|
-
})
|
|
672
|
-
|
|
673
|
-
callDecodeAndSubmit(stateOption.value.values)
|
|
674
|
-
}, [setFormState, callDecodeAndSubmit, setCrossFieldErrors, registry])
|
|
675
|
-
|
|
676
|
-
const reset = React.useCallback(() => {
|
|
677
|
-
setFormState((prev) => {
|
|
678
|
-
if (Option.isNone(prev)) return prev
|
|
679
|
-
return Option.some(operations.createResetState(prev.value))
|
|
680
|
-
})
|
|
681
|
-
setCrossFieldErrors(new Map())
|
|
682
|
-
resetValidationAtoms(registry)
|
|
683
|
-
callDecodeAndSubmit(Atom.Reset)
|
|
684
|
-
}, [setFormState, setCrossFieldErrors, callDecodeAndSubmit, registry])
|
|
685
|
-
|
|
686
|
-
const revertToLastSubmit = React.useCallback(() => {
|
|
687
|
-
setFormState((prev) => {
|
|
688
|
-
if (Option.isNone(prev)) return prev
|
|
689
|
-
return Option.some(operations.revertToLastSubmit(prev.value))
|
|
690
|
-
})
|
|
691
|
-
setCrossFieldErrors(new Map())
|
|
692
|
-
}, [setFormState, setCrossFieldErrors])
|
|
693
|
-
|
|
694
|
-
const setValue = React.useCallback(<S,>(
|
|
695
|
-
field: FormBuilder.FieldRef<S>,
|
|
696
|
-
update: S | ((prev: S) => S),
|
|
697
|
-
) => {
|
|
698
|
-
const path = field.key
|
|
699
|
-
|
|
700
|
-
setFormState((prev) => {
|
|
701
|
-
if (Option.isNone(prev)) return prev
|
|
702
|
-
const state = prev.value
|
|
703
|
-
|
|
704
|
-
const currentValue = getNestedValue(state.values, path) as S
|
|
705
|
-
const newValue = typeof update === "function"
|
|
706
|
-
? (update as (prev: S) => S)(currentValue)
|
|
707
|
-
: update
|
|
708
|
-
|
|
709
|
-
return Option.some(operations.setFieldValue(state, path, newValue))
|
|
710
|
-
})
|
|
711
|
-
|
|
712
|
-
setCrossFieldErrors((prev) => {
|
|
713
|
-
let changed = false
|
|
714
|
-
const next = new Map(prev)
|
|
715
|
-
for (const errorPath of prev.keys()) {
|
|
716
|
-
if (errorPath === path || errorPath.startsWith(path + ".") || errorPath.startsWith(path + "[")) {
|
|
717
|
-
next.delete(errorPath)
|
|
718
|
-
changed = true
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
return changed ? next : prev
|
|
722
|
-
})
|
|
723
|
-
}, [setFormState, setCrossFieldErrors])
|
|
724
|
-
|
|
725
|
-
const setValues = React.useCallback((values: Field.EncodedFromFields<TFields>) => {
|
|
726
|
-
setFormState((prev) => {
|
|
727
|
-
if (Option.isNone(prev)) return prev
|
|
728
|
-
return Option.some(operations.setFormValues(prev.value, values))
|
|
729
|
-
})
|
|
730
|
-
|
|
731
|
-
setCrossFieldErrors(new Map())
|
|
732
|
-
}, [setFormState, setCrossFieldErrors])
|
|
733
|
-
|
|
734
|
-
return {
|
|
735
|
-
submit,
|
|
736
|
-
reset,
|
|
737
|
-
revertToLastSubmit,
|
|
738
|
-
isDirty,
|
|
739
|
-
hasChangedSinceSubmit,
|
|
740
|
-
lastSubmittedValues,
|
|
741
|
-
submitResult: decodeAndSubmitResult,
|
|
742
|
-
values: formValues,
|
|
743
|
-
setValue,
|
|
744
|
-
setValues,
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const SubscribeComponent: React.FC<{
|
|
749
|
-
readonly children: (state: SubscribeState<TFields>) => React.ReactNode
|
|
750
|
-
}> = ({ children }) => {
|
|
751
|
-
const {
|
|
752
|
-
hasChangedSinceSubmit,
|
|
753
|
-
isDirty,
|
|
754
|
-
lastSubmittedValues,
|
|
755
|
-
reset,
|
|
756
|
-
revertToLastSubmit,
|
|
757
|
-
setValue,
|
|
758
|
-
setValues,
|
|
759
|
-
submit,
|
|
760
|
-
submitResult,
|
|
761
|
-
values,
|
|
762
|
-
} = useFormHook()
|
|
763
|
-
|
|
764
|
-
return (
|
|
765
|
-
<>
|
|
766
|
-
{children({
|
|
767
|
-
hasChangedSinceSubmit,
|
|
768
|
-
isDirty,
|
|
769
|
-
lastSubmittedValues,
|
|
770
|
-
reset,
|
|
771
|
-
revertToLastSubmit,
|
|
772
|
-
setValue,
|
|
773
|
-
setValues,
|
|
774
|
-
submit,
|
|
775
|
-
submitResult,
|
|
776
|
-
values,
|
|
777
|
-
})}
|
|
778
|
-
</>
|
|
779
|
-
)
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const submitHelper = <A,>(
|
|
783
|
-
fn: (values: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A,
|
|
784
|
-
) =>
|
|
785
|
-
runtime.fn<Field.DecodedFromFields<TFields>>()((values, get) => {
|
|
786
|
-
const result = fn(values, get)
|
|
787
|
-
return (Effect.isEffect(result) ? result : Effect.succeed(result)) as Effect.Effect<
|
|
788
|
-
A extends Effect.Effect<infer T, any, any> ? T : A,
|
|
789
|
-
A extends Effect.Effect<any, infer E, any> ? E : never,
|
|
790
|
-
R
|
|
791
|
-
>
|
|
792
|
-
}) as Atom.AtomResultFn<
|
|
793
|
-
Field.DecodedFromFields<TFields>,
|
|
794
|
-
A extends Effect.Effect<infer T, any, any> ? T : A,
|
|
795
|
-
A extends Effect.Effect<any, infer E, any> ? E : never
|
|
796
|
-
>
|
|
797
|
-
|
|
798
652
|
const fieldComponents = makeFieldComponents(
|
|
799
653
|
fields,
|
|
800
654
|
stateAtom,
|
|
@@ -809,13 +663,47 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
|
|
|
809
663
|
)
|
|
810
664
|
|
|
811
665
|
return {
|
|
812
|
-
|
|
666
|
+
isDirty: isDirtyAtom,
|
|
667
|
+
hasChangedSinceSubmit: hasChangedSinceSubmitAtom,
|
|
668
|
+
lastSubmittedValues: lastSubmittedValuesAtom,
|
|
669
|
+
submitCount: submitCountAtom,
|
|
813
670
|
schema: combinedSchema,
|
|
814
671
|
fields: fieldRefs,
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
672
|
+
Initialize: InitializeComponent,
|
|
673
|
+
submit: submitAtom,
|
|
674
|
+
reset: resetAtom,
|
|
675
|
+
revertToLastSubmit: revertToLastSubmitAtom,
|
|
676
|
+
setValues: setValuesAtom,
|
|
677
|
+
setValue,
|
|
819
678
|
...fieldComponents,
|
|
820
|
-
} as BuiltForm<TFields, R>
|
|
679
|
+
} as BuiltForm<TFields, R, A, E, CM>
|
|
821
680
|
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* A curried helper that infers the schema type from the field definition.
|
|
684
|
+
* Provides ergonomic type inference when defining field components.
|
|
685
|
+
*
|
|
686
|
+
* @example
|
|
687
|
+
* ```tsx
|
|
688
|
+
* import { FormReact } from "@lucas-barake/effect-form-react"
|
|
689
|
+
*
|
|
690
|
+
* // Without extra props - schema inferred from field
|
|
691
|
+
* const TextInput = FormReact.forField(EmailField)(({ field }) => (
|
|
692
|
+
* <input value={field.value} onChange={e => field.onChange(e.target.value)} />
|
|
693
|
+
* ))
|
|
694
|
+
*
|
|
695
|
+
* // With extra props - just specify the props type
|
|
696
|
+
* const TextInput = FormReact.forField(EmailField)<{ placeholder?: string }>(({ field, props }) => (
|
|
697
|
+
* <input value={field.value} placeholder={props.placeholder} ... />
|
|
698
|
+
* ))
|
|
699
|
+
* ```
|
|
700
|
+
*
|
|
701
|
+
* @since 1.0.0
|
|
702
|
+
* @category Constructors
|
|
703
|
+
*/
|
|
704
|
+
export const forField = <K extends string, S extends Schema.Schema.Any>(
|
|
705
|
+
_field: Field.FieldDef<K, S>,
|
|
706
|
+
) =>
|
|
707
|
+
<P extends Record<string, unknown> = Record<string, never>>(
|
|
708
|
+
component: React.FC<FieldComponentProps<S, P>>,
|
|
709
|
+
): React.FC<FieldComponentProps<S, P>> => component
|