import * as AsyncData from "@typed/async-data" 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 * as Component from "./Component.js" import * as PropertyPath from "./PropertyPath.js" import * as Subscribable from "./Subscribable.js" import * as SubscriptionRef from "./SubscriptionRef.js" 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 extends Pipeable.Pipeable { readonly [FormTypeId]: FormTypeId readonly schema: Schema.Schema readonly onSubmit: (value: NoInfer) => Effect.Effect readonly autosubmit: boolean readonly debounce: Option.Option readonly valueRef: SubscriptionRef.SubscriptionRef> readonly encodedValueRef: SubscriptionRef.SubscriptionRef readonly errorRef: SubscriptionRef.SubscriptionRef> readonly validationFiberRef: SubscriptionRef.SubscriptionRef>> readonly submitStateRef: SubscriptionRef.SubscriptionRef> readonly canSubmitSubscribable: Subscribable.Subscribable } class FormImpl extends Pipeable.Class() implements Form { readonly [FormTypeId]: FormTypeId = FormTypeId constructor( readonly schema: Schema.Schema, readonly onSubmit: (value: NoInfer) => Effect.Effect, readonly autosubmit: boolean, readonly debounce: Option.Option, readonly valueRef: SubscriptionRef.SubscriptionRef>, readonly encodedValueRef: SubscriptionRef.SubscriptionRef, readonly errorRef: SubscriptionRef.SubscriptionRef>, readonly validationFiberRef: SubscriptionRef.SubscriptionRef>>, readonly submitStateRef: SubscriptionRef.SubscriptionRef>, readonly canSubmitSubscribable: Subscribable.Subscribable, ) { super() } } export const isForm = (u: unknown): u is Form => Predicate.hasProperty(u, FormTypeId) export namespace make { export interface Options { readonly schema: Schema.Schema readonly initialEncodedValue: NoInfer readonly onSubmit: ( this: Form, NoInfer, NoInfer, unknown, unknown, unknown>, value: NoInfer, ) => Effect.Effect readonly autosubmit?: boolean readonly debounce?: Duration.DurationInput } } export const make: { ( options: make.Options ): Effect.Effect> } = Effect.fnUntraced(function* ( options: make.Options ) { const valueRef = yield* SubscriptionRef.make(Option.none()) const errorRef = yield* SubscriptionRef.make(Option.none()) const validationFiberRef = yield* SubscriptionRef.make(Option.none>()) const submitStateRef = yield* SubscriptionRef.make(AsyncData.noData()) return new FormImpl( options.schema, options.onSubmit, options.autosubmit ?? false, Option.fromNullable(options.debounce), valueRef, yield* SubscriptionRef.make(options.initialEncodedValue), errorRef, validationFiberRef, submitStateRef, Subscribable.map( Subscribable.zipLatestAll(valueRef, errorRef, validationFiberRef, submitStateRef), ([value, error, validationFiber, submitState]) => ( Option.isSome(value) && Option.isNone(error) && Option.isNone(validationFiber) && !AsyncData.isLoading(submitState) ), ), ) }) export const run = ( self: Form ): Effect.Effect => Stream.runForEach( self.encodedValueRef.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.addFinalizer(() => Ref.set(self.validationFiberRef, Option.none())).pipe( Effect.andThen(Schema.decode(self.schema, { errors: "all" })(encodedValue)), Effect.exit, Effect.andThen(flow( Exit.matchEffect({ onSuccess: v => Ref.set(self.valueRef, Option.some(v)).pipe( Effect.andThen(Ref.set(self.errorRef, Option.none())), Effect.as(Option.some(v)), ), onFailure: c => Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError").pipe( Option.match({ onSome: e => Ref.set(self.errorRef, Option.some(e)), onNone: () => Effect.void, }), Effect.as(Option.none()), ), }), Effect.uninterruptible, )), Effect.scoped, Effect.andThen(value => Option.isSome(value) && self.autosubmit ? Effect.asVoid(Effect.forkScoped(submit(self))) : Effect.void ), Effect.forkScoped, ) ), Effect.andThen(fiber => Ref.set(self.validationFiberRef, Option.some(fiber))) ), ) 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.onSubmit as (value: NoInfer) => Effect.Effect, Effect.tapErrorTag("ParseError", e => Ref.set(self.errorRef, Option.some(e as ParseResult.ParseError))), Effect.exit, Effect.map(Exit.match({ onSuccess: a => AsyncData.success(a), onFailure: e => AsyncData.failure(e as Cause.Cause), })), Effect.tap(v => Ref.set(self.submitStateRef, v)), )), ), self.canSubmitSubscribable.get, ) export namespace service { export interface Options extends make.Options {} } export const service = ( options: service.Options ): Effect.Effect, never, Scope.Scope | R | SR> => Effect.tap( make(options), form => Effect.forkScoped(run(form)), ) export const field = >>( 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.submitStateRef, AsyncData.isLoading) ) 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 const isFormField = (u: unknown): u is FormField => Predicate.hasProperty(u, FormFieldTypeId) export const useSubmit = ( self: Form ): Effect.Effect< () => Promise>>, never, 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 } export interface Result { readonly value: T readonly setValue: React.Dispatch> } } export const useInput: { ( field: FormField, options?: useInput.Options, ): Effect.Effect, NoSuchElementException, Scope.Scope> } = Effect.fnUntraced(function* ( field: FormField, options?: useInput.Options, ) { const internalValueRef = yield* Component.useOnChange(() => Effect.tap( Effect.andThen(field.encodedValueRef, SubscriptionRef.make), internalValueRef => Effect.forkScoped(Effect.all([ Stream.runForEach( Stream.drop(field.encodedValueRef, 1), upstreamEncodedValue => Effect.whenEffect( Ref.set(internalValueRef, upstreamEncodedValue), Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), ), ), Stream.runForEach( internalValueRef.changes.pipe( Stream.drop(1), Stream.changesWith(Equal.equivalence()), options?.debounce ? Stream.debounce(options.debounce) : identity, ), internalValue => Ref.set(field.encodedValueRef, internalValue), ), ], { concurrency: "unbounded" })), ), [field, options?.debounce]) const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return { value, setValue } }) export namespace useOptionalInput { export interface Options extends useInput.Options { readonly defaultValue: T } export interface Result extends useInput.Result { readonly enabled: boolean readonly setEnabled: React.Dispatch> } } export const useOptionalInput: { ( field: FormField>, options: useOptionalInput.Options, ): Effect.Effect, NoSuchElementException, Scope.Scope> } = Effect.fnUntraced(function* ( field: FormField>, options: useOptionalInput.Options, ) { const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( Effect.andThen( field.encodedValueRef, Option.match({ onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]), onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]), }), ), ([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([ Stream.runForEach( Stream.drop(field.encodedValueRef, 1), upstreamEncodedValue => Effect.whenEffect( Option.match(upstreamEncodedValue, { onSome: v => Effect.andThen( Ref.set(enabledRef, true), Ref.set(internalValueRef, v), ), onNone: () => Effect.andThen( Ref.set(enabledRef, false), Ref.set(internalValueRef, options.defaultValue), ), }), Effect.andThen( Effect.all([enabledRef, internalValueRef]), ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()), ), ), ), Stream.runForEach( enabledRef.changes.pipe( Stream.zipLatest(internalValueRef.changes), Stream.drop(1), Stream.changesWith(Equal.equivalence()), options?.debounce ? Stream.debounce(options.debounce) : identity, ), ([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()), ), ], { concurrency: "unbounded" })), ), [field, options.debounce]) const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef) const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return { enabled, setEnabled, value, setValue } })