0.1.3 (#4)
All checks were successful
Publish / publish (push) Successful in 14s
Lint / lint (push) Successful in 11s

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: https://gitea:3000/Thilawyn/effect-fc/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
Julien Valverdé
2025-08-23 03:07:28 +02:00
parent 16fa750b30
commit 831a808568
55 changed files with 1539 additions and 859 deletions

View File

@@ -11,42 +11,37 @@
"preview": "vite preview"
},
"devDependencies": {
"@effect/language-service": "^0.23.4",
"@eslint/js": "^9.26.0",
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-router-devtools": "^1.120.3",
"@tanstack/router-plugin": "^1.120.3",
"@thilawyn/thilaschema": "^0.1.4",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.26.0",
"@effect/language-service": "^0.35.2",
"@eslint/js": "^9.34.0",
"@tanstack/react-router": "^1.131.27",
"@tanstack/react-router-devtools": "^1.131.27",
"@tanstack/router-plugin": "^1.131.27",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.1",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5"
"globals": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.3"
},
"dependencies": {
"@effect/platform": "^0.82.1",
"@effect/platform-browser": "^0.62.1",
"@effect/platform": "^0.90.6",
"@effect/platform-browser": "^0.70.0",
"@radix-ui/themes": "^3.2.1",
"@typed/async-data": "^0.13.1",
"@typed/id": "^0.17.2",
"@typed/lazy-ref": "^0.3.3",
"effect": "^3.15.1",
"effect": "^3.17.9",
"effect-fc": "workspace:*",
"lucide-react": "^0.510.0",
"mobx": "^6.13.7",
"react-icons": "^5.5.0"
},
"overrides": {
"effect": "^3.15.1",
"@effect/platform": "^0.82.1",
"@effect/platform-browser": "^0.62.1",
"@typed/lazy-ref": "^0.3.3",
"@typed/async-data": "^0.13.1"
"@types/react": "^19.1.11",
"effect": "^3.17.9",
"react": "^19.1.1"
}
}

View File

@@ -1,4 +1,4 @@
import { ThSchema } from "@thilawyn/thilaschema"
import { assertEncodedJsonifiable } from "@/lib/schema"
import { Schema } from "effect"
@@ -14,7 +14,7 @@ export const TodoFromJsonStruct = Schema.Struct({
...Todo.fields,
completedAt: Schema.Option(Schema.DateTimeUtc),
}).pipe(
ThSchema.assertEncodedJsonifiable
assertEncodedJsonifiable
)
export const TodoFromJson = Schema.compose(TodoFromJsonStruct, Todo)

View File

@@ -0,0 +1,40 @@
import { Callout, Flex, TextArea, TextAreaProps } from "@radix-ui/themes"
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
import { Component } from "effect-fc"
import { useInput } from "effect-fc/hooks"
import * as React from "react"
export type TextAreaInputProps<A, R> = Omit<useInput.Options<A, R>, "schema" | "equivalence"> & Omit<TextAreaProps, "ref">
export const TextAreaInput = <A, R>(options: {
readonly schema: Schema.Schema<A, string, R>
readonly equivalence?: Equivalence.Equivalence<A>
}): Component.Component<
TextAreaInputProps<A, R>,
React.JSX.Element,
ParseResult.ParseError,
R
> => Component.makeUntraced(function* TextFieldInput(props) {
const input = yield* useInput({ ...options, ...props })
const issue = React.useMemo(() => input.error.pipe(
Option.map(ParseResult.ArrayFormatter.formatErrorSync),
Option.flatMap(Array.head),
), [input.error])
return (
<Flex direction="column" gap="1">
<TextArea
value={input.value}
onChange={e => input.setValue(e.target.value)}
{...Struct.omit(props, "ref")}
/>
{Option.isSome(issue) &&
<Callout.Root color="red" role="alert">
<Callout.Text>{issue.value.message}</Callout.Text>
</Callout.Root>
}
</Flex>
)
})

View File

@@ -0,0 +1,70 @@
import { Callout, Checkbox, Flex, TextField } from "@radix-ui/themes"
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
import { Component, Memo } from "effect-fc"
import { useInput, useOptionalInput } from "effect-fc/hooks"
import * as React from "react"
export type TextFieldInputProps<A, R> = (
& Omit<useInput.Options<A, R>, "schema" | "equivalence">
& Omit<TextField.RootProps, "ref">
)
export type TextFieldOptionalInputProps<A, R> = (
& Omit<useOptionalInput.Options<A, R>, "schema" | "equivalence">
& Omit<TextField.RootProps, "ref" | "defaultValue">
)
export const TextFieldInput = <A, R, O extends boolean = false>(options: {
readonly optional?: O
readonly schema: Schema.Schema<A, string, R>
readonly equivalence?: Equivalence.Equivalence<A>
}) => Component.makeUntraced(function* TextFieldInput(props: O extends true
? TextFieldOptionalInputProps<A, R>
: TextFieldInputProps<A, R>
) {
const input: (
| { readonly optional: true } & useOptionalInput.Result
| { readonly optional: false } & useInput.Result
) = options.optional
? {
optional: true,
// eslint-disable-next-line react-hooks/rules-of-hooks
...yield* useOptionalInput({ ...options, ...props as TextFieldOptionalInputProps<A, R> }),
}
: {
optional: false,
// eslint-disable-next-line react-hooks/rules-of-hooks
...yield* useInput({ ...options, ...props as TextFieldInputProps<A, R> }),
}
const issue = React.useMemo(() => input.error.pipe(
Option.map(ParseResult.ArrayFormatter.formatErrorSync),
Option.flatMap(Array.head),
), [input.error])
return (
<Flex direction="column" gap="1">
<Flex direction="row" align="center" gap="1">
{input.optional &&
<Checkbox
checked={input.enabled}
onCheckedChange={checked => input.setEnabled(checked !== "indeterminate" && checked)}
/>
}
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={input.optional ? !input.enabled : undefined}
{...Struct.omit(props as TextFieldOptionalInputProps<A, R> | TextFieldInputProps<A, R>, "ref", "defaultValue")}
/>
</Flex>
{(!(input.optional && !input.enabled) && Option.isSome(issue)) &&
<Callout.Root color="red" role="alert">
<Callout.Text>{issue.value.message}</Callout.Text>
</Callout.Root>
}
</Flex>
)
}).pipe(Memo.memo)

View File

@@ -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`)),
}),
),
},
) {}

View File

@@ -0,0 +1,2 @@
export * from "./datetime"
export * from "./json"

View File

@@ -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

View File

@@ -1,5 +1,5 @@
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReactManagedRuntime } from "effect-fc"
import { ReactRuntime } from "effect-fc"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { routeTree } from "./routeTree.gen"
@@ -16,8 +16,8 @@ declare module "@tanstack/react-router" {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactManagedRuntime.Provider runtime={runtime}>
<ReactRuntime.Provider runtime={runtime}>
<RouterProvider router={router} />
</ReactManagedRuntime.Provider>
</ReactRuntime.Provider>
</StrictMode>
)

View File

@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevInputRouteImport } from './routes/dev/input'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const BlankRoute = BlankRouteImport.update({
@@ -29,6 +30,11 @@ const DevMemoRoute = DevMemoRouteImport.update({
path: '/dev/memo',
getParentRoute: () => rootRouteImport,
} as any)
const DevInputRoute = DevInputRouteImport.update({
id: '/dev/input',
path: '/dev/input',
getParentRoute: () => rootRouteImport,
} as any)
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
id: '/dev/async-rendering',
path: '/dev/async-rendering',
@@ -39,12 +45,14 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesById {
@@ -52,20 +60,33 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
fullPaths:
| '/'
| '/blank'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
id: '__root__' | '/' | '/blank' | '/dev/async-rendering' | '/dev/memo'
to: '/' | '/blank' | '/dev/async-rendering' | '/dev/input' | '/dev/memo'
id:
| '__root__'
| '/'
| '/blank'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BlankRoute: typeof BlankRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevInputRoute: typeof DevInputRoute
DevMemoRoute: typeof DevMemoRoute
}
@@ -92,6 +113,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DevMemoRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/input': {
id: '/dev/input'
path: '/dev/input'
fullPath: '/dev/input'
preLoaderRoute: typeof DevInputRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/async-rendering': {
id: '/dev/async-rendering'
path: '/dev/async-rendering'
@@ -106,6 +134,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BlankRoute: BlankRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevInputRoute: DevInputRoute,
DevMemoRoute: DevMemoRoute,
}
export const routeTree = rootRouteImport

View File

@@ -3,12 +3,13 @@ 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, Hook, Memoized, Suspense } from "effect-fc"
import { Component, Memo, Suspense } from "effect-fc"
import { Hooks } from "effect-fc/hooks"
import * as React from "react"
// Generator version
const RouteComponent = Component.make(function* AsyncRendering() {
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent
const AsyncComponentFC = yield* AsyncComponent
const [input, setInput] = React.useState("")
@@ -50,7 +51,7 @@ const RouteComponent = Component.make(function* AsyncRendering() {
// )
class AsyncComponent extends Component.make(function* AsyncComponent() {
class AsyncComponent extends Component.makeUntraced(function* AsyncComponent() {
const SubComponentFC = yield* SubComponent
yield* Effect.sleep("500 millis") // Async operation
@@ -66,10 +67,10 @@ class AsyncComponent extends Component.make(function* AsyncComponent() {
Suspense.suspense,
Suspense.withOptions({ defaultFallback: <p>Loading...</p> }),
) {}
class MemoizedAsyncComponent extends Memoized.memo(AsyncComponent) {}
class MemoizedAsyncComponent extends Memo.memo(AsyncComponent) {}
class SubComponent extends Component.make(function* SubComponent() {
const [state] = React.useState(yield* Hook.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
class SubComponent extends Component.makeUntraced(function* SubComponent() {
const [state] = React.useState(yield* Hooks.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}

View File

@@ -0,0 +1,42 @@
import { TextFieldInput } from "@/lib/input/TextFieldInput"
import { runtime } from "@/runtime"
import { Container } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Schema, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useInput, useOnce, useRefState } from "effect-fc/hooks"
const IntFromString = Schema.NumberFromString.pipe(Schema.int())
const IntTextFieldInput = TextFieldInput({ schema: IntFromString })
const StringTextFieldInput = TextFieldInput({ schema: Schema.String })
const Input = Component.makeUntraced(function* Input() {
const IntTextFieldInputFC = yield* IntTextFieldInput
const StringTextFieldInputFC = yield* StringTextFieldInput
const intRef1 = yield* useOnce(() => SubscriptionRef.make(0))
const intRef2 = yield* useOnce(() => SubscriptionRef.make(0))
const stringRef = yield* useOnce(() => SubscriptionRef.make(""))
// yield* useFork(() => Stream.runForEach(intRef1.changes, Console.log), [intRef1])
const input2 = yield* useInput({ schema: IntFromString, ref: intRef2 })
const [str, setStr] = yield* useRefState(stringRef)
return (
<Container>
<IntTextFieldInputFC ref={intRef1} />
<StringTextFieldInputFC ref={stringRef} />
<StringTextFieldInputFC ref={stringRef} />
</Container>
)
}).pipe(
Memo.memo,
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/dev/input")({
component: Input,
})

View File

@@ -3,11 +3,11 @@ 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 { Component, Memo } from "effect-fc"
import * as React from "react"
const RouteComponent = Component.make(function* RouteComponent() {
const RouteComponent = Component.makeUntraced(function* RouteComponent() {
const [value, setValue] = React.useState("")
return (
@@ -25,12 +25,12 @@ const RouteComponent = Component.make(function* RouteComponent() {
Component.withRuntime(runtime.context)
)
class SubComponent extends Component.make(function* SubComponent() {
class SubComponent extends Component.makeUntraced(function* SubComponent() {
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
return <Text>{id}</Text>
}) {}
class MemoizedSubComponent extends Memoized.memo(SubComponent) {}
class MemoizedSubComponent extends Memo.memo(SubComponent) {}
export const Route = createFileRoute("/dev/memo")({
component: RouteComponent,

View File

@@ -3,18 +3,21 @@ import { Todos } from "@/todo/Todos"
import { TodosState } from "@/todo/TodosState.service"
import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect"
import { Component, Hook } from "effect-fc"
import { Component } from "effect-fc"
import { useContext } from "effect-fc/hooks"
const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced(function* Index() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)
return <TodosFC />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/")({
component: Component.make(function* Index() {
return yield* Todos.pipe(
Effect.map(FC => <FC />),
Effect.provide(yield* Hook.useContext(TodosStateLive, { finalizerExecutionMode: "fork" })),
)
}).pipe(
Component.withRuntime(runtime.context)
)
component: Index
})

View File

@@ -1,14 +1,15 @@
import { FetchHttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { Layer } from "effect"
import { ReactManagedRuntime } from "effect-fc"
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 = ReactManagedRuntime.make(AppLive)
export const runtime = ReactRuntime.make(AppLive)

View File

@@ -1,14 +1,21 @@
import * as Domain from "@/domain"
import { Box, Button, Flex, IconButton, TextArea } from "@radix-ui/themes"
import { TextAreaInput } from "@/lib/input/TextAreaInput"
import { TextFieldInput } from "@/lib/input/TextFieldInput"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, Effect, Match, Option, Ref, Runtime, SubscriptionRef } from "effect"
import { Component, Hook, Memoized } from "effect-fc"
import { SubscriptionSubRef } from "effect-fc/types"
import { Chunk, DateTime, Effect, Match, Option, Ref, Runtime, Schema, Stream, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useMemo, useOnce, useSubscribe } from "effect-fc/hooks"
import { Subscribable, SubscriptionSubRef } from "effect-fc/types"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import { TodosState } from "./TodosState.service"
const StringTextAreaInput = TextAreaInput({ schema: Schema.String })
const OptionalDateTimeInput = TextFieldInput({ optional: true, schema: DateTimeUtcFromZonedInput })
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
@@ -20,96 +27,86 @@ const makeTodo = makeUuid4.pipe(
export type TodoProps = (
| { readonly _tag: "new", readonly index?: never }
| { readonly _tag: "edit", readonly index: number }
| { readonly _tag: "new" }
| { readonly _tag: "edit", readonly id: string }
)
export class Todo extends Component.make(function* Todo(props: TodoProps) {
export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps) {
const runtime = yield* Effect.runtime()
const state = yield* TodosState
const [ref, contentRef] = yield* Hook.useMemo(() => Match.value(props).pipe(
Match.tag("new", () => Effect.andThen(makeTodo, SubscriptionRef.make)),
Match.tag("edit", ({ index }) => Effect.succeed(SubscriptionSubRef.makeFromChunkRef(state.ref, index))),
const { ref, indexRef, contentRef, completedAtRef } = yield* useMemo(() => Match.value(props).pipe(
Match.tag("new", () => Effect.Do.pipe(
Effect.bind("ref", () => Effect.andThen(makeTodo, SubscriptionRef.make)),
Effect.let("indexRef", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.empty })),
)),
Match.tag("edit", ({ id }) => Effect.Do.pipe(
Effect.let("ref", () => state.getElementRef(id)),
Effect.let("indexRef", () => state.getIndexSubscribable(id)),
)),
Match.exhaustive,
Effect.map(ref => [
ref,
SubscriptionSubRef.makeFromPath(ref, ["content"]),
] as const),
), [props._tag, props.index])
Effect.let("contentRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["content"])),
Effect.let("completedAtRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["completedAt"])),
// eslint-disable-next-line react-hooks/exhaustive-deps
), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size] = yield* useSubscribe(indexRef, state.sizeSubscribable)
const StringTextAreaInputFC = yield* StringTextAreaInput
const OptionalDateTimeInputFC = yield* OptionalDateTimeInput
const [content, size] = yield* Hook.useSubscribeRefs(contentRef, state.sizeRef)
return (
<Flex direction="column" align="stretch" gap="2">
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<TextArea
value={content}
onChange={e => Runtime.runSync(runtime)(Ref.set(contentRef, e.target.value))}
/>
</Box>
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<StringTextAreaInputFC ref={contentRef} />
{props._tag === "edit" &&
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={props.index <= 0}
onClick={() => Runtime.runSync(runtime)(
SubscriptionRef.updateEffect(state.ref, todos => Effect.gen(function*() {
if (props.index <= 0) return yield* Option.none()
return todos.pipe(
Chunk.replace(props.index, yield* Chunk.get(todos, props.index - 1)),
Chunk.replace(props.index - 1, yield* ref),
)
}))
)}
>
<FaArrowUp />
</IconButton>
<Flex direction="row" justify="center" align="center" gap="2">
<OptionalDateTimeInputFC
type="datetime-local"
ref={completedAtRef}
defaultValue={yield* useOnce(() => DateTime.now)}
/>
<IconButton
disabled={props.index >= size - 1}
onClick={() => Runtime.runSync(runtime)(
SubscriptionRef.updateEffect(state.ref, todos => Effect.gen(function*() {
if (props.index >= size - 1) return yield* Option.none()
return todos.pipe(
Chunk.replace(props.index, yield* Chunk.get(todos, props.index + 1)),
Chunk.replace(props.index + 1, yield* ref),
)
}))
)}
>
<FaArrowDown />
</IconButton>
<IconButton
onClick={() => Runtime.runSync(runtime)(
Ref.update(state.ref, Chunk.remove(props.index))
)}
>
<FaDeleteLeft />
</IconButton>
{props._tag === "new" &&
<Button
onClick={() => ref.pipe(
Effect.andThen(todo => Ref.update(state.ref, Chunk.prepend(todo))),
Effect.andThen(makeTodo),
Effect.andThen(todo => Ref.set(ref, todo)),
Runtime.runSync(runtime),
)}
>
Add
</Button>
}
</Flex>
}
</Flex>
</Flex>
</Box>
{props._tag === "new" &&
<Flex direction="row" justify="center">
<Button
onClick={() => ref.pipe(
Effect.andThen(todo => Ref.update(state.ref, Chunk.prepend(todo))),
Effect.andThen(makeTodo),
Effect.andThen(todo => Ref.set(ref, todo)),
Runtime.runSync(runtime),
)}
{props._tag === "edit" &&
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => Runtime.runSync(runtime)(state.moveLeft(props.id))}
>
Add
</Button>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => Runtime.runSync(runtime)(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => Runtime.runSync(runtime)(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
}
</Flex>
)
}).pipe(
Memoized.memo
) {}
}).pipe(Memo.memo) {}

View File

@@ -1,15 +1,16 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component, Hook } from "effect-fc"
import { Component } from "effect-fc"
import { useOnce, useSubscribe } from "effect-fc/hooks"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
export class Todos extends Component.make(function* Todos() {
export class Todos extends Component.makeUntraced(function* Todos() {
const state = yield* TodosState
const [todos] = yield* Hook.useSubscribeRefs(state.ref)
const [todos] = yield* useSubscribe(state.ref)
yield* Hook.useOnce(() => Effect.andThen(
yield* useOnce(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
@@ -23,8 +24,8 @@ export class Todos extends Component.make(function* Todos() {
<Flex direction="column" align="stretch" gap="2" mt="2">
<TodoFC _tag="new" />
{Chunk.map(todos, (v, k) =>
<TodoFC key={v.id} _tag="edit" index={k} />
{Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
)}
</Flex>
</Container>

View File

@@ -2,11 +2,11 @@ import { Todo } from "@/domain"
import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { SubscriptionSubRef } from "effect-fc/types"
import { Subscribable, SubscriptionSubRef } from "effect-fc/types"
export class TodosState extends Effect.Service<TodosState>()("TodosState", {
effect: Effect.fn("TodosState")(function*(key: string) {
scoped: Effect.fnUntraced(function*(key: string) {
const kv = yield* KeyValueStore.KeyValueStore
const readFromLocalStorage = Console.log("Reading todos from local storage...").pipe(
@@ -18,7 +18,6 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
onNone: () => Effect.succeed(Chunk.empty()),
}))
)
const saveToLocalStorage = (todos: Chunk.Chunk<Todo.Todo>) => Effect.andThen(
Console.log("Saving todos to local storage..."),
Chunk.isNonEmpty(todos)
@@ -32,8 +31,6 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
)
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
const sizeRef = SubscriptionSubRef.makeFromPath(ref, ["length"])
yield* Effect.forkScoped(ref.changes.pipe(
Stream.debounce("500 millis"),
Stream.runForEach(saveToLocalStorage),
@@ -43,7 +40,54 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
Effect.ignore,
))
return { ref, sizeRef } as const
const sizeSubscribable = Subscribable.make({
get: Effect.andThen(ref, Chunk.size),
get changes() { return Stream.map(ref.changes, Chunk.size) },
})
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
const getIndexSubscribable = (id: string) => Subscribable.make({
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
})
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, 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) => SubscriptionRef.updateEffect(ref, 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) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
Chunk.findFirstIndex(todos, v => v.id === id),
index => Chunk.remove(todos, index),
))
return {
ref,
sizeSubscribable,
getElementRef,
getIndexSubscribable,
moveLeft,
moveRight,
remove,
} as const
}),
dependencies: [BrowserKeyValueStore.layerLocalStorage],