From b3d6cc6764eddc804d2ccc010947f200972c595c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 25 Sep 2025 12:52:44 +0200 Subject: [PATCH] Form refactoring --- bun.lock | 3 + packages/effect-fc/package.json | 3 + packages/effect-fc/src/Form.ts | 150 ++++++++++++++++++++------------ 3 files changed, 98 insertions(+), 58 deletions(-) diff --git a/bun.lock b/bun.lock index 661bc39..a9d6aef 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,9 @@ "packages/effect-fc": { "name": "effect-fc", "version": "0.1.3", + "dependencies": { + "@typed/async-data": "^0.13.1", + }, "devDependencies": { "@effect/language-service": "^0.35.2", }, diff --git a/packages/effect-fc/package.json b/packages/effect-fc/package.json index 8ca1343..92572b8 100644 --- a/packages/effect-fc/package.json +++ b/packages/effect-fc/package.json @@ -45,5 +45,8 @@ }, "devDependencies": { "@effect/language-service": "^0.35.2" + }, + "dependencies": { + "@typed/async-data": "^0.13.1" } } diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 0a4b3bc..ad20e04 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,51 +1,57 @@ -import { Array, Duration, Effect, Equal, Equivalence, flow, identity, Option, ParseResult, pipe, Pipeable, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect" +import * as AsyncData from "@typed/async-data" +import { Array, Duration, Effect, Equal, Equivalence, identity, Option, ParseResult, pipe, Pipeable, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" import { Hooks } from "./hooks/index.js" import { PropertyPath, Subscribable as SubscribableInternal } from "./types/index.js" -export const TypeId: unique symbol = Symbol.for("effect-fc/Form") -export type TypeId = typeof TypeId +export const FormTypeId: unique symbol = Symbol.for("effect-fc/Form") +export type FormTypeId = typeof FormTypeId -export interface Form +export interface Form extends Pipeable.Pipeable { + readonly [FormTypeId]: FormTypeId + readonly schema: Schema.Schema, - /** A reference to the latest valid value produced by the form, if any. */ + readonly submit: (value: NoInfer) => Effect.Effect, + readonly valueRef: SubscriptionRef.SubscriptionRef>, readonly encodedValueRef: SubscriptionRef.SubscriptionRef, readonly errorRef: SubscriptionRef.SubscriptionRef>, + /** Whether or not a validation is currently being executed */ + readonly isValidatingRef: SubscriptionRef.SubscriptionRef + readonly submitStateRef: SubscriptionRef.SubscriptionRef>, readonly canSubmitSubscribable: Subscribable.Subscribable - - makeFieldIssuesSubscribable>( - path: P - ): Subscribable.Subscribable } - -class FormImpl -extends Pipeable.Class() implements Form { - readonly [TypeId]: TypeId = TypeId +class FormImpl +extends Pipeable.Class() implements Form { + readonly [FormTypeId]: FormTypeId = FormTypeId readonly canSubmitSubscribable: Subscribable.Subscribable constructor( readonly schema: Schema.Schema, + readonly submit: (value: NoInfer) => Effect.Effect, readonly valueRef: SubscriptionRef.SubscriptionRef>, readonly encodedValueRef: SubscriptionRef.SubscriptionRef, readonly errorRef: SubscriptionRef.SubscriptionRef>, + readonly isValidatingRef: SubscriptionRef.SubscriptionRef, + readonly submitStateRef: SubscriptionRef.SubscriptionRef>, ) { super() this.canSubmitSubscribable = pipe( - ([value, error]: readonly [ + ([value, error, isValidating]: readonly [ Option.Option, Option.Option, - ]) => Option.isSome(value) && Option.isNone(error), + boolean, + ]) => Option.isSome(value) && Option.isNone(error) && !isValidating, filter => SubscribableInternal.make({ - get: Effect.map(Effect.all([valueRef, errorRef]), filter), - get changes() { return Stream.map(Stream.zipLatestAll(valueRef.changes, errorRef.changes), filter)}, + get: Effect.map(Effect.all([valueRef, errorRef, isValidatingRef]), filter), + get changes() { return Stream.map(Stream.zipLatestAll(valueRef.changes, errorRef.changes, isValidatingRef.changes), filter)}, }), ) } @@ -70,54 +76,99 @@ extends Pipeable.Class() implements Form { } +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 { + export interface Options { readonly schema: Schema.Schema readonly initialEncodedValue: NoInfer + readonly submit: (value: NoInfer) => Effect.Effect } } export const make: { - (options: make.Options): Effect.Effect> -} = Effect.fnUntraced(function* (options: make.Options) { + ( + options: make.Options + ): Effect.Effect> +} = Effect.fnUntraced(function* ( + options: make.Options +) { return new FormImpl( options.schema, + options.submit, yield* SubscriptionRef.make(Option.none()), yield* SubscriptionRef.make(options.initialEncodedValue), yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(false), + yield* SubscriptionRef.make(AsyncData.noData()), ) }) export namespace service { - export interface Options extends make.Options {} + export interface Options + extends make.Options {} } -export const service = ( - options: service.Options -): Effect.Effect, never, R | Scope.Scope> => Effect.tap( +export const service = ( + options: service.Options +): Effect.Effect, never, R | Scope.Scope> => Effect.tap( make(options), form => Effect.forkScoped(run(form)), ) export namespace useForm { - export interface Options extends make.Options {} + export interface Options + extends make.Options {} } export const useForm: { - (options: service.Options): Effect.Effect, never, R | Scope.Scope> -} = Effect.fnUntraced(function* (options: service.Options) { + ( + 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( +const run = (self: Form) => Stream.runForEach( self.encodedValueRef.changes, - flow( - Schema.decode(self.schema, { errors: "all" }), + 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.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e))), + Effect.andThen(SubscriptionRef.set(self.isValidatingRef, false)), ), ) @@ -135,34 +186,21 @@ export namespace useInput { } export const useInput: { - >>( - self: Form, - path: P, + ( + field: FormField, options?: useInput.Options, - ): Effect.Effect>, NoSuchElementException, R> -} = Effect.fnUntraced(function* >( - self: Form, - path: P, + ): Effect.Effect, NoSuchElementException> +} = Effect.fnUntraced(function* ( + field: FormField, options?: useInput.Options, ) { - const [internalValueRef, issuesSubscribable] = yield* Hooks.useMemo(() => Effect.all([ - self.encodedValueRef.pipe( - Effect.andThen(PropertyPath.get(path)), - Effect.andThen(SubscriptionRef.make>), - ), - Effect.succeed(self.makeFieldIssuesSubscribable(path)), - ]), [self, ...path]) - + const internalValueRef = yield* Hooks.useMemo(() => Effect.andThen(field.encodedValueRef, SubscriptionRef.make), [field]) const [value, setValue] = yield* Hooks.useRefState(internalValueRef) - const [issues] = yield* Hooks.useSubscribables(issuesSubscribable) + const [issues] = yield* Hooks.useSubscribables(field.issuesSubscribable) yield* Hooks.useFork(() => Effect.all([ Stream.runForEach( - self.encodedValueRef.changes.pipe( - Stream.flatMap(PropertyPath.get(path)), - Stream.drop(1), - ), - + Stream.drop(field.encodedValueRef, 1), upstreamEncodedValue => Effect.whenEffect( SubscriptionRef.set(internalValueRef, upstreamEncodedValue), Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), @@ -175,13 +213,9 @@ export const useInput: { options?.debounce ? Stream.debounce(options.debounce) : identity, Stream.drop(1), ), - - internalValue => self.encodedValueRef.pipe( - Effect.andThen(encodedValue => PropertyPath.immutableSet(encodedValue, path, internalValue)), - Effect.andThen(encodedValue => SubscriptionRef.set(self.encodedValueRef, encodedValue)), - ), + internalValue => SubscriptionRef.set(field.encodedValueRef, internalValue), ), - ], { concurrency: "unbounded" }), [internalValueRef, self, ...path]) + ], { concurrency: "unbounded" }), [field, internalValueRef]) return { value, setValue, issues } })