@lucas-barake/effect-form-react 0.11.0 → 0.13.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 +200 -77
- package/dist/cjs/FormReact.js.map +1 -1
- package/dist/dts/FormReact.d.ts +109 -44
- package/dist/dts/FormReact.d.ts.map +1 -1
- package/dist/esm/FormReact.js +199 -77
- package/dist/esm/FormReact.js.map +1 -1
- package/package.json +2 -2
- package/src/FormReact.tsx +325 -126
package/src/FormReact.tsx
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
RegistryContext,
|
|
3
|
+
useAtom,
|
|
4
|
+
useAtomMount,
|
|
5
|
+
useAtomSet,
|
|
6
|
+
useAtomSubscribe,
|
|
7
|
+
useAtomValue,
|
|
8
|
+
} from "@effect-atom/atom-react"
|
|
9
|
+
import * as Atom from "@effect-atom/atom/Atom"
|
|
3
10
|
import { Field, FormAtoms, Mode, Validation } from "@lucas-barake/effect-form"
|
|
4
11
|
import type * as FormBuilder from "@lucas-barake/effect-form/FormBuilder"
|
|
5
|
-
import { getNestedValue, isPathOrParentDirty
|
|
12
|
+
import { getNestedValue, isPathOrParentDirty } from "@lucas-barake/effect-form/Path"
|
|
6
13
|
import * as Cause from "effect/Cause"
|
|
7
14
|
import type * as Effect from "effect/Effect"
|
|
15
|
+
import * as Layer from "effect/Layer"
|
|
8
16
|
import * as Option from "effect/Option"
|
|
9
17
|
import * as ParseResult from "effect/ParseResult"
|
|
18
|
+
import * as Predicate from "effect/Predicate"
|
|
10
19
|
import type * as Schema from "effect/Schema"
|
|
11
20
|
import * as AST from "effect/SchemaAST"
|
|
12
21
|
import * as React from "react"
|
|
@@ -42,12 +51,32 @@ export interface FieldComponentProps<
|
|
|
42
51
|
readonly props: P
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A bundled field definition + component for reusable form fields.
|
|
56
|
+
* Created with `FormReact.makeField`.
|
|
57
|
+
*
|
|
58
|
+
* @category Models
|
|
59
|
+
*/
|
|
60
|
+
export interface FieldBundle<
|
|
61
|
+
K extends string,
|
|
62
|
+
S extends Schema.Schema.Any,
|
|
63
|
+
P extends Record<string, unknown> = Record<string, never>,
|
|
64
|
+
> {
|
|
65
|
+
readonly _tag: "FieldBundle"
|
|
66
|
+
readonly field: Field.FieldDef<K, S>
|
|
67
|
+
readonly component: React.FC<FieldComponentProps<S, P>>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isFieldBundle = (x: unknown): x is FieldBundle<string, Schema.Schema.Any, Record<string, unknown>> =>
|
|
71
|
+
Predicate.isTagged(x, "FieldBundle")
|
|
72
|
+
|
|
45
73
|
/**
|
|
46
74
|
* Extracts the extra props type from a field component.
|
|
47
75
|
*
|
|
48
76
|
* @category Type-level utilities
|
|
49
77
|
*/
|
|
50
78
|
export type ExtractExtraProps<C> = C extends React.FC<FieldComponentProps<any, infer P>> ? P
|
|
79
|
+
: C extends FieldBundle<any, any, infer P> ? P
|
|
51
80
|
: Record<string, never>
|
|
52
81
|
|
|
53
82
|
/**
|
|
@@ -69,7 +98,8 @@ export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schem
|
|
|
69
98
|
* @category Models
|
|
70
99
|
*/
|
|
71
100
|
export type FieldComponentMap<TFields extends Field.FieldsRecord> = {
|
|
72
|
-
readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S>
|
|
101
|
+
readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S>
|
|
102
|
+
? React.FC<FieldComponentProps<S, any>> | FieldBundle<any, S, any>
|
|
73
103
|
: TFields[K] extends Field.ArrayFieldDef<any, infer S> ? ArrayItemComponentMap<S>
|
|
74
104
|
: never
|
|
75
105
|
}
|
|
@@ -108,7 +138,6 @@ export type BuiltForm<
|
|
|
108
138
|
SubmitArgs = void,
|
|
109
139
|
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
110
140
|
> = {
|
|
111
|
-
// Atoms for fine-grained subscriptions (use with useAtomValue)
|
|
112
141
|
readonly values: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
|
|
113
142
|
readonly isDirty: Atom.Atom<boolean>
|
|
114
143
|
readonly hasChangedSinceSubmit: Atom.Atom<boolean>
|
|
@@ -128,7 +157,10 @@ export type BuiltForm<
|
|
|
128
157
|
readonly revertToLastSubmit: Atom.Writable<void, void>
|
|
129
158
|
readonly setValues: Atom.Writable<void, Field.EncodedFromFields<TFields>>
|
|
130
159
|
readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>
|
|
131
|
-
readonly getFieldAtom: <S>(field: FormBuilder.FieldRef<S>) => Atom.Atom<S
|
|
160
|
+
readonly getFieldAtom: <S>(field: FormBuilder.FieldRef<S>) => Atom.Atom<Option.Option<S>>
|
|
161
|
+
|
|
162
|
+
readonly mount: Atom.Atom<void>
|
|
163
|
+
readonly KeepAlive: React.FC
|
|
132
164
|
} & FieldComponents<TFields, CM>
|
|
133
165
|
|
|
134
166
|
type FieldComponents<TFields extends Field.FieldsRecord, CM extends FieldComponentMap<TFields>> = {
|
|
@@ -171,7 +203,7 @@ const AutoSubmitContext = createContext<(() => void) | null>(null)
|
|
|
171
203
|
const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string, unknown>>(
|
|
172
204
|
fieldKey: string,
|
|
173
205
|
fieldDef: Field.FieldDef<string, S>,
|
|
174
|
-
|
|
206
|
+
errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
|
|
175
207
|
submitCountAtom: Atom.Atom<number>,
|
|
176
208
|
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
177
209
|
parsedMode: Mode.ParsedMode,
|
|
@@ -187,15 +219,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
187
219
|
const autoSubmitOnBlur = useContext(AutoSubmitContext)
|
|
188
220
|
const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
|
|
189
221
|
|
|
190
|
-
const {
|
|
222
|
+
const { errorAtom, touchedAtom, valueAtom } = React.useMemo(
|
|
191
223
|
() => getOrCreateFieldAtoms(fieldPath),
|
|
192
224
|
[fieldPath],
|
|
193
225
|
)
|
|
194
226
|
|
|
195
227
|
const [value, setValue] = useAtom(valueAtom) as [Schema.Schema.Encoded<S>, (v: unknown) => void]
|
|
196
228
|
const [isTouched, setTouched] = useAtom(touchedAtom)
|
|
197
|
-
const
|
|
198
|
-
const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
|
|
229
|
+
const storedError = useAtomValue(errorAtom)
|
|
199
230
|
const submitCount = useAtomValue(submitCountAtom)
|
|
200
231
|
|
|
201
232
|
const validationAtom = React.useMemo(
|
|
@@ -219,13 +250,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
219
250
|
|
|
220
251
|
const shouldValidate = parsedMode.validation === "onChange"
|
|
221
252
|
|| (parsedMode.validation === "onBlur" && isTouched)
|
|
253
|
+
|| (parsedMode.validation === "onSubmit" && submitCount > 0)
|
|
222
254
|
|
|
223
255
|
if (shouldValidate) {
|
|
224
256
|
validate(value)
|
|
225
257
|
}
|
|
226
|
-
}, [value, isTouched, validate])
|
|
258
|
+
}, [value, isTouched, submitCount, validate])
|
|
227
259
|
|
|
228
|
-
const
|
|
260
|
+
const livePerFieldError: Option.Option<string> = React.useMemo(() => {
|
|
229
261
|
if (validationResult._tag === "Failure") {
|
|
230
262
|
const parseError = Cause.failureOption(validationResult.cause)
|
|
231
263
|
if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
|
|
@@ -235,25 +267,33 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
235
267
|
return Option.none()
|
|
236
268
|
}, [validationResult])
|
|
237
269
|
|
|
238
|
-
const
|
|
270
|
+
const isValidating = validationResult.waiting
|
|
271
|
+
|
|
272
|
+
const validationError: Option.Option<string> = React.useMemo(() => {
|
|
273
|
+
if (Option.isSome(livePerFieldError)) {
|
|
274
|
+
return livePerFieldError
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (Option.isSome(storedError)) {
|
|
278
|
+
// Hide field-sourced errors when validation passes or is pending (async gap).
|
|
279
|
+
// Refinement errors persist until re-submit - they can't be cleared by typing.
|
|
280
|
+
const shouldHideStoredError = storedError.value.source === "field" &&
|
|
281
|
+
(validationResult._tag === "Success" || isValidating)
|
|
282
|
+
|
|
283
|
+
if (shouldHideStoredError) {
|
|
284
|
+
return Option.none()
|
|
285
|
+
}
|
|
286
|
+
return Option.some(storedError.value.message)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return Option.none()
|
|
290
|
+
}, [livePerFieldError, storedError, validationResult, isValidating])
|
|
239
291
|
|
|
240
292
|
const onChange = React.useCallback(
|
|
241
293
|
(newValue: Schema.Schema.Encoded<S>) => {
|
|
242
294
|
setValue(newValue)
|
|
243
|
-
setCrossFieldErrors((prev) => {
|
|
244
|
-
const next = new Map<string, string>()
|
|
245
|
-
for (const [errorPath, message] of prev) {
|
|
246
|
-
if (!isPathUnderRoot(errorPath, fieldPath)) {
|
|
247
|
-
next.set(errorPath, message)
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return next.size !== prev.size ? next : prev
|
|
251
|
-
})
|
|
252
|
-
if (parsedMode.validation === "onChange") {
|
|
253
|
-
validate(newValue)
|
|
254
|
-
}
|
|
255
295
|
},
|
|
256
|
-
[
|
|
296
|
+
[setValue],
|
|
257
297
|
)
|
|
258
298
|
|
|
259
299
|
const onBlur = React.useCallback(() => {
|
|
@@ -269,8 +309,11 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
269
309
|
() => isPathOrParentDirty(dirtyFields, fieldPath),
|
|
270
310
|
[dirtyFields, fieldPath],
|
|
271
311
|
)
|
|
272
|
-
const
|
|
273
|
-
|
|
312
|
+
const shouldShowError = parsedMode.validation === "onChange"
|
|
313
|
+
? (isDirty || submitCount > 0)
|
|
314
|
+
: parsedMode.validation === "onBlur"
|
|
315
|
+
? (isTouched || submitCount > 0)
|
|
316
|
+
: submitCount > 0
|
|
274
317
|
|
|
275
318
|
const fieldState: FieldState<S> = React.useMemo(() => ({
|
|
276
319
|
value,
|
|
@@ -292,7 +335,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
292
335
|
fieldKey: string,
|
|
293
336
|
def: Field.ArrayFieldDef<string, S>,
|
|
294
337
|
stateAtom: Atom.Writable<Option.Option<FormBuilder.FormState<any>>, Option.Option<FormBuilder.FormState<any>>>,
|
|
295
|
-
|
|
338
|
+
errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
|
|
296
339
|
submitCountAtom: Atom.Atom<number>,
|
|
297
340
|
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
298
341
|
parsedMode: Mode.ParsedMode,
|
|
@@ -398,7 +441,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
398
441
|
itemFieldComponents[itemKey] = makeFieldComponent(
|
|
399
442
|
itemKey,
|
|
400
443
|
itemDef,
|
|
401
|
-
|
|
444
|
+
errorsAtom,
|
|
402
445
|
submitCountAtom,
|
|
403
446
|
dirtyFieldsAtom,
|
|
404
447
|
parsedMode,
|
|
@@ -414,7 +457,6 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
414
457
|
...itemFieldComponents,
|
|
415
458
|
}
|
|
416
459
|
|
|
417
|
-
// Proxy enables <Form.items.Item> and <Form.items.name> syntax
|
|
418
460
|
return new Proxy(ArrayWrapper, {
|
|
419
461
|
get(target, prop) {
|
|
420
462
|
if (prop in properties) {
|
|
@@ -434,7 +476,7 @@ const makeFieldComponents = <
|
|
|
434
476
|
Option.Option<FormBuilder.FormState<TFields>>,
|
|
435
477
|
Option.Option<FormBuilder.FormState<TFields>>
|
|
436
478
|
>,
|
|
437
|
-
|
|
479
|
+
errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
|
|
438
480
|
submitCountAtom: Atom.Atom<number>,
|
|
439
481
|
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
440
482
|
parsedMode: Mode.ParsedMode,
|
|
@@ -455,7 +497,7 @@ const makeFieldComponents = <
|
|
|
455
497
|
key,
|
|
456
498
|
def as Field.ArrayFieldDef<string, Schema.Schema.Any>,
|
|
457
499
|
stateAtom,
|
|
458
|
-
|
|
500
|
+
errorsAtom,
|
|
459
501
|
submitCountAtom,
|
|
460
502
|
dirtyFieldsAtom,
|
|
461
503
|
parsedMode,
|
|
@@ -465,11 +507,14 @@ const makeFieldComponents = <
|
|
|
465
507
|
arrayComponentMap,
|
|
466
508
|
)
|
|
467
509
|
} else if (Field.isFieldDef(def)) {
|
|
468
|
-
const
|
|
510
|
+
const componentOrBundle = (componentMap as Record<string, unknown>)[key]
|
|
511
|
+
const fieldComponent = isFieldBundle(componentOrBundle)
|
|
512
|
+
? componentOrBundle.component
|
|
513
|
+
: componentOrBundle as React.FC<FieldComponentProps<any, any>>
|
|
469
514
|
components[key] = makeFieldComponent(
|
|
470
515
|
key,
|
|
471
516
|
def,
|
|
472
|
-
|
|
517
|
+
errorsAtom,
|
|
473
518
|
submitCountAtom,
|
|
474
519
|
dirtyFieldsAtom,
|
|
475
520
|
parsedMode,
|
|
@@ -484,34 +529,30 @@ const makeFieldComponents = <
|
|
|
484
529
|
}
|
|
485
530
|
|
|
486
531
|
/**
|
|
487
|
-
*
|
|
532
|
+
* Creates a React form from a FormBuilder.
|
|
488
533
|
*
|
|
489
534
|
* @example
|
|
490
535
|
* ```tsx
|
|
491
536
|
* import { FormBuilder } from "@lucas-barake/effect-form"
|
|
492
537
|
* import { FormReact } from "@lucas-barake/effect-form-react"
|
|
493
538
|
* import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"
|
|
494
|
-
* import * as Atom from "@effect-atom/atom/Atom"
|
|
495
539
|
* import * as Schema from "effect/Schema"
|
|
496
|
-
* import * as Layer from "effect/Layer"
|
|
497
|
-
*
|
|
498
|
-
* const runtime = Atom.runtime(Layer.empty)
|
|
499
540
|
*
|
|
500
|
-
* const
|
|
541
|
+
* const loginFormBuilder = FormBuilder.empty
|
|
501
542
|
* .addField("email", Schema.String)
|
|
502
543
|
* .addField("password", Schema.String)
|
|
503
544
|
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
545
|
+
* // Runtime is optional for forms without service requirements
|
|
546
|
+
* const loginForm = FormReact.make(loginFormBuilder, {
|
|
506
547
|
* fields: { email: TextInput, password: PasswordInput },
|
|
507
|
-
* onSubmit: (
|
|
548
|
+
* onSubmit: (_, { decoded }) => Effect.log(`Login: ${decoded.email}`),
|
|
508
549
|
* })
|
|
509
550
|
*
|
|
510
551
|
* // Subscribe to atoms anywhere in the tree
|
|
511
552
|
* function SubmitButton() {
|
|
512
|
-
* const isDirty = useAtomValue(
|
|
513
|
-
* const submit = useAtomValue(
|
|
514
|
-
* const callSubmit = useAtomSet(
|
|
553
|
+
* const isDirty = useAtomValue(loginForm.isDirty)
|
|
554
|
+
* const submit = useAtomValue(loginForm.submit)
|
|
555
|
+
* const callSubmit = useAtomSet(loginForm.submit)
|
|
515
556
|
* return (
|
|
516
557
|
* <button onClick={() => callSubmit()} disabled={!isDirty || submit.waiting}>
|
|
517
558
|
* {submit.waiting ? "Validating..." : "Login"}
|
|
@@ -519,48 +560,74 @@ const makeFieldComponents = <
|
|
|
519
560
|
* )
|
|
520
561
|
* }
|
|
521
562
|
*
|
|
522
|
-
* function
|
|
563
|
+
* function LoginPage() {
|
|
523
564
|
* return (
|
|
524
|
-
* <
|
|
525
|
-
* <
|
|
526
|
-
* <
|
|
565
|
+
* <loginForm.Initialize defaultValues={{ email: "", password: "" }}>
|
|
566
|
+
* <loginForm.email />
|
|
567
|
+
* <loginForm.password />
|
|
527
568
|
* <SubmitButton />
|
|
528
|
-
* </
|
|
569
|
+
* </loginForm.Initialize>
|
|
529
570
|
* )
|
|
530
571
|
* }
|
|
531
572
|
* ```
|
|
532
573
|
*
|
|
533
574
|
* @category Constructors
|
|
534
575
|
*/
|
|
535
|
-
export const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
576
|
+
export const make: {
|
|
577
|
+
<
|
|
578
|
+
TFields extends Field.FieldsRecord,
|
|
579
|
+
A,
|
|
580
|
+
E,
|
|
581
|
+
SubmitArgs = void,
|
|
582
|
+
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
583
|
+
>(
|
|
584
|
+
self: FormBuilder.FormBuilder<TFields, never>,
|
|
585
|
+
options: {
|
|
586
|
+
readonly runtime?: Atom.AtomRuntime<never, never>
|
|
587
|
+
readonly fields: CM
|
|
588
|
+
readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
|
|
589
|
+
readonly onSubmit: (
|
|
590
|
+
args: SubmitArgs,
|
|
591
|
+
ctx: {
|
|
592
|
+
readonly decoded: Field.DecodedFromFields<TFields>
|
|
593
|
+
readonly encoded: Field.EncodedFromFields<TFields>
|
|
594
|
+
readonly get: Atom.FnContext
|
|
595
|
+
},
|
|
596
|
+
) => A | Effect.Effect<A, E, never>
|
|
597
|
+
},
|
|
598
|
+
): BuiltForm<TFields, never, A, E, SubmitArgs, CM>
|
|
599
|
+
|
|
600
|
+
<
|
|
601
|
+
TFields extends Field.FieldsRecord,
|
|
602
|
+
R,
|
|
603
|
+
A,
|
|
604
|
+
E,
|
|
605
|
+
SubmitArgs = void,
|
|
606
|
+
ER = never,
|
|
607
|
+
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
608
|
+
>(
|
|
609
|
+
self: FormBuilder.FormBuilder<TFields, R>,
|
|
610
|
+
options: {
|
|
611
|
+
readonly runtime: Atom.AtomRuntime<R, ER>
|
|
612
|
+
readonly fields: CM
|
|
613
|
+
readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
|
|
614
|
+
readonly onSubmit: (
|
|
615
|
+
args: SubmitArgs,
|
|
616
|
+
ctx: {
|
|
617
|
+
readonly decoded: Field.DecodedFromFields<TFields>
|
|
618
|
+
readonly encoded: Field.EncodedFromFields<TFields>
|
|
619
|
+
readonly get: Atom.FnContext
|
|
620
|
+
},
|
|
621
|
+
) => A | Effect.Effect<A, E, R>
|
|
622
|
+
},
|
|
623
|
+
): BuiltForm<TFields, R, A, E, SubmitArgs, CM>
|
|
624
|
+
} = (self: any, options: any): any => {
|
|
625
|
+
const { fields: components, mode, onSubmit, runtime: providedRuntime } = options
|
|
626
|
+
const runtime = providedRuntime ?? Atom.runtime(Layer.empty)
|
|
560
627
|
const parsedMode = Mode.parse(mode)
|
|
561
628
|
const { fields } = self
|
|
562
629
|
|
|
563
|
-
const formAtoms
|
|
630
|
+
const formAtoms = FormAtoms.make({
|
|
564
631
|
formBuilder: self,
|
|
565
632
|
runtime,
|
|
566
633
|
onSubmit,
|
|
@@ -568,18 +635,21 @@ export const build = <
|
|
|
568
635
|
|
|
569
636
|
const {
|
|
570
637
|
combinedSchema,
|
|
571
|
-
crossFieldErrorsAtom,
|
|
572
638
|
dirtyFieldsAtom,
|
|
639
|
+
errorsAtom,
|
|
573
640
|
fieldRefs,
|
|
574
641
|
getFieldAtom,
|
|
575
642
|
getOrCreateFieldAtoms,
|
|
576
643
|
getOrCreateValidationAtom,
|
|
577
644
|
hasChangedSinceSubmitAtom,
|
|
578
645
|
isDirtyAtom,
|
|
646
|
+
keepAliveActiveAtom,
|
|
579
647
|
lastSubmittedValuesAtom,
|
|
648
|
+
mountAtom,
|
|
580
649
|
operations,
|
|
581
650
|
resetAtom,
|
|
582
651
|
revertToLastSubmitAtom,
|
|
652
|
+
rootErrorAtom,
|
|
583
653
|
setValue,
|
|
584
654
|
setValuesAtom,
|
|
585
655
|
stateAtom,
|
|
@@ -589,7 +659,7 @@ export const build = <
|
|
|
589
659
|
} = formAtoms
|
|
590
660
|
|
|
591
661
|
const InitializeComponent: React.FC<{
|
|
592
|
-
readonly defaultValues:
|
|
662
|
+
readonly defaultValues: any
|
|
593
663
|
readonly children: React.ReactNode
|
|
594
664
|
}> = ({ children, defaultValues }) => {
|
|
595
665
|
const registry = React.useContext(RegistryContext)
|
|
@@ -599,56 +669,110 @@ export const build = <
|
|
|
599
669
|
const isInitializedRef = React.useRef(false)
|
|
600
670
|
|
|
601
671
|
React.useEffect(() => {
|
|
602
|
-
|
|
672
|
+
const isKeptAlive = registry.get(keepAliveActiveAtom)
|
|
673
|
+
const currentState = registry.get(stateAtom)
|
|
674
|
+
|
|
675
|
+
if (!isKeptAlive) {
|
|
676
|
+
setFormState(Option.some(operations.createInitialState(defaultValues)))
|
|
677
|
+
} else if (Option.isNone(currentState)) {
|
|
678
|
+
setFormState(Option.some(operations.createInitialState(defaultValues)))
|
|
679
|
+
}
|
|
680
|
+
|
|
603
681
|
isInitializedRef.current = true
|
|
604
682
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
|
|
605
|
-
}, [])
|
|
683
|
+
}, [registry])
|
|
606
684
|
|
|
607
685
|
const debouncedAutoSubmit = useDebounced(() => {
|
|
608
686
|
const stateOption = registry.get(stateAtom)
|
|
609
687
|
if (Option.isNone(stateOption)) return
|
|
610
|
-
callSubmit(undefined
|
|
688
|
+
callSubmit(undefined)
|
|
611
689
|
}, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
|
|
612
690
|
|
|
691
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
692
|
+
// Auto-Submit Coordination
|
|
693
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
694
|
+
// Two-subscription model to avoid infinite loop:
|
|
695
|
+
// - Stream 1 reacts to value changes (reference equality), triggers or queues submit
|
|
696
|
+
// - Stream 2 reacts to submit completion, flushes queued changes
|
|
697
|
+
//
|
|
698
|
+
// Single subscription to stateAtom cannot distinguish value changes from submit
|
|
699
|
+
// metadata updates (submitCount, lastSubmittedValues).
|
|
700
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
701
|
+
|
|
702
|
+
const lastValuesRef = React.useRef<unknown>(null)
|
|
703
|
+
const pendingChangesRef = React.useRef(false)
|
|
704
|
+
const wasSubmittingRef = React.useRef(false)
|
|
705
|
+
|
|
613
706
|
useAtomSubscribe(
|
|
614
707
|
stateAtom,
|
|
615
708
|
React.useCallback(() => {
|
|
616
709
|
if (!isInitializedRef.current) return
|
|
617
|
-
|
|
710
|
+
|
|
711
|
+
const state = registry.get(stateAtom)
|
|
712
|
+
if (Option.isNone(state)) return
|
|
713
|
+
const currentValues = state.value.values
|
|
714
|
+
|
|
715
|
+
// Reference equality filters out submit metadata changes.
|
|
716
|
+
// Works because setFieldValue creates new values object (immutable update).
|
|
717
|
+
if (currentValues === lastValuesRef.current) return
|
|
718
|
+
lastValuesRef.current = currentValues
|
|
719
|
+
|
|
720
|
+
if (!parsedMode.autoSubmit || parsedMode.validation !== "onChange") return
|
|
721
|
+
|
|
722
|
+
const submitResult = registry.get(submitAtom)
|
|
723
|
+
if (submitResult.waiting) {
|
|
724
|
+
pendingChangesRef.current = true
|
|
725
|
+
} else {
|
|
618
726
|
debouncedAutoSubmit()
|
|
619
727
|
}
|
|
620
|
-
}, [debouncedAutoSubmit]),
|
|
728
|
+
}, [debouncedAutoSubmit, registry]),
|
|
729
|
+
{ immediate: false },
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
useAtomSubscribe(
|
|
733
|
+
submitAtom,
|
|
734
|
+
React.useCallback(
|
|
735
|
+
(result) => {
|
|
736
|
+
if (!parsedMode.autoSubmit || parsedMode.validation !== "onChange") return
|
|
737
|
+
|
|
738
|
+
const isSubmitting = result.waiting
|
|
739
|
+
const wasSubmitting = wasSubmittingRef.current
|
|
740
|
+
wasSubmittingRef.current = isSubmitting
|
|
741
|
+
|
|
742
|
+
// Flush queued changes when submit completes
|
|
743
|
+
if (wasSubmitting && !isSubmitting) {
|
|
744
|
+
if (pendingChangesRef.current) {
|
|
745
|
+
pendingChangesRef.current = false
|
|
746
|
+
debouncedAutoSubmit()
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
[debouncedAutoSubmit],
|
|
751
|
+
),
|
|
621
752
|
{ immediate: false },
|
|
622
753
|
)
|
|
623
754
|
|
|
624
755
|
const onBlurAutoSubmit = React.useCallback(() => {
|
|
625
|
-
if (parsedMode.autoSubmit
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
756
|
+
if (!parsedMode.autoSubmit || parsedMode.validation !== "onBlur") return
|
|
757
|
+
|
|
758
|
+
const stateOption = registry.get(stateAtom)
|
|
759
|
+
if (Option.isNone(stateOption)) return
|
|
760
|
+
|
|
761
|
+
const { lastSubmittedValues, values } = stateOption.value
|
|
762
|
+
if (Option.isSome(lastSubmittedValues) && values === lastSubmittedValues.value.encoded) return
|
|
763
|
+
|
|
764
|
+
callSubmit(undefined)
|
|
630
765
|
}, [registry, callSubmit])
|
|
631
766
|
|
|
632
767
|
if (Option.isNone(state)) return null
|
|
633
768
|
|
|
634
|
-
return
|
|
635
|
-
<AutoSubmitContext.Provider value={onBlurAutoSubmit}>
|
|
636
|
-
<form
|
|
637
|
-
onSubmit={(e) => {
|
|
638
|
-
e.preventDefault()
|
|
639
|
-
e.stopPropagation()
|
|
640
|
-
}}
|
|
641
|
-
>
|
|
642
|
-
{children}
|
|
643
|
-
</form>
|
|
644
|
-
</AutoSubmitContext.Provider>
|
|
645
|
-
)
|
|
769
|
+
return <AutoSubmitContext.Provider value={onBlurAutoSubmit}>{children}</AutoSubmitContext.Provider>
|
|
646
770
|
}
|
|
647
771
|
|
|
648
772
|
const fieldComponents = makeFieldComponents(
|
|
649
773
|
fields,
|
|
650
774
|
stateAtom,
|
|
651
|
-
|
|
775
|
+
errorsAtom,
|
|
652
776
|
submitCountAtom,
|
|
653
777
|
dirtyFieldsAtom,
|
|
654
778
|
parsedMode,
|
|
@@ -658,12 +782,25 @@ export const build = <
|
|
|
658
782
|
components,
|
|
659
783
|
)
|
|
660
784
|
|
|
785
|
+
const KeepAlive: React.FC = () => {
|
|
786
|
+
const setKeepAliveActive = useAtomSet(keepAliveActiveAtom)
|
|
787
|
+
|
|
788
|
+
React.useLayoutEffect(() => {
|
|
789
|
+
setKeepAliveActive(true)
|
|
790
|
+
return () => setKeepAliveActive(false)
|
|
791
|
+
}, [setKeepAliveActive])
|
|
792
|
+
|
|
793
|
+
useAtomMount(mountAtom)
|
|
794
|
+
return null
|
|
795
|
+
}
|
|
796
|
+
|
|
661
797
|
return {
|
|
662
798
|
values: valuesAtom,
|
|
663
799
|
isDirty: isDirtyAtom,
|
|
664
800
|
hasChangedSinceSubmit: hasChangedSinceSubmitAtom,
|
|
665
801
|
lastSubmittedValues: lastSubmittedValuesAtom,
|
|
666
802
|
submitCount: submitCountAtom,
|
|
803
|
+
rootError: rootErrorAtom,
|
|
667
804
|
schema: combinedSchema,
|
|
668
805
|
fields: fieldRefs,
|
|
669
806
|
Initialize: InitializeComponent,
|
|
@@ -673,47 +810,109 @@ export const build = <
|
|
|
673
810
|
setValues: setValuesAtom,
|
|
674
811
|
setValue,
|
|
675
812
|
getFieldAtom,
|
|
813
|
+
mount: mountAtom,
|
|
814
|
+
KeepAlive,
|
|
676
815
|
...fieldComponents,
|
|
677
|
-
}
|
|
816
|
+
}
|
|
678
817
|
}
|
|
679
818
|
|
|
680
819
|
/**
|
|
681
|
-
* A curried helper that infers the schema type from a field definition
|
|
820
|
+
* A curried helper that infers the schema type from a field definition.
|
|
682
821
|
* Provides ergonomic type inference when defining field components.
|
|
683
822
|
*
|
|
684
823
|
* @example
|
|
685
824
|
* ```tsx
|
|
686
|
-
* import { FormReact } from "@lucas-barake/effect-form-react"
|
|
825
|
+
* import { Field, FormReact } from "@lucas-barake/effect-form-react"
|
|
687
826
|
*
|
|
688
|
-
* // Using a FieldRef from the built form
|
|
689
|
-
* const TextInput = FormReact.forField(form.fields.email)(({ field }) => (
|
|
690
|
-
* <input value={field.value} onChange={e => field.onChange(e.target.value)} />
|
|
691
|
-
* ))
|
|
692
|
-
*
|
|
693
|
-
* // Using a FieldDef (for reusable fields)
|
|
694
827
|
* const EmailField = Field.makeField("email", Schema.String)
|
|
695
828
|
* const TextInput = FormReact.forField(EmailField)(({ field }) => (
|
|
696
829
|
* <input value={field.value} onChange={e => field.onChange(e.target.value)} />
|
|
697
830
|
* ))
|
|
698
831
|
*
|
|
699
832
|
* // With extra props - just specify the props type
|
|
700
|
-
* const TextInput = FormReact.forField(
|
|
833
|
+
* const TextInput = FormReact.forField(EmailField)<{ placeholder?: string }>(({ field, props }) => (
|
|
701
834
|
* <input value={field.value} placeholder={props.placeholder} ... />
|
|
702
835
|
* ))
|
|
703
836
|
* ```
|
|
704
837
|
*
|
|
705
838
|
* @category Constructors
|
|
706
839
|
*/
|
|
707
|
-
export const forField
|
|
708
|
-
<S
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
840
|
+
export const forField = <K extends string, S extends Schema.Schema.Any>(
|
|
841
|
+
_field: Field.FieldDef<K, S>,
|
|
842
|
+
): <P extends Record<string, unknown> = Record<string, never>>(
|
|
843
|
+
component: React.FC<FieldComponentProps<S, P>>,
|
|
844
|
+
) => React.FC<FieldComponentProps<S, P>> =>
|
|
845
|
+
(component) => component
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Creates a bundled field definition + component for reusable form fields.
|
|
849
|
+
* Reduces boilerplate when you need both a field and its component together.
|
|
850
|
+
*
|
|
851
|
+
* Uses a curried API for better type inference - the schema type is captured
|
|
852
|
+
* first, so you only need to specify the extra props type (if any).
|
|
853
|
+
*
|
|
854
|
+
* @example
|
|
855
|
+
* ```tsx
|
|
856
|
+
* import { FormReact } from "@lucas-barake/effect-form-react"
|
|
857
|
+
* import * as Schema from "effect/Schema"
|
|
858
|
+
*
|
|
859
|
+
* // Define field + component in one place (no extra props)
|
|
860
|
+
* const NameInput = FormReact.makeField({
|
|
861
|
+
* key: "name",
|
|
862
|
+
* schema: Schema.String.pipe(Schema.nonEmptyString()),
|
|
863
|
+
* })(({ field }) => (
|
|
864
|
+
* <input
|
|
865
|
+
* value={field.value}
|
|
866
|
+
* onChange={(e) => field.onChange(e.target.value)}
|
|
867
|
+
* onBlur={field.onBlur}
|
|
868
|
+
* />
|
|
869
|
+
* ))
|
|
870
|
+
*
|
|
871
|
+
* // With extra props - specify only the props type
|
|
872
|
+
* const EmailInput = FormReact.makeField({
|
|
873
|
+
* key: "email",
|
|
874
|
+
* schema: Schema.String,
|
|
875
|
+
* })<{ placeholder: string }>(({ field, props }) => (
|
|
876
|
+
* <input
|
|
877
|
+
* value={field.value}
|
|
878
|
+
* onChange={(e) => field.onChange(e.target.value)}
|
|
879
|
+
* placeholder={props.placeholder}
|
|
880
|
+
* />
|
|
881
|
+
* ))
|
|
882
|
+
*
|
|
883
|
+
* // Use in form builder
|
|
884
|
+
* const formBuilder = FormBuilder.empty.addField(NameInput.field)
|
|
885
|
+
*
|
|
886
|
+
* // Use in make()
|
|
887
|
+
* const form = FormReact.make(formBuilder, {
|
|
888
|
+
* runtime,
|
|
889
|
+
* fields: { name: NameInput },
|
|
890
|
+
* onSubmit: (_, { decoded }) => Effect.log(decoded.name),
|
|
891
|
+
* })
|
|
892
|
+
* ```
|
|
893
|
+
*
|
|
894
|
+
* @category Constructors
|
|
895
|
+
*/
|
|
896
|
+
export const makeField = <K extends string, S extends Schema.Schema.Any>(options: {
|
|
897
|
+
readonly key: K
|
|
898
|
+
readonly schema: S
|
|
899
|
+
}): <P extends Record<string, unknown> = Record<string, never>>(
|
|
900
|
+
component: React.FC<FieldComponentProps<S, P>>,
|
|
901
|
+
) => FieldBundle<K, S, P> => {
|
|
902
|
+
const field = Field.makeField(options.key, options.schema)
|
|
903
|
+
return (component) => {
|
|
904
|
+
if (!component.displayName) {
|
|
905
|
+
const displayName = `${options.key.charAt(0).toUpperCase()}${options.key.slice(1)}Field`
|
|
906
|
+
try {
|
|
907
|
+
;(component as any).displayName = displayName
|
|
908
|
+
} catch {
|
|
909
|
+
// Ignore - some environments freeze function properties
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
_tag: "FieldBundle",
|
|
914
|
+
field,
|
|
915
|
+
component,
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|