From da0847b3f9e5bbbbaec20696c9822f3a3bcb0660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 12 Nov 2025 01:20:13 +0100 Subject: [PATCH] Refactor form --- packages/effect-fc/src/Form.ts | 96 +++++++++++++--------------------- 1 file changed, 37 insertions(+), 59 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 736f2e6..16577e4 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,6 +1,6 @@ import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" import type { NoSuchElementException } from "effect/Cause" -import * as React from "react" +import type * as React from "react" import * as Component from "./Component.js" import * as PropertyPath from "./PropertyPath.js" import * as Result from "./Result.js" @@ -12,12 +12,13 @@ import * as SubscriptionSubRef from "./SubscriptionSubRef.js" export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form") export type FormTypeId = typeof FormTypeId -export interface Form +export interface Form extends Pipeable.Pipeable { readonly [FormTypeId]: FormTypeId readonly schema: Schema.Schema readonly onSubmit: (value: NoInfer) => Effect.Effect + readonly initialSubmitProgress: SP readonly autosubmit: boolean readonly debounce: Option.Option @@ -25,18 +26,19 @@ extends Pipeable.Pipeable { readonly encodedValueRef: SubscriptionRef.SubscriptionRef readonly errorRef: SubscriptionRef.SubscriptionRef> readonly validationFiberRef: SubscriptionRef.SubscriptionRef>> - readonly submitResultRef: SubscriptionRef.SubscriptionRef> + readonly submitResultRef: SubscriptionRef.SubscriptionRef> readonly canSubmitSubscribable: Subscribable.Subscribable } -class FormImpl -extends Pipeable.Class() implements Form { +class FormImpl +extends Pipeable.Class() implements Form { readonly [FormTypeId]: FormTypeId = FormTypeId constructor( readonly schema: Schema.Schema, readonly onSubmit: (value: NoInfer) => Effect.Effect, + readonly initialSubmitProgress: SP, readonly autosubmit: boolean, readonly debounce: Option.Option, @@ -44,7 +46,7 @@ extends Pipeable.Class() implements Form { readonly encodedValueRef: SubscriptionRef.SubscriptionRef, readonly errorRef: SubscriptionRef.SubscriptionRef>, readonly validationFiberRef: SubscriptionRef.SubscriptionRef>>, - readonly submitResultRef: SubscriptionRef.SubscriptionRef>, + readonly submitResultRef: SubscriptionRef.SubscriptionRef>, readonly canSubmitSubscribable: Subscribable.Subscribable, ) { @@ -55,33 +57,31 @@ extends Pipeable.Class() implements Form { export const isForm = (u: unknown): u is Form => Predicate.hasProperty(u, FormTypeId) export namespace make { - export interface Options { + export interface Options { readonly schema: Schema.Schema readonly initialEncodedValue: NoInfer readonly onSubmit: ( this: Form, NoInfer, NoInfer, unknown, unknown, unknown>, value: NoInfer, ) => Effect.Effect + readonly initialSubmitProgress?: SP readonly autosubmit?: boolean readonly debounce?: Duration.DurationInput } } -export const make: { - ( - options: make.Options - ): Effect.Effect> -} = Effect.fnUntraced(function* ( - options: make.Options -) { +export const make = Effect.fnUntraced(function* ( + options: make.Options +): Effect.fn.Return> { const valueRef = yield* SubscriptionRef.make(Option.none()) const errorRef = yield* SubscriptionRef.make(Option.none()) const validationFiberRef = yield* SubscriptionRef.make(Option.none>()) - const submitResultRef = yield* SubscriptionRef.make>(Result.initial()) + const submitResultRef = yield* SubscriptionRef.make>(Result.initial()) return new FormImpl( options.schema, options.onSubmit, + options.initialSubmitProgress as SP, options.autosubmit ?? false, Option.fromNullable(options.debounce), @@ -103,8 +103,8 @@ export const make: { ) }) -export const run = ( - self: Form +export const run = ( + self: Form ): Effect.Effect => Stream.runForEach( self.encodedValueRef.changes.pipe( Option.isSome(self.debounce) ? Stream.debounce(self.debounce.value) : identity @@ -144,19 +144,24 @@ export const run = ( ), ) -export const submit = ( - self: Form -): Effect.Effect>, NoSuchElementException, Scope.Scope | SR> => Effect.whenEffect( +export const submit = ( + self: Form +): Effect.Effect< + Option.Option>, + NoSuchElementException, + Scope.Scope | Result.forkEffectPubSub.OutputContext +> => Effect.whenEffect( self.valueRef.pipe( Effect.andThen(identity), Effect.andThen(value => Result.forkEffectPubSub( - self.onSubmit(value) as Effect.Effect>) - ), + self.onSubmit(value) as Effect.Effect>, + { initialProgress: self.initialSubmitProgress }, + )), Effect.andThen(identity), Effect.andThen(Stream.fromQueue), Stream.unwrapScoped, Stream.runFoldEffect( - Result.initial() as Result.Result, + Result.initial() as Result.Result, (_, result) => Effect.as(Ref.set(self.submitResultRef, result), result), ), Effect.tap(result => Result.isFailure(result) @@ -178,13 +183,13 @@ export const submit = ( ) export namespace service { - export interface Options - extends make.Options {} + export interface Options + extends make.Options {} } -export const service = ( - options: service.Options -): Effect.Effect, never, Scope.Scope | R | SR> => Effect.tap( +export const service = ( + options: service.Options +): Effect.Effect, never, Scope.Scope | R | SR> => Effect.tap( make(options), form => Effect.forkScoped(run(form)), ) @@ -242,23 +247,6 @@ extends Pipeable.Class() implements FormField { export const isFormField = (u: unknown): u is FormField => Predicate.hasProperty(u, FormFieldTypeId) -export const useSubmit = ( - self: Form -): Effect.Effect< - () => Promise>>, - never, - Scope.Scope | SR -> => Component.useCallbackPromise(() => submit(self), [self]) - -export const useField = >>( - self: Form, - path: P, -): FormField< - PropertyPath.ValueFromPath, - PropertyPath.ValueFromPath -// biome-ignore lint/correctness/useExhaustiveDependencies: individual path components need to be compared -> => React.useMemo(() => field(self, path), [self, ...path]) - export namespace useInput { export interface Options { readonly debounce?: Duration.DurationInput @@ -270,15 +258,10 @@ export namespace useInput { } } -export const useInput: { - ( - field: FormField, - options?: useInput.Options, - ): Effect.Effect, NoSuchElementException, Scope.Scope> -} = Effect.fnUntraced(function* ( +export const useInput = Effect.fnUntraced(function* ( field: FormField, options?: useInput.Options, -) { +): Effect.fn.Return, NoSuchElementException, Scope.Scope> { const internalValueRef = yield* Component.useOnChange(() => Effect.tap( Effect.andThen(field.encodedValueRef, SubscriptionRef.make), internalValueRef => Effect.forkScoped(Effect.all([ @@ -316,15 +299,10 @@ export namespace useOptionalInput { } } -export const useOptionalInput: { - ( - field: FormField>, - options: useOptionalInput.Options, - ): Effect.Effect, NoSuchElementException, Scope.Scope> -} = Effect.fnUntraced(function* ( +export const useOptionalInput = Effect.fnUntraced(function* ( field: FormField>, options: useOptionalInput.Options, -) { +): Effect.fn.Return, NoSuchElementException, Scope.Scope> { const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( Effect.andThen( field.encodedValueRef,