Working form
All checks were successful
Lint / lint (push) Successful in 12s

This commit is contained in:
Julien Valverdé
2025-09-29 14:52:51 +02:00
parent 71d3c77e1a
commit 73134478ed
2 changed files with 142 additions and 94 deletions

View File

@@ -1,5 +1,5 @@
import * as AsyncData from "@typed/async-data" 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 { Array, Duration, Effect, Equal, Equivalence, Exit, flow, identity, Option, ParseResult, pipe, Pipeable, Ref, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect"
import type { NoSuchElementException } from "effect/Cause" import type { NoSuchElementException } from "effect/Cause"
import * as React from "react" import * as React from "react"
import { Hooks } from "./hooks/index.js" import { Hooks } from "./hooks/index.js"
@@ -43,51 +43,31 @@ extends Pipeable.Class() implements Form<A, I, R, SA, SE, SR> {
super() super()
this.canSubmitSubscribable = pipe( this.canSubmitSubscribable = pipe(
<A>([value, error, isValidating]: readonly [ <A>([value, error, isValidating, submitState]: readonly [
Option.Option<A>, Option.Option<A>,
Option.Option<ParseResult.ParseError>, Option.Option<ParseResult.ParseError>,
boolean, boolean,
]) => Option.isSome(value) && Option.isNone(error) && !isValidating, AsyncData.AsyncData<SA, SE>,
]) => Option.isSome(value) && Option.isNone(error) && !isValidating && !AsyncData.isLoading(submitState),
filter => SubscribableInternal.make({ filter => SubscribableInternal.make({
get: Effect.map(Effect.all([valueRef, errorRef, isValidatingRef]), filter), get: Effect.map(Effect.all([valueRef, errorRef, isValidatingRef, submitStateRef]), filter),
get changes() { return Stream.map(Stream.zipLatestAll(valueRef.changes, errorRef.changes, isValidatingRef.changes), filter)}, get changes() {
return Stream.map(
Stream.zipLatestAll(
valueRef.changes,
errorRef.changes,
isValidatingRef.changes,
submitStateRef.changes,
),
filter,
)
},
}), }),
) )
} }
} }
export const FormFieldTypeId: unique symbol = Symbol.for("effect-fc/FormField")
export type FormFieldTypeId = typeof FormFieldTypeId
export interface FormField<in out A, in out I = A>
extends Pipeable.Pipeable {
readonly [FormFieldTypeId]: FormFieldTypeId
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>
}
class FormFieldImpl<in out A, in out I = A>
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>,
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>,
) {
super()
}
}
export namespace make { export namespace make {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never> { export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never> {
readonly schema: Schema.Schema<A, I, R> readonly schema: Schema.Schema<A, I, R>
@@ -114,6 +94,39 @@ export const make: {
) )
}) })
export const run = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<void, never, R> => 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 submit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<Option.Option<AsyncData.AsyncData<SA, SE>>, NoSuchElementException, SR> => Effect.whenEffect(
self.valueRef.pipe(
Effect.andThen(identity),
Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())),
Effect.andThen(flow(
self.submit,
Effect.exit,
Effect.map(Exit.match({
onSuccess: a => AsyncData.success(a),
onFailure: e => AsyncData.failure(e),
})),
Effect.tap(v => Ref.set(self.submitStateRef, v))
)),
),
self.canSubmitSubscribable.get,
)
export namespace service { export namespace service {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never> export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {} extends make.Options<A, I, R, SA, SE, SR> {}
@@ -126,35 +139,6 @@ export const service = <A, I = A, R = never, SA = void, SE = A, SR = never>(
form => Effect.forkScoped(run(form)), form => Effect.forkScoped(run(form)),
) )
export namespace useForm {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {}
}
export const useForm: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: service.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: service.Options<A, I, R, SA, SE, SR>
) {
const form = yield* Hooks.useOnce(() => make(options))
yield* Hooks.useFork(() => run(form), [form])
return form
})
const run = <A, I, R, SA, SE, SR>(self: Form<A, I, R, SA, SE, SR>) => 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 = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>( export const field = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>, self: Form<A, I, R, SA, SE, SR>,
path: P, path: P,
@@ -197,6 +181,62 @@ export const field = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<No
), ),
) )
export const FormFieldTypeId: unique symbol = Symbol.for("effect-fc/FormField")
export type FormFieldTypeId = typeof FormFieldTypeId
export interface FormField<in out A, in out I = A>
extends Pipeable.Pipeable {
readonly [FormFieldTypeId]: FormFieldTypeId
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>
}
class FormFieldImpl<in out A, in out I = A>
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>,
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>,
) {
super()
}
}
export namespace useForm {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {}
}
export const useForm: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: service.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: service.Options<A, I, R, SA, SE, SR>
) {
const form = yield* Hooks.useOnce(() => make(options))
yield* Hooks.useFork(() => run(form), [form])
return form
})
export const useSubmit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<
() => Promise<Option.Option<AsyncData.AsyncData<SA, SE>>>,
never,
SR
> => Hooks.useCallbackPromise(() => submit(self), [self])
export const useField = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>( export const useField = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>, self: Form<A, I, R, SA, SE, SR>,
path: P, path: P,
@@ -205,7 +245,6 @@ export const useField = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths
PropertyPath.ValueFromPath<I, P> PropertyPath.ValueFromPath<I, P>
> => React.useMemo(() => field(self, path), [self, ...path]) > => React.useMemo(() => field(self, path), [self, ...path])
export namespace useInput { export namespace useInput {
export interface Options { export interface Options {
readonly debounce?: Duration.DurationInput readonly debounce?: Duration.DurationInput

View File

@@ -25,7 +25,10 @@ class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
scoped: Form.service({ scoped: Form.service({
schema: RegisterFormSchema, schema: RegisterFormSchema,
initialEncodedValue: { email: "", password: "" }, initialEncodedValue: { email: "", password: "" },
submit: () => Effect.void, submit: () => Effect.andThen(
Effect.sleep("500 millis"),
Effect.sync(() => alert("Done!")),
),
}) })
}) {} }) {}
@@ -36,43 +39,49 @@ class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() {
const emailInput = yield* Form.useInput(emailField, { debounce: "200 millis" }) const emailInput = yield* Form.useInput(emailField, { debounce: "200 millis" })
const passwordInput = yield* Form.useInput(passwordField, { debounce: "200 millis" }) const passwordInput = yield* Form.useInput(passwordField, { debounce: "200 millis" })
const submit = yield* Form.useSubmit(form)
const [canSubmit] = yield* useSubscribables(form.canSubmitSubscribable) const [canSubmit] = yield* useSubscribables(form.canSubmitSubscribable)
return ( return (
<Container> <Container>
<Flex direction="column" gap="2"> <form onSubmit={e => {
<TextField.Root e.preventDefault()
value={emailInput.value} void submit()
onChange={e => emailInput.setValue(e.target.value)} }}>
/> <Flex direction="column" gap="2">
<TextField.Root
value={emailInput.value}
onChange={e => emailInput.setValue(e.target.value)}
/>
{Option.match(Array.head(emailInput.issues), { {Option.match(Array.head(emailInput.issues), {
onSome: issue => ( onSome: issue => (
<Callout.Root> <Callout.Root>
<Callout.Text>{issue.message}</Callout.Text> <Callout.Text>{issue.message}</Callout.Text>
</Callout.Root> </Callout.Root>
), ),
onNone: () => <></>, onNone: () => <></>,
})} })}
<TextField.Root <TextField.Root
value={passwordInput.value} value={passwordInput.value}
onChange={e => passwordInput.setValue(e.target.value)} onChange={e => passwordInput.setValue(e.target.value)}
/> />
{Option.match(Array.head(passwordInput.issues), { {Option.match(Array.head(passwordInput.issues), {
onSome: issue => ( onSome: issue => (
<Callout.Root> <Callout.Root>
<Callout.Text>{issue.message}</Callout.Text> <Callout.Text>{issue.message}</Callout.Text>
</Callout.Root> </Callout.Root>
), ),
onNone: () => <></>, onNone: () => <></>,
})} })}
<Button disabled={!canSubmit}>Submit</Button> <Button disabled={!canSubmit}>Submit</Button>
</Flex> </Flex>
</form>
</Container> </Container>
) )
}) {} }) {}