From 1dc28ce8c6ed0d39beb2f65011218d070aea80dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 29 Apr 2026 18:02:10 +0200 Subject: [PATCH] Fix example --- .../src/lib/form/TextFieldFormInputView.tsx | 2 +- .../form/TextFieldOptionalFormInputView.tsx | 2 +- packages/example/src/routes/form.tsx | 12 +- packages/example/src/routes/query.tsx | 18 +-- packages/example/src/routes/result.tsx | 2 +- packages/example/src/todo/EditTodoView.tsx | 88 +++++++++++ packages/example/src/todo/NewTodoView.tsx | 78 ++++++++++ packages/example/src/todo/TodoFormSchema.ts | 9 ++ packages/example/src/todo/TodoView.tsx | 137 ------------------ packages/example/src/todo/TodosState.ts | 38 ++--- packages/example/src/todo/TodosView.tsx | 12 +- 11 files changed, 220 insertions(+), 178 deletions(-) create mode 100644 packages/example/src/todo/EditTodoView.tsx create mode 100644 packages/example/src/todo/NewTodoView.tsx create mode 100644 packages/example/src/todo/TodoFormSchema.ts delete mode 100644 packages/example/src/todo/TodoView.tsx diff --git a/packages/example/src/lib/form/TextFieldFormInputView.tsx b/packages/example/src/lib/form/TextFieldFormInputView.tsx index 0fef82c..94b0b1f 100644 --- a/packages/example/src/lib/form/TextFieldFormInputView.tsx +++ b/packages/example/src/lib/form/TextFieldFormInputView.tsx @@ -13,7 +13,7 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi props: TextFieldFormInputView.Props ) { const input = yield* Form.useInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* Subscribable.useSubscribables([ + const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, diff --git a/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx b/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx index 97be16f..ea5a0c0 100644 --- a/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx +++ b/packages/example/src/lib/form/TextFieldOptionalFormInputView.tsx @@ -13,7 +13,7 @@ export class TextFieldOptionalFormInputView extends Component.make("TextFieldOpt props: TextFieldOptionalFormInputView.Props ) { const input = yield* Form.useOptionalInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* Subscribable.useSubscribables([ + const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index d1c5354..95bb4c7 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -1,11 +1,11 @@ +import { Button, Container, Flex, Text } from "@radix-ui/themes" +import { createFileRoute } from "@tanstack/react-router" +import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" +import { Component, Form, SubmittableForm, Subscribable } from "effect-fc" import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView" import { DateTimeUtcFromZonedInput } from "@/lib/schema" import { runtime } from "@/runtime" -import { Button, Container, Flex, Text } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" -import { Component, Form, Subscribable } from "effect-fc" const email = Schema.pattern( @@ -41,7 +41,7 @@ const RegisterFormSubmitSchema = Schema.Struct({ class RegisterFormService extends Effect.Service()("RegisterFormService", { scoped: Effect.gen(function*() { - const form = yield* Form.service({ + const form = yield* SubmittableForm.service({ schema: RegisterFormSchema.pipe( Schema.compose( Schema.transformOrFail( @@ -73,7 +73,7 @@ class RegisterFormService extends Effect.Service()("Registe class RegisterFormView extends Component.make("RegisterFormView")(function*() { const form = yield* RegisterFormService - const [canCommit, submitResult] = yield* Subscribable.useSubscribables([ + const [canCommit, submitResult] = yield* Subscribable.useAll([ form.form.canCommit, form.form.mutation.result, ]) diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index a6e2f4d..047560e 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -1,8 +1,8 @@ import { HttpClient, type HttpClientError } from "@effect/platform" import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect" -import { Component, ErrorObserver, Mutation, Query, Result, Subscribable, SubscriptionRef } from "effect-fc" +import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect" +import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc" import { runtime } from "@/runtime" @@ -16,9 +16,9 @@ const Post = Schema.Struct({ const ResultView = Component.make("ResultView")(function*() { const runPromise = yield* Component.useRunPromise() - const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { - const idRef = yield* SubscriptionRef.make(1) - const key = Stream.map(idRef.changes, id => [id] as const) + const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { + const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1)) + const key = Stream.map(idLens.changes, id => [id] as const) const query = yield* Query.service({ key, @@ -40,11 +40,11 @@ const ResultView = Component.make("ResultView")(function*() { ), }) - return [idRef, query, mutation] as const + return [idLens, query, mutation] as const })) - const [id, setId] = yield* SubscriptionRef.useSubscriptionRefState(idRef) - const [queryResult, mutationResult] = yield* Subscribable.useSubscribables([query.result, mutation.result]) + const [id, setId] = yield* Lens.useState(idLens) + const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result]) yield* Component.useOnMount(() => ErrorObserver.ErrorObserver().pipe( Effect.andThen(observer => observer.subscribe), @@ -105,7 +105,7 @@ const ResultView = Component.make("ResultView")(function*() { - + diff --git a/packages/example/src/routes/result.tsx b/packages/example/src/routes/result.tsx index 5e3c545..0828f37 100644 --- a/packages/example/src/routes/result.tsx +++ b/packages/example/src/routes/result.tsx @@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() { Effect.tap(Effect.sleep("250 millis")), Result.forkEffect, )) - const [result] = yield* Subscribable.useSubscribables([resultSubscribable]) + const [result] = yield* Subscribable.useAll([resultSubscribable]) yield* Component.useOnMount(() => ErrorObserver.ErrorObserver().pipe( Effect.andThen(observer => observer.subscribe), diff --git a/packages/example/src/todo/EditTodoView.tsx b/packages/example/src/todo/EditTodoView.tsx new file mode 100644 index 0000000..b49797a --- /dev/null +++ b/packages/example/src/todo/EditTodoView.tsx @@ -0,0 +1,88 @@ +import { Box, Flex, IconButton } from "@radix-ui/themes" +import { Effect } from "effect" +import { Component, Form, Subscribable, SynchronizedForm } from "effect-fc" +import { FaArrowDown, FaArrowUp } from "react-icons/fa" +import { FaDeleteLeft } from "react-icons/fa6" +import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" +import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView" +import { TodoFormSchema } from "./TodoFormSchema" +import { TodosState } from "./TodosState" + + +export interface EditTodoViewProps { + readonly id: string +} + +export class EditTodoView extends Component.make("TodoView")(function*(props: EditTodoViewProps) { + const state = yield* TodosState + + const [ + indexSubscribable, + contentField, + completedAtField, + ] = yield* Component.useOnChange(() => Effect.gen(function*() { + const indexSubscribable = state.getIndexSubscribable(props.id) + + const form = yield* SynchronizedForm.service({ + schema: TodoFormSchema, + target: state.getElementLens(props.id), + }) + + return [ + indexSubscribable, + Form.focusObjectField(form, "content"), + Form.focusObjectField(form, "completedAt"), + ] as const + }), [props.id]) + + const [index, size] = yield* Subscribable.useAll([ + indexSubscribable, + state.sizeSubscribable, + ]) + + const runSync = yield* Component.useRunSync() + const TextFieldFormInput = yield* TextFieldFormInputView.use + const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use + + + return ( + + + + + + + + + + + + + runSync(state.moveLeft(props.id))} + > + + + + = size - 1} + onClick={() => runSync(state.moveRight(props.id))} + > + + + + runSync(state.remove(props.id))}> + + + + + ) +}) {} diff --git a/packages/example/src/todo/NewTodoView.tsx b/packages/example/src/todo/NewTodoView.tsx new file mode 100644 index 0000000..5fd7670 --- /dev/null +++ b/packages/example/src/todo/NewTodoView.tsx @@ -0,0 +1,78 @@ +import { Box, Button, Flex } from "@radix-ui/themes" +import { GetRandomValues, makeUuid4 } from "@typed/id" +import { Chunk, type DateTime, Effect, Option, Schema } from "effect" +import { Component, Form, Lens, SubmittableForm, Subscribable } from "effect-fc" +import * as Domain from "@/domain" +import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" +import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView" +import { TodoFormSchema } from "./TodoFormSchema" +import { TodosState } from "./TodosState" + + +const makeTodo = makeUuid4.pipe( + Effect.map(id => Domain.Todo.Todo.make({ + id, + content: "", + completedAt: Option.none(), + })), + Effect.provide(GetRandomValues.CryptoRandom), +) + + +export class NewTodoView extends Component.make("NewTodoView")(function*() { + const state = yield* TodosState + + const [ + form, + contentField, + completedAtField, + ] = yield* Component.useOnMount(() => Effect.gen(function*() { + const form = yield* SubmittableForm.service({ + schema: TodoFormSchema, + initialEncodedValue: yield* Schema.encode(TodoFormSchema)(yield* makeTodo), + f: ([todo, form]) => Lens.update(state.lens, Chunk.prepend(todo)).pipe( + Effect.andThen(makeTodo), + Effect.andThen(Schema.encode(TodoFormSchema)), + Effect.andThen(v => Lens.set(form.encodedValue, v)), + ), + }) + + return [ + form, + Form.focusObjectField(form, "content"), + Form.focusObjectField(form, "completedAt"), + ] as const + })) + + const [canCommit] = yield* Subscribable.useAll([form.canCommit]) + + const runPromise = yield* Component.useRunPromise() + const TextFieldFormInput = yield* TextFieldFormInputView.use + const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use + + + return ( + + + + + + + + + + + + + + ) +}) {} diff --git a/packages/example/src/todo/TodoFormSchema.ts b/packages/example/src/todo/TodoFormSchema.ts new file mode 100644 index 0000000..b2d4153 --- /dev/null +++ b/packages/example/src/todo/TodoFormSchema.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" +import * as Domain from "@/domain" +import { DateTimeUtcFromZonedInput } from "@/lib/schema" + + +export const TodoFormSchema = Schema.compose(Schema.Struct({ + ...Domain.Todo.Todo.fields, + completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput), +}), Domain.Todo.Todo) diff --git a/packages/example/src/todo/TodoView.tsx b/packages/example/src/todo/TodoView.tsx deleted file mode 100644 index 9e2b6be..0000000 --- a/packages/example/src/todo/TodoView.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import * as Domain from "@/domain" -import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" -import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView" -import { DateTimeUtcFromZonedInput } from "@/lib/schema" -import { Box, Button, Flex, IconButton } from "@radix-ui/themes" -import { GetRandomValues, makeUuid4 } from "@typed/id" -import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect" -import { Component, Form, Lens, Subscribable } from "effect-fc" -import { FaArrowDown, FaArrowUp } from "react-icons/fa" -import { FaDeleteLeft } from "react-icons/fa6" -import { TodosState } from "./TodosState" - - -const TodoFormSchema = Schema.compose(Schema.Struct({ - ...Domain.Todo.Todo.fields, - completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput), -}), Domain.Todo.Todo) - -const makeTodo = makeUuid4.pipe( - Effect.map(id => Domain.Todo.Todo.make({ - id, - content: "", - completedAt: Option.none(), - })), - Effect.provide(GetRandomValues.CryptoRandom), -) - - -export type TodoProps = ( - | { readonly _tag: "new" } - | { readonly _tag: "edit", readonly id: string } -) - -export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) { - const state = yield* TodosState - - const [ - indexRef, - form, - contentField, - completedAtField, - ] = yield* Component.useOnChange(() => Effect.gen(function*() { - const indexRef = Match.value(props).pipe( - Match.tag("new", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.make(-1) })), - Match.tag("edit", ({ id }) => state.getIndexSubscribable(id)), - Match.exhaustive, - ) - - const form = yield* Form.service({ - schema: TodoFormSchema, - initialEncodedValue: yield* Schema.encode(TodoFormSchema)( - yield* Match.value(props).pipe( - Match.tag("new", () => makeTodo), - Match.tag("edit", ({ id }) => state.getElementRef(id)), - Match.exhaustive, - ) - ), - f: ([todo, form]) => Match.value(props).pipe( - Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe( - Effect.andThen(makeTodo), - Effect.andThen(Schema.encode(TodoFormSchema)), - Effect.andThen(v => Lens.set(form.encodedValue, v)), - )), - Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)), - Match.exhaustive, - ), - }) - - return [ - indexRef, - form, - Form.focusObjectField(form, "content"), - Form.focusObjectField(form, "completedAt"), - ] as const - }), [props._tag, props._tag === "edit" ? props.id : undefined]) - - const [index, size, canCommit] = yield* Subscribable.useSubscribables([ - indexRef, - state.sizeSubscribable, - form.canCommit, - ]) - - const runSync = yield* Component.useRunSync() - const runPromise = yield* Component.useRunPromise() - const TextFieldFormInput = yield* TextFieldFormInputView.use - const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use - - - return ( - - - - - - - - - {props._tag === "new" && - - } - - - - - {props._tag === "edit" && - - runSync(state.moveLeft(props.id))} - > - - - - = size - 1} - onClick={() => runSync(state.moveRight(props.id))} - > - - - - runSync(state.remove(props.id))}> - - - - } - - ) -}) {} diff --git a/packages/example/src/todo/TodosState.ts b/packages/example/src/todo/TodosState.ts index 14595c0..d77041d 100644 --- a/packages/example/src/todo/TodosState.ts +++ b/packages/example/src/todo/TodosState.ts @@ -1,7 +1,7 @@ import { KeyValueStore } from "@effect/platform" import { BrowserKeyValueStore } from "@effect/platform-browser" import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect" -import { Subscribable, SubscriptionSubRef } from "effect-fc" +import { Lens, Subscribable } from "effect-fc" import { Todo } from "@/domain" @@ -30,27 +30,29 @@ export class TodosState extends Effect.Service()("TodosState", { : kv.remove(key) ) - const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage) - yield* Effect.forkScoped(ref.changes.pipe( + const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage)) + yield* Effect.forkScoped(lens.changes.pipe( Stream.debounce("500 millis"), Stream.runForEach(saveToLocalStorage), )) - yield* Effect.addFinalizer(() => ref.pipe( + yield* Effect.addFinalizer(() => Lens.get(lens).pipe( Effect.andThen(saveToLocalStorage), Effect.ignore, )) - const sizeSubscribable = Subscribable.make({ - get: Effect.andThen(ref, Chunk.size), - get changes() { return Stream.map(ref.changes, Chunk.size) }, - }) - const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id) - const getIndexSubscribable = (id: string) => Subscribable.make({ - get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)), - get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) }, - }) + const sizeSubscribable = Subscribable.map(lens, Chunk.size) - const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe( + const getElementLens = (id: string) => Lens.mapEffect( + lens, + Chunk.findFirst(v => v.id === id), + (a, b) => Effect.flatMap( + Chunk.findFirstIndex(a, v => v.id === id), + i => Chunk.replaceOption(a, i, b), + ) + ) + const getIndexSubscribable = (id: string) => Subscribable.mapEffect(lens, Chunk.findFirstIndex(v => v.id === id)) + + const moveLeft = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe( Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)), Effect.bind("todo", ({ index }) => Chunk.get(todos, index)), Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)), @@ -62,7 +64,7 @@ export class TodosState extends Effect.Service()("TodosState", { : todos ), )) - const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe( + const moveRight = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe( Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)), Effect.bind("todo", ({ index }) => Chunk.get(todos, index)), Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)), @@ -74,15 +76,15 @@ export class TodosState extends Effect.Service()("TodosState", { : todos ), )) - const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen( + const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen( Chunk.findFirstIndex(todos, v => v.id === id), index => Chunk.remove(todos, index), )) return { - ref, + lens, sizeSubscribable, - getElementRef, + getElementLens, getIndexSubscribable, moveLeft, moveRight, diff --git a/packages/example/src/todo/TodosView.tsx b/packages/example/src/todo/TodosView.tsx index 0f34c36..b935681 100644 --- a/packages/example/src/todo/TodosView.tsx +++ b/packages/example/src/todo/TodosView.tsx @@ -1,30 +1,32 @@ import { Container, Flex, Heading } from "@radix-ui/themes" import { Chunk, Console, Effect } from "effect" import { Component, Subscribable } from "effect-fc" +import { EditTodoView } from "./EditTodoView" +import { NewTodoView } from "./NewTodoView" import { TodosState } from "./TodosState" -import { TodoView } from "./TodoView" export class TodosView extends Component.make("TodosView")(function*() { const state = yield* TodosState - const [todos] = yield* Subscribable.useSubscribables([state.ref]) + const [todos] = yield* Subscribable.useAll([state.lens]) yield* Component.useOnMount(() => Effect.andThen( Console.log("Todos mounted"), Effect.addFinalizer(() => Console.log("Todos unmounted")), )) - const Todo = yield* TodoView.use + const NewTodo = yield* NewTodoView.use + const EditTodo = yield* EditTodoView.use return ( Todos - + {Chunk.map(todos, todo => - + )}