diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index b339fcc..6b1849f 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -20,8 +20,8 @@ extends Pipeable.Pipeable { readonly encodedValue: Lens.Lens readonly issues: Subscribable.Subscribable readonly isValidating: Subscribable.Subscribable - readonly canSubmit: Subscribable.Subscribable - readonly isSubmitting: Subscribable.Subscribable + readonly canCommit: Subscribable.Subscribable + readonly isCommitting: Subscribable.Subscribable } export class FormImpl @@ -34,23 +34,25 @@ extends Pipeable.Class() implements Form { readonly encodedValue: Lens.Lens, readonly issues: Subscribable.Subscribable, readonly isValidating: Subscribable.Subscribable, - readonly canSubmit: Subscribable.Subscribable, - readonly isSubmitting: Subscribable.Subscribable, + readonly canCommit: Subscribable.Subscribable, + readonly isCommitting: Subscribable.Subscribable, ) { super() } } -export const RootFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/RootForm") -export type RootFormTypeId = typeof RootFormTypeId +export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm") +export type SubmittableFormTypeId = typeof SubmittableFormTypeId -export interface RootForm +export interface SubmittableForm extends Form { + readonly [SubmittableFormTypeId]: SubmittableFormTypeId + readonly schema: Schema.Schema readonly context: Context.Context readonly mutation: Mutation.Mutation< - readonly [value: A, form: RootForm], + readonly [value: A, form: SubmittableForm], MA, ME, MR, MP > readonly autosubmit: boolean @@ -61,10 +63,10 @@ extends Form { readonly submit: Effect.Effect>, Cause.NoSuchElementException> } -export class RootFormImpl -extends Pipeable.Class() implements RootForm { +export class SubmittableFormImpl +extends Pipeable.Class() implements SubmittableForm { readonly [FormTypeId]: FormTypeId = FormTypeId - readonly [RootFormTypeId]: RootFormTypeId = RootFormTypeId + readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId readonly path = [] as const @@ -72,7 +74,7 @@ extends Pipeable.Class() implements RootForm { readonly schema: Schema.Schema, readonly context: Context.Context, readonly mutation: Mutation.Mutation< - readonly [value: A, form: RootForm], + readonly [value: A, form: SubmittableForm], MA, ME, MR, MP >, readonly autosubmit: boolean, @@ -83,8 +85,8 @@ extends Pipeable.Class() implements RootForm { readonly validationFiber: Lens.Lens>, never, never, never, never>, readonly isValidating: Subscribable.Subscribable, - readonly canSubmit: Subscribable.Subscribable, - readonly isSubmitting: Subscribable.Subscribable, + readonly canCommit: Subscribable.Subscribable, + readonly isCommitting: Subscribable.Subscribable, readonly runSemaphore: Effect.Semaphore, ) { @@ -161,20 +163,141 @@ extends Pipeable.Class() implements RootForm { ) : Effect.void ), - this.canSubmit.get, + this.canCommit.get, ) } } +export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm") +export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId + +export interface SynchronizedForm< + in out A, + in out I = A, + in out R = never, + in out TER = never, + in out TEW = never, + in out TRR = never, + in out TRW = never, +> extends Form { + readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId + + readonly schema: Schema.Schema + readonly context: Context.Context + readonly target: Lens.Lens + readonly validationFiber: Subscribable.Subscribable>, never, never> + + readonly run: Effect.Effect +} + +export class SynchronizedFormImpl< + in out A, + in out I = A, + in out R = never, + in out TER = never, + in out TEW = never, + in out TRR = never, + in out TRW = never, +> extends Pipeable.Class() implements SynchronizedForm { + readonly [FormTypeId]: FormTypeId = FormTypeId + readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId + + readonly path = [] as const + + constructor( + readonly schema: Schema.Schema, + readonly context: Context.Context, + readonly target: Lens.Lens, + + readonly value: Lens.Lens, never, never, never, never>, + readonly encodedValue: Lens.Lens, + readonly issues: Lens.Lens, + readonly validationFiber: Lens.Lens>, never, never, never, never>, + readonly isValidating: Subscribable.Subscribable, + + readonly canCommit: Subscribable.Subscribable, + readonly isCommitting: Subscribable.Subscribable, + + readonly runSemaphore: Effect.Semaphore, + ) { + super() + } + + get run(): Effect.Effect { + return this.runSemaphore.withPermits(1)(Effect.provide( + Effect.all([ + Stream.runForEach( + this.encodedValue.changes, + + encodedValue => Lens.get(this.validationFiber).pipe( + Effect.andThen(Option.match({ + onSome: Fiber.interrupt, + onNone: () => Effect.void, + })), + Effect.andThen( + Effect.forkScoped(Effect.onExit( + Schema.decode(this.schema, { errors: "all" })(encodedValue), + exit => Effect.andThen( + Exit.matchEffect(exit, { + onSuccess: v => Lens.set(this.value, Option.some(v)).pipe( + Effect.andThen(Lens.set(this.issues, Array.empty())), + ), + onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), { + onSome: e => Effect.flatMap( + ParseResult.ArrayFormatter.formatError(e), + v => Lens.set(this.issues, v), + ), + onNone: () => Effect.void, + }), + }), + Lens.set(this.validationFiber, Option.none()), + ), + )).pipe( + Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))), + Effect.andThen(Fiber.join), + Effect.tap(value => Lens.set(this.target, value)), + Effect.forkScoped, + ) + ), + ), + ), + + Stream.runForEach( + Stream.drop(this.target.changes, 1), + + targetValue => Schema.encode(this.schema, { errors: "all" })(targetValue).pipe( + Effect.flatMap(encodedValue => Effect.whenEffect( + Lens.set(this.encodedValue, encodedValue), + Effect.map( + Lens.get(this.encodedValue), + currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue), + ), + )), + ), + ), + ], { concurrency: "unbounded", discard: true }), + + this.context, + )) + } +} + + export const isForm = (u: unknown): u is Form => Predicate.hasProperty(u, FormTypeId) -export const isRootForm = (u: unknown): u is RootForm => Predicate.hasProperty(u, RootFormTypeId) +export const isSubmittableForm = (u: unknown): u is SubmittableForm => Predicate.hasProperty(u, SubmittableFormTypeId) +export const isSynchronizedForm = (u: unknown): u is SynchronizedForm => Predicate.hasProperty(u, SynchronizedFormTypeId) + +const falseSubscribable: Subscribable.Subscribable = Subscribable.make({ + get: Effect.succeed(false), + changes: Stream.make(false), +}) -export declare namespace make { +export declare namespace makeSubmittable { export interface Options extends Mutation.make.Options< - readonly [value: NoInfer, form: RootForm, NoInfer, NoInfer, unknown, unknown, unknown>], + readonly [value: NoInfer, form: SubmittableForm, NoInfer, NoInfer, unknown, unknown, unknown>], MA, ME, MR, MP > { readonly schema: Schema.Schema @@ -183,10 +306,10 @@ export declare namespace make { } } -export const make = Effect.fnUntraced(function* ( - options: make.Options +export const makeSubmittable = Effect.fnUntraced(function* ( + options: makeSubmittable.Options ): Effect.fn.Return< - RootForm, MP>, + SubmittableForm, MP>, never, Scope.Scope | R | Result.forkEffect.OutputContext > { @@ -195,7 +318,7 @@ export const make = Effect.fnUntraced(function* (Array.empty())) const validationFiberLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())) - return new RootFormImpl( + return new SubmittableFormImpl( options.schema, yield* Effect.context(), mutation, @@ -222,19 +345,82 @@ export const make = Effect.fnUntraced(function* - extends make.Options {} + extends makeSubmittable.Options {} } -export const service = ( - options: service.Options +export const serviceSubmittable = ( + options: serviceSubmittable.Options ): Effect.Effect< - RootForm, MP>, + SubmittableForm, MP>, never, Scope.Scope | R | Result.forkEffect.OutputContext > => Effect.tap( - make(options), + makeSubmittable(options), + form => Effect.forkScoped(form.run), +) + +export declare namespace makeSynchronized { + export interface Options { + readonly schema: Schema.Schema + readonly target: Lens.Lens + readonly initialEncodedValue: NoInfer + } +} + +export const makeSynchronized = Effect.fnUntraced(function* ( + options: makeSynchronized.Options +): Effect.fn.Return< + SynchronizedForm, + ParseResult.ParseError | TER, + Scope.Scope | R | TRR | TRW +> { + const valueLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())) + const issuesLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Array.empty())) + const validationFiberLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())) + const initialEncodedValue = yield* Lens.get(options.target).pipe( + Effect.flatMap(Schema.encode(options.schema, { errors: "all" })), + ) + + return new SynchronizedFormImpl( + options.schema, + yield* Effect.context(), + options.target, + + valueLens, + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)), + issuesLens, + validationFiberLens, + Subscribable.map(validationFiberLens, Option.isSome), + + Subscribable.map( + Subscribable.zipLatestAll(valueLens, issuesLens, validationFiberLens), + ([value, issues, validationFiber]) => ( + Option.isSome(value) && + Array.isEmptyReadonlyArray(issues) && + Option.isNone(validationFiber) + ), + ), + falseSubscribable, + + yield* Effect.makeSemaphore(1), + ) +}) + +export declare namespace serviceSynchronized { + export interface Options + extends makeSynchronized.Options {} +} + +export const serviceSynchronized = ( + options: serviceSynchronized.Options +): Effect.Effect< + SynchronizedForm, + ParseResult.ParseError | TER, + Scope.Scope | R | TRR | TRW +> => Effect.tap( + makeSynchronized(options), form => Effect.forkScoped(form.run), ) @@ -267,8 +453,8 @@ export const focusObjectField: { Lens.focusObjectField(form.encodedValue, key), Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), form.isValidating, - form.canSubmit, - form.isSubmitting, + form.canCommit, + form.isCommitting, ) }) @@ -293,8 +479,8 @@ export const focusArrayAt: { Lens.focusArrayAt(form.encodedValue, index), Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), form.isValidating, - form.canSubmit, - form.isSubmitting, + form.canCommit, + form.isCommitting, ) }) @@ -319,8 +505,8 @@ export const focusTupleAt: { Lens.focusTupleAt(form.encodedValue, index), Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), form.isValidating, - form.canSubmit, - form.isSubmitting, + form.canCommit, + form.isCommitting, ) }) @@ -345,8 +531,8 @@ export const focusChunkAt: { Lens.focusChunkAt(form.encodedValue, index), Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)), form.isValidating, - form.canSubmit, - form.isSubmitting, + form.canCommit, + form.isCommitting, ) }) diff --git a/packages/example/src/lib/form/TextFieldFormInputView.tsx b/packages/example/src/lib/form/TextFieldFormInputView.tsx index d7b462c..0fef82c 100644 --- a/packages/example/src/lib/form/TextFieldFormInputView.tsx +++ b/packages/example/src/lib/form/TextFieldFormInputView.tsx @@ -13,10 +13,10 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi props: TextFieldFormInputView.Props ) { const input = yield* Form.useInput(props.form, props) - const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables([ + const [issues, isValidating, isCommitting] = yield* Subscribable.useSubscribables([ props.form.issues, props.form.isValidating, - props.form.isSubmitting, + props.form.isCommitting, ]) return ( @@ -24,7 +24,7 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi input.setValue(e.target.value)} - disabled={isSubmitting} + disabled={isCommitting} {...Struct.omit(props, "form")} > {isValidating && diff --git a/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx b/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx index a3b5eda..97be16f 100644 --- a/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx +++ b/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx @@ -13,10 +13,10 @@ export class TextFieldOptionalFormInputView extends Component.make("TextFieldOpt props: TextFieldOptionalFormInputView.Props ) { const input = yield* Form.useOptionalInput(props.form, props) - const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables([ + const [issues, isValidating, isCommitting] = yield* Subscribable.useSubscribables([ props.form.issues, props.form.isValidating, - props.form.isSubmitting, + props.form.isCommitting, ]) return ( @@ -24,7 +24,7 @@ export class TextFieldOptionalFormInputView extends Component.make("TextFieldOpt input.setValue(e.target.value)} - disabled={!input.enabled || isSubmitting} + disabled={!input.enabled || isCommitting} {...Struct.omit(props, "form", "defaultValue")} > diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index 2d596fa..d1c5354 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -73,8 +73,8 @@ class RegisterFormService extends Effect.Service()("Registe class RegisterFormView extends Component.make("RegisterFormView")(function*() { const form = yield* RegisterFormService - const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([ - form.form.canSubmit, + const [canCommit, submitResult] = yield* Subscribable.useSubscribables([ + form.form.canCommit, form.form.mutation.result, ]) @@ -111,7 +111,7 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() { defaultValue="" /> - + diff --git a/packages/example/src/todo/TodoView.tsx b/packages/example/src/todo/TodoView.tsx index e826a3c..225cca6 100644 --- a/packages/example/src/todo/TodoView.tsx +++ b/packages/example/src/todo/TodoView.tsx @@ -75,10 +75,10 @@ export class TodoView extends Component.make("TodoView")(function*(props: TodoPr ] as const }), [props._tag, props._tag === "edit" ? props.id : undefined]) - const [index, size, canSubmit] = yield* Subscribable.useSubscribables([ + const [index, size, canCommit] = yield* Subscribable.useSubscribables([ indexRef, state.sizeSubscribable, - form.canSubmit, + form.canCommit, ]) const runSync = yield* Component.useRunSync() @@ -104,7 +104,7 @@ export class TodoView extends Component.make("TodoView")(function*(props: TodoPr /> {props._tag === "new" && - }