From 943c2aa35dc5460f8eaa570786af37f077c47c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 1 Dec 2025 19:08:02 +0100 Subject: [PATCH] Refactor Form --- packages/effect-fc/src/Form.ts | 146 ++++++++++++++++++--------------- 1 file changed, 79 insertions(+), 67 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index d69f869..50eccc1 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,7 +1,7 @@ import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" import type * as React from "react" import * as Component from "./Component.js" -import type * as Mutation from "./Mutation.js" +import * as Mutation from "./Mutation.js" import * as PropertyPath from "./PropertyPath.js" import * as Result from "./Result.js" import * as Subscribable from "./Subscribable.js" @@ -12,13 +12,16 @@ 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 context: Context.Context - readonly mutation: Mutation.Mutation + readonly mutation: Mutation.Mutation< + readonly [value: A, form: Form], + MA, ME, MR, MP + > readonly autosubmit: boolean readonly debounce: Option.Option @@ -29,16 +32,23 @@ extends Pipeable.Pipeable { readonly canSubmit: Subscribable.Subscribable + field>( + path: P + ): Effect.Effect, PropertyPath.ValueFromPath>> readonly submit: Effect.Effect>, Cause.NoSuchElementException> } -export class FormImpl -extends Pipeable.Class() implements Form { +export class FormImpl +extends Pipeable.Class() implements Form { readonly [FormTypeId]: FormTypeId = FormTypeId constructor( readonly schema: Schema.Schema, - readonly mutation: Mutation.Mutation, + readonly context: Context.Context, + readonly mutation: Mutation.Mutation< + readonly [value: A, form: Form], + MA, ME, MR, MP + >, readonly autosubmit: boolean, readonly debounce: Option.Option, @@ -53,6 +63,21 @@ extends Pipeable.Class() implements Form { super() } + field>( + path: P + ): Effect.Effect, PropertyPath.ValueFromPath>> { + return this.fieldCache.pipe( + Effect.map(HashMap.get(new FormFieldKey(path))), + Effect.flatMap(Option.match({ + onSome: v => Effect.succeed(v as FormField, PropertyPath.ValueFromPath>), + onNone: () => Effect.tap( + Effect.succeed(makeFormField(this as Form, path)), + v => Ref.update(this.fieldCache, HashMap.set(new FormFieldKey(path), v as FormField)), + ), + })), + ) + } + get canSubmit(): Subscribable.Subscribable { return Subscribable.map( Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result), @@ -66,11 +91,16 @@ extends Pipeable.Class() implements Form { } get submit(): Effect.Effect>, Cause.NoSuchElementException> { + return this.value.pipe( + Effect.andThen(identity), + Effect.andThen(value => this.submitValue(value)), + ) + } + submitValue(value: A): Effect.Effect>> { return Effect.whenEffect( - this.value.pipe( - Effect.andThen(identity), - Effect.andThen(value => this.mutation.mutate([value])), - Effect.tap(result => Result.isFailure(result) + Effect.tap( + this.mutation.mutate([value, this as any]), + result => Result.isFailure(result) ? Option.match( Chunk.findFirst( Cause.failures(result.cause as Cause.Cause), @@ -82,9 +112,7 @@ extends Pipeable.Class() implements Form { }, ) : Effect.void - ), ), - this.canSubmit.get, ) } @@ -93,50 +121,49 @@ 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 + extends Mutation.make.Options< + readonly [value: NoInfer, form: Form, NoInfer, NoInfer, unknown, unknown, unknown>], + MA, ME, MR, MP + > { 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 type Success = ( - Form | Result.Progress>, SP> - ) } -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>()) - +export const make = Effect.fnUntraced(function* ( + options: make.Options +): Effect.fn.Return< + Form, MP>, + never, + Scope.Scope | R | Result.forkEffect.OutputContext +> { return new FormImpl( options.schema, + yield* Effect.context(), + yield* Mutation.make(options), options.autosubmit ?? false, Option.fromNullable(options.debounce), - yield* Ref.make(HashMap.empty>()), - valueRef, + yield* SubscriptionRef.make(Option.none()), yield* SubscriptionRef.make(options.initialEncodedValue), - errorRef, - validationFiberRef, + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(Option.none>()), + + yield* Effect.makeSemaphore(1), + yield* Ref.make(HashMap.empty>()), ) }) -export const run = ( - self: Form +export const run = ( + self: Form ): Effect.Effect => { - const _self = self as FormImpl + const _self = self as FormImpl return _self.runSemaphore.withPermits(1)(Stream.runForEach( _self.encodedValue.changes.pipe( - Option.isSome(self.debounce) ? Stream.debounce(self.debounce.value) : identity + Option.isSome(_self.debounce) ? Stream.debounce(_self.debounce.value) : identity ), encodedValue => _self.validationFiber.pipe( @@ -163,49 +190,34 @@ export const run = ( )).pipe( Effect.tap(fiber => Ref.set(_self.validationFiber, Option.some(fiber))), Effect.andThen(Fiber.join), - Effect.andThen(() => self.autosubmit - ? Effect.asVoid(Effect.forkScoped(submit(self))) + Effect.andThen(value => _self.autosubmit + ? Effect.asVoid(Effect.forkScoped(_self.submitValue(value))) : Effect.void ), Effect.forkScoped, ) ), + Effect.provide(_self.context), ), )) } export namespace service { - export interface Options - extends make.Options {} - - export type Return = Effect.Effect< - Form | Result.Progress>, SP>, - never, - Scope.Scope | R | Exclude | Result.Progress> - > + export interface Options + extends make.Options {} } -export const service = ( - options: service.Options -): service.Return => Effect.tap( +export const service = ( + options: service.Options +): Effect.Effect< + Form, MP>, + never, + Scope.Scope | R | Result.forkEffect.OutputContext +> => Effect.tap( make(options), form => Effect.forkScoped(run(form)), ) -export const field = >>( - self: Form, - path: P, -): Effect.Effect, PropertyPath.ValueFromPath>> => self.fieldCacheRef.pipe( - Effect.map(HashMap.get(new FormFieldKey(path))), - Effect.flatMap(Option.match({ - onSome: v => Effect.succeed(v as FormField, PropertyPath.ValueFromPath>), - onNone: () => Effect.tap( - Effect.succeed(makeFormField(self, path)), - v => Ref.update(self.fieldCacheRef, HashMap.set(new FormFieldKey(path), v as FormField)), - ), - })), -) - export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField") export type FormFieldTypeId = typeof FormFieldTypeId @@ -254,11 +266,11 @@ class FormFieldKey implements Equal.Equal { export const isFormField = (u: unknown): u is FormField => Predicate.hasProperty(u, FormFieldTypeId) const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId) -export const makeFormField = >>( - self: Form, +export const makeFormField = >>( + self: Form, path: P, ): FormField, PropertyPath.ValueFromPath> => { - const _self = self as FormImpl + const _self = self as FormImpl return new FormFieldImpl( Subscribable.mapEffect(_self.value, Option.match({ onSome: v => Option.map(PropertyPath.get(v, path), Option.some),