0.2.6 (#49)
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud> Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
@@ -13,30 +13,30 @@
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-router": "^1.168.3",
|
||||
"@tanstack/react-router-devtools": "^1.166.11",
|
||||
"@tanstack/router-plugin": "^1.167.4",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/react-router-devtools": "^1.166.13",
|
||||
"@tanstack/router-plugin": "^1.167.32",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"globals": "^17.4.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"type-fest": "^5.5.0",
|
||||
"vite": "^8.0.2"
|
||||
"globals": "^17.6.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"type-fest": "^5.6.0",
|
||||
"vite": "^8.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform": "^0.96.0",
|
||||
"@effect/platform": "^0.96.1",
|
||||
"@effect/platform-browser": "^0.76.0",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"@typed/id": "^0.17.2",
|
||||
"effect": "^3.21.0",
|
||||
"effect": "^3.21.2",
|
||||
"effect-fc": "workspace:*",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.2.14",
|
||||
"effect": "^3.21.0",
|
||||
"react": "^19.2.4"
|
||||
"effect": "^3.21.2",
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||
import { Array, Option, Struct } from "effect"
|
||||
import { Component, Form, Subscribable } from "effect-fc"
|
||||
import type * as React from "react"
|
||||
|
||||
|
||||
export declare namespace TextFieldFormInputView {
|
||||
export interface Props extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
|
||||
readonly form: Form.Form<readonly PropertyKey[], any, string>
|
||||
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 class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(
|
||||
props: TextFieldFormInputView.Props
|
||||
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, isSubmitting] = yield* Subscribable.useSubscribables([
|
||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
||||
props.form.issues,
|
||||
props.form.isValidating,
|
||||
props.form.isSubmitting,
|
||||
props.form.isCommitting,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -24,7 +28,7 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi
|
||||
<TextField.Root
|
||||
value={input.value}
|
||||
onChange={e => input.setValue(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
disabled={isCommitting}
|
||||
{...Struct.omit(props, "form")}
|
||||
>
|
||||
{isValidating &&
|
||||
@@ -47,4 +51,6 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}) {}
|
||||
}).pipe(
|
||||
Component.withSignature<TextFieldFormInputView.Signature>()
|
||||
)
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
||||
import { Array, Option, Struct } from "effect"
|
||||
import { Component, Form, Subscribable } from "effect-fc"
|
||||
import type * as React from "react"
|
||||
|
||||
|
||||
export declare namespace TextFieldOptionalFormInputView {
|
||||
export interface Props extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
|
||||
readonly form: Form.Form<readonly PropertyKey[], any, Option.Option<string>>
|
||||
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 class TextFieldOptionalFormInputView extends Component.make("TextFieldOptionalFormInputView")(function*(
|
||||
props: TextFieldOptionalFormInputView.Props
|
||||
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, isSubmitting] = yield* Subscribable.useSubscribables([
|
||||
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
|
||||
props.form.issues,
|
||||
props.form.isValidating,
|
||||
props.form.isSubmitting,
|
||||
props.form.isCommitting,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -24,7 +28,7 @@ export class TextFieldOptionalFormInputView extends Component.make("TextFieldOpt
|
||||
<TextField.Root
|
||||
value={input.value}
|
||||
onChange={e => input.setValue(e.target.value)}
|
||||
disabled={!input.enabled || isSubmitting}
|
||||
disabled={!input.enabled || isCommitting}
|
||||
{...Struct.omit(props, "form", "defaultValue")}
|
||||
>
|
||||
<TextField.Slot side="left">
|
||||
@@ -55,4 +59,6 @@ export class TextFieldOptionalFormInputView extends Component.make("TextFieldOpt
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}) {}
|
||||
}).pipe(
|
||||
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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"
|
||||
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, Subscribable } from "effect-fc"
|
||||
|
||||
|
||||
const email = Schema.pattern<typeof Schema.String>(
|
||||
@@ -41,7 +41,7 @@ const RegisterFormSubmitSchema = Schema.Struct({
|
||||
|
||||
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
|
||||
scoped: Effect.gen(function*() {
|
||||
const form = yield* Form.service({
|
||||
const form = yield* SubmittableForm.service({
|
||||
schema: RegisterFormSchema.pipe(
|
||||
Schema.compose(
|
||||
Schema.transformOrFail(
|
||||
@@ -64,17 +64,17 @@ class RegisterFormService extends Effect.Service<RegisterFormService>()("Registe
|
||||
|
||||
return {
|
||||
form,
|
||||
emailField: Form.focusObjectField(form, "email"),
|
||||
passwordField: Form.focusObjectField(form, "password"),
|
||||
birthField: Form.focusObjectField(form, "birth"),
|
||||
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 [canSubmit, submitResult] = yield* Subscribable.useSubscribables([
|
||||
form.form.canSubmit,
|
||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
||||
form.form.canCommit,
|
||||
form.form.mutation.result,
|
||||
])
|
||||
|
||||
@@ -111,7 +111,7 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
||||
defaultValue=""
|
||||
/>
|
||||
|
||||
<Button disabled={!canSubmit}>Submit</Button>
|
||||
<Button disabled={!canCommit}>Submit</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 } from "effect"
|
||||
import { Component, ErrorObserver, Mutation, Query, Result, Subscribable, SubscriptionRef } from "effect-fc"
|
||||
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"
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ const Post = Schema.Struct({
|
||||
const ResultView = Component.make("ResultView")(function*() {
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
const idRef = yield* SubscriptionRef.make(1)
|
||||
const key = Stream.map(idRef.changes, id => [id] as const)
|
||||
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,
|
||||
@@ -40,11 +40,11 @@ const ResultView = Component.make("ResultView")(function*() {
|
||||
),
|
||||
})
|
||||
|
||||
return [idRef, query, mutation] as const
|
||||
return [idLens, query, mutation] as const
|
||||
}))
|
||||
|
||||
const [id, setId] = yield* SubscriptionRef.useSubscriptionRefState(idRef)
|
||||
const [queryResult, mutationResult] = yield* Subscribable.useSubscribables([query.result, mutation.result])
|
||||
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),
|
||||
@@ -105,7 +105,7 @@ const ResultView = Component.make("ResultView")(function*() {
|
||||
</div>
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(Effect.andThen(idRef, id => mutation.mutate([id])))}>Mutate</Button>
|
||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
@@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
|
||||
Effect.tap(Effect.sleep("250 millis")),
|
||||
Result.forkEffect,
|
||||
))
|
||||
const [result] = yield* Subscribable.useSubscribables([resultSubscribable])
|
||||
const [result] = yield* Subscribable.useAll([resultSubscribable])
|
||||
|
||||
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
|
||||
Effect.andThen(observer => observer.subscribe),
|
||||
|
||||
88
packages/example/src/todo/EditTodoView.tsx
Normal file
88
packages/example/src/todo/EditTodoView.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}) {}
|
||||
78
packages/example/src/todo/NewTodoView.tsx
Normal file
78
packages/example/src/todo/NewTodoView.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}) {}
|
||||
9
packages/example/src/todo/TodoFormSchema.ts
Normal file
9
packages/example/src/todo/TodoFormSchema.ts
Normal file
@@ -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)
|
||||
@@ -1,138 +0,0 @@
|
||||
import * as Domain from "@/domain"
|
||||
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
|
||||
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
|
||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
|
||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||
import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect"
|
||||
import { Component, Form, Lens, Subscribable } from "effect-fc"
|
||||
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
||||
import { FaDeleteLeft } from "react-icons/fa6"
|
||||
import { TodosState } from "./TodosState"
|
||||
|
||||
|
||||
const TodoFormSchema = Schema.compose(Schema.Struct({
|
||||
...Domain.Todo.Todo.fields,
|
||||
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
||||
}), Domain.Todo.Todo)
|
||||
|
||||
const makeTodo = makeUuid4.pipe(
|
||||
Effect.map(id => Domain.Todo.Todo.make({
|
||||
id,
|
||||
content: "",
|
||||
completedAt: Option.none(),
|
||||
})),
|
||||
Effect.provide(GetRandomValues.CryptoRandom),
|
||||
)
|
||||
|
||||
|
||||
export type TodoProps = (
|
||||
| { readonly _tag: "new" }
|
||||
| { readonly _tag: "edit", readonly id: string }
|
||||
)
|
||||
|
||||
export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) {
|
||||
const state = yield* TodosState
|
||||
|
||||
const [
|
||||
indexRef,
|
||||
form,
|
||||
contentField,
|
||||
completedAtField,
|
||||
] = yield* Component.useOnChange(() => Effect.gen(function*() {
|
||||
const indexRef = Match.value(props).pipe(
|
||||
Match.tag("new", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.make(-1) })),
|
||||
Match.tag("edit", ({ id }) => state.getIndexSubscribable(id)),
|
||||
Match.exhaustive,
|
||||
)
|
||||
|
||||
const form = yield* Form.service({
|
||||
schema: TodoFormSchema,
|
||||
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(
|
||||
yield* Match.value(props).pipe(
|
||||
Match.tag("new", () => makeTodo),
|
||||
Match.tag("edit", ({ id }) => state.getElementRef(id)),
|
||||
Match.exhaustive,
|
||||
)
|
||||
),
|
||||
f: ([todo, form]) => Match.value(props).pipe(
|
||||
Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe(
|
||||
Effect.andThen(makeTodo),
|
||||
Effect.andThen(Schema.encode(TodoFormSchema)),
|
||||
Effect.andThen(v => Lens.set(form.encodedValue, v)),
|
||||
)),
|
||||
Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)),
|
||||
Match.exhaustive,
|
||||
),
|
||||
autosubmit: props._tag === "edit",
|
||||
})
|
||||
|
||||
return [
|
||||
indexRef,
|
||||
form,
|
||||
Form.focusObjectField(form, "content"),
|
||||
Form.focusObjectField(form, "completedAt"),
|
||||
] as const
|
||||
}), [props._tag, props._tag === "edit" ? props.id : undefined])
|
||||
|
||||
const [index, size, canSubmit] = yield* Subscribable.useSubscribables([
|
||||
indexRef,
|
||||
state.sizeSubscribable,
|
||||
form.canSubmit,
|
||||
])
|
||||
|
||||
const runSync = yield* Component.useRunSync()
|
||||
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=""
|
||||
/>
|
||||
|
||||
{props._tag === "new" &&
|
||||
<Button disabled={!canSubmit} onClick={() => void runPromise(form.submit)}>
|
||||
Add
|
||||
</Button>
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{props._tag === "edit" &&
|
||||
<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,7 +1,7 @@
|
||||
import { KeyValueStore } from "@effect/platform"
|
||||
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
||||
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
|
||||
import { Subscribable, SubscriptionSubRef } from "effect-fc"
|
||||
import { Lens, Subscribable } from "effect-fc"
|
||||
import { Todo } from "@/domain"
|
||||
|
||||
|
||||
@@ -30,27 +30,29 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||
: kv.remove(key)
|
||||
)
|
||||
|
||||
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
|
||||
yield* Effect.forkScoped(ref.changes.pipe(
|
||||
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(() => ref.pipe(
|
||||
yield* Effect.addFinalizer(() => Lens.get(lens).pipe(
|
||||
Effect.andThen(saveToLocalStorage),
|
||||
Effect.ignore,
|
||||
))
|
||||
|
||||
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 sizeSubscribable = Subscribable.map(lens, Chunk.size)
|
||||
|
||||
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||
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)),
|
||||
@@ -62,7 +64,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||
: todos
|
||||
),
|
||||
))
|
||||
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
|
||||
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)),
|
||||
@@ -74,15 +76,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||
: todos
|
||||
),
|
||||
))
|
||||
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
|
||||
const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen(
|
||||
Chunk.findFirstIndex(todos, v => v.id === id),
|
||||
index => Chunk.remove(todos, index),
|
||||
))
|
||||
|
||||
return {
|
||||
ref,
|
||||
lens,
|
||||
sizeSubscribable,
|
||||
getElementRef,
|
||||
getElementLens,
|
||||
getIndexSubscribable,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
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"
|
||||
import { TodoView } from "./TodoView"
|
||||
|
||||
|
||||
export class TodosView extends Component.make("TodosView")(function*() {
|
||||
const state = yield* TodosState
|
||||
const [todos] = yield* Subscribable.useSubscribables([state.ref])
|
||||
const [todos] = yield* Subscribable.useAll([state.lens])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.andThen(
|
||||
Console.log("Todos mounted"),
|
||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||
))
|
||||
|
||||
const Todo = yield* TodoView.use
|
||||
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">
|
||||
<Todo _tag="new" />
|
||||
<NewTodo />
|
||||
|
||||
{Chunk.map(todos, todo =>
|
||||
<Todo key={todo.id} _tag="edit" id={todo.id} />
|
||||
<EditTodo key={todo.id} id={todo.id} />
|
||||
)}
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
Reference in New Issue
Block a user