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 type { NoSuchElementException } from "effect/Cause" import * as React from "react" import { Hooks } from "./hooks/index.js" import { PropertyPath, Subscribable as SubscribableInternal, SubscriptionSubRef } from "./types/index.js" export const FormTypeId: unique symbol = Symbol.for("effect-fc/Form") export type FormTypeId = typeof FormTypeId export interface Form extends Pipeable.Pipeable { readonly [FormTypeId]: FormTypeId readonly schema: Schema.Schema, 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 } 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, isValidating]: readonly [ Option.Option, Option.Option, boolean, ]) => Option.isSome(value) && Option.isNone(error) && !isValidating, 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)}, }), ) } } 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 readonly initialEncodedValue: NoInfer readonly submit: (value: NoInfer) => Effect.Effect } } export const make: { ( 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 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 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, ): FormField, PropertyPath.ValueFromPath> => new FormFieldImpl( pipe( Option.match({ onSome: (v: A) => Option.map(PropertyPath.get(v, path), Option.some), onNone: () => Option.some(Option.none()), }), filter => SubscribableInternal.make({ get: Effect.flatMap(self.valueRef, filter), get changes() { return Stream.flatMap(self.valueRef.changes, filter) }, }), ), SubscriptionSubRef.makeFromPath(self.encodedValueRef, path), pipe( Option.match({ onSome: (v: ParseResult.ParseError) => Effect.andThen( ParseResult.ArrayFormatter.formatError(v), Array.filter(issue => PropertyPath.equivalence(issue.path, path)), ), onNone: () => Effect.succeed([]), }), filter => SubscribableInternal.make({ get: Effect.flatMap(self.errorRef.get, filter), get changes() { return Stream.flatMap(self.errorRef.changes, filter) }, }), ), self.isValidatingRef, pipe( AsyncData.isLoading, filter => SubscribableInternal.make({ get: Effect.map(self.submitStateRef, filter), get changes() { return Stream.map(self.submitStateRef.changes, filter) }, }), ), ) export const useField = >>( self: Form, path: P, ): FormField, PropertyPath.ValueFromPath> => 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> readonly issues: readonly ParseResult.ArrayFormatterIssue[] } } export const useInput: { ( field: FormField, options?: useInput.Options, ): Effect.Effect, NoSuchElementException> } = Effect.fnUntraced(function* ( field: FormField, options?: useInput.Options, ) { const internalValueRef = yield* Hooks.useMemo(() => Effect.andThen(field.encodedValueRef, SubscriptionRef.make), [field]) const [value, setValue] = yield* Hooks.useRefState(internalValueRef) const [issues] = yield* Hooks.useSubscribables(field.issuesSubscribable) yield* Hooks.useFork(() => 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(Equivalence.strict()), options?.debounce ? Stream.debounce(options.debounce) : identity, ), internalValue => Ref.set(field.encodedValueRef, internalValue), ), ], { concurrency: "unbounded" }), [field, internalValueRef]) return { value, setValue, issues } })