From 73134478ed9187b9181eba00a77bd2d9a6308cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 29 Sep 2025 14:52:51 +0200 Subject: [PATCH] Working form --- packages/effect-fc/src/Form.ts | 171 ++++++++++++++++----------- packages/example/src/routes/form.tsx | 65 +++++----- 2 files changed, 142 insertions(+), 94 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index aca094a..eccc270 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,5 +1,5 @@ import * as AsyncData from "@typed/async-data" -import { Array, Duration, Effect, Equal, Equivalence, identity, Option, ParseResult, pipe, Pipeable, Ref, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect" +import { Array, Duration, Effect, Equal, Equivalence, Exit, flow, identity, Option, ParseResult, pipe, Pipeable, Ref, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" import { Hooks } from "./hooks/index.js" @@ -43,51 +43,31 @@ extends Pipeable.Class() implements Form { super() this.canSubmitSubscribable = pipe( - ([value, error, isValidating]: readonly [ + ([value, error, isValidating, submitState]: readonly [ Option.Option, Option.Option, boolean, - ]) => Option.isSome(value) && Option.isNone(error) && !isValidating, + AsyncData.AsyncData, + ]) => Option.isSome(value) && Option.isNone(error) && !isValidating && !AsyncData.isLoading(submitState), filter => SubscribableInternal.make({ - get: Effect.map(Effect.all([valueRef, errorRef, isValidatingRef]), filter), - get changes() { return Stream.map(Stream.zipLatestAll(valueRef.changes, errorRef.changes, isValidatingRef.changes), filter)}, + get: Effect.map(Effect.all([valueRef, errorRef, isValidatingRef, submitStateRef]), filter), + get changes() { + return Stream.map( + Stream.zipLatestAll( + valueRef.changes, + errorRef.changes, + isValidatingRef.changes, + submitStateRef.changes, + ), + filter, + ) + }, }), ) } } - -export const FormFieldTypeId: unique symbol = Symbol.for("effect-fc/FormField") -export type FormFieldTypeId = typeof FormFieldTypeId - -export interface FormField -extends Pipeable.Pipeable { - readonly [FormFieldTypeId]: FormFieldTypeId - - readonly valueSubscribable: Subscribable.Subscribable, NoSuchElementException> - readonly encodedValueRef: SubscriptionRef.SubscriptionRef - readonly issuesSubscribable: Subscribable.Subscribable - readonly isValidatingSubscribable: Subscribable.Subscribable - readonly isSubmittingSubscribable: Subscribable.Subscribable -} - -class FormFieldImpl -extends Pipeable.Class() implements FormField { - readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId - - constructor( - readonly valueSubscribable: Subscribable.Subscribable, NoSuchElementException>, - readonly encodedValueRef: SubscriptionRef.SubscriptionRef, - readonly issuesSubscribable: Subscribable.Subscribable, - readonly isValidatingSubscribable: Subscribable.Subscribable, - readonly isSubmittingSubscribable: Subscribable.Subscribable, - ) { - super() - } -} - - export namespace make { export interface Options { readonly schema: Schema.Schema @@ -114,6 +94,39 @@ export const make: { ) }) +export const run = ( + self: Form +): Effect.Effect => Stream.runForEach( + self.encodedValueRef.changes, + encodedValue => SubscriptionRef.set(self.isValidatingRef, true).pipe( + Effect.andThen(Schema.decode(self.schema, { errors: "all" })(encodedValue)), + Effect.andThen(v => SubscriptionRef.set(self.valueRef, Option.some(v))), + Effect.andThen(SubscriptionRef.set(self.errorRef, Option.none())), + Effect.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e))), + Effect.andThen(SubscriptionRef.set(self.isValidatingRef, false)), + ), +) + +export const submit = ( + self: Form +): Effect.Effect>, NoSuchElementException, SR> => Effect.whenEffect( + self.valueRef.pipe( + Effect.andThen(identity), + Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())), + Effect.andThen(flow( + self.submit, + Effect.exit, + Effect.map(Exit.match({ + onSuccess: a => AsyncData.success(a), + onFailure: e => AsyncData.failure(e), + })), + Effect.tap(v => Ref.set(self.submitStateRef, v)) + )), + ), + + self.canSubmitSubscribable.get, +) + export namespace service { export interface Options extends make.Options {} @@ -126,35 +139,6 @@ export const service = ( form => Effect.forkScoped(run(form)), ) -export namespace useForm { - export interface Options - extends make.Options {} -} - -export const useForm: { - ( - options: service.Options - ): Effect.Effect, never, R> -} = Effect.fnUntraced(function* ( - options: service.Options -) { - const form = yield* Hooks.useOnce(() => make(options)) - yield* Hooks.useFork(() => run(form), [form]) - return form -}) - -const run = (self: Form) => Stream.runForEach( - self.encodedValueRef.changes, - encodedValue => SubscriptionRef.set(self.isValidatingRef, true).pipe( - Effect.andThen(Schema.decode(self.schema, { errors: "all" })(encodedValue)), - Effect.andThen(v => SubscriptionRef.set(self.valueRef, Option.some(v))), - Effect.andThen(SubscriptionRef.set(self.errorRef, Option.none())), - Effect.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e))), - Effect.andThen(SubscriptionRef.set(self.isValidatingRef, false)), - ), -) - - export const field = >>( self: Form, path: P, @@ -197,6 +181,62 @@ export const field = +extends Pipeable.Pipeable { + readonly [FormFieldTypeId]: FormFieldTypeId + + readonly valueSubscribable: Subscribable.Subscribable, NoSuchElementException> + readonly encodedValueRef: SubscriptionRef.SubscriptionRef + readonly issuesSubscribable: Subscribable.Subscribable + readonly isValidatingSubscribable: Subscribable.Subscribable + readonly isSubmittingSubscribable: Subscribable.Subscribable +} + +class FormFieldImpl +extends Pipeable.Class() implements FormField { + readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId + + constructor( + readonly valueSubscribable: Subscribable.Subscribable, NoSuchElementException>, + readonly encodedValueRef: SubscriptionRef.SubscriptionRef, + readonly issuesSubscribable: Subscribable.Subscribable, + readonly isValidatingSubscribable: Subscribable.Subscribable, + readonly isSubmittingSubscribable: Subscribable.Subscribable, + ) { + super() + } +} + + +export namespace useForm { + export interface Options + extends make.Options {} +} + +export const useForm: { + ( + options: service.Options + ): Effect.Effect, never, R> +} = Effect.fnUntraced(function* ( + options: service.Options +) { + const form = yield* Hooks.useOnce(() => make(options)) + yield* Hooks.useFork(() => run(form), [form]) + return form +}) + +export const useSubmit = ( + self: Form +): Effect.Effect< + () => Promise>>, + never, + SR +> => Hooks.useCallbackPromise(() => submit(self), [self]) + export const useField = >>( self: Form, path: P, @@ -205,7 +245,6 @@ export const useField = > => React.useMemo(() => field(self, path), [self, ...path]) - export namespace useInput { export interface Options { readonly debounce?: Duration.DurationInput diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index 127019f..ddb6942 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -25,7 +25,10 @@ class RegisterForm extends Effect.Service()("RegisterForm", { scoped: Form.service({ schema: RegisterFormSchema, initialEncodedValue: { email: "", password: "" }, - submit: () => Effect.void, + submit: () => Effect.andThen( + Effect.sleep("500 millis"), + Effect.sync(() => alert("Done!")), + ), }) }) {} @@ -36,43 +39,49 @@ class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() { const emailInput = yield* Form.useInput(emailField, { debounce: "200 millis" }) const passwordInput = yield* Form.useInput(passwordField, { debounce: "200 millis" }) + const submit = yield* Form.useSubmit(form) const [canSubmit] = yield* useSubscribables(form.canSubmitSubscribable) return ( - - emailInput.setValue(e.target.value)} - /> +
{ + e.preventDefault() + void submit() + }}> + + emailInput.setValue(e.target.value)} + /> - {Option.match(Array.head(emailInput.issues), { - onSome: issue => ( - - {issue.message} - - ), + {Option.match(Array.head(emailInput.issues), { + onSome: issue => ( + + {issue.message} + + ), - onNone: () => <>, - })} + onNone: () => <>, + })} - passwordInput.setValue(e.target.value)} - /> + passwordInput.setValue(e.target.value)} + /> - {Option.match(Array.head(passwordInput.issues), { - onSome: issue => ( - - {issue.message} - - ), + {Option.match(Array.head(passwordInput.issues), { + onSome: issue => ( + + {issue.message} + + ), - onNone: () => <>, - })} + onNone: () => <>, + })} - - + + +
) }) {}