diff --git a/packages/example-next/src/domain/Todo.ts b/packages/example-next/src/domain/Todo.ts deleted file mode 100644 index 500faab..0000000 --- a/packages/example-next/src/domain/Todo.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Schema } from "effect" -import { assertEncodedJsonifiable } from "@/lib/schema" - - -export class Todo extends Schema.Class("Todo")({ - _tag: Schema.tag("Todo"), - id: Schema.String, - content: Schema.String, - completedAt: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf), -}) {} - - -export const TodoFromJsonStruct = Schema.Struct({ - ...Todo.fields, - completedAt: Schema.Option(Schema.DateTimeUtc), -}).pipe( - assertEncodedJsonifiable -) - -export const TodoFromJson = Schema.compose(TodoFromJsonStruct, Todo) diff --git a/packages/example-next/src/domain/index.ts b/packages/example-next/src/domain/index.ts deleted file mode 100644 index 8d70c13..0000000 --- a/packages/example-next/src/domain/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as Todo from "./Todo" diff --git a/packages/example-next/src/lib/form/TextFieldFormInputView.tsx b/packages/example-next/src/lib/form/TextFieldFormInputView.tsx index ca4f843..48241a9 100644 --- a/packages/example-next/src/lib/form/TextFieldFormInputView.tsx +++ b/packages/example-next/src/lib/form/TextFieldFormInputView.tsx @@ -1,6 +1,6 @@ import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" import { Array, Option, Struct } from "effect" -import { Component, Form, Subscribable } from "effect-fc" +import { Component, Form, View } from "effect-fc-next" import type * as React from "react" @@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f props: TextFieldFormInputView.Props ) { const input = yield* Form.useInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ + const [issues, isValidating, isCommitting] = yield* View.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, @@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f value={input.value} onChange={e => input.setValue(e.target.value)} disabled={isCommitting} - {...Struct.omit(props, "form")} + {...Struct.omit(props, ["form"])} > {isValidating && diff --git a/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx b/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx index 68693ea..cd2bea4 100644 --- a/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx +++ b/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx @@ -1,6 +1,6 @@ import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" import { Array, Option, Struct } from "effect" -import { Component, Form, Subscribable } from "effect-fc" +import { Component, Form, View } from "effect-fc-next" import type * as React from "react" @@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF props: TextFieldOptionalFormInputView.Props ) { const input = yield* Form.useOptionalInput(props.form, props) - const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ + const [issues, isValidating, isCommitting] = yield* View.useAll([ props.form.issues, props.form.isValidating, props.form.isCommitting, @@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF value={input.value} onChange={e => input.setValue(e.target.value)} disabled={!input.enabled || isCommitting} - {...Struct.omit(props, "form", "defaultValue")} + {...Struct.omit(props, ["form", "defaultValue"])} > ParseResult.succeed(DateTime.toUtc(i)), - }, -) {} - -export class DateTimeZonedFromUtc extends Schema.transformOrFail( - Schema.DateTimeUtcFromSelf, - Schema.DateTimeZonedFromSelf, - { - strict: true, - encode: a => ParseResult.succeed(DateTime.toUtc(a)), - decode: DateTime.setZoneCurrent, - }, -) {} - -export class DateTimeUtcFromZonedInput extends Schema.transformOrFail( - Schema.String, - DateTimeUtcFromZoned, - { - strict: true, - encode: a => ParseResult.succeed(DateTime.formatIsoZoned(a).slice(0, 16)), - decode: (i, _, ast) => Effect.flatMap( - DateTime.CurrentTimeZone, - timeZone => Option.match(DateTime.makeZoned(i, { timeZone, adjustForTimeZone: true }), { - onSome: ParseResult.succeed, - onNone: () => ParseResult.fail(new ParseResult.Type(ast, i, `Unable to decode ${JSON.stringify(i)} into a DateTime.Zoned`)), - }), - ), - }, -) {} diff --git a/packages/example-next/src/lib/schema/index.ts b/packages/example-next/src/lib/schema/index.ts deleted file mode 100644 index 260895c..0000000 --- a/packages/example-next/src/lib/schema/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./datetime" -export * from "./json" diff --git a/packages/example-next/src/lib/schema/json.ts b/packages/example-next/src/lib/schema/json.ts deleted file mode 100644 index 5b69cc4..0000000 --- a/packages/example-next/src/lib/schema/json.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Schema } from "effect" -import type { JsonValue } from "type-fest" - - -export const assertEncodedJsonifiable = , A, I extends JsonValue, R>(schema: S & Schema.Schema): S => schema -export const assertTypeJsonifiable = , A extends JsonValue, I, R>(schema: S & Schema.Schema): S => schema diff --git a/packages/example-next/src/main.tsx b/packages/example-next/src/main.tsx index 59d0c0c..1ed395f 100644 --- a/packages/example-next/src/main.tsx +++ b/packages/example-next/src/main.tsx @@ -1,103 +1,21 @@ -import { Effect, Layer, Schema, SubscriptionRef } from "effect" -import { - Component, - Form, - Lens, - LensForm, - MutationForm, - ReactRuntime, - View, -} from "effect-fc-next" +import { createRouter, RouterProvider } from "@tanstack/react-router" +import { ReactRuntime } from "effect-fc-next" import { StrictMode } from "react" import { createRoot } from "react-dom/client" -import "./index.css" +import { routeTree } from "./routeTree.gen" +import { runtime } from "./runtime" +const router = createRouter({ routeTree }) -const runtime = ReactRuntime.make(Layer.empty) - -const MutationFormExample = Component.make("MutationFormExample")(function*() { - const form = yield* Component.useOnMount(() => MutationForm.service({ - schema: Schema.String, - initialEncodedValue: "", - f: ([value]) => Effect.log(`Submitted: ${value}`), - })) - const input = yield* Form.useInput(form) - const [canCommit, isCommitting, issues] = yield* View.useAll([ - form.canCommit, - form.isCommitting, - form.issues, - ]) - const runPromise = yield* Component.useRunPromise() - - return ( -
-

MutationForm

-

Owns a draft and commits it explicitly through a Mutation.

-
{ - event.preventDefault() - void runPromise(form.submit) - }}> - input.setValue(event.currentTarget.value)} - disabled={isCommitting} - placeholder="Write something to submit" - /> - -
- {issues.map((issue) =>

{issue.message}

)} -
- ) -}).pipe(Component.withRuntime(runtime.context)) - -const LensFormExample = Component.make("LensFormExample")(function*() { - const target = yield* Component.useOnMount(() => Effect.map( - SubscriptionRef.make("A value owned by the application"), - Lens.fromSubscriptionRef, - )) - const form = yield* Component.useOnMount(() => LensForm.service({ - schema: Schema.String, - target, - })) - const input = yield* Form.useInput(form) - const [value, canCommit, isCommitting, issues] = yield* View.useAll([ - target, - form.canCommit, - form.isCommitting, - form.issues, - ]) - - return ( -
-

LensForm

-

Synchronizes valid input directly with a target Lens.

- input.setValue(event.currentTarget.value)} - disabled={!canCommit || isCommitting} - /> -

Target value: {value}

- {issues.map((issue) =>

{issue.message}

)} -
- ) -}).pipe(Component.withRuntime(runtime.context)) - -const App = () => ( -
-

Effect FC Next

-

Effect v4 examples for the finalized form API.

- - -
-) +declare module "@tanstack/react-router" { + interface Register { router: typeof router } +} // biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element createRoot(document.getElementById("root")!).render( - + , ) diff --git a/packages/example-next/src/post.ts b/packages/example-next/src/post.ts new file mode 100644 index 0000000..2bbe6e6 --- /dev/null +++ b/packages/example-next/src/post.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" + + +export interface Post { + readonly title: string + readonly body: string +} + +export const fetchPost = (id: number) => Effect.tryPromise({ + try: () => + fetch(`https://jsonplaceholder.typicode.com/posts/${id}`) + .then(response => response.json() as Promise), + + catch: () => new Error("Unable to fetch post"), +}) diff --git a/packages/example-next/src/routeTree.gen.ts b/packages/example-next/src/routeTree.gen.ts index 3ad431c..5c09612 100644 --- a/packages/example-next/src/routeTree.gen.ts +++ b/packages/example-next/src/routeTree.gen.ts @@ -15,8 +15,6 @@ import { Route as BlankRouteImport } from './routes/blank' import { Route as FormRouteImport } from './routes/form' import { Route as QueryRouteImport } from './routes/query' import { Route as ResultRouteImport } from './routes/result' -import { Route as DevContextRouteImport } from './routes/dev/context' -import { Route as DevMemoRouteImport } from './routes/dev/memo' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -48,16 +46,6 @@ const ResultRoute = ResultRouteImport.update({ path: '/result', getParentRoute: () => rootRouteImport, } as any) -const DevContextRoute = DevContextRouteImport.update({ - id: '/dev/context', - path: '/dev/context', - getParentRoute: () => rootRouteImport, -} as any) -const DevMemoRoute = DevMemoRouteImport.update({ - id: '/dev/memo', - path: '/dev/memo', - getParentRoute: () => rootRouteImport, -} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -66,8 +54,6 @@ export interface FileRoutesByFullPath { '/form': typeof FormRoute '/query': typeof QueryRoute '/result': typeof ResultRoute - '/dev/context': typeof DevContextRoute - '/dev/memo': typeof DevMemoRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -76,8 +62,6 @@ export interface FileRoutesByTo { '/form': typeof FormRoute '/query': typeof QueryRoute '/result': typeof ResultRoute - '/dev/context': typeof DevContextRoute - '/dev/memo': typeof DevMemoRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -87,40 +71,13 @@ export interface FileRoutesById { '/form': typeof FormRoute '/query': typeof QueryRoute '/result': typeof ResultRoute - '/dev/context': typeof DevContextRoute - '/dev/memo': typeof DevMemoRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/async' - | '/blank' - | '/form' - | '/query' - | '/result' - | '/dev/context' - | '/dev/memo' + fullPaths: '/' | '/async' | '/blank' | '/form' | '/query' | '/result' fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/async' - | '/blank' - | '/form' - | '/query' - | '/result' - | '/dev/context' - | '/dev/memo' - id: - | '__root__' - | '/' - | '/async' - | '/blank' - | '/form' - | '/query' - | '/result' - | '/dev/context' - | '/dev/memo' + to: '/' | '/async' | '/blank' | '/form' | '/query' | '/result' + id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/query' | '/result' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -130,8 +87,6 @@ export interface RootRouteChildren { FormRoute: typeof FormRoute QueryRoute: typeof QueryRoute ResultRoute: typeof ResultRoute - DevContextRoute: typeof DevContextRoute - DevMemoRoute: typeof DevMemoRoute } declare module '@tanstack/react-router' { @@ -178,20 +133,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResultRouteImport parentRoute: typeof rootRouteImport } - '/dev/context': { - id: '/dev/context' - path: '/dev/context' - fullPath: '/dev/context' - preLoaderRoute: typeof DevContextRouteImport - parentRoute: typeof rootRouteImport - } - '/dev/memo': { - id: '/dev/memo' - path: '/dev/memo' - fullPath: '/dev/memo' - preLoaderRoute: typeof DevMemoRouteImport - parentRoute: typeof rootRouteImport - } } } @@ -202,8 +143,6 @@ const rootRouteChildren: RootRouteChildren = { FormRoute: FormRoute, QueryRoute: QueryRoute, ResultRoute: ResultRoute, - DevContextRoute: DevContextRoute, - DevMemoRoute: DevMemoRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/example-next/src/routes/__root.tsx b/packages/example-next/src/routes/__root.tsx index 67b0910..c9826b6 100644 --- a/packages/example-next/src/routes/__root.tsx +++ b/packages/example-next/src/routes/__root.tsx @@ -7,7 +7,7 @@ import "../index.css" export const Route = createRootRoute({ - component: Root + component: Root, }) function Root() { @@ -17,6 +17,10 @@ function Root() { Index Blank + Async + Query + Result + Form diff --git a/packages/example-next/src/routes/async.tsx b/packages/example-next/src/routes/async.tsx index 62e0234..4be061b 100644 --- a/packages/example-next/src/routes/async.tsx +++ b/packages/example-next/src/routes/async.tsx @@ -1,30 +1,22 @@ -import { HttpClient } from "@effect/platform" import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Array, Effect, flow, Option, Schema } from "effect" -import { Async, Component, Memoized } from "effect-fc" +import { Async, Component, Memoized } from "effect-fc-next" import * as React from "react" +import { fetchPost } from "@/post" import { runtime } from "@/runtime" -const Post = Schema.Struct({ - userId: Schema.Int, - id: Schema.Int, - title: Schema.String, - body: Schema.String, -}) - interface AsyncFetchPostViewProps { readonly id: number } -class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) { - const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("500 millis")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknown(Post)), - ), [props.id]) +const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*( + props: AsyncFetchPostViewProps, +) { + const post = yield* Component.useOnChange( + () => fetchPost(props.id), + [props.id], + ) return (
@@ -34,10 +26,9 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*( ) }).pipe( Async.async, - Async.withOptions({ defaultFallback: Default fallback }), + Async.withOptions({ defaultFallback: Loading post... }), Memoized.memoized, -) {} - +) const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { const [text, setText] = React.useState("Typing here should not trigger a refetch of the post") @@ -50,12 +41,14 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { setText(e.currentTarget.value)} + onChange={event => setText(event.currentTarget.value)} /> setId(value ?? 1)} /> Loading post...} /> @@ -63,7 +56,7 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { ) }).pipe( - Component.withRuntime(runtime.context) + Component.withRuntime(runtime.context), ) export const Route = createFileRoute("/async")({ diff --git a/packages/example-next/src/routes/dev/context.tsx b/packages/example-next/src/routes/dev/context.tsx deleted file mode 100644 index 26048a9..0000000 --- a/packages/example-next/src/routes/dev/context.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Container, Flex, Text, TextField } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { Console, Effect } from "effect" -import { Component } from "effect-fc" -import * as React from "react" -import { runtime } from "@/runtime" - - -class SubService extends Effect.Service()("SubService", { - effect: (value: string) => Effect.succeed({ value }) -}) {} - -const SubComponent = Component.makeUntraced("SubComponent")(function*() { - const service = yield* SubService - yield* Component.useOnMount(() => Effect.gen(function*() { - yield* Effect.addFinalizer(() => Console.log("SubComponent unmounted")) - yield* Console.log("SubComponent mounted") - })) - - return {service.value} -}) - -const ContextView = Component.makeUntraced("ContextView")(function*() { - const [serviceValue, setServiceValue] = React.useState("test") - const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue]) - const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer)) - - return ( - - - setServiceValue(e.target.value)} /> - - - - ) -}).pipe( - Component.withRuntime(runtime.context) -) - -export const Route = createFileRoute("/dev/context")({ - component: ContextView -}) diff --git a/packages/example-next/src/routes/dev/memo.tsx b/packages/example-next/src/routes/dev/memo.tsx deleted file mode 100644 index ae672bd..0000000 --- a/packages/example-next/src/routes/dev/memo.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Flex, Text, TextField } from "@radix-ui/themes" -import { createFileRoute } from "@tanstack/react-router" -import { GetRandomValues, makeUuid4 } from "@typed/id" -import { Effect } from "effect" -import { Component, Memoized } from "effect-fc" -import * as React from "react" -import { runtime } from "@/runtime" - - -const RouteComponent = Component.makeUntraced("RouteComponent")(function*() { - const [value, setValue] = React.useState("") - - return ( - - setValue(e.target.value)} - /> - - {yield* Effect.map(SubComponent.use, FC => )} - {yield* Effect.map(MemoizedSubComponent.use, FC => )} - - ) -}).pipe( - Component.withRuntime(runtime.context) -) - -class SubComponent extends Component.makeUntraced("SubComponent")(function*() { - const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom)) - return {id} -}) {} - -class MemoizedSubComponent extends Memoized.memoized(SubComponent) {} - -export const Route = createFileRoute("/dev/memo")({ - component: RouteComponent, -}) diff --git a/packages/example-next/src/routes/form.tsx b/packages/example-next/src/routes/form.tsx index 9ce3fae..88885a4 100644 --- a/packages/example-next/src/routes/form.tsx +++ b/packages/example-next/src/routes/form.tsx @@ -1,143 +1,63 @@ -import { Button, Container, Flex, Text } from "@radix-ui/themes" +import { Button, Container, Flex, Text, TextField } 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 { Effect, Schema } from "effect" +import { Component, Form, MutationForm, View } from "effect-fc-next" import { runtime } from "@/runtime" -const email = Schema.pattern( - /^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i, - - { - identifier: "email", - title: "email", - message: () => "Not an email address", - }, -) - -const RegisterFormSchema = Schema.Struct({ - email: Schema.String.pipe(email), - password: Schema.String.pipe(Schema.minLength(3)), - birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput), -}) - -const RegisterFormSubmitSchema = Schema.Struct({ - email: Schema.transformOrFail( - Schema.String, - Schema.String, - { - decode: (input, _options, ast) => input !== "admin@admin.com" - ? ParseResult.succeed(input) - : ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")), - encode: ParseResult.succeed, - }, - ), +const RegisterSchema = Schema.Struct({ + email: Schema.String, password: Schema.String, - birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf), }) -class RegisterFormService extends Effect.Service()("RegisterFormService", { - scoped: Effect.gen(function*() { - const form = yield* SubmittableForm.service({ - schema: RegisterFormSchema.pipe( - Schema.compose( - Schema.transformOrFail( - Schema.typeSchema(RegisterFormSchema), - Schema.typeSchema(RegisterFormSchema), - { - decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)), - encode: ParseResult.succeed, - }, - ), - ), - ), - - initialEncodedValue: { email: "", password: "", birth: Option.none() }, - f: Effect.fnUntraced(function*([value]) { - yield* Effect.sleep("500 millis") - return yield* Schema.decode(RegisterFormSubmitSchema)(value) - }), - }) - - return { - form, - emailField: Form.focusObjectOn(form, "email"), - passwordField: Form.focusObjectOn(form, "password"), - birthField: Form.focusObjectOn(form, "birth"), - } as const - }) -}) {} - -class RegisterFormView extends Component.make("RegisterFormView")(function*() { - const form = yield* RegisterFormService - const [canCommit, submitResult] = yield* Subscribable.useAll([ - form.form.canCommit, - form.form.mutation.result, - ]) - - const runPromise = yield* Component.useRunPromise() - const TextFieldFormInput = yield* TextFieldFormInputView.use - const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use - - yield* Component.useOnMount(() => Effect.gen(function*() { - yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) - yield* Console.log("RegisterFormView mounted") +const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() { + const form = yield* Component.useOnMount(() => MutationForm.service({ + schema: RegisterSchema, + initialEncodedValue: { email: "", password: "" }, + f: ([value]) => Effect.log(`Registered ${value.email}`), })) - + const emailField = yield* Form.useInput( + Form.focusObjectOn(form, "email"), + ) + const passwordField = yield* Form.useInput( + Form.focusObjectOn(form, "password"), + ) + const [canCommit, isCommitting] = yield* View.useAll([ + form.canCommit, + form.isCommitting, + ]) + const runPromise = yield* Component.useRunPromise() return ( -
{ - e.preventDefault() - void runPromise(form.form.submit) + { + event.preventDefault() + void runPromise(form.submit) }}> - emailField.setValue(event.currentTarget.value)} + placeholder="Email" /> - - passwordField.setValue(event.currentTarget.value)} + placeholder="Password" + type="password" /> - - - - +
- - {Match.value(submitResult).pipe( - Match.tag("Initial", () => <>), - Match.tag("Running", () => Submitting...), - Match.tag("Success", () => Submitted successfully!), - Match.tag("Failure", e => Error: {e.cause.toString()}), - Match.exhaustive, - )} + A MutationForm validates local input, then submits it.
) -}) {} - -const RegisterPage = Component.make("RegisterPageView")(function*() { - const RegisterForm = yield* Effect.provide( - RegisterFormView.use, - yield* Component.useContextFromLayer(RegisterFormService.Default), - ) - - return }).pipe( - Component.withRuntime(runtime.context) + Component.withRuntime(runtime.context), ) - export const Route = createFileRoute("/form")({ - component: RegisterPage + component: RegisterRouteComponent, }) diff --git a/packages/example-next/src/routes/index.tsx b/packages/example-next/src/routes/index.tsx index d747b39..0b4d271 100644 --- a/packages/example-next/src/routes/index.tsx +++ b/packages/example-next/src/routes/index.tsx @@ -1,24 +1,56 @@ +import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Effect } from "effect" -import { Component } from "effect-fc" +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, View } from "effect-fc-next" import { runtime } from "@/runtime" -import { TodosState } from "@/todo/TodosState" -import { TodosView } from "@/todo/TodosView" -const TodosStateLive = TodosState.Default("todos") +const TodoRouteComponent = Component.make("TodoRouteView")(function*() { + const todosLens = yield* Component.useOnMount(() => Effect.map( + SubscriptionRef.make([]), + Lens.fromSubscriptionRef, + )) + const draftLens = yield* Component.useOnMount(() => Effect.map( + SubscriptionRef.make(""), + Lens.fromSubscriptionRef, + )) -const Index = Component.make("IndexView")(function*() { - const Todos = yield* Effect.provide( - TodosView.use, - yield* Component.useContextFromLayer(TodosStateLive), + const [todos] = yield* View.useAll([todosLens]) + const [draft, setDraft] = yield* Lens.useState(draftLens) + const runPromise = yield* Component.useRunPromise() + + const addTodo = Lens.update(todosLens, todos => + draft.trim() === "" + ? todos + : [...todos, draft.trim()], + ).pipe( + Effect.andThen(Lens.set(draftLens, "")), ) - return + return ( + + + A small Effect v4 todo state example backed by a Lens. + + + setDraft(event.currentTarget.value)} + /> + + + + + {todos.map(todo => • {todo})} + + + ) }).pipe( - Component.withRuntime(runtime.context) + Component.withRuntime(runtime.context), ) export const Route = createFileRoute("/")({ - component: Index + component: TodoRouteComponent, }) diff --git a/packages/example-next/src/routes/query.tsx b/packages/example-next/src/routes/query.tsx index 047560e..a97a8bf 100644 --- a/packages/example-next/src/routes/query.tsx +++ b/packages/example-next/src/routes/query.tsx @@ -1,117 +1,85 @@ -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, SubscriptionRef } from "effect" -import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc" +import { Effect, SubscriptionRef } from "effect" +import { AsyncResult } from "effect/unstable/reactivity" +import { Component, Lens, Mutation, Query, View } from "effect-fc-next" +import { fetchPost, type Post } from "@/post" import { runtime } from "@/runtime" -const Post = Schema.Struct({ - userId: Schema.Int, - id: Schema.Int, - title: Schema.String, - body: Schema.String, -}) +interface PostResultViewProps { + readonly result: AsyncResult.AsyncResult +} -const ResultView = Component.make("ResultView")(function*() { - const runPromise = yield* Component.useRunPromise() +const PostResultView = (props: PostResultViewProps) => + AsyncResult.match(props.result, { + onInitial: () => Loading..., + onFailure: () => Request failed., + onSuccess: result => ( + <> + {result.value.title} + {result.value.body} + + ), + }) +const QueryRouteComponent = Component.make("QueryRouteView")(function*() { 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, - f: ([id]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("500 millis")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknown(Post)), - ), + key: idLens, + f: fetchPost, staleTime: "10 seconds", }) - const mutation = yield* Mutation.make({ - f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("500 millis")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknown(Post)), - ), + f: fetchPost, }) return [idLens, query, mutation] as const })) - 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), - Effect.andThen(Stream.fromQueue), - Stream.unwrapScoped, - Stream.runForEach(flow( - Cause.failures, - Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"), - Option.match({ - onSome: e => Console.log("ResultView HttpClient error", e), - onNone: () => Effect.void, - }), - )), - Effect.forkScoped, - )) + const [id] = yield* View.useAll([idLens]) + const [queryState, mutationResult] = yield* View.useAll([ + query.state, + mutation.state, + ]) + const runPromise = yield* Component.useRunPromise() return ( + void runPromise(Lens.set(idLens, value ?? 1)) + } /> -
- {Match.value(queryResult).pipe( - Match.tag("Running", () => Loading...), - Match.tag("Success", result => <> - {result.value.title} - {result.value.body} - {Result.hasRefreshingFlag(result) && Refreshing...} - ), - Match.tag("Failure", result => - An error has occured: {result.cause.toString()} - ), - Match.orElse(() => <>), - )} -
+ - - + + -
- {Match.value(mutationResult).pipe( - Match.tag("Running", () => Loading...), - Match.tag("Success", result => <> - {result.value.title} - {result.value.body} - {Result.hasRefreshingFlag(result) && Refreshing...} - ), - Match.tag("Failure", result => - An error has occured: {result.cause.toString()} - ), - Match.orElse(() => <>), - )} -
+ - - - +
) -}) +}).pipe( + Component.withRuntime(runtime.context), +) export const Route = createFileRoute("/query")({ - component: Component.withRuntime(ResultView, runtime.context) + component: QueryRouteComponent, }) diff --git a/packages/example-next/src/routes/result.tsx b/packages/example-next/src/routes/result.tsx index 0828f37..bfcdb0c 100644 --- a/packages/example-next/src/routes/result.tsx +++ b/packages/example-next/src/routes/result.tsx @@ -1,60 +1,45 @@ -import { HttpClient, type HttpClientError } from "@effect/platform" -import { Container, Heading, Text } from "@radix-ui/themes" +import { Button, Container, Flex, Heading, Text } from "@radix-ui/themes" import { createFileRoute } from "@tanstack/react-router" -import { Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect" -import { Component, ErrorObserver, Result, Subscribable } from "effect-fc" +import { AsyncResult } from "effect/unstable/reactivity" +import { Component, Mutation, View } from "effect-fc-next" +import { fetchPost, type Post } from "@/post" import { runtime } from "@/runtime" -const Post = Schema.Struct({ - userId: Schema.Int, - id: Schema.Int, - title: Schema.String, - body: Schema.String, -}) +const PostResultView = (props: { readonly result: AsyncResult.AsyncResult }) => + AsyncResult.match(props.result, { + onInitial: () => Ready to load., + onFailure: () => Request failed., + onSuccess: result => ( + <> + {result.value.title} + {result.value.body} + + ), + }) -const ResultView = Component.makeUntraced("Result")(function*() { - const [resultSubscribable] = yield* Component.useOnMount(() => HttpClient.HttpClient.pipe( - Effect.andThen(client => client.get("https://jsonplaceholder.typicode.com/posts/1")), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknown(Post)), - Effect.tap(Effect.sleep("250 millis")), - Result.forkEffect, - )) - const [result] = yield* Subscribable.useAll([resultSubscribable]) - - yield* Component.useOnMount(() => ErrorObserver.ErrorObserver().pipe( - Effect.andThen(observer => observer.subscribe), - Effect.andThen(Stream.fromQueue), - Stream.unwrapScoped, - Stream.runForEach(flow( - Cause.failures, - Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"), - Option.match({ - onSome: e => Console.log("ResultView HttpClient error", e), - onNone: () => Effect.void, - }), - )), - Effect.forkScoped, - )) +const ResultRouteComponent = Component.make("ResultRouteView")(function*() { + const mutation = yield* Component.useOnMount(() => Mutation.make({ + f: (_: undefined) => fetchPost(1), + })) + const [result] = yield* View.useAll([mutation.state]) + const runPromise = yield* Component.useRunPromise() return ( - {Match.value(result).pipe( - Match.tag("Running", () => Loading...), - Match.tag("Success", result => <> - {result.value.title} - {result.value.body} - ), - Match.tag("Failure", result => - An error has occured: {result.cause.toString()} - ), - Match.orElse(() => <>), - )} + + + + + ) -}) +}).pipe( + Component.withRuntime(runtime.context), +) export const Route = createFileRoute("/result")({ - component: Component.withRuntime(ResultView, runtime.context) + component: ResultRouteComponent, }) diff --git a/packages/example-next/src/runtime.ts b/packages/example-next/src/runtime.ts index 3467529..e09447f 100644 --- a/packages/example-next/src/runtime.ts +++ b/packages/example-next/src/runtime.ts @@ -1,15 +1,4 @@ -import { FetchHttpClient } from "@effect/platform" -import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" -import { DateTime, Layer } from "effect" -import { ReactRuntime } from "effect-fc" +import { QueryClient, ReactRuntime } from "effect-fc-next" -export const AppLive = Layer.empty.pipe( - Layer.provideMerge(DateTime.layerCurrentZoneLocal), - Layer.provideMerge(Clipboard.layer), - Layer.provideMerge(Geolocation.layer), - Layer.provideMerge(Permissions.layer), - Layer.provideMerge(FetchHttpClient.layer), -) - -export const runtime = ReactRuntime.make(AppLive) +export const runtime = ReactRuntime.make(QueryClient.layer()) diff --git a/packages/example-next/src/todo/EditTodoView.tsx b/packages/example-next/src/todo/EditTodoView.tsx deleted file mode 100644 index 69a9016..0000000 --- a/packages/example-next/src/todo/EditTodoView.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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.focusObjectOn(form, "content"), - Form.focusObjectOn(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-next/src/todo/NewTodoView.tsx b/packages/example-next/src/todo/NewTodoView.tsx deleted file mode 100644 index e382778..0000000 --- a/packages/example-next/src/todo/NewTodoView.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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.focusObjectOn(form, "content"), - Form.focusObjectOn(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-next/src/todo/TodoFormSchema.ts b/packages/example-next/src/todo/TodoFormSchema.ts deleted file mode 100644 index b2d4153..0000000 --- a/packages/example-next/src/todo/TodoFormSchema.ts +++ /dev/null @@ -1,9 +0,0 @@ -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-next/src/todo/TodosState.ts b/packages/example-next/src/todo/TodosState.ts deleted file mode 100644 index d77041d..0000000 --- a/packages/example-next/src/todo/TodosState.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { KeyValueStore } from "@effect/platform" -import { BrowserKeyValueStore } from "@effect/platform-browser" -import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect" -import { Lens, Subscribable } from "effect-fc" -import { Todo } from "@/domain" - - -export class TodosState extends Effect.Service()("TodosState", { - scoped: Effect.fnUntraced(function*(key: string) { - const kv = yield* KeyValueStore.KeyValueStore - - const readFromLocalStorage = Console.log("Reading todos from local storage...").pipe( - Effect.andThen(kv.get(key)), - Effect.andThen(Option.match({ - onSome: Schema.decode( - Schema.parseJson(Schema.Chunk(Todo.TodoFromJson)) - ), - onNone: () => Effect.succeed(Chunk.empty()), - })) - ) - const saveToLocalStorage = (todos: Chunk.Chunk) => Effect.andThen( - Console.log("Saving todos to local storage..."), - Chunk.isNonEmpty(todos) - ? Effect.andThen( - Schema.encode( - Schema.parseJson(Schema.Chunk(Todo.TodoFromJson)) - )(todos), - v => kv.set(key, v), - ) - : kv.remove(key) - ) - - 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(() => Lens.get(lens).pipe( - Effect.andThen(saveToLocalStorage), - Effect.ignore, - )) - - const sizeSubscribable = Subscribable.map(lens, Chunk.size) - - 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)), - Effect.andThen(({ todo, index, previous }) => index > 0 - ? todos.pipe( - Chunk.replace(index, previous), - Chunk.replace(index - 1, todo), - ) - : todos - ), - )) - 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)), - Effect.andThen(({ todo, index, next }) => index < Chunk.size(todos) - 1 - ? todos.pipe( - Chunk.replace(index, next), - Chunk.replace(index + 1, todo), - ) - : todos - ), - )) - const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen( - Chunk.findFirstIndex(todos, v => v.id === id), - index => Chunk.remove(todos, index), - )) - - return { - lens, - sizeSubscribable, - getElementLens, - getIndexSubscribable, - moveLeft, - moveRight, - remove, - } as const - }), - - dependencies: [BrowserKeyValueStore.layerLocalStorage], -}) {} diff --git a/packages/example-next/src/todo/TodosView.tsx b/packages/example-next/src/todo/TodosView.tsx deleted file mode 100644 index b935681..0000000 --- a/packages/example-next/src/todo/TodosView.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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" - - -export class TodosView extends Component.make("TodosView")(function*() { - const state = yield* TodosState - const [todos] = yield* Subscribable.useAll([state.lens]) - - yield* Component.useOnMount(() => Effect.andThen( - Console.log("Todos mounted"), - Effect.addFinalizer(() => Console.log("Todos unmounted")), - )) - - const NewTodo = yield* NewTodoView.use - const EditTodo = yield* EditTodoView.use - - return ( - - Todos - - - - - {Chunk.map(todos, todo => - - )} - - - ) -}) {}