Fix
Lint / lint (push) Successful in 38s

This commit is contained in:
Julien Valverdé
2026-07-19 04:00:46 +02:00
parent 885cff74b3
commit 31d87ffcab
24 changed files with 219 additions and 907 deletions
-20
View File
@@ -1,20 +0,0 @@
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)
@@ -1 +0,0 @@
export * as Todo from "./Todo"
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect" import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc" import { Component, Form, View } from "effect-fc-next"
import type * as React from "react" import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any> props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) { ) {
const input = yield* Form.useInput(props.form, props) const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues, props.form.issues,
props.form.isValidating, props.form.isValidating,
props.form.isCommitting, props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
value={input.value} value={input.value}
onChange={e => input.setValue(e.target.value)} onChange={e => input.setValue(e.target.value)}
disabled={isCommitting} disabled={isCommitting}
{...Struct.omit(props, "form")} {...Struct.omit(props, ["form"])}
> >
{isValidating && {isValidating &&
<TextField.Slot side="right"> <TextField.Slot side="right">
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect" import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc" import { Component, Form, View } from "effect-fc-next"
import type * as React from "react" import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any> props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) { ) {
const input = yield* Form.useOptionalInput(props.form, props) const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues, props.form.issues,
props.form.isValidating, props.form.isValidating,
props.form.isCommitting, props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
value={input.value} value={input.value}
onChange={e => input.setValue(e.target.value)} onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting} disabled={!input.enabled || isCommitting}
{...Struct.omit(props, "form", "defaultValue")} {...Struct.omit(props, ["form", "defaultValue"])}
> >
<TextField.Slot side="left"> <TextField.Slot side="left">
<Switch <Switch
@@ -1,38 +0,0 @@
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`)),
}),
),
},
) {}
@@ -1,2 +0,0 @@
export * from "./datetime"
export * from "./json"
@@ -1,6 +0,0 @@
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
+9 -91
View File
@@ -1,103 +1,21 @@
import { Effect, Layer, Schema, SubscriptionRef } from "effect" import { createRouter, RouterProvider } from "@tanstack/react-router"
import { import { ReactRuntime } from "effect-fc-next"
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 { routeTree } from "./routeTree.gen"
import { runtime } from "./runtime"
const router = createRouter({ routeTree })
const runtime = ReactRuntime.make(Layer.empty) declare module "@tanstack/react-router" {
interface Register { router: typeof router }
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 (
<section>
<h2>MutationForm</h2>
<p>Owns a draft and commits it explicitly through a Mutation.</p>
<form onSubmit={(event) => {
event.preventDefault()
void runPromise(form.submit)
}}>
<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 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 (
<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}>
<App /> <RouterProvider router={router} />
</ReactRuntime.Provider> </ReactRuntime.Provider>
</StrictMode>, </StrictMode>,
) )
+15
View File
@@ -0,0 +1,15 @@
import { Effect } from "effect"
export interface Post {
readonly title: string
readonly body: string
}
export const fetchPost = (id: number) => Effect.tryPromise({
try: () =>
fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
.then(response => response.json() as Promise<Post>),
catch: () => new Error("Unable to fetch post"),
})
+3 -64
View File
@@ -15,8 +15,6 @@ import { Route as BlankRouteImport } from './routes/blank'
import { Route as FormRouteImport } from './routes/form' import { Route as FormRouteImport } from './routes/form'
import { Route as QueryRouteImport } from './routes/query' import { Route as QueryRouteImport } from './routes/query'
import { Route as ResultRouteImport } from './routes/result' 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({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
@@ -48,16 +46,6 @@ const ResultRoute = ResultRouteImport.update({
path: '/result', path: '/result',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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 { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -66,8 +54,6 @@ export interface FileRoutesByFullPath {
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -76,8 +62,6 @@ export interface FileRoutesByTo {
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -87,40 +71,13 @@ export interface FileRoutesById {
'/form': typeof FormRoute '/form': typeof FormRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute '/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths: '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to: '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
| '/' id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/query' | '/result'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -130,8 +87,6 @@ export interface RootRouteChildren {
FormRoute: typeof FormRoute FormRoute: typeof FormRoute
QueryRoute: typeof QueryRoute QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute ResultRoute: typeof ResultRoute
DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -178,20 +133,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ResultRouteImport preLoaderRoute: typeof ResultRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/dev/context': {
id: '/dev/context'
path: '/dev/context'
fullPath: '/dev/context'
preLoaderRoute: typeof DevContextRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/memo': {
id: '/dev/memo'
path: '/dev/memo'
fullPath: '/dev/memo'
preLoaderRoute: typeof DevMemoRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
@@ -202,8 +143,6 @@ const rootRouteChildren: RootRouteChildren = {
FormRoute: FormRoute, FormRoute: FormRoute,
QueryRoute: QueryRoute, QueryRoute: QueryRoute,
ResultRoute: ResultRoute, ResultRoute: ResultRoute,
DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+5 -1
View File
@@ -7,7 +7,7 @@ import "../index.css"
export const Route = createRootRoute({ export const Route = createRootRoute({
component: Root component: Root,
}) })
function Root() { function Root() {
@@ -17,6 +17,10 @@ function Root() {
<Flex direction="row" justify="center" align="center" gap="2"> <Flex direction="row" justify="center" align="center" gap="2">
<Link to="/">Index</Link> <Link to="/">Index</Link>
<Link to="/blank">Blank</Link> <Link to="/blank">Blank</Link>
<Link to="/async">Async</Link>
<Link to="/query">Query</Link>
<Link to="/result">Result</Link>
<Link to="/form">Form</Link>
</Flex> </Flex>
</Container> </Container>
+16 -23
View File
@@ -1,30 +1,22 @@
import { HttpClient } from "@effect/platform"
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes" import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Array, Effect, flow, Option, Schema } from "effect" import { Async, Component, Memoized } from "effect-fc-next"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react" import * as React from "react"
import { fetchPost } from "@/post"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
interface AsyncFetchPostViewProps { interface AsyncFetchPostViewProps {
readonly id: number readonly id: number
} }
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) { const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe( props: AsyncFetchPostViewProps,
Effect.tap(Effect.sleep("500 millis")), ) {
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)), const post = yield* Component.useOnChange(
Effect.andThen(response => response.json), () => fetchPost(props.id),
Effect.andThen(Schema.decodeUnknown(Post)), [props.id],
), [props.id]) )
return ( return (
<div> <div>
@@ -34,10 +26,9 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(
) )
}).pipe( }).pipe(
Async.async, Async.async,
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }), Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }),
Memoized.memoized, Memoized.memoized,
) {} )
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post") const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
@@ -50,12 +41,14 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
<Flex direction="column" align="stretch" gap="2"> <Flex direction="column" align="stretch" gap="2">
<TextField.Root <TextField.Root
value={text} value={text}
onChange={e => setText(e.currentTarget.value)} onChange={event => setText(event.currentTarget.value)}
/> />
<Slider <Slider
value={[id]} value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)} min={1}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/> />
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} /> <AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
@@ -63,7 +56,7 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
</Container> </Container>
) )
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context),
) )
export const Route = createFileRoute("/async")({ export const Route = createFileRoute("/async")({
@@ -1,42 +0,0 @@
import { Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect } from "effect"
import { Component } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
class SubService extends Effect.Service<SubService>()("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
})
@@ -1,37 +0,0 @@
import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
const [value, setValue] = React.useState("")
return (
<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,
})
+39 -119
View File
@@ -1,143 +1,63 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes" import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" import { Effect, Schema } from "effect"
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc" import { Component, Form, MutationForm, View } from "effect-fc-next"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
const email = Schema.pattern<typeof Schema.String>( const RegisterSchema = Schema.Struct({
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i, email: Schema.String,
{
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, password: Schema.String,
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
}) })
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", { const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
scoped: Effect.gen(function*() { const form = yield* Component.useOnMount(() => MutationForm.service({
const form = yield* SubmittableForm.service({ schema: RegisterSchema,
schema: RegisterFormSchema.pipe( initialEncodedValue: { email: "", password: "" },
Schema.compose( f: ([value]) => Effect.log(`Registered ${value.email}`),
Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema),
Schema.typeSchema(RegisterFormSchema),
{
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
encode: ParseResult.succeed,
},
),
),
),
initialEncodedValue: { email: "", password: "", birth: Option.none() },
f: Effect.fnUntraced(function*([value]) {
yield* Effect.sleep("500 millis")
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
}),
})
return {
form,
emailField: Form.focusObjectOn(form, "email"),
passwordField: Form.focusObjectOn(form, "password"),
birthField: Form.focusObjectOn(form, "birth"),
} as const
})
}) {}
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
const form = yield* RegisterFormService
const [canCommit, submitResult] = yield* Subscribable.useAll([
form.form.canCommit,
form.form.mutation.result,
])
const runPromise = yield* Component.useRunPromise()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
yield* Console.log("RegisterFormView mounted")
})) }))
const emailField = yield* Form.useInput(
Form.focusObjectOn(form, "email"),
)
const passwordField = yield* Form.useInput(
Form.focusObjectOn(form, "password"),
)
const [canCommit, isCommitting] = yield* View.useAll([
form.canCommit,
form.isCommitting,
])
const runPromise = yield* Component.useRunPromise()
return ( return (
<Container width="300"> <Container width="300">
<form onSubmit={e => { <form onSubmit={(event) => {
e.preventDefault() event.preventDefault()
void runPromise(form.form.submit) void runPromise(form.submit)
}}> }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<TextFieldFormInput <TextField.Root
form={form.emailField} value={emailField.value}
debounce="250 millis" onChange={event => emailField.setValue(event.currentTarget.value)}
placeholder="Email"
/> />
<TextField.Root
<TextFieldFormInput value={passwordField.value}
form={form.passwordField} onChange={event => passwordField.setValue(event.currentTarget.value)}
debounce="250 millis" placeholder="Password"
type="password"
/> />
<Button disabled={!canCommit || isCommitting}>
<TextFieldOptionalFormInput {isCommitting ? "Submitting…" : "Submit"}
type="datetime-local" </Button>
form={form.birthField}
defaultValue=""
/>
<Button disabled={!canCommit}>Submit</Button>
</Flex> </Flex>
</form> </form>
<Text size="2">A MutationForm validates local input, then submits it.</Text>
{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> </Container>
) )
}) {}
const RegisterPage = Component.make("RegisterPageView")(function*() {
const RegisterForm = yield* Effect.provide(
RegisterFormView.use,
yield* Component.useContextFromLayer(RegisterFormService.Default),
)
return <RegisterForm />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context),
) )
export const Route = createFileRoute("/form")({ export const Route = createFileRoute("/form")({
component: RegisterPage component: RegisterRouteComponent,
}) })
+44 -12
View File
@@ -1,24 +1,56 @@
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect" import { Effect, SubscriptionRef } from "effect"
import { Component } from "effect-fc" import { Component, Lens, View } from "effect-fc-next"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
import { TodosState } from "@/todo/TodosState"
import { TodosView } from "@/todo/TodosView"
const TodosStateLive = TodosState.Default("todos") const TodoRouteComponent = Component.make("TodoRouteView")(function*() {
const todosLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make<readonly string[]>([]),
Lens.fromSubscriptionRef,
))
const draftLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(""),
Lens.fromSubscriptionRef,
))
const Index = Component.make("IndexView")(function*() { const [todos] = yield* View.useAll([todosLens])
const Todos = yield* Effect.provide( const [draft, setDraft] = yield* Lens.useState(draftLens)
TodosView.use, const runPromise = yield* Component.useRunPromise()
yield* Component.useContextFromLayer(TodosStateLive),
const addTodo = Lens.update(todosLens, todos =>
draft.trim() === ""
? todos
: [...todos, draft.trim()],
).pipe(
Effect.andThen(Lens.set(draftLens, "")),
) )
return <Todos /> return (
<Container width="480">
<Flex direction="column" gap="3">
<Text size="2">A small Effect v4 todo state example backed by a Lens.</Text>
<Flex gap="2">
<TextField.Root
value={draft}
onChange={event => setDraft(event.currentTarget.value)}
/>
<Button onClick={() => void runPromise(addTodo)}>
Add
</Button>
</Flex>
{todos.map(todo => <Text key={todo}> {todo}</Text>)}
</Flex>
</Container>
)
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withRuntime(runtime.context),
) )
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: Index component: TodoRouteComponent,
}) })
+48 -80
View File
@@ -1,117 +1,85 @@
import { HttpClient, type HttpClientError } from "@effect/platform"
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect" import { Effect, SubscriptionRef } from "effect"
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc" import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Lens, Mutation, Query, View } from "effect-fc-next"
import { fetchPost, type Post } from "@/post"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
const Post = Schema.Struct({ interface PostResultViewProps {
userId: Schema.Int, readonly result: AsyncResult.AsyncResult<Post, Error>
id: Schema.Int, }
title: Schema.String,
body: Schema.String,
})
const ResultView = Component.make("ResultView")(function*() { const PostResultView = (props: PostResultViewProps) =>
const runPromise = yield* Component.useRunPromise() AsyncResult.match(props.result, {
onInitial: () => <Text>Loading...</Text>,
onFailure: () => <Text>Request failed.</Text>,
onSuccess: result => (
<>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
</>
),
})
const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1)) const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
const key = Stream.map(idLens.changes, id => [id] as const)
const query = yield* Query.service({ const query = yield* Query.service({
key, key: idLens,
f: ([id]) => HttpClient.HttpClient.pipe( f: fetchPost,
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", staleTime: "10 seconds",
}) })
const mutation = yield* Mutation.make({ const mutation = yield* Mutation.make({
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe( f: fetchPost,
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 return [idLens, query, mutation] as const
})) }))
const [id, setId] = yield* Lens.useState(idLens) const [id] = yield* View.useAll([idLens])
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result]) const [queryState, mutationResult] = yield* View.useAll([
query.state,
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe( mutation.state,
Effect.andThen(observer => observer.subscribe), ])
Effect.andThen(Stream.fromQueue), const runPromise = yield* Component.useRunPromise()
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 ( return (
<Container> <Container>
<Flex direction="column" align="center" gap="2"> <Flex direction="column" align="center" gap="2">
<Slider <Slider
value={[id]} value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)} min={1}
max={10}
onValueChange={([value]) =>
void runPromise(Lens.set(idLens, value ?? 1))
}
/> />
<div> <PostResultView result={queryState.result} />
{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"> <Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button> <Button onClick={() => void runPromise(query.refresh)}>
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button> Refresh
</Button>
<Button onClick={() => void runPromise(query.invalidateCache)}>
Invalidate cache
</Button>
</Flex> </Flex>
<div> <PostResultView result={mutationResult} />
{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={() => void runPromise(mutation.mutate(id))}>
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button> Mutate
</Flex> </Button>
</Flex> </Flex>
</Container> </Container>
) )
}) }).pipe(
Component.withRuntime(runtime.context),
)
export const Route = createFileRoute("/query")({ export const Route = createFileRoute("/query")({
component: Component.withRuntime(ResultView, runtime.context) component: QueryRouteComponent,
}) })
+32 -47
View File
@@ -1,60 +1,45 @@
import { HttpClient, type HttpClientError } from "@effect/platform" import { Button, Container, Flex, Heading, Text } from "@radix-ui/themes"
import { Container, Heading, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect" import { AsyncResult } from "effect/unstable/reactivity"
import { Component, ErrorObserver, Result, Subscribable } from "effect-fc" import { Component, Mutation, View } from "effect-fc-next"
import { fetchPost, type Post } from "@/post"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
const Post = Schema.Struct({ const PostResultView = (props: { readonly result: AsyncResult.AsyncResult<Post, Error> }) =>
userId: Schema.Int, AsyncResult.match(props.result, {
id: Schema.Int, onInitial: () => <Text>Ready to load.</Text>,
title: Schema.String, onFailure: () => <Text>Request failed.</Text>,
body: Schema.String, onSuccess: result => (
}) <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
</>
),
})
const ResultView = Component.makeUntraced("Result")(function*() { const ResultRouteComponent = Component.make("ResultRouteView")(function*() {
const [resultSubscribable] = yield* Component.useOnMount(() => HttpClient.HttpClient.pipe( const mutation = yield* Component.useOnMount(() => Mutation.make({
Effect.andThen(client => client.get("https://jsonplaceholder.typicode.com/posts/1")), f: (_: undefined) => fetchPost(1),
Effect.andThen(response => response.json), }))
Effect.andThen(Schema.decodeUnknown(Post)), const [result] = yield* View.useAll([mutation.state])
Effect.tap(Effect.sleep("250 millis")), const runPromise = yield* Component.useRunPromise()
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 ( return (
<Container> <Container>
{Match.value(result).pipe( <Flex direction="column" gap="2">
Match.tag("Running", () => <Text>Loading...</Text>), <Button onClick={() => void runPromise(mutation.mutate(undefined))}>
Match.tag("Success", result => <> Load post
<Heading>{result.value.title}</Heading> </Button>
<Text>{result.value.body}</Text>
</>), <PostResultView result={result} />
Match.tag("Failure", result => </Flex>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</Container> </Container>
) )
}) }).pipe(
Component.withRuntime(runtime.context),
)
export const Route = createFileRoute("/result")({ export const Route = createFileRoute("/result")({
component: Component.withRuntime(ResultView, runtime.context) component: ResultRouteComponent,
}) })
+2 -13
View File
@@ -1,15 +1,4 @@
import { FetchHttpClient } from "@effect/platform" import { QueryClient, ReactRuntime } from "effect-fc-next"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { DateTime, Layer } from "effect"
import { ReactRuntime } from "effect-fc"
export const AppLive = Layer.empty.pipe( export const runtime = ReactRuntime.make(QueryClient.layer())
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)
@@ -1,88 +0,0 @@
import { Box, Flex, IconButton } from "@radix-ui/themes"
import { Effect } from "effect"
import { Component, Form, Subscribable, SynchronizedForm } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
export interface EditTodoViewProps {
readonly id: string
}
export class EditTodoView extends Component.make("TodoView")(function*(props: EditTodoViewProps) {
const state = yield* TodosState
const [
indexSubscribable,
contentField,
completedAtField,
] = yield* Component.useOnChange(() => Effect.gen(function*() {
const indexSubscribable = state.getIndexSubscribable(props.id)
const form = yield* SynchronizedForm.service({
schema: TodoFormSchema,
target: state.getElementLens(props.id),
})
return [
indexSubscribable,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}), [props.id])
const [index, size] = yield* Subscribable.useAll([
indexSubscribable,
state.sizeSubscribable,
])
const runSync = yield* Component.useRunSync()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<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>
)
}) {}
@@ -1,78 +0,0 @@
import { Box, Button, Flex } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, type DateTime, Effect, Option, Schema } from "effect"
import { Component, Form, Lens, SubmittableForm, Subscribable } from "effect-fc"
import * as Domain from "@/domain"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
content: "",
completedAt: Option.none(),
})),
Effect.provide(GetRandomValues.CryptoRandom),
)
export class NewTodoView extends Component.make("NewTodoView")(function*() {
const state = yield* TodosState
const [
form,
contentField,
completedAtField,
] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: TodoFormSchema,
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(yield* makeTodo),
f: ([todo, form]) => Lens.update(state.lens, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Lens.set(form.encodedValue, v)),
),
})
return [
form,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}))
const [canCommit] = yield* Subscribable.useAll([form.canCommit])
const runPromise = yield* Component.useRunPromise<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>
)
}) {}
@@ -1,9 +0,0 @@
import { Schema } from "effect"
import * as Domain from "@/domain"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
export const TodoFormSchema = Schema.compose(Schema.Struct({
...Domain.Todo.Todo.fields,
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}), Domain.Todo.Todo)
@@ -1,96 +0,0 @@
import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Lens, Subscribable } from "effect-fc"
import { Todo } from "@/domain"
export class TodosState extends Effect.Service<TodosState>()("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],
}) {}
@@ -1,34 +0,0 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component, Subscribable } from "effect-fc"
import { EditTodoView } from "./EditTodoView"
import { NewTodoView } from "./NewTodoView"
import { TodosState } from "./TodosState"
export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState
const [todos] = yield* Subscribable.useAll([state.lens])
yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
const NewTodo = yield* NewTodoView.use
const EditTodo = yield* EditTodoView.use
return (
<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>
)
}) {}