From 9dd7592c45a97828d709231b4762754aa75b256f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sat, 29 Nov 2025 01:16:11 +0100 Subject: [PATCH] Form work --- packages/effect-fc/src/Form.ts | 269 +++++++++++++++----------------- packages/effect-fc/src/Query.ts | 5 + 2 files changed, 134 insertions(+), 140 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index adc8eb9..d69f869 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,7 +1,7 @@ -import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" -import type { NoSuchElementException } from "effect/Cause" +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 PropertyPath from "./PropertyPath.js" import * as Result from "./Result.js" import * as Subscribable from "./Subscribable.js" @@ -12,54 +12,88 @@ 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 context: Context.Context + readonly mutation: Mutation.Mutation readonly autosubmit: boolean readonly debounce: Option.Option - readonly fieldCacheRef: Ref.Ref>> - readonly valueRef: SubscriptionRef.SubscriptionRef> - readonly encodedValueRef: SubscriptionRef.SubscriptionRef - readonly errorRef: SubscriptionRef.SubscriptionRef> - readonly validationFiberRef: SubscriptionRef.SubscriptionRef>> - readonly submitResultRef: SubscriptionRef.SubscriptionRef> + readonly value: Subscribable.Subscribable> + readonly encodedValue: Subscribable.Subscribable + readonly error: Subscribable.Subscribable> + readonly validationFiber: Subscribable.Subscribable>> - readonly canSubmitSubscribable: Subscribable.Subscribable + readonly canSubmit: Subscribable.Subscribable + + readonly submit: Effect.Effect>, Cause.NoSuchElementException> } -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 onSubmit: (value: NoInfer) => Effect.Effect, - readonly initialSubmitProgress: SP, + readonly mutation: Mutation.Mutation, readonly autosubmit: boolean, readonly debounce: Option.Option, - readonly fieldCacheRef: Ref.Ref>>, - readonly valueRef: SubscriptionRef.SubscriptionRef>, - readonly encodedValueRef: SubscriptionRef.SubscriptionRef, - readonly errorRef: SubscriptionRef.SubscriptionRef>, - readonly validationFiberRef: SubscriptionRef.SubscriptionRef>>, - readonly submitResultRef: SubscriptionRef.SubscriptionRef>, + readonly value: SubscriptionRef.SubscriptionRef>, + readonly encodedValue: SubscriptionRef.SubscriptionRef, + readonly error: SubscriptionRef.SubscriptionRef>, + readonly validationFiber: SubscriptionRef.SubscriptionRef>>, - readonly canSubmitSubscribable: Subscribable.Subscribable, + readonly runSemaphore: Effect.Semaphore, + readonly fieldCache: Ref.Ref>>, ) { super() } + + get canSubmit(): Subscribable.Subscribable { + return Subscribable.map( + Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result), + ([value, error, validationFiber, submitResult]) => ( + Option.isSome(value) && + Option.isNone(error) && + Option.isNone(validationFiber) && + !(Result.isRunning(submitResult) || Result.isRefreshing(submitResult)) + ), + ) + } + + get submit(): Effect.Effect>, Cause.NoSuchElementException> { + return Effect.whenEffect( + this.value.pipe( + Effect.andThen(identity), + Effect.andThen(value => this.mutation.mutate([value])), + Effect.tap(result => Result.isFailure(result) + ? Option.match( + Chunk.findFirst( + Cause.failures(result.cause as Cause.Cause), + e => e._tag === "ParseError", + ), + { + onSome: e => Ref.set(this.error, Option.some(e)), + onNone: () => Effect.void, + }, + ) + : Effect.void + ), + ), + + this.canSubmit.get, + ) + } } 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: ( @@ -82,12 +116,9 @@ export const make = Effect.fnUntraced(function* ()) const errorRef = yield* SubscriptionRef.make(Option.none()) const validationFiberRef = yield* SubscriptionRef.make(Option.none>()) - const submitResultRef = yield* SubscriptionRef.make>(Result.initial()) return new FormImpl( options.schema, - options.onSubmit as any, - options.initialSubmitProgress as SP, options.autosubmit ?? false, Option.fromNullable(options.debounce), @@ -96,97 +127,52 @@ export const make = Effect.fnUntraced(function* ( - Option.isSome(value) && - Option.isNone(error) && - Option.isNone(validationFiber) && - !(Result.isRunning(submitResult) || Result.isRefreshing(submitResult)) - ), - ), ) }) -export const run = ( - self: Form -): Effect.Effect => Stream.runForEach( - self.encodedValueRef.changes.pipe( - Option.isSome(self.debounce) ? Stream.debounce(self.debounce.value) : identity - ), +export const run = ( + self: Form +): Effect.Effect => { + 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 + ), - encodedValue => self.validationFiberRef.pipe( - Effect.andThen(Option.match({ - onSome: Fiber.interrupt, - onNone: () => Effect.void, - })), - Effect.andThen( - Effect.forkScoped(Effect.onExit( - Schema.decode(self.schema, { errors: "all" })(encodedValue), - exit => Effect.andThen( - Exit.matchEffect(exit, { - onSuccess: v => Effect.andThen( - Ref.set(self.valueRef, Option.some(v)), - Ref.set(self.errorRef, Option.none()), - ), - onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), { - onSome: e => Ref.set(self.errorRef, Option.some(e)), - onNone: () => Effect.void, + encodedValue => _self.validationFiber.pipe( + Effect.andThen(Option.match({ + onSome: Fiber.interrupt, + onNone: () => Effect.void, + })), + Effect.andThen( + Effect.forkScoped(Effect.onExit( + Schema.decode(_self.schema, { errors: "all" })(encodedValue), + exit => Effect.andThen( + Exit.matchEffect(exit, { + onSuccess: v => Effect.andThen( + Ref.set(_self.value, Option.some(v)), + Ref.set(_self.error, Option.none()), + ), + onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), { + onSome: e => Ref.set(_self.error, Option.some(e)), + onNone: () => Effect.void, + }), }), - }), - Ref.set(self.validationFiberRef, Option.none()), - ), - )).pipe( - Effect.tap(fiber => Ref.set(self.validationFiberRef, Option.some(fiber))), - Effect.andThen(Fiber.join), - Effect.andThen(() => self.autosubmit - ? Effect.asVoid(Effect.forkScoped(submit(self))) - : Effect.void - ), - Effect.forkScoped, - ) + Ref.set(_self.validationFiber, Option.none()), + ), + )).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.void + ), + Effect.forkScoped, + ) + ), ), - ), -) - -export const submit = ( - self: Form -): Effect.Effect< - Option.Option>, - NoSuchElementException, - Scope.Scope | SR -> => Effect.whenEffect( - self.valueRef.pipe( - Effect.andThen(identity), - Effect.andThen(value => Result.unsafeForkEffect( - self.onSubmit(value), - { initialProgress: self.initialSubmitProgress }, - )), - Effect.andThen(([sub]) => Effect.all([Effect.succeed(sub), sub.get])), - Effect.andThen(([sub, initial]) => Stream.runFoldEffect( - sub.changes, - initial, - (_, result) => Effect.as(Ref.set(self.submitResultRef, result), result), - )), - Effect.tap(result => Result.isFailure(result) - ? Option.match( - Chunk.findFirst( - Cause.failures(result.cause as Cause.Cause), - e => e._tag === "ParseError", - ), - { - onSome: e => Ref.set(self.errorRef, Option.some(e)), - onNone: () => Effect.void, - }, - ) - : Effect.void - ), - ), - - self.canSubmitSubscribable.get, -) + )) +} export namespace service { export interface Options @@ -228,11 +214,11 @@ 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 + readonly value: Subscribable.Subscribable, Cause.NoSuchElementException> + readonly encodedValue: SubscriptionRef.SubscriptionRef + readonly issues: Subscribable.Subscribable + readonly isValidating: Subscribable.Subscribable + readonly isSubmitting: Subscribable.Subscribable } class FormFieldImpl @@ -240,11 +226,11 @@ 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, + readonly value: Subscribable.Subscribable, Cause.NoSuchElementException>, + readonly encodedValue: SubscriptionRef.SubscriptionRef, + readonly issues: Subscribable.Subscribable, + readonly isValidating: Subscribable.Subscribable, + readonly isSubmitting: Subscribable.Subscribable, ) { super() } @@ -268,25 +254,28 @@ 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> => new FormFieldImpl( - Subscribable.mapEffect(self.valueRef, Option.match({ - onSome: v => Option.map(PropertyPath.get(v, path), Option.some), - onNone: () => Option.some(Option.none()), - })), - SubscriptionSubRef.makeFromPath(self.encodedValueRef, path), - Subscribable.mapEffect(self.errorRef, Option.match({ - onSome: flow( - ParseResult.ArrayFormatter.formatError, - Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))), - ), - onNone: () => Effect.succeed([]), - })), - Subscribable.map(self.validationFiberRef, Option.isSome), - Subscribable.map(self.submitResultRef, result => Result.isRunning(result) || Result.isRefreshing(result)), -) +): FormField, PropertyPath.ValueFromPath> => { + const _self = self as FormImpl + return new FormFieldImpl( + Subscribable.mapEffect(_self.value, Option.match({ + onSome: v => Option.map(PropertyPath.get(v, path), Option.some), + onNone: () => Option.some(Option.none()), + })), + SubscriptionSubRef.makeFromPath(_self.encodedValue, path), + Subscribable.mapEffect(_self.error, Option.match({ + onSome: flow( + ParseResult.ArrayFormatter.formatError, + Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))), + ), + onNone: () => Effect.succeed([]), + })), + Subscribable.map(_self.validationFiber, Option.isSome), + Subscribable.map(_self.mutation.result, result => Result.isRunning(result) || Result.isRefreshing(result)), + ) +} export namespace useInput { diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index b04fa49..93c4e96 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -39,6 +39,8 @@ extends Pipeable.Class() implements Query { readonly latestKey: SubscriptionRef.SubscriptionRef>, readonly fiber: SubscriptionRef.SubscriptionRef>>, readonly result: SubscriptionRef.SubscriptionRef>, + + readonly runSemaphore: Effect.Semaphore, ) { super() } @@ -160,6 +162,8 @@ export const make = Effect.fnUntraced(function* ()), yield* SubscriptionRef.make(Option.none>()), yield* SubscriptionRef.make(Result.initial()), + + yield* Effect.makeSemaphore(1), ) }) @@ -183,5 +187,6 @@ export const run = ( Effect.andThen(_self.start(key)), Effect.andThen(sub => Effect.forkScoped(_self.watch(sub))), Effect.provide(_self.context), + _self.runSemaphore.withPermits(1), )) }