@lucas-barake/effect-form-react 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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, schemaPathToFieldPath } from "@lucas-barake/effect-form/internal/path"
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"
@@ -105,25 +104,6 @@ export interface ArrayFieldOperations<TItem> {
105
104
  readonly move: (from: number, to: number) => void
106
105
  }
107
106
 
108
- /**
109
- * State exposed to form.Subscribe render prop.
110
- *
111
- * @since 1.0.0
112
- * @category Models
113
- */
114
- export interface SubscribeState<TFields extends Field.FieldsRecord> {
115
- readonly values: Field.EncodedFromFields<TFields>
116
- readonly isDirty: boolean
117
- readonly hasChangedSinceSubmit: boolean
118
- readonly lastSubmittedValues: Option.Option<Field.EncodedFromFields<TFields>>
119
- readonly submitResult: Result.Result<unknown, unknown>
120
- readonly submit: () => void
121
- readonly reset: () => void
122
- readonly revertToLastSubmit: () => void
123
- readonly setValue: <S>(field: FormBuilder.FieldRef<S>, update: S | ((prev: S) => S)) => void
124
- readonly setValues: (values: Field.EncodedFromFields<TFields>) => void
125
- }
126
-
127
107
  /**
128
108
  * The result of building a form, containing all components and utilities needed
129
109
  * for form rendering and submission.
@@ -134,45 +114,29 @@ export interface SubscribeState<TFields extends Field.FieldsRecord> {
134
114
  export type BuiltForm<
135
115
  TFields extends Field.FieldsRecord,
136
116
  R,
117
+ A = void,
118
+ E = never,
137
119
  CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
138
120
  > = {
139
- readonly atom: Atom.Writable<
140
- Option.Option<FormBuilder.FormState<TFields>>,
141
- Option.Option<FormBuilder.FormState<TFields>>
142
- >
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<FormBuilder.SubmittedValues<TFields>>>
125
+ readonly submitCount: Atom.Atom<number>
126
+
143
127
  readonly schema: Schema.Schema<Field.DecodedFromFields<TFields>, Field.EncodedFromFields<TFields>, R>
144
128
  readonly fields: FieldRefs<TFields>
145
129
 
146
- readonly Form: React.FC<{
130
+ readonly Initialize: React.FC<{
147
131
  readonly defaultValues: Field.EncodedFromFields<TFields>
148
- readonly onSubmit: Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown>
149
132
  readonly children: React.ReactNode
150
133
  }>
151
134
 
152
- readonly Subscribe: React.FC<{
153
- readonly children: (state: SubscribeState<TFields>) => React.ReactNode
154
- }>
155
-
156
- readonly useForm: () => {
157
- readonly submit: () => void
158
- readonly reset: () => void
159
- readonly revertToLastSubmit: () => void
160
- readonly isDirty: boolean
161
- readonly hasChangedSinceSubmit: boolean
162
- readonly lastSubmittedValues: Option.Option<Field.EncodedFromFields<TFields>>
163
- readonly submitResult: Result.Result<unknown, unknown>
164
- readonly values: Field.EncodedFromFields<TFields>
165
- readonly setValue: <S>(field: FormBuilder.FieldRef<S>, update: S | ((prev: S) => S)) => void
166
- readonly setValues: (values: Field.EncodedFromFields<TFields>) => void
167
- }
168
-
169
- readonly submit: <A>(
170
- fn: (values: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A,
171
- ) => Atom.AtomResultFn<
172
- Field.DecodedFromFields<TFields>,
173
- A extends Effect.Effect<infer T, any, any> ? T : A,
174
- A extends Effect.Effect<any, infer E, any> ? E : never
175
- >
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)>
176
140
  } & FieldComponents<TFields, CM>
177
141
 
178
142
  type FieldComponents<TFields extends Field.FieldsRecord, CM extends FieldComponentMap<TFields>> = {
@@ -285,12 +249,13 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
285
249
  (newValue: Schema.Schema.Encoded<S>) => {
286
250
  setValue(newValue)
287
251
  setCrossFieldErrors((prev) => {
288
- if (prev.has(fieldPath)) {
289
- const next = new Map(prev)
290
- next.delete(fieldPath)
291
- return next
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
+ }
292
257
  }
293
- return prev
258
+ return next.size !== prev.size ? next : prev
294
259
  })
295
260
  if (parsedMode.validation === "onChange") {
296
261
  validate(newValue)
@@ -457,6 +422,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
457
422
  ...itemFieldComponents,
458
423
  }
459
424
 
425
+ // Proxy enables <Form.items.Item> and <Form.items.name> syntax
460
426
  return new Proxy(ArrayWrapper, {
461
427
  get(target, prop) {
462
428
  if (prop in properties) {
@@ -530,11 +496,11 @@ const makeFieldComponents = <
530
496
  *
531
497
  * @example
532
498
  * ```tsx
533
- * import { Form } from "@lucas-barake/effect-form"
499
+ * import { FormBuilder } from "@lucas-barake/effect-form"
534
500
  * import { FormReact } from "@lucas-barake/effect-form-react"
501
+ * import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"
535
502
  * import * as Atom from "@effect-atom/atom/Atom"
536
503
  * import * as Schema from "effect/Schema"
537
- * import * as Effect from "effect/Effect"
538
504
  * import * as Layer from "effect/Layer"
539
505
  *
540
506
  * const runtime = Atom.runtime(Layer.empty)
@@ -546,26 +512,28 @@ const makeFieldComponents = <
546
512
  * const form = FormReact.build(loginForm, {
547
513
  * runtime,
548
514
  * fields: { email: TextInput, password: PasswordInput },
515
+ * onSubmit: (values) => Effect.log(`Login: ${values.email}`),
549
516
  * })
550
517
  *
551
- * function LoginDialog({ onClose }) {
552
- * const handleSubmit = form.submit((values) =>
553
- * Effect.gen(function* () {
554
- * yield* saveUser(values)
555
- * onClose()
556
- * })
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>
557
527
  * )
528
+ * }
558
529
  *
530
+ * function LoginDialog({ onClose }) {
559
531
  * return (
560
- * <form.Form defaultValues={{ email: "", password: "" }} onSubmit={handleSubmit}>
532
+ * <form.Initialize defaultValues={{ email: "", password: "" }}>
561
533
  * <form.email />
562
534
  * <form.password />
563
- * <form.Subscribe>
564
- * {({ isDirty, submit }) => (
565
- * <button onClick={submit} disabled={!isDirty}>Login</button>
566
- * )}
567
- * </form.Subscribe>
568
- * </form.Form>
535
+ * <SubmitButton />
536
+ * </form.Initialize>
569
537
  * )
570
538
  * }
571
539
  * ```
@@ -576,6 +544,8 @@ const makeFieldComponents = <
576
544
  export const build = <
577
545
  TFields extends Field.FieldsRecord,
578
546
  R,
547
+ A,
548
+ E,
579
549
  ER = never,
580
550
  CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
581
551
  >(
@@ -584,21 +554,22 @@ export const build = <
584
554
  readonly runtime: Atom.AtomRuntime<R, ER>
585
555
  readonly fields: CM
586
556
  readonly mode?: Mode.FormMode
557
+ readonly onSubmit: (decoded: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A | Effect.Effect<A, E, R>
587
558
  },
588
- ): BuiltForm<TFields, R, CM> => {
589
- const { fields: components, mode, runtime } = options
559
+ ): BuiltForm<TFields, R, A, E, CM> => {
560
+ const { fields: components, mode, onSubmit, runtime } = options
590
561
  const parsedMode = Mode.parse(mode)
591
562
  const { fields } = self
592
563
 
593
- const formAtoms: FormAtoms.FormAtoms<TFields, R> = FormAtoms.make({
564
+ const formAtoms: FormAtoms.FormAtoms<TFields, R, A, E> = FormAtoms.make({
594
565
  formBuilder: self,
595
566
  runtime,
567
+ onSubmit,
596
568
  })
597
569
 
598
570
  const {
599
571
  combinedSchema,
600
572
  crossFieldErrorsAtom,
601
- decodeAndSubmit,
602
573
  dirtyFieldsAtom,
603
574
  fieldRefs,
604
575
  getOrCreateFieldAtoms,
@@ -606,42 +577,42 @@ export const build = <
606
577
  hasChangedSinceSubmitAtom,
607
578
  isDirtyAtom,
608
579
  lastSubmittedValuesAtom,
609
- onSubmitAtom,
610
580
  operations,
611
- resetValidationAtoms,
581
+ resetAtom,
582
+ revertToLastSubmitAtom,
583
+ setValue,
584
+ setValuesAtom,
612
585
  stateAtom,
586
+ submitAtom,
613
587
  submitCountAtom,
614
588
  } = formAtoms
615
589
 
616
- const FormComponent: React.FC<{
590
+ const InitializeComponent: React.FC<{
617
591
  readonly defaultValues: Field.EncodedFromFields<TFields>
618
- readonly onSubmit: Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown>
619
592
  readonly children: React.ReactNode
620
- }> = ({ children, defaultValues, onSubmit }) => {
593
+ }> = ({ children, defaultValues }) => {
621
594
  const registry = React.useContext(RegistryContext)
622
595
  const state = useAtomValue(stateAtom)
623
596
  const setFormState = useAtomSet(stateAtom)
624
- const setOnSubmit = useAtomSet(onSubmitAtom)
625
- const callDecodeAndSubmit = useAtomSet(decodeAndSubmit)
626
-
627
- React.useEffect(() => {
628
- setOnSubmit(onSubmit)
629
- }, [onSubmit, setOnSubmit])
597
+ const callSubmit = useAtomSet(submitAtom)
598
+ const isInitializedRef = React.useRef(false)
630
599
 
631
600
  React.useEffect(() => {
632
601
  setFormState(Option.some(operations.createInitialState(defaultValues)))
602
+ isInitializedRef.current = true
633
603
  // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
634
604
  }, [])
635
605
 
636
606
  const debouncedAutoSubmit = useDebounced(() => {
637
607
  const stateOption = registry.get(stateAtom)
638
608
  if (Option.isNone(stateOption)) return
639
- callDecodeAndSubmit(stateOption.value.values)
609
+ callSubmit()
640
610
  }, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
641
611
 
642
612
  useAtomSubscribe(
643
613
  stateAtom,
644
614
  React.useCallback(() => {
615
+ if (!isInitializedRef.current) return
645
616
  if (parsedMode.autoSubmit && parsedMode.validation === "onChange") {
646
617
  debouncedAutoSubmit()
647
618
  }
@@ -653,9 +624,9 @@ export const build = <
653
624
  if (parsedMode.autoSubmit && parsedMode.validation === "onBlur") {
654
625
  const stateOption = registry.get(stateAtom)
655
626
  if (Option.isNone(stateOption)) return
656
- callDecodeAndSubmit(stateOption.value.values)
627
+ callSubmit()
657
628
  }
658
- }, [registry, callDecodeAndSubmit])
629
+ }, [registry, callSubmit])
659
630
 
660
631
  if (Option.isNone(state)) return null
661
632
 
@@ -673,175 +644,6 @@ export const build = <
673
644
  )
674
645
  }
675
646
 
676
- const useFormHook = () => {
677
- const registry = React.useContext(RegistryContext)
678
- const formValues = Option.getOrThrow(useAtomValue(stateAtom)).values
679
- const setFormState = useAtomSet(stateAtom)
680
- const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
681
- const [decodeAndSubmitResult, callDecodeAndSubmit] = useAtom(decodeAndSubmit)
682
- const isDirty = useAtomValue(isDirtyAtom)
683
- const hasChangedSinceSubmit = useAtomValue(hasChangedSinceSubmitAtom)
684
- const lastSubmittedValues = useAtomValue(lastSubmittedValuesAtom)
685
-
686
- React.useEffect(() => {
687
- if (decodeAndSubmitResult._tag === "Failure") {
688
- const parseError = Cause.failureOption(decodeAndSubmitResult.cause)
689
- if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
690
- const issues = ParseResult.ArrayFormatter.formatErrorSync(parseError.value)
691
-
692
- const fieldErrors = new Map<string, string>()
693
- for (const issue of issues) {
694
- if (issue.path.length > 0) {
695
- const fieldPath = schemaPathToFieldPath(issue.path)
696
- if (!fieldErrors.has(fieldPath)) {
697
- fieldErrors.set(fieldPath, issue.message)
698
- }
699
- }
700
- }
701
-
702
- if (fieldErrors.size > 0) {
703
- setCrossFieldErrors(fieldErrors)
704
- }
705
- }
706
- }
707
- }, [decodeAndSubmitResult, setCrossFieldErrors])
708
-
709
- const submit = React.useCallback(() => {
710
- const stateOption = registry.get(stateAtom)
711
- if (Option.isNone(stateOption)) return
712
-
713
- setCrossFieldErrors(new Map())
714
-
715
- setFormState((prev) => {
716
- if (Option.isNone(prev)) return prev
717
- return Option.some(operations.createSubmitState(prev.value))
718
- })
719
-
720
- callDecodeAndSubmit(stateOption.value.values)
721
- }, [setFormState, callDecodeAndSubmit, setCrossFieldErrors, registry])
722
-
723
- const reset = React.useCallback(() => {
724
- setFormState((prev) => {
725
- if (Option.isNone(prev)) return prev
726
- return Option.some(operations.createResetState(prev.value))
727
- })
728
- setCrossFieldErrors(new Map())
729
- resetValidationAtoms(registry)
730
- callDecodeAndSubmit(Atom.Reset)
731
- }, [setFormState, setCrossFieldErrors, callDecodeAndSubmit, registry])
732
-
733
- const revertToLastSubmit = React.useCallback(() => {
734
- setFormState((prev) => {
735
- if (Option.isNone(prev)) return prev
736
- return Option.some(operations.revertToLastSubmit(prev.value))
737
- })
738
- setCrossFieldErrors(new Map())
739
- }, [setFormState, setCrossFieldErrors])
740
-
741
- const setValue = React.useCallback(<S,>(
742
- field: FormBuilder.FieldRef<S>,
743
- update: S | ((prev: S) => S),
744
- ) => {
745
- const path = field.key
746
-
747
- setFormState((prev) => {
748
- if (Option.isNone(prev)) return prev
749
- const state = prev.value
750
-
751
- const currentValue = getNestedValue(state.values, path) as S
752
- const newValue = typeof update === "function"
753
- ? (update as (prev: S) => S)(currentValue)
754
- : update
755
-
756
- return Option.some(operations.setFieldValue(state, path, newValue))
757
- })
758
-
759
- setCrossFieldErrors((prev) => {
760
- let changed = false
761
- const next = new Map(prev)
762
- for (const errorPath of prev.keys()) {
763
- if (errorPath === path || errorPath.startsWith(path + ".") || errorPath.startsWith(path + "[")) {
764
- next.delete(errorPath)
765
- changed = true
766
- }
767
- }
768
- return changed ? next : prev
769
- })
770
- }, [setFormState, setCrossFieldErrors])
771
-
772
- const setValues = React.useCallback((values: Field.EncodedFromFields<TFields>) => {
773
- setFormState((prev) => {
774
- if (Option.isNone(prev)) return prev
775
- return Option.some(operations.setFormValues(prev.value, values))
776
- })
777
-
778
- setCrossFieldErrors(new Map())
779
- }, [setFormState, setCrossFieldErrors])
780
-
781
- return {
782
- submit,
783
- reset,
784
- revertToLastSubmit,
785
- isDirty,
786
- hasChangedSinceSubmit,
787
- lastSubmittedValues,
788
- submitResult: decodeAndSubmitResult,
789
- values: formValues,
790
- setValue,
791
- setValues,
792
- }
793
- }
794
-
795
- const SubscribeComponent: React.FC<{
796
- readonly children: (state: SubscribeState<TFields>) => React.ReactNode
797
- }> = ({ children }) => {
798
- const {
799
- hasChangedSinceSubmit,
800
- isDirty,
801
- lastSubmittedValues,
802
- reset,
803
- revertToLastSubmit,
804
- setValue,
805
- setValues,
806
- submit,
807
- submitResult,
808
- values,
809
- } = useFormHook()
810
-
811
- return (
812
- <>
813
- {children({
814
- hasChangedSinceSubmit,
815
- isDirty,
816
- lastSubmittedValues,
817
- reset,
818
- revertToLastSubmit,
819
- setValue,
820
- setValues,
821
- submit,
822
- submitResult,
823
- values,
824
- })}
825
- </>
826
- )
827
- }
828
-
829
- const submitHelper = <A,>(
830
- fn: (values: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A,
831
- ) =>
832
- runtime.fn<Field.DecodedFromFields<TFields>>()((values, get) => {
833
- const result = fn(values, get)
834
- return (Effect.isEffect(result) ? result : Effect.succeed(result)) as Effect.Effect<
835
- A extends Effect.Effect<infer T, any, any> ? T : A,
836
- A extends Effect.Effect<any, infer E, any> ? E : never,
837
- R
838
- >
839
- }) as Atom.AtomResultFn<
840
- Field.DecodedFromFields<TFields>,
841
- A extends Effect.Effect<infer T, any, any> ? T : A,
842
- A extends Effect.Effect<any, infer E, any> ? E : never
843
- >
844
-
845
647
  const fieldComponents = makeFieldComponents(
846
648
  fields,
847
649
  stateAtom,
@@ -856,15 +658,20 @@ export const build = <
856
658
  )
857
659
 
858
660
  return {
859
- atom: stateAtom,
661
+ isDirty: isDirtyAtom,
662
+ hasChangedSinceSubmit: hasChangedSinceSubmitAtom,
663
+ lastSubmittedValues: lastSubmittedValuesAtom,
664
+ submitCount: submitCountAtom,
860
665
  schema: combinedSchema,
861
666
  fields: fieldRefs,
862
- Form: FormComponent,
863
- Subscribe: SubscribeComponent,
864
- useForm: useFormHook,
865
- submit: submitHelper,
667
+ Initialize: InitializeComponent,
668
+ submit: submitAtom,
669
+ reset: resetAtom,
670
+ revertToLastSubmit: revertToLastSubmitAtom,
671
+ setValues: setValuesAtom,
672
+ setValue,
866
673
  ...fieldComponents,
867
- } as BuiltForm<TFields, R, CM>
674
+ } as BuiltForm<TFields, R, A, E, CM>
868
675
  }
869
676
 
870
677
  /**