@@ -104,10 +104,16 @@
|
|||||||
"name": "@effect-fc/example-next",
|
"name": "@effect-fc/example-next",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@effect/platform-browser": "4.0.0-beta.98",
|
||||||
|
"@radix-ui/themes": "^3.3.0",
|
||||||
"effect": "4.0.0-beta.98",
|
"effect": "4.0.0-beta.98",
|
||||||
"effect-fc-next": "workspace:*",
|
"effect-fc-next": "workspace:*",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const make = <R, ER>(
|
|||||||
): ReactRuntime<Layer.Success<typeof preludeLayer> | R, ER> => Object.setPrototypeOf(
|
): ReactRuntime<Layer.Success<typeof preludeLayer> | R, ER> => Object.setPrototypeOf(
|
||||||
Object.assign(function() {}, {
|
Object.assign(function() {}, {
|
||||||
runtime: ManagedRuntime.make(
|
runtime: ManagedRuntime.make(
|
||||||
Layer.merge(preludeLayer, layer),
|
Layer.mergeAll(preludeLayer, layer),
|
||||||
{ memoMap },
|
{ memoMap },
|
||||||
),
|
),
|
||||||
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Effect FC Next Example</title>
|
<title>Vite + React + TS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"clean:modules": "rm -rf node_modules"
|
"clean:modules": "rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
@@ -23,8 +26,11 @@
|
|||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@effect/platform-browser": "4.0.0-beta.98",
|
||||||
|
"@radix-ui/themes": "^3.3.0",
|
||||||
"effect": "4.0.0-beta.98",
|
"effect": "4.0.0-beta.98",
|
||||||
"effect-fc-next": "workspace:*"
|
"effect-fc-next": "workspace:*",
|
||||||
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Schema } from "effect"
|
||||||
|
import { assertEncodedJsonifiable } from "@/lib/schema"
|
||||||
|
|
||||||
|
|
||||||
|
export class Todo extends Schema.Class<Todo>("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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * as Todo from "./Todo"
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<out P extends readonly PropertyKey[], A, ER, EW>
|
||||||
|
extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
|
||||||
|
readonly form: Form.Form<P, A, string, ER, EW>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*(
|
||||||
|
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||||
|
) {
|
||||||
|
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 (
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
<TextField.Root
|
||||||
|
value={input.value}
|
||||||
|
onChange={e => input.setValue(e.target.value)}
|
||||||
|
disabled={isCommitting}
|
||||||
|
{...Struct.omit(props, "form")}
|
||||||
|
>
|
||||||
|
{isValidating &&
|
||||||
|
<TextField.Slot side="right">
|
||||||
|
<Spinner />
|
||||||
|
</TextField.Slot>
|
||||||
|
}
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{Option.match(Array.head(issues), {
|
||||||
|
onSome: issue => (
|
||||||
|
<Callout.Root>
|
||||||
|
<Callout.Text>{issue.message}</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
),
|
||||||
|
|
||||||
|
onNone: () => <></>,
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withSignature<TextFieldFormInputView.Signature>()
|
||||||
|
)
|
||||||
@@ -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<out P extends readonly PropertyKey[], A, ER, EW>
|
||||||
|
extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
|
||||||
|
readonly form: Form.Form<P, A, Option.Option<string>, ER, EW>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*(
|
||||||
|
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||||
|
) {
|
||||||
|
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 (
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
<TextField.Root
|
||||||
|
value={input.value}
|
||||||
|
onChange={e => input.setValue(e.target.value)}
|
||||||
|
disabled={!input.enabled || isCommitting}
|
||||||
|
{...Struct.omit(props, "form", "defaultValue")}
|
||||||
|
>
|
||||||
|
<TextField.Slot side="left">
|
||||||
|
<Switch
|
||||||
|
size="1"
|
||||||
|
checked={input.enabled}
|
||||||
|
onCheckedChange={input.setEnabled}
|
||||||
|
/>
|
||||||
|
</TextField.Slot>
|
||||||
|
|
||||||
|
{isValidating &&
|
||||||
|
<TextField.Slot side="right">
|
||||||
|
<Spinner />
|
||||||
|
</TextField.Slot>
|
||||||
|
}
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{Option.match(Array.head(issues), {
|
||||||
|
onSome: issue => (
|
||||||
|
<Callout.Root>
|
||||||
|
<Callout.Text>{issue.message}</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
),
|
||||||
|
|
||||||
|
onNone: () => <></>,
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
|
||||||
|
)
|
||||||
@@ -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`)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./datetime"
|
||||||
|
export * from "./json"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Schema } from "effect"
|
||||||
|
import type { JsonValue } from "type-fest"
|
||||||
|
|
||||||
|
|
||||||
|
export const assertEncodedJsonifiable = <S extends Schema.Schema<A, I, R>, A, I extends JsonValue, R>(schema: S & Schema.Schema<A, I, R>): S => schema
|
||||||
|
export const assertTypeJsonifiable = <S extends Schema.Schema<A, I, R>, A extends JsonValue, I, R>(schema: S & Schema.Schema<A, I, R>): S => schema
|
||||||
@@ -1,40 +1,103 @@
|
|||||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
import { Effect, Layer, Schema, SubscriptionRef } from "effect"
|
||||||
import { Component, Lens, ReactRuntime, View } from "effect-fc-next"
|
import {
|
||||||
|
Component,
|
||||||
|
Form,
|
||||||
|
Lens,
|
||||||
|
LensForm,
|
||||||
|
MutationForm,
|
||||||
|
ReactRuntime,
|
||||||
|
View,
|
||||||
|
} from "effect-fc-next"
|
||||||
import { StrictMode } from "react"
|
import { StrictMode } from "react"
|
||||||
import { createRoot } from "react-dom/client"
|
import { createRoot } from "react-dom/client"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
|
||||||
|
|
||||||
const Counter = Component.make("Counter")(function*() {
|
const runtime = ReactRuntime.make(Layer.empty)
|
||||||
const count = yield* Component.useOnMount(() => Effect.map(
|
|
||||||
SubscriptionRef.make(0),
|
const MutationFormExample = Component.make("MutationFormExample")(function*() {
|
||||||
Lens.fromSubscriptionRef,
|
const form = yield* Component.useOnMount(() => MutationForm.service({
|
||||||
))
|
schema: Schema.String,
|
||||||
const [value] = yield* View.useAll([count])
|
initialEncodedValue: "",
|
||||||
const increment = yield* Component.useCallbackSync(
|
f: ([value]) => Effect.log(`Submitted: ${value}`),
|
||||||
() => Lens.update(count, n => n + 1),
|
}))
|
||||||
[count],
|
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 (
|
return (
|
||||||
<main>
|
<section>
|
||||||
<h1>Effect FC Next</h1>
|
<h2>MutationForm</h2>
|
||||||
<p>Running on Effect V4.</p>
|
<p>Owns a draft and commits it explicitly through a Mutation.</p>
|
||||||
<button type="button" onClick={increment}>
|
<form onSubmit={(event) => {
|
||||||
Count: {value}
|
event.preventDefault()
|
||||||
</button>
|
void runPromise(form.submit)
|
||||||
</main>
|
}}>
|
||||||
|
<input
|
||||||
|
value={input.value}
|
||||||
|
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||||
|
disabled={isCommitting}
|
||||||
|
placeholder="Write something to submit"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!canCommit || isCommitting}>
|
||||||
|
{isCommitting ? "Submitting…" : "Submit"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{issues.map((issue) => <p key={issue.message}>{issue.message}</p>)}
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
})
|
}).pipe(Component.withRuntime(runtime.context))
|
||||||
|
|
||||||
const runtime = ReactRuntime.make(Layer.empty)
|
const LensFormExample = Component.make("LensFormExample")(function*() {
|
||||||
const CounterApp = Counter.pipe(Component.withRuntime(runtime.context))
|
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 (
|
||||||
|
<section>
|
||||||
|
<h2>LensForm</h2>
|
||||||
|
<p>Synchronizes valid input directly with a target Lens.</p>
|
||||||
|
<input
|
||||||
|
value={input.value}
|
||||||
|
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||||
|
disabled={!canCommit || isCommitting}
|
||||||
|
/>
|
||||||
|
<p>Target value: {value}</p>
|
||||||
|
{issues.map((issue) => <p key={issue.message}>{issue.message}</p>)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}).pipe(Component.withRuntime(runtime.context))
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<main>
|
||||||
|
<h1>Effect FC Next</h1>
|
||||||
|
<p>Effect v4 examples for the finalized form API.</p>
|
||||||
|
<MutationFormExample />
|
||||||
|
<LensFormExample />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
|
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ReactRuntime.Provider runtime={runtime}>
|
<ReactRuntime.Provider runtime={runtime}>
|
||||||
<CounterApp />
|
<App />
|
||||||
</ReactRuntime.Provider>
|
</ReactRuntime.Provider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<FileRouteTypes>()
|
||||||
@@ -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 (
|
||||||
|
<Theme>
|
||||||
|
<Container mb="4">
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="2">
|
||||||
|
<Link to="/">Index</Link>
|
||||||
|
<Link to="/blank">Blank</Link>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
</Theme>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<Heading>{post.title}</Heading>
|
||||||
|
<Text>{post.body}</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Async.async,
|
||||||
|
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
|
||||||
|
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 (
|
||||||
|
<Container>
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
<TextField.Root
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={[id]}
|
||||||
|
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/async")({
|
||||||
|
component: AsyncRouteComponent,
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blank")({
|
||||||
|
component: RouteComponent
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <div>Hello "/blank"!</div>
|
||||||
|
}
|
||||||
@@ -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>()("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 <Text>{service.value}</Text>
|
||||||
|
})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Container>
|
||||||
|
<Flex direction="column" align="center">
|
||||||
|
<TextField.Root value={serviceValue} onChange={e => setServiceValue(e.target.value)} />
|
||||||
|
<SubComponentFC />
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dev/context")({
|
||||||
|
component: ContextView
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<TextField.Root
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{yield* Effect.map(SubComponent.use, FC => <FC />)}
|
||||||
|
{yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
|
||||||
|
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
|
||||||
|
return <Text>{id}</Text>
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class MemoizedSubComponent extends Memoized.memoized(SubComponent) {}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dev/memo")({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
@@ -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<typeof Schema.String>(
|
||||||
|
/^(?!\.)(?!.*\.\.)([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>()("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 (
|
||||||
|
<Container width="300">
|
||||||
|
<form onSubmit={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
void runPromise(form.form.submit)
|
||||||
|
}}>
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
<TextFieldFormInput
|
||||||
|
form={form.emailField}
|
||||||
|
debounce="250 millis"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextFieldFormInput
|
||||||
|
form={form.passwordField}
|
||||||
|
debounce="250 millis"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextFieldOptionalFormInput
|
||||||
|
type="datetime-local"
|
||||||
|
form={form.birthField}
|
||||||
|
defaultValue=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button disabled={!canCommit}>Submit</Button>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{Match.value(submitResult).pipe(
|
||||||
|
Match.tag("Initial", () => <></>),
|
||||||
|
Match.tag("Running", () => <Text>Submitting...</Text>),
|
||||||
|
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
|
||||||
|
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
|
||||||
|
Match.exhaustive,
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const RegisterPage = Component.make("RegisterPageView")(function*() {
|
||||||
|
const RegisterForm = yield* Effect.provide(
|
||||||
|
RegisterFormView.use,
|
||||||
|
yield* Component.useContextFromLayer(RegisterFormService.Default),
|
||||||
|
)
|
||||||
|
|
||||||
|
return <RegisterForm />
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/form")({
|
||||||
|
component: RegisterPage
|
||||||
|
})
|
||||||
@@ -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 <Todos />
|
||||||
|
}).pipe(
|
||||||
|
Component.withRuntime(runtime.context)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: Index
|
||||||
|
})
|
||||||
@@ -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<HttpClientError.HttpClientError>().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 (
|
||||||
|
<Container>
|
||||||
|
<Flex direction="column" align="center" gap="2">
|
||||||
|
<Slider
|
||||||
|
value={[id]}
|
||||||
|
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{Match.value(queryResult).pipe(
|
||||||
|
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||||
|
Match.tag("Success", result => <>
|
||||||
|
<Heading>{result.value.title}</Heading>
|
||||||
|
<Text>{result.value.body}</Text>
|
||||||
|
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||||
|
</>),
|
||||||
|
Match.tag("Failure", result =>
|
||||||
|
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||||
|
),
|
||||||
|
Match.orElse(() => <></>),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="1">
|
||||||
|
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
|
||||||
|
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{Match.value(mutationResult).pipe(
|
||||||
|
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||||
|
Match.tag("Success", result => <>
|
||||||
|
<Heading>{result.value.title}</Heading>
|
||||||
|
<Text>{result.value.body}</Text>
|
||||||
|
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
|
||||||
|
</>),
|
||||||
|
Match.tag("Failure", result =>
|
||||||
|
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||||
|
),
|
||||||
|
Match.orElse(() => <></>),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="1">
|
||||||
|
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/query")({
|
||||||
|
component: Component.withRuntime(ResultView, runtime.context)
|
||||||
|
})
|
||||||
@@ -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<HttpClientError.HttpClientError>().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 (
|
||||||
|
<Container>
|
||||||
|
{Match.value(result).pipe(
|
||||||
|
Match.tag("Running", () => <Text>Loading...</Text>),
|
||||||
|
Match.tag("Success", result => <>
|
||||||
|
<Heading>{result.value.title}</Heading>
|
||||||
|
<Text>{result.value.body}</Text>
|
||||||
|
</>),
|
||||||
|
Match.tag("Failure", result =>
|
||||||
|
<Text>An error has occured: {result.cause.toString()}</Text>
|
||||||
|
),
|
||||||
|
Match.orElse(() => <></>),
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/result")({
|
||||||
|
component: Component.withRuntime(ResultView, runtime.context)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -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 (
|
||||||
|
<Flex direction="row" align="center" gap="2">
|
||||||
|
<Box flexGrow="1">
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
<TextFieldFormInput
|
||||||
|
form={contentField}
|
||||||
|
debounce="250 millis"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="2">
|
||||||
|
<TextFieldOptionalFormInput
|
||||||
|
form={completedAtField}
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue=""
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Flex direction="column" justify="center" align="center" gap="1">
|
||||||
|
<IconButton
|
||||||
|
disabled={index <= 0}
|
||||||
|
onClick={() => runSync(state.moveLeft(props.id))}
|
||||||
|
>
|
||||||
|
<FaArrowUp />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
disabled={index >= size - 1}
|
||||||
|
onClick={() => runSync(state.moveRight(props.id))}
|
||||||
|
>
|
||||||
|
<FaArrowDown />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton onClick={() => runSync(state.remove(props.id))}>
|
||||||
|
<FaDeleteLeft />
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}) {}
|
||||||
@@ -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<DateTime.CurrentTimeZone>()
|
||||||
|
const TextFieldFormInput = yield* TextFieldFormInputView.use
|
||||||
|
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="row" align="center" gap="2">
|
||||||
|
<Box flexGrow="1">
|
||||||
|
<Flex direction="column" align="stretch" gap="2">
|
||||||
|
<TextFieldFormInput
|
||||||
|
form={contentField}
|
||||||
|
debounce="250 millis"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex direction="row" justify="center" align="center" gap="2">
|
||||||
|
<TextFieldOptionalFormInput
|
||||||
|
form={completedAtField}
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button disabled={!canCommit} onClick={() => void runPromise(form.submit)}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}) {}
|
||||||
@@ -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)
|
||||||
@@ -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>()("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<Todo.Todo>) => 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],
|
||||||
|
}) {}
|
||||||
@@ -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 (
|
||||||
|
<Container>
|
||||||
|
<Heading align="center">Todos</Heading>
|
||||||
|
|
||||||
|
<Flex direction="column" align="stretch" gap="2" mt="2">
|
||||||
|
<NewTodo />
|
||||||
|
|
||||||
|
{Chunk.map(todos, todo =>
|
||||||
|
<EditTodo key={todo.id} id={todo.id} />
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}) {}
|
||||||
@@ -22,6 +22,10 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{ "name": "@effect/language-service" }
|
{ "name": "@effect/language-service" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
|
import path from "node:path"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
tanstackRouter({
|
||||||
|
target: "react",
|
||||||
|
autoCodeSplitting: true,
|
||||||
|
}),
|
||||||
react(),
|
react(),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user