From 885cff74b3927aef932598002591446989237114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 19 Jul 2026 03:27:41 +0200 Subject: [PATCH] Example fix --- bun.lock | 8 + packages/effect-fc-next/src/ReactRuntime.ts | 2 +- packages/example-next/index.html | 2 +- packages/example-next/package.json | 8 +- packages/example-next/src/domain/Todo.ts | 20 ++ packages/example-next/src/domain/index.ts | 1 + packages/example-next/src/index.css | 28 --- .../src/lib/form/TextFieldFormInputView.tsx | 56 +++++ .../form/TextFieldOptionalFormInputView.tsx | 64 ++++++ .../example-next/src/lib/schema/datetime.ts | 38 ++++ packages/example-next/src/lib/schema/index.ts | 2 + packages/example-next/src/lib/schema/json.ts | 6 + packages/example-next/src/main.tsx | 109 +++++++-- packages/example-next/src/routeTree.gen.ts | 210 ++++++++++++++++++ packages/example-next/src/routes/__root.tsx | 28 +++ packages/example-next/src/routes/async.tsx | 71 ++++++ packages/example-next/src/routes/blank.tsx | 10 + .../example-next/src/routes/dev/context.tsx | 42 ++++ packages/example-next/src/routes/dev/memo.tsx | 37 +++ packages/example-next/src/routes/form.tsx | 143 ++++++++++++ packages/example-next/src/routes/index.tsx | 24 ++ packages/example-next/src/routes/query.tsx | 117 ++++++++++ packages/example-next/src/routes/result.tsx | 60 +++++ packages/example-next/src/runtime.ts | 15 ++ .../example-next/src/todo/EditTodoView.tsx | 88 ++++++++ .../example-next/src/todo/NewTodoView.tsx | 78 +++++++ .../example-next/src/todo/TodoFormSchema.ts | 9 + packages/example-next/src/todo/TodosState.ts | 96 ++++++++ packages/example-next/src/todo/TodosView.tsx | 34 +++ packages/example-next/tsconfig.app.json | 4 + packages/example-next/vite.config.ts | 12 + 31 files changed, 1368 insertions(+), 54 deletions(-) create mode 100644 packages/example-next/src/domain/Todo.ts create mode 100644 packages/example-next/src/domain/index.ts create mode 100644 packages/example-next/src/lib/form/TextFieldFormInputView.tsx create mode 100644 packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx create mode 100644 packages/example-next/src/lib/schema/datetime.ts create mode 100644 packages/example-next/src/lib/schema/index.ts create mode 100644 packages/example-next/src/lib/schema/json.ts create mode 100644 packages/example-next/src/routeTree.gen.ts create mode 100644 packages/example-next/src/routes/__root.tsx create mode 100644 packages/example-next/src/routes/async.tsx create mode 100644 packages/example-next/src/routes/blank.tsx create mode 100644 packages/example-next/src/routes/dev/context.tsx create mode 100644 packages/example-next/src/routes/dev/memo.tsx create mode 100644 packages/example-next/src/routes/form.tsx create mode 100644 packages/example-next/src/routes/index.tsx create mode 100644 packages/example-next/src/routes/query.tsx create mode 100644 packages/example-next/src/routes/result.tsx create mode 100644 packages/example-next/src/runtime.ts create mode 100644 packages/example-next/src/todo/EditTodoView.tsx create mode 100644 packages/example-next/src/todo/NewTodoView.tsx create mode 100644 packages/example-next/src/todo/TodoFormSchema.ts create mode 100644 packages/example-next/src/todo/TodosState.ts create mode 100644 packages/example-next/src/todo/TodosView.tsx diff --git a/bun.lock b/bun.lock index 99fe735..2a9a52f 100644 --- a/bun.lock +++ b/bun.lock @@ -104,10 +104,16 @@ "name": "@effect-fc/example-next", "version": "0.0.0", "dependencies": { + "@effect/platform-browser": "4.0.0-beta.98", + "@radix-ui/themes": "^3.3.0", "effect": "4.0.0-beta.98", "effect-fc-next": "workspace:*", + "react-icons": "^5.6.0", }, "devDependencies": { + "@tanstack/react-router": "^1.170.10", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/router-plugin": "^1.168.13", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", @@ -2999,6 +3005,8 @@ "@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "@effect-fc/example-next/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.98", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-Y0BEe0N8clKmJTHqmZpS85pLZbqAxP5uV3IYF0tDw/1dcs8AbTRHjbRmOLdyLtmU32NBVAiaUD3Vopb5Et5R/g=="], + "@effect-fc/example-next/effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="], "@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="], diff --git a/packages/effect-fc-next/src/ReactRuntime.ts b/packages/effect-fc-next/src/ReactRuntime.ts index 29c738a..87023f3 100644 --- a/packages/effect-fc-next/src/ReactRuntime.ts +++ b/packages/effect-fc-next/src/ReactRuntime.ts @@ -26,7 +26,7 @@ export const make = ( ): ReactRuntime | R, ER> => Object.setPrototypeOf( Object.assign(function() {}, { runtime: ManagedRuntime.make( - Layer.merge(preludeLayer, layer), + Layer.mergeAll(preludeLayer, layer), { memoMap }, ), // biome-ignore lint/style/noNonNullAssertion: context initialization diff --git a/packages/example-next/index.html b/packages/example-next/index.html index e7e38b3..e4b78ea 100644 --- a/packages/example-next/index.html +++ b/packages/example-next/index.html @@ -4,7 +4,7 @@ - Effect FC Next Example + Vite + React + TS
diff --git a/packages/example-next/package.json b/packages/example-next/package.json index fb45b01..78995c3 100644 --- a/packages/example-next/package.json +++ b/packages/example-next/package.json @@ -14,6 +14,9 @@ "clean:modules": "rm -rf node_modules" }, "devDependencies": { + "@tanstack/react-router": "^1.170.10", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/router-plugin": "^1.168.13", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", @@ -23,8 +26,11 @@ "vite": "^8.0.16" }, "dependencies": { + "@effect/platform-browser": "4.0.0-beta.98", + "@radix-ui/themes": "^3.3.0", "effect": "4.0.0-beta.98", - "effect-fc-next": "workspace:*" + "effect-fc-next": "workspace:*", + "react-icons": "^5.6.0" }, "overrides": { "@types/react": "^19.2.15", diff --git a/packages/example-next/src/domain/Todo.ts b/packages/example-next/src/domain/Todo.ts new file mode 100644 index 0000000..500faab --- /dev/null +++ b/packages/example-next/src/domain/Todo.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..8d70c13 --- /dev/null +++ b/packages/example-next/src/domain/index.ts @@ -0,0 +1 @@ +export * as Todo from "./Todo" diff --git a/packages/example-next/src/index.css b/packages/example-next/src/index.css index 8f84e7d..e69de29 100644 --- a/packages/example-next/src/index.css +++ b/packages/example-next/src/index.css @@ -1,28 +0,0 @@ -:root { - color: #17202a; - font-family: system-ui, sans-serif; - background: #f4f6f7; -} - -body { - display: grid; - min-width: 320px; - min-height: 100vh; - margin: 0; - place-items: center; -} - -main { - padding: 2rem; - text-align: center; -} - -button { - padding: 0.65rem 1rem; - border: 0; - border-radius: 0.5rem; - color: white; - font: inherit; - background: #7d3c98; - cursor: pointer; -} diff --git a/packages/example-next/src/lib/form/TextFieldFormInputView.tsx b/packages/example-next/src/lib/form/TextFieldFormInputView.tsx new file mode 100644 index 0000000..ca4f843 --- /dev/null +++ b/packages/example-next/src/lib/form/TextFieldFormInputView.tsx @@ -0,0 +1,56 @@ +import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" +import { Array, Option, Struct } from "effect" +import { Component, Form, Subscribable } from "effect-fc" +import type * as React from "react" + + +export declare namespace TextFieldFormInputView { + export interface Props + extends Omit, Form.useInput.Options { + readonly form: Form.Form + } + + export type Signature =

(props: Props) => React.ReactNode +} + +export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*( + props: TextFieldFormInputView.Props +) { + const input = yield* Form.useInput(props.form, props) + const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ + props.form.issues, + props.form.isValidating, + props.form.isCommitting, + ]) + + return ( + + input.setValue(e.target.value)} + disabled={isCommitting} + {...Struct.omit(props, "form")} + > + {isValidating && + + + + } + + {props.children} + + + {Option.match(Array.head(issues), { + onSome: issue => ( + + {issue.message} + + ), + + onNone: () => <>, + })} + + ) +}).pipe( + Component.withSignature() +) diff --git a/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx b/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx new file mode 100644 index 0000000..68693ea --- /dev/null +++ b/packages/example-next/src/lib/form/TextFieldOptionalFormInputView.tsx @@ -0,0 +1,64 @@ +import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" +import { Array, Option, Struct } from "effect" +import { Component, Form, Subscribable } from "effect-fc" +import type * as React from "react" + + +export declare namespace TextFieldOptionalFormInputView { + export interface Props + extends Omit, Form.useOptionalInput.Options { + readonly form: Form.Form, ER, EW> + } + + export type Signature =

(props: Props) => React.ReactNode +} + +export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*( + props: TextFieldOptionalFormInputView.Props +) { + const input = yield* Form.useOptionalInput(props.form, props) + const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ + props.form.issues, + props.form.isValidating, + props.form.isCommitting, + ]) + + return ( + + input.setValue(e.target.value)} + disabled={!input.enabled || isCommitting} + {...Struct.omit(props, "form", "defaultValue")} + > + + + + + {isValidating && + + + + } + + {props.children} + + + {Option.match(Array.head(issues), { + onSome: issue => ( + + {issue.message} + + ), + + onNone: () => <>, + })} + + ) +}).pipe( + Component.withSignature() +) diff --git a/packages/example-next/src/lib/schema/datetime.ts b/packages/example-next/src/lib/schema/datetime.ts new file mode 100644 index 0000000..ce85d3a --- /dev/null +++ b/packages/example-next/src/lib/schema/datetime.ts @@ -0,0 +1,38 @@ +import { DateTime, Effect, Option, ParseResult, Schema } from "effect" + + +export class DateTimeUtcFromZoned extends Schema.transformOrFail( + Schema.DateTimeZonedFromSelf, + Schema.DateTimeUtcFromSelf, + { + strict: true, + encode: DateTime.setZoneCurrent, + decode: i => 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 new file mode 100644 index 0000000..260895c --- /dev/null +++ b/packages/example-next/src/lib/schema/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..5b69cc4 --- /dev/null +++ b/packages/example-next/src/lib/schema/json.ts @@ -0,0 +1,6 @@ +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 3b4d312..59d0c0c 100644 --- a/packages/example-next/src/main.tsx +++ b/packages/example-next/src/main.tsx @@ -1,40 +1,103 @@ -import { Effect, Layer, SubscriptionRef } from "effect" -import { Component, Lens, ReactRuntime, View } from "effect-fc-next" +import { Effect, Layer, Schema, SubscriptionRef } from "effect" +import { + Component, + Form, + Lens, + LensForm, + MutationForm, + ReactRuntime, + View, +} from "effect-fc-next" import { StrictMode } from "react" import { createRoot } from "react-dom/client" import "./index.css" -const Counter = Component.make("Counter")(function*() { - const count = yield* Component.useOnMount(() => Effect.map( - SubscriptionRef.make(0), - Lens.fromSubscriptionRef, - )) - const [value] = yield* View.useAll([count]) - const increment = yield* Component.useCallbackSync( - () => Lens.update(count, n => n + 1), - [count], - ) +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 ( -

-

Effect FC Next

-

Running on Effect V4.

- -
+
+

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 runtime = ReactRuntime.make(Layer.empty) -const CounterApp = Counter.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.

+ + +
+) // biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element createRoot(document.getElementById("root")!).render( - + , ) diff --git a/packages/example-next/src/routeTree.gen.ts b/packages/example-next/src/routeTree.gen.ts new file mode 100644 index 0000000..3ad431c --- /dev/null +++ b/packages/example-next/src/routeTree.gen.ts @@ -0,0 +1,210 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AsyncRouteImport } from './routes/async' +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: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AsyncRoute = AsyncRouteImport.update({ + id: '/async', + path: '/async', + getParentRoute: () => rootRouteImport, +} as any) +const BlankRoute = BlankRouteImport.update({ + id: '/blank', + path: '/blank', + getParentRoute: () => rootRouteImport, +} as any) +const FormRoute = FormRouteImport.update({ + id: '/form', + path: '/form', + getParentRoute: () => rootRouteImport, +} as any) +const QueryRoute = QueryRouteImport.update({ + id: '/query', + path: '/query', + getParentRoute: () => rootRouteImport, +} as any) +const ResultRoute = ResultRouteImport.update({ + id: '/result', + 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 + '/async': typeof AsyncRoute + '/blank': typeof BlankRoute + '/form': typeof FormRoute + '/query': typeof QueryRoute + '/result': typeof ResultRoute + '/dev/context': typeof DevContextRoute + '/dev/memo': typeof DevMemoRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/async': typeof AsyncRoute + '/blank': typeof BlankRoute + '/form': typeof FormRoute + '/query': typeof QueryRoute + '/result': typeof ResultRoute + '/dev/context': typeof DevContextRoute + '/dev/memo': typeof DevMemoRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/async': typeof AsyncRoute + '/blank': typeof BlankRoute + '/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' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/async' + | '/blank' + | '/form' + | '/query' + | '/result' + | '/dev/context' + | '/dev/memo' + id: + | '__root__' + | '/' + | '/async' + | '/blank' + | '/form' + | '/query' + | '/result' + | '/dev/context' + | '/dev/memo' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AsyncRoute: typeof AsyncRoute + BlankRoute: typeof BlankRoute + FormRoute: typeof FormRoute + QueryRoute: typeof QueryRoute + ResultRoute: typeof ResultRoute + DevContextRoute: typeof DevContextRoute + DevMemoRoute: typeof DevMemoRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/async': { + id: '/async' + path: '/async' + fullPath: '/async' + preLoaderRoute: typeof AsyncRouteImport + parentRoute: typeof rootRouteImport + } + '/blank': { + id: '/blank' + path: '/blank' + fullPath: '/blank' + preLoaderRoute: typeof BlankRouteImport + parentRoute: typeof rootRouteImport + } + '/form': { + id: '/form' + path: '/form' + fullPath: '/form' + preLoaderRoute: typeof FormRouteImport + parentRoute: typeof rootRouteImport + } + '/query': { + id: '/query' + path: '/query' + fullPath: '/query' + preLoaderRoute: typeof QueryRouteImport + parentRoute: typeof rootRouteImport + } + '/result': { + id: '/result' + path: '/result' + fullPath: '/result' + 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 + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AsyncRoute: AsyncRoute, + BlankRoute: BlankRoute, + FormRoute: FormRoute, + QueryRoute: QueryRoute, + ResultRoute: ResultRoute, + DevContextRoute: DevContextRoute, + DevMemoRoute: DevMemoRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/example-next/src/routes/__root.tsx b/packages/example-next/src/routes/__root.tsx new file mode 100644 index 0000000..67b0910 --- /dev/null +++ b/packages/example-next/src/routes/__root.tsx @@ -0,0 +1,28 @@ +import { Container, Flex, Theme } from "@radix-ui/themes" +import { createRootRoute, Link, Outlet } from "@tanstack/react-router" +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" + +import "@radix-ui/themes/styles.css" +import "../index.css" + + +export const Route = createRootRoute({ + component: Root +}) + +function Root() { + return ( + + + + Index + Blank + + + + + + + + ) +} diff --git a/packages/example-next/src/routes/async.tsx b/packages/example-next/src/routes/async.tsx new file mode 100644 index 0000000..62e0234 --- /dev/null +++ b/packages/example-next/src/routes/async.tsx @@ -0,0 +1,71 @@ +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 * as React from "react" +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]) + + return ( +
+ {post.title} + {post.body} +
+ ) +}).pipe( + Async.async, + Async.withOptions({ defaultFallback: Default fallback }), + Memoized.memoized, +) {} + + +const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { + const [text, setText] = React.useState("Typing here should not trigger a refetch of the post") + const [id, setId] = React.useState(1) + + const AsyncFetchPost = yield* AsyncFetchPostView.use + + return ( + + + setText(e.currentTarget.value)} + /> + + + + Loading post...} /> + + + ) +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/async")({ + component: AsyncRouteComponent, +}) diff --git a/packages/example-next/src/routes/blank.tsx b/packages/example-next/src/routes/blank.tsx new file mode 100644 index 0000000..4f3c7df --- /dev/null +++ b/packages/example-next/src/routes/blank.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router" + + +export const Route = createFileRoute("/blank")({ + component: RouteComponent +}) + +function RouteComponent() { + return
Hello "/blank"!
+} diff --git a/packages/example-next/src/routes/dev/context.tsx b/packages/example-next/src/routes/dev/context.tsx new file mode 100644 index 0000000..26048a9 --- /dev/null +++ b/packages/example-next/src/routes/dev/context.tsx @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..ae672bd --- /dev/null +++ b/packages/example-next/src/routes/dev/memo.tsx @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..9ce3fae --- /dev/null +++ b/packages/example-next/src/routes/form.tsx @@ -0,0 +1,143 @@ +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" + + +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, + }, + ), + 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") + })) + + + return ( + +
{ + e.preventDefault() + void runPromise(form.form.submit) + }}> + + + + + + + + + +
+ + {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, + )} +
+ ) +}) {} + +const RegisterPage = Component.make("RegisterPageView")(function*() { + const RegisterForm = yield* Effect.provide( + RegisterFormView.use, + yield* Component.useContextFromLayer(RegisterFormService.Default), + ) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + + +export const Route = createFileRoute("/form")({ + component: RegisterPage +}) diff --git a/packages/example-next/src/routes/index.tsx b/packages/example-next/src/routes/index.tsx new file mode 100644 index 0000000..d747b39 --- /dev/null +++ b/packages/example-next/src/routes/index.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router" +import { Effect } from "effect" +import { Component } from "effect-fc" +import { runtime } from "@/runtime" +import { TodosState } from "@/todo/TodosState" +import { TodosView } from "@/todo/TodosView" + + +const TodosStateLive = TodosState.Default("todos") + +const Index = Component.make("IndexView")(function*() { + const Todos = yield* Effect.provide( + TodosView.use, + yield* Component.useContextFromLayer(TodosStateLive), + ) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/")({ + component: Index +}) diff --git a/packages/example-next/src/routes/query.tsx b/packages/example-next/src/routes/query.tsx new file mode 100644 index 0000000..047560e --- /dev/null +++ b/packages/example-next/src/routes/query.tsx @@ -0,0 +1,117 @@ +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 { runtime } from "@/runtime" + + +const Post = Schema.Struct({ + userId: Schema.Int, + id: Schema.Int, + title: Schema.String, + body: Schema.String, +}) + +const ResultView = Component.make("ResultView")(function*() { + const runPromise = yield* Component.useRunPromise() + + 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)), + ), + 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)), + ), + }) + + 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, + )) + + return ( + + + + +
+ {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(() => <>), + )} +
+ + + + +
+
+ ) +}) + +export const Route = createFileRoute("/query")({ + component: Component.withRuntime(ResultView, runtime.context) +}) diff --git a/packages/example-next/src/routes/result.tsx b/packages/example-next/src/routes/result.tsx new file mode 100644 index 0000000..0828f37 --- /dev/null +++ b/packages/example-next/src/routes/result.tsx @@ -0,0 +1,60 @@ +import { HttpClient, type HttpClientError } from "@effect/platform" +import { Container, 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 { runtime } from "@/runtime" + + +const Post = Schema.Struct({ + userId: Schema.Int, + id: Schema.Int, + title: Schema.String, + body: Schema.String, +}) + +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, + )) + + 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(() => <>), + )} + + ) +}) + +export const Route = createFileRoute("/result")({ + component: Component.withRuntime(ResultView, runtime.context) +}) diff --git a/packages/example-next/src/runtime.ts b/packages/example-next/src/runtime.ts new file mode 100644 index 0000000..3467529 --- /dev/null +++ b/packages/example-next/src/runtime.ts @@ -0,0 +1,15 @@ +import { FetchHttpClient } from "@effect/platform" +import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" +import { DateTime, Layer } from "effect" +import { ReactRuntime } from "effect-fc" + + +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) diff --git a/packages/example-next/src/todo/EditTodoView.tsx b/packages/example-next/src/todo/EditTodoView.tsx new file mode 100644 index 0000000..69a9016 --- /dev/null +++ b/packages/example-next/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.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 new file mode 100644 index 0000000..e382778 --- /dev/null +++ b/packages/example-next/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.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 new file mode 100644 index 0000000..b2d4153 --- /dev/null +++ b/packages/example-next/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-next/src/todo/TodosState.ts b/packages/example-next/src/todo/TodosState.ts new file mode 100644 index 0000000..d77041d --- /dev/null +++ b/packages/example-next/src/todo/TodosState.ts @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..b935681 --- /dev/null +++ b/packages/example-next/src/todo/TodosView.tsx @@ -0,0 +1,34 @@ +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 => + + )} + + + ) +}) {} diff --git a/packages/example-next/tsconfig.app.json b/packages/example-next/tsconfig.app.json index f95336a..b823bd2 100644 --- a/packages/example-next/tsconfig.app.json +++ b/packages/example-next/tsconfig.app.json @@ -22,6 +22,10 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [ { "name": "@effect/language-service" } ] diff --git a/packages/example-next/vite.config.ts b/packages/example-next/vite.config.ts index 5c94b15..79367b3 100644 --- a/packages/example-next/vite.config.ts +++ b/packages/example-next/vite.config.ts @@ -1,10 +1,22 @@ +import { tanstackRouter } from "@tanstack/router-plugin/vite" import react from "@vitejs/plugin-react" +import path from "node:path" import { defineConfig } from "vite" // https://vite.dev/config/ export default defineConfig({ plugins: [ + tanstackRouter({ + target: "react", + autoCodeSplitting: true, + }), react(), ], + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, })