diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 70af85e..127a54b 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,5 +1,7 @@ -import { Array, Effect, Option, ParseResult, Pipeable, Schema, Stream, Subscribable, SubscriptionRef } from "effect" +import { Array, Effect, Either, Equivalence, flow, Option, ParseResult, Pipeable, Schema, 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" @@ -9,45 +11,134 @@ export type TypeId = typeof TypeId export interface Form extends Pipeable.Pipeable { readonly schema: Schema.Schema - readonly valueRef: SubscriptionRef.SubscriptionRef - readonly errorSubscribable: Subscribable.Subscribable> + readonly latestValueSubscribable: Subscribable.Subscribable> - useRef

>(path: P): SubscriptionRef.SubscriptionRef> - useIssuesSubscribable(path: PropertyPath.Paths): Subscribable.Subscribable + readonly fieldLatestValueSubscribable:

>>( + path: P + ) => Effect.Effect, P>, NoSuchElementException>> + readonly fieldIssuesSubscribable: ( + path: PropertyPath.Paths> + ) => Effect.Effect> + + useInput

>>( + path: P + ): Effect.Effect< + Form.useInput.Result, P>>, + ParseResult.ParseError | NoSuchElementException, + R + > } +export namespace Form { + export namespace useInput { + export interface Result { + readonly value: I + readonly setValue: React.Dispatch> + } + } +} + + class FormImpl extends Pipeable.Class() implements Form { readonly [TypeId]: TypeId = TypeId + readonly latestValueSubscribable: Subscribable.Subscribable> constructor( readonly schema: Schema.Schema, - readonly valueRef: SubscriptionRef.SubscriptionRef, - readonly errorSubscribable: Subscribable.Subscribable> + readonly latestValueRef: SubscriptionRef.SubscriptionRef>, + readonly errorRef: SubscriptionRef.SubscriptionRef>, + + readonly fieldLatestValueSubscribable:

>>( + path: P + ) => Effect.Effect, P>, NoSuchElementException>>, + readonly fieldIssuesSubscribable: ( + path: PropertyPath.Paths> + ) => Effect.Effect>, ) { super() + this.latestValueSubscribable = latestValueRef } - useRef

>(path: P) { - return React.useMemo(() => SubscriptionSubRef.makeFromPath(this.valueRef, path), [this.valueRef, ...path]) - } + useInput

>(path: P) { + const self = this + return Effect.gen(function*() { + const issuesSubscribable = yield* self.fieldIssuesSubscribable(path) + const internalValueRef = yield* Hooks.useMemo(() => self.latestValueRef.pipe( + Effect.andThen(Schema.encode(self.schema)), + Effect.andThen(PropertyPath.get(path)), + Effect.andThen(SubscriptionRef.make>), + ), [self.latestValueRef, ...path]) - useIssuesSubscribable(path: PropertyPath.Paths) { - return React.useMemo(() => { - const filter = Option.match({ - onSome: (v: ParseResult.ParseError) => Effect.andThen( - ParseResult.ArrayFormatter.formatError(v), - Array.filter(issue => PropertyPath.equivalence(issue.path, path)), + const [value, setValue] = yield* Hooks.useRefState(internalValueRef) + const [issues] = yield* Hooks.useSubscribe(issuesSubscribable) + + yield* Hooks.useFork(() => Stream.runForEach( + internalValueRef.changes.pipe( + Stream.changesWith(Equivalence.strict()), + // options.debounce ? Stream.debounce(options.debounce) : identity, + Stream.drop(1), ), - onNone: () => Effect.succeed([]), - }) + internalValue => self.latestValueRef.pipe( + Effect.andThen(Schema.encode(self.schema)), + Effect.andThen(PropertyPath.immutableSet(path, internalValue)), + Effect.andThen(flow( + Schema.decode(self.schema), + Effect.andThen(v => SubscriptionRef.set(self.latestValueRef, v)), + Effect.andThen(SubscriptionRef.set(self.errorRef, Option.none())), + Effect.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e))) + )), + ), + ), [internalValueRef, self.latestValueRef, self.schema, self.errorRef, ...path]) - const errorSubscribable = this.errorSubscribable - return SubscribableInternal.make({ - get: Effect.andThen(errorSubscribable.get, filter), - get changes() { return Stream.flatMap(errorSubscribable.changes, filter) }, - }) - }, [this.errorSubscribable, ...path]) + return { value, setValue, issues } + }) + } +} + + +export const make = ( + options: make.Options +): Effect.Effect> => Effect.gen(function*() { + const latestValueRef = yield* SubscriptionRef.make(options.initialValue) + const errorRef = yield* SubscriptionRef.make(Option.none()) + + return new FormImpl( + options.schema, + latestValueRef, + errorRef, + + yield* Effect.cachedFunction( + (path: PropertyPath.PropertyPath) => Effect.succeed(SubscribableInternal.make({ + get: Effect.flatMap(latestValueRef.get, PropertyPath.get(path as PropertyPath.Paths)), + get changes() { return Stream.flatMap(latestValueRef.changes, PropertyPath.get(path as PropertyPath.Paths)) }, + })), + PropertyPath.equivalence, + ), + yield* Effect.cachedFunction( + (path: PropertyPath.PropertyPath) => Effect.gen(function*() { + const filter = Option.match({ + onSome: (v: ParseResult.ParseError) => Effect.andThen( + ParseResult.ArrayFormatter.formatError(v), + Array.filter(issue => PropertyPath.equivalence(issue.path, path)), + ), + onNone: () => Effect.succeed([]), + }) + + return SubscribableInternal.make({ + get: Effect.flatMap(errorRef.get, filter), + get changes() { return Stream.flatMap(errorRef.changes, filter) }, + }) + }), + PropertyPath.equivalence, + ), + ) +}) + +export namespace make { + export interface Options { + readonly schema: Schema.Schema + readonly initialValue: NoInfer } } @@ -56,4 +147,4 @@ const TestSchema = Schema.Struct({ name: Schema.String }) declare const form: Form -const t = form.useRef(["name"]) +const t = form.useInput(["name"]) diff --git a/packages/effect-fc/src/types/PropertyPath.ts b/packages/effect-fc/src/types/PropertyPath.ts index 08c00ac..af47a8c 100644 --- a/packages/effect-fc/src/types/PropertyPath.ts +++ b/packages/effect-fc/src/types/PropertyPath.ts @@ -64,7 +64,7 @@ export const get: { ) export const immutableSet: { - >(path: P, value: ValueFromPath): (self: T) => ValueFromPath + >(path: P, value: ValueFromPath): (self: T) => Option.Option >(self: T, path: P, value: ValueFromPath): Option.Option } = Function.dual(3, >(self: T, path: P, value: ValueFromPath): Option.Option => { const key = Array.head(path as PropertyPath)