diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index be86f0c..7e65d70 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,9 +1,8 @@ -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 { Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, identity, Option, type ParseResult, Pipeable, Predicate, Schema, type Scope, Stream } from "effect" import type * as React from "react" import * as Component from "./Component.js" import * as Lens from "./Lens.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" import * as SubscriptionRef from "./SubscriptionRef.js" @@ -12,92 +11,90 @@ import * as SubscriptionRef from "./SubscriptionRef.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 [value: A, form: Form], - MA, ME, MR, MP - > - readonly autosubmit: boolean - readonly debounce: Option.Option - + readonly path: P readonly value: Subscribable.Subscribable> readonly encodedValue: Lens.Lens readonly error: Subscribable.Subscribable> readonly validationFiber: Subscribable.Subscribable>> - readonly canSubmit: Subscribable.Subscribable +} - field>( - path: P - ): Effect.Effect, PropertyPath.ValueFromPath>> +export const FormImplTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormImpl") +export type FormImplTypeId = typeof FormImplTypeId + +export class FormImpl +extends Pipeable.Class() implements Form { + readonly [FormTypeId]: FormTypeId = FormTypeId + readonly [FormImplTypeId]: FormImplTypeId = FormImplTypeId + + constructor( + readonly path: P, + readonly value: Lens.Lens>, + readonly encodedValue: Lens.Lens, + readonly error: Lens.Lens>, + readonly validationFiber: Lens.Lens>>, + readonly canSubmit: Subscribable.Subscribable, + ) { + super() + } +} + + +export const RootFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/RootForm") +export type RootFormTypeId = typeof RootFormTypeId + +export interface RootForm +extends Form { + readonly schema: Schema.Schema + readonly context: Context.Context + readonly mutation: Mutation.Mutation< + readonly [value: A, form: RootForm], + MA, ME, MR, MP + > + readonly autosubmit: boolean readonly run: Effect.Effect readonly submit: Effect.Effect>, Cause.NoSuchElementException> } -export class FormImpl -extends Pipeable.Class() implements Form { - readonly [FormTypeId]: FormTypeId = FormTypeId +export class RootFormImpl +extends FormImpl implements RootForm { + readonly [RootFormTypeId]: RootFormTypeId = RootFormTypeId constructor( readonly schema: Schema.Schema, readonly context: Context.Context, readonly mutation: Mutation.Mutation< - readonly [value: A, form: Form], + readonly [value: A, form: RootForm], MA, ME, MR, MP >, readonly autosubmit: boolean, - readonly debounce: Option.Option, - readonly value: Lens.Lens>, - readonly encodedValue: Lens.Lens, - readonly error: Lens.Lens>, - readonly validationFiber: Lens.Lens>>, + value: Lens.Lens>, + encodedValue: Lens.Lens, + error: Lens.Lens>, + validationFiber: Lens.Lens>>, + canSubmit: Subscribable.Subscribable, readonly runSemaphore: Effect.Semaphore, - readonly fieldCache: Ref.Ref>>, ) { - super() - - this.canSubmit = Subscribable.map( - Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result), - ([value, error, validationFiber, result]) => ( - Option.isSome(value) && - Option.isNone(error) && - Option.isNone(validationFiber) && - !(Result.isRunning(result) || Result.hasRefreshingFlag(result)) - ), + super( + [], + value, + encodedValue, + error, + validationFiber, + canSubmit, ) } - field>( - path: P - ): Effect.Effect, PropertyPath.ValueFromPath>> { - const key = new FormFieldKey(path) - return this.fieldCache.pipe( - Effect.map(HashMap.get(key)), - 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(key, v as FormField)), - ), - })), - ) - } - - readonly canSubmit: Subscribable.Subscribable - get run(): Effect.Effect { return this.runSemaphore.withPermits(1)(Stream.runForEach( - this.encodedValue.changes.pipe( - Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity - ), + this.encodedValue.changes, encodedValue => Lens.get(this.validationFiber).pipe( Effect.andThen(Option.match({ @@ -164,12 +161,16 @@ extends Pipeable.Class() implements Form { } } -export const isForm = (u: unknown): u is Form => Predicate.hasProperty(u, FormTypeId) + +export const isForm = (u: unknown): u is Form => Predicate.hasProperty(u, FormTypeId) +export const isFormImpl = (u: unknown): u is FormImpl => Predicate.hasProperty(u, FormImplTypeId) +export const isRootForm = (u: unknown): u is RootForm => Predicate.hasProperty(u, RootFormTypeId) + export declare namespace make { export interface Options extends Mutation.make.Options< - readonly [value: NoInfer, form: Form, NoInfer, NoInfer, unknown, unknown, unknown>], + readonly [value: NoInfer, form: RootForm, NoInfer, NoInfer, unknown, unknown, unknown>], MA, ME, MR, MP > { readonly schema: Schema.Schema @@ -182,24 +183,36 @@ export declare namespace make { export const make = Effect.fnUntraced(function* ( options: make.Options ): Effect.fn.Return< - Form, MP>, + RootForm, MP>, never, Scope.Scope | R | Result.forkEffect.OutputContext > { - return new FormImpl( + const mutation = yield* Mutation.make(options) + const valueLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())) + const errorLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())) + const validationFiberLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())) + + return new RootFormImpl( options.schema, yield* Effect.context(), - yield* Mutation.make(options), + mutation, options.autosubmit ?? false, - Option.fromNullable(options.debounce), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), + valueLens, Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), - Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), + errorLens, + validationFiberLens, + Subscribable.map( + Subscribable.zipLatestAll(valueLens, errorLens, validationFiberLens, mutation.result), + ([value, error, validationFiber, result]) => ( + Option.isSome(value) && + Option.isNone(error) && + Option.isNone(validationFiber) && + !(Result.isRunning(result) || Result.hasRefreshingFlag(result)) + ), + ), yield* Effect.makeSemaphore(1), - yield* Ref.make(HashMap.empty>()), ) }) @@ -211,7 +224,7 @@ export declare namespace service { export const service = ( options: service.Options ): Effect.Effect< - Form, MP>, + RootForm, MP>, never, Scope.Scope | R | Result.forkEffect.OutputContext > => Effect.tap( @@ -220,74 +233,6 @@ export const service = -extends Pipeable.Pipeable { - readonly [FormFieldTypeId]: FormFieldTypeId - - readonly value: Subscribable.Subscribable, Cause.NoSuchElementException> - readonly encodedValue: Lens.Lens - readonly issues: Subscribable.Subscribable - readonly isValidating: Subscribable.Subscribable - readonly isSubmitting: Subscribable.Subscribable -} - -class FormFieldImpl -extends Pipeable.Class() implements FormField { - readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId - - constructor( - readonly value: Subscribable.Subscribable, Cause.NoSuchElementException>, - readonly encodedValue: Lens.Lens, - readonly issues: Subscribable.Subscribable, - readonly isValidating: Subscribable.Subscribable, - readonly isSubmitting: Subscribable.Subscribable, - ) { - super() - } -} - -const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey") -type FormFieldKeyTypeId = typeof FormFieldKeyTypeId - -class FormFieldKey implements Equal.Equal { - readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId - constructor(readonly path: PropertyPath.PropertyPath) {} - - [Equal.symbol](that: Equal.Equal) { - return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path) - } - [Hash.symbol]() { - return Hash.array(this.path) - } -} - -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, - path: P, -): FormField, PropertyPath.ValueFromPath> => { - return new FormFieldImpl( - Subscribable.mapEffect(self.value, Option.match({ - onSome: v => Option.map(PropertyPath.get(v, path), Option.some), - onNone: () => Option.some(Option.none()), - })), - Lens.map(self.encodedValue, a => Option.getOrThrow(PropertyPath.get(a, path)), (a, b) => Option.getOrThrow(PropertyPath.immutableSet(a, path, b))), - 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.hasRefreshingFlag(result)), - ) -} export namespace useInput { diff --git a/packages/effect-fc/src/Lens.ts b/packages/effect-fc/src/Lens.ts index 3f1720c..ef33fc1 100644 --- a/packages/effect-fc/src/Lens.ts +++ b/packages/effect-fc/src/Lens.ts @@ -6,6 +6,8 @@ import * as SetStateAction from "./SetStateAction.js" import * as SubscriptionRef from "./SubscriptionRef.js" +export * from "effect-lens/Lens" + export declare namespace useState { export interface Options { readonly equivalence?: Equivalence.Equivalence @@ -59,5 +61,3 @@ export const useFromState = Effect.fnUntraced(function* ( return lens }) - -export * from "effect-lens/Lens" diff --git a/packages/effect-fc/src/Subscribable.ts b/packages/effect-fc/src/Subscribable.ts index 138f16e..67e60ad 100644 --- a/packages/effect-fc/src/Subscribable.ts +++ b/packages/effect-fc/src/Subscribable.ts @@ -3,6 +3,8 @@ import * as React from "react" import * as Component from "./Component.js" +export * from "effect/Subscribable" + export const zipLatestAll = []>( ...elements: T ): Subscribable.Subscribable< @@ -48,5 +50,3 @@ export const useSubscribables = Effect.fnUntraced(function*