From 620c2f4fac8ff66efb833028ef34283da65b0c32 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Thu, 13 Jun 2024 11:45:41 -0400 Subject: [PATCH 1/9] continue working on tutorials --- ...e-to-start.mdx => 100-getting-started.mdx} | 13 +- .../200-dealing-with-errors.mdx | 173 ++++++++++++++++++ .../tutorials/incremental-adoption/index.mdx | 8 +- next.config.js | 13 ++ .../[...slug]/components/Navigation.tsx} | 4 +- .../[...slug]/components/Navigation/Menu.tsx} | 4 +- .../components/Navigation/MenuButton.tsx} | 0 .../components/Navigation/MenuGroup.tsx} | 2 +- .../[...slug]/components/SolveButton.tsx} | 15 +- .../[...slug]/components/Toolbar.tsx | 38 ++++ .../[...slug]/components/ToolbarItems.tsx | 19 ++ .../components/ToolbarItemsDynamic.tsx | 10 + .../components/{tutorial.tsx => Tutorial.tsx} | 78 ++++---- .../[...slug]/{page.disabled.tsx => page.tsx} | 12 +- src/app/tutorials/layout.tsx | 4 +- src/components/editor/CodeEditor.tsx | 6 +- .../components/{common => }/file-editor.tsx | 2 +- .../components/{common => }/file-explorer.tsx | 0 .../file-explorer/directory-node.tsx | 0 .../{common => }/file-explorer/file-input.tsx | 0 .../{common => }/file-explorer/file-node.tsx | 0 .../{common => }/file-explorer/file-tree.tsx | 0 .../components/{common => }/terminal.css | 0 .../components/{common => }/terminal.tsx | 0 .../editor/components/tutorial/toolbar.tsx | 20 -- src/components/editor/rx.ts | 10 +- .../incremental-adoption/100/repo.ts | 7 +- .../incremental-adoption/200/main.initial.txt | 23 +++ .../incremental-adoption/200/main.ts | 31 ++++ .../incremental-adoption/200/repo.ts | 66 +++++++ .../incremental-adoption/300/main.ts | 42 +++++ .../incremental-adoption/300/repo.ts | 59 ++++++ 32 files changed, 553 insertions(+), 106 deletions(-) rename content/tutorials/incremental-adoption/{100-where-to-start.mdx => 100-getting-started.mdx} (79%) create mode 100644 content/tutorials/incremental-adoption/200-dealing-with-errors.mdx rename src/{components/editor/components/tutorial/navigation.tsx => app/tutorials/[...slug]/components/Navigation.tsx} (94%) rename src/{components/editor/components/tutorial/navigation/menu.tsx => app/tutorials/[...slug]/components/Navigation/Menu.tsx} (95%) rename src/{components/editor/components/tutorial/navigation/menu-button.tsx => app/tutorials/[...slug]/components/Navigation/MenuButton.tsx} (100%) rename src/{components/editor/components/tutorial/navigation/menu-group.tsx => app/tutorials/[...slug]/components/Navigation/MenuGroup.tsx} (100%) rename src/{components/editor/components/tutorial/solve-button.tsx => app/tutorials/[...slug]/components/SolveButton.tsx} (64%) create mode 100644 src/app/tutorials/[...slug]/components/Toolbar.tsx create mode 100644 src/app/tutorials/[...slug]/components/ToolbarItems.tsx create mode 100644 src/app/tutorials/[...slug]/components/ToolbarItemsDynamic.tsx rename src/app/tutorials/[...slug]/components/{tutorial.tsx => Tutorial.tsx} (51%) rename src/app/tutorials/[...slug]/{page.disabled.tsx => page.tsx} (91%) rename src/components/editor/components/{common => }/file-editor.tsx (96%) rename src/components/editor/components/{common => }/file-explorer.tsx (100%) rename src/components/editor/components/{common => }/file-explorer/directory-node.tsx (100%) rename src/components/editor/components/{common => }/file-explorer/file-input.tsx (100%) rename src/components/editor/components/{common => }/file-explorer/file-node.tsx (100%) rename src/components/editor/components/{common => }/file-explorer/file-tree.tsx (100%) rename src/components/editor/components/{common => }/terminal.css (100%) rename src/components/editor/components/{common => }/terminal.tsx (100%) delete mode 100644 src/components/editor/components/tutorial/toolbar.tsx create mode 100644 src/tutorials/incremental-adoption/200/main.initial.txt create mode 100644 src/tutorials/incremental-adoption/200/main.ts create mode 100644 src/tutorials/incremental-adoption/200/repo.ts create mode 100644 src/tutorials/incremental-adoption/300/main.ts create mode 100644 src/tutorials/incremental-adoption/300/repo.ts diff --git a/content/tutorials/incremental-adoption/100-where-to-start.mdx b/content/tutorials/incremental-adoption/100-getting-started.mdx similarity index 79% rename from content/tutorials/incremental-adoption/100-where-to-start.mdx rename to content/tutorials/incremental-adoption/100-getting-started.mdx index d35476b10..60b1bbe1f 100644 --- a/content/tutorials/incremental-adoption/100-where-to-start.mdx +++ b/content/tutorials/incremental-adoption/100-getting-started.mdx @@ -1,11 +1,11 @@ --- -title: Where to Start +title: Getting Started excerpt: Learn how to incrementally adopt Effect into your application section: Incremental Adoption workspace: express --- -### Where to Start +### Getting Started When adopting Effect into an existing codebase, it's often best to start with discrete, self-contained pieces of code. You can then use features of Effect to easily interop with your existing code and gradually continue to adopt Effect at your own pace. @@ -42,6 +42,11 @@ async function main() { ### Exercise -The team in charge of the `TodoRepository` has re-factored the `create` method from promises to Effect. The method now returns an `Effect` instead of a `Promise`. +The team in charge of the `TodoRepository` has re-factored the `create` method. The method now returns an `Effect` instead of a `Promise`. -Your job is to fix the `POST /todos` route of our Express API. In the editor to the right, modify the code to restore functionality to our server. +Using what we have learned above, your tasks for this exercise include: + + - Fix all type errors within the `POST /todos` route of our Express API + - Ensure that the Express API responds with the created `Todo` as JSON + +In the editor to the right, modify the code to restore functionality to our server. diff --git a/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx b/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx new file mode 100644 index 000000000..c22582a67 --- /dev/null +++ b/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx @@ -0,0 +1,173 @@ +--- +title: Dealing with Errors +excerpt: Learn how to incrementally adopt Effect into your application +section: Incremental Adoption +workspace: express +--- + +### Dealing with Errors + +As you continue to adopt Effect into your codebase, you may encounter situations where you need to propagate error information from Effect code to non-Effect code. + +For example, perhaps your business requirements dictate that you must log all errors to a third-party log aggregator. + +In this section, we will explore several strategies for allowing your existing application to access both expected and unexpected errors coming from Effect code. + + +For more information about managing errors within your Effect programs, checkout the section on Error Management in the documentation. + + +### Example Business Case + +Let's imagine that we are working on refactoring a part of our application to use Effect. The part of our application we are refactoring involves communicating with one of our company's REST APIs to retrieve the price of a particular product we sell. If requests to our company's API are unsuccessful, the program will fail with an `HttpError`. + +Our business requirements dictate the following: +- Our application's `ErrorReporter` must be used to report all errors occurring within our application to an external error tracking service +- Our application's `LogAggregator` must be used to log the results of successful API calls to an external log aggregation service + +### Working with Expected Errors + +Effect provides a variety of tools to make working with expected errors in your program as simple as possible. These tools can also be used to provide the surrounding application with error information coming from your Effect code. + +In the first example below, we utilize `Effect.catchTag` to "catch" the `HttpError` in our Effect program. We then use `Effect.promise` to allow our application's `ErrorReporter` to report on the error. + +```ts twoslash +import { Data, Effect } from "effect" + +// === Effect Code === +class HttpError extends Data.TaggedError("HttpError") {} + +const program = Effect.gen(function*() { + // Simulate the possibility of error + if (Math.random() > 0.5) { + return yield* new HttpError() + } + // Simulate a response from our API + return yield* Effect.succeed(42) +}) + +// === Application Code === +interface LogAggregator { + log(value: any): Promise +} +interface ErrorReporter { + report(error: any): Promise +} +declare const logger: LogAggregator +declare const reporter: ErrorReporter + +async function main() { + await program.pipe( + Effect.andThen((result) => logger.log(result)), + Effect.catchTag("HttpError", (error) => + Effect.promise(() => reporter.report(error)) + ), + Effect.runPromise + ) +} +``` + +In the next example below, we wrap our _entire program_ in a call to `Effect.either`. This operator will convert an `Effect` into an `Effect, never, R>`, thereby exposing expected errors in the success channel of your program. + +We can then use methods from the `Either` module to handle error and success cases separately. + +```ts twoslash +import { Data, Effect, Either } from "effect" + +// === Effect Code === +class HttpError extends Data.TaggedError("HttpError") {} + +const program = Effect.gen(function*() { + // Simulate the possibility of error + if (Math.random() > 0.5) { + return yield* new HttpError() + } + return yield* Effect.succeed(42) +}) + +// === Application Code === +interface LogAggregator { + log(value: any): Promise +} +interface ErrorReporter { + report(error: any): Promise +} +declare const logger: LogAggregator +declare const reporter: ErrorReporter + +async function main() { + await Effect.runPromise(Effect.either(program)).then( + Either.match({ + // Handle the error case + onLeft: (error) => reporter.report(error), + // Handle the success case + onRight: (value) => logger.log(value) + }) + ) +} +``` + +### Expected and Unexpected Errors + +The simplest method of gaining access to both expected _and_ unexpected errors returned from an Effect program is to simply run your program with `Effect.runPromise` and then catch any thrown errors. + +```ts twoslash +import { Data, Effect } from "effect" + +// === Effect Code === +class HttpError extends Data.TaggedError("HttpError") {} + +const program = Effect.gen(function*() { + // Simulate the possibility of error + if (Math.random() > 0.5) { + return yield* new HttpError() + } + return yield* Effect.succeed(42) +}) + +// === Application Code === +interface LogAggregator { + log(value: any): Promise +} +interface ErrorReporter { + report(error: any): Promise +} +declare const logger: LogAggregator +declare const reporter: ErrorReporter + +async function main() { + await Effect.runPromise(program) + .then((value) => logger.log(value)) + .catch((error) => reporter.report(error)) +} +``` + +The benefit of this approach is that errors returned by `Effect.runPromise` will be automatically wrapped by Effect's special `FiberFailure` error. The `FiberFailure` error type will prettify the `Cause` of the failure for you (see the Cause documentation for more information), making it easier to log the error and any associated stack trace. + +However, there are several downsides to this approach: + +- There is not an "easy" way to introspect the `Cause` of the failure for additional information +- If there are multiple errors in the underlying `Cause`, only the first error will be rendered by the `FiberFailure` + +### Introspecting the Cause + +Introspecting the full `Cause` of failure of our Effect programs can be extremely useful when we need granular information about the failure(s) that occurred. + +For example, perhaps we need to respond to an `Interrupt` cause in a different manner than a `Fail` cause. + +If we want to perform some operations on the full `Cause` of failure of our program, we have a variety options available to us: + +- We could utilize sandboxing to expose the full `Cause` of failure in our program's error channel +- We could run our program to an Exit using `Effect.runPromiseExit` and then match on the success and error cases + +### Exercise + +The team in charge of the `TodoRepository` has continued to refactor the `create` method. The method now returns an `Effect`. + +Using what we have learned above, your tasks for this exercise include: + + - If creation of a `Todo` results in a `CreateTodoError` + - Set the response status code to `500` + - Return a JSON response that conforms to the following `{ "type": "CreateTodoError", "text": }` + +In the editor to the right, modify the code to restore functionality to our server. Please note that there is no one "correct" answer, as there are multiple ways to achieve the desired outcome. diff --git a/content/tutorials/incremental-adoption/index.mdx b/content/tutorials/incremental-adoption/index.mdx index 17375e954..76296b71a 100644 --- a/content/tutorials/incremental-adoption/index.mdx +++ b/content/tutorials/incremental-adoption/index.mdx @@ -7,11 +7,13 @@ workspace: express ### Incrementally Adopting Effect -Effect was designed from the start with incremental adoption in mind. You can continue to use your existing code and incorporate Effect as much (or as little) as you prefer, where it makes the most sense within your application. In this way, you can immediately begin to benefit from Effect without needing to make drastic changes to your codebase. +Effect was designed from the start with incremental adoption in mind. You can continue to use your existing code and incorporate Effect as much (or as little) as you prefer, and only where it makes the most sense within your application. + +In this way, you can immediately begin to benefit from Effect without needing to make drastic changes to your codebase. ### What We'll Be Doing -In this tutorial we will walk through strategies for incrementally adopting Effect into an existing application. Our goal will be to taking a REST API built with [Express](https://expressjs.com) and gradually refactor portions of the application to utilize Effect. +In this tutorial we will walk through strategies for incrementally adopting Effect into an existing application. Our goal will be to gradually refactor the code within an existing REST API built with [Express](https://expressjs.com) to use Effect. In the editor to the right, you will see the following files in the `src` directory: @@ -24,4 +26,4 @@ To the right is an editor and a console window. As you make changes to the code Each section will present an exercise designed to illustrate a feature. Later exercises build on the knowledge gained in earlier ones, so it's recommended that you go from start to finish. If necessary, you can navigate via the menu above. -If you want to view the recommended solution to an exercise, click the "Solve" button. +If you want to view the recommended solution to an exercise, click the "Show Solution" button in the upper-right corner of the page. diff --git a/next.config.js b/next.config.js index ec6db0ec5..89f31ae59 100644 --- a/next.config.js +++ b/next.config.js @@ -72,6 +72,19 @@ const nextConfig = { value: "same-origin" } ] + }, + { + source: "/tutorials/(.*)", + headers: [ + { + key: "Cross-Origin-Embedder-Policy", + value: "require-corp" + }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin" + } + ] } ] } diff --git a/src/components/editor/components/tutorial/navigation.tsx b/src/app/tutorials/[...slug]/components/Navigation.tsx similarity index 94% rename from src/components/editor/components/tutorial/navigation.tsx rename to src/app/tutorials/[...slug]/components/Navigation.tsx index b5af1a024..f19bfcb90 100644 --- a/src/components/editor/components/tutorial/navigation.tsx +++ b/src/app/tutorials/[...slug]/components/Navigation.tsx @@ -4,8 +4,8 @@ import React from "react" import Link from "next/link" import { Icon } from "@/components/icons" import { Tutorial } from "contentlayer/generated" -import { Menu } from "./navigation/menu" -import { MenuButton } from "./navigation/menu-button" +import { Menu } from "./Navigation/Menu" +import { MenuButton } from "./Navigation/MenuButton" import { cn } from "@/lib/utils" import { groupedTutorials, tutorialSection } from "@/workspaces/domain/tutorial" diff --git a/src/components/editor/components/tutorial/navigation/menu.tsx b/src/app/tutorials/[...slug]/components/Navigation/Menu.tsx similarity index 95% rename from src/components/editor/components/tutorial/navigation/menu.tsx rename to src/app/tutorials/[...slug]/components/Navigation/Menu.tsx index a54d7e98b..d09d0e0ac 100644 --- a/src/components/editor/components/tutorial/navigation/menu.tsx +++ b/src/app/tutorials/[...slug]/components/Navigation/Menu.tsx @@ -1,10 +1,10 @@ "use client" import React from "react" +import { Tutorial } from "contentlayer/generated" import { groupedTutorials } from "@/workspaces/domain/tutorial" import { Accordion } from "@/components/ui/accordion" -import { Tutorial } from "contentlayer/generated" -import { MenuGroup } from "./menu-group" +import { MenuGroup } from "./MenuGroup" export declare namespace Menu { export interface Props { diff --git a/src/components/editor/components/tutorial/navigation/menu-button.tsx b/src/app/tutorials/[...slug]/components/Navigation/MenuButton.tsx similarity index 100% rename from src/components/editor/components/tutorial/navigation/menu-button.tsx rename to src/app/tutorials/[...slug]/components/Navigation/MenuButton.tsx diff --git a/src/components/editor/components/tutorial/navigation/menu-group.tsx b/src/app/tutorials/[...slug]/components/Navigation/MenuGroup.tsx similarity index 100% rename from src/components/editor/components/tutorial/navigation/menu-group.tsx rename to src/app/tutorials/[...slug]/components/Navigation/MenuGroup.tsx index 8c95efc38..f4cd41cc7 100644 --- a/src/components/editor/components/tutorial/navigation/menu-group.tsx +++ b/src/app/tutorials/[...slug]/components/Navigation/MenuGroup.tsx @@ -2,13 +2,13 @@ import React from "react" import Link from "next/link" +import { Tutorial } from "contentlayer/generated" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { cn } from "@/lib/utils" -import { Tutorial } from "contentlayer/generated" import { TutorialGroup } from "@/workspaces/domain/tutorial" export function MenuGroup({ diff --git a/src/components/editor/components/tutorial/solve-button.tsx b/src/app/tutorials/[...slug]/components/SolveButton.tsx similarity index 64% rename from src/components/editor/components/tutorial/solve-button.tsx rename to src/app/tutorials/[...slug]/components/SolveButton.tsx index e9617bc0d..5d9c1c048 100644 --- a/src/components/editor/components/tutorial/solve-button.tsx +++ b/src/app/tutorials/[...slug]/components/SolveButton.tsx @@ -1,14 +1,11 @@ import React from "react" import { useRx } from "@effect-rx/rx-react" -import { useWorkspaceHandle } from "@/workspaces/context" import { Button } from "@/components/ui/button" +import { useWorkspaceHandle } from "@/workspaces/context" -export declare namespace SolveButton { - export interface Props {} -} - -export const SolveButton: React.FC = () => { - const [solved, setSolved] = useRx(useWorkspaceHandle().solved) +export function SolveButton() { + const handle = useWorkspaceHandle() + const [solved, setSolved] = useRx(handle.solved) return ( ) } - -SolveButton.displayName = "SolveButton" diff --git a/src/app/tutorials/[...slug]/components/Toolbar.tsx b/src/app/tutorials/[...slug]/components/Toolbar.tsx new file mode 100644 index 000000000..33e638537 --- /dev/null +++ b/src/app/tutorials/[...slug]/components/Toolbar.tsx @@ -0,0 +1,38 @@ +import Link from "next/link" +import { Logo } from "@/components/atoms/logo" +import { LogoDark } from "@/components/atoms/logo-dark" +import { NavigationMenu } from "@/components/layout/navigation" + +export function Toolbar({ items }: { readonly items: React.ReactNode }) { + return ( +
+ + +
+ +
{items}
+
+ ) +} + +function ToolbarHeader() { + return ( +
+ + + + + +

+ Tutorials + + Alpha + +

+ + +
+ ) +} diff --git a/src/app/tutorials/[...slug]/components/ToolbarItems.tsx b/src/app/tutorials/[...slug]/components/ToolbarItems.tsx new file mode 100644 index 000000000..ca1305058 --- /dev/null +++ b/src/app/tutorials/[...slug]/components/ToolbarItems.tsx @@ -0,0 +1,19 @@ +import { ThemeSwitcher } from "@/components/atoms/theme-switcher" +import { SolveButton } from "./SolveButton" +import { Workspace } from "@/workspaces/domain/workspace" +import { WorkspaceProvider } from "@/workspaces/WorkspaceProvider" + +export function ToolbarItems({ + workspace +}: { + readonly workspace: Workspace +}) { + return ( + +
+ + +
+
+ ) +} diff --git a/src/app/tutorials/[...slug]/components/ToolbarItemsDynamic.tsx b/src/app/tutorials/[...slug]/components/ToolbarItemsDynamic.tsx new file mode 100644 index 000000000..793ab0f5a --- /dev/null +++ b/src/app/tutorials/[...slug]/components/ToolbarItemsDynamic.tsx @@ -0,0 +1,10 @@ +"use client" + +import dynamic from "next/dynamic" + +export const ToolbarItemsDynamic = dynamic( + async () => { + return (await import("./ToolbarItems")).ToolbarItems + }, + { ssr: false } +) diff --git a/src/app/tutorials/[...slug]/components/tutorial.tsx b/src/app/tutorials/[...slug]/components/Tutorial.tsx similarity index 51% rename from src/app/tutorials/[...slug]/components/tutorial.tsx rename to src/app/tutorials/[...slug]/components/Tutorial.tsx index c926f30df..7f60f5279 100644 --- a/src/app/tutorials/[...slug]/components/tutorial.tsx +++ b/src/app/tutorials/[...slug]/components/Tutorial.tsx @@ -3,11 +3,13 @@ import React, { useMemo } from "react" import dynamic from "next/dynamic" import Link from "next/link" -import { File, makeDirectory, Workspace } from "@/workspaces/domain/workspace" import { Panel, PanelGroup } from "react-resizable-panels" +import { Tutorial as ITutorial } from "contentlayer/generated" import { PanelResizeHandleVertical } from "@/components/ui/resizable" import { tutorialWorkspaces } from "@/tutorials/common" -import { Tutorial as ITutorial } from "contentlayer/generated" +import { File, makeDirectory, Workspace } from "@/workspaces/domain/workspace" +import { Toolbar } from "./Toolbar" +import { ToolbarItemsDynamic } from "./ToolbarItemsDynamic" export const Tutorial = ({ name, @@ -33,51 +35,51 @@ export const Tutorial = ({ } | undefined }) => { - const Editor = useMemo( + const tutorial = useMemo( () => - editor( - tutorialWorkspaces[workspace].withName(name).append( - makeDirectory( - "src", - files.map( - (file) => - new File({ - name: file.name, - initialContent: file.initial, - solution: file.solution - }) - ) + tutorialWorkspaces[workspace].withName(name).append( + makeDirectory( + "src", + files.map( + (file) => + new File({ + name: file.name, + initialContent: file.initial, + solution: file.solution + }) ) ) ), [files, name, workspace] ) + const Editor = useMemo(() => editor(tutorial), [tutorial]) + return ( - - + } /> + - {navigation} -
- {children} - {next && ( -

- Next: {next.title} -

- )} -
-
- - - - -
+ + {navigation} +
+ {children} + {next && ( +

+ Next: {next.title} +

+ )} +
+
+ + + + + + ) } diff --git a/src/app/tutorials/[...slug]/page.disabled.tsx b/src/app/tutorials/[...slug]/page.tsx similarity index 91% rename from src/app/tutorials/[...slug]/page.disabled.tsx rename to src/app/tutorials/[...slug]/page.tsx index e5c2f64a6..7df1948f3 100644 --- a/src/app/tutorials/[...slug]/page.disabled.tsx +++ b/src/app/tutorials/[...slug]/page.tsx @@ -1,11 +1,11 @@ -import { MDX } from "@/components/atoms/mdx" -import { Navigation } from "@/components/editor/components/tutorial/navigation" -import { groupedTutorials, tutorialSection } from "@/workspaces/domain/tutorial" import { allTutorials } from "contentlayer/generated" -import * as FS from "fs/promises" import { notFound } from "next/navigation" -import * as Path from "path" -import { Tutorial } from "./components/tutorial" +import * as FS from "node:fs/promises" +import * as Path from "node:path" +import { MDX } from "@/components/atoms/mdx" +import { groupedTutorials, tutorialSection } from "@/workspaces/domain/tutorial" +import { Navigation } from "./components/Navigation" +import { Tutorial } from "./components/Tutorial" export const generateStaticParams = () => allTutorials.map((page) => ({ diff --git a/src/app/tutorials/layout.tsx b/src/app/tutorials/layout.tsx index c088643b5..ad9aef5cc 100644 --- a/src/app/tutorials/layout.tsx +++ b/src/app/tutorials/layout.tsx @@ -1,12 +1,10 @@ -import { Navigation } from "@/components/layout/navigation" -import { Toaster } from "@/components/ui/toaster" import { ReactNode } from "react" +import { Toaster } from "@/components/ui/toaster" export default function RootLayout({ children }: { children: ReactNode }) { return ( <>
- {children}
diff --git a/src/components/editor/CodeEditor.tsx b/src/components/editor/CodeEditor.tsx index 4e65fc88b..f8c67063e 100644 --- a/src/components/editor/CodeEditor.tsx +++ b/src/components/editor/CodeEditor.tsx @@ -6,9 +6,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner" import { Workspace } from "@/workspaces/domain/workspace" import { useWorkspaceHandle, useWorkspaceShells } from "@/workspaces/context" import { WorkspaceProvider } from "@/workspaces/WorkspaceProvider" -import { FileEditor } from "./components/common/file-editor" -import { FileExplorer } from "./components/common/file-explorer" -import { Terminal } from "./components/common/terminal" +import { FileEditor } from "./components/file-editor" +import { FileExplorer } from "./components/file-explorer" +import { Terminal } from "./components/terminal" import { PanelResizeHandleVertical } from "../ui/resizable" import { TooltipProvider } from "../ui/tooltip" diff --git a/src/components/editor/components/common/file-editor.tsx b/src/components/editor/components/file-editor.tsx similarity index 96% rename from src/components/editor/components/common/file-editor.tsx rename to src/components/editor/components/file-editor.tsx index b75bd123c..36f9e8747 100644 --- a/src/components/editor/components/common/file-editor.tsx +++ b/src/components/editor/components/file-editor.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils" import { useRxSet, useRxValue } from "@effect-rx/rx-react" import * as Option from "effect/Option" import { useWorkspaceHandle } from "@/workspaces/context" -import { editorRx } from "../../rx" +import { editorRx } from "../rx" export function FileEditor() { const handle = useWorkspaceHandle() diff --git a/src/components/editor/components/common/file-explorer.tsx b/src/components/editor/components/file-explorer.tsx similarity index 100% rename from src/components/editor/components/common/file-explorer.tsx rename to src/components/editor/components/file-explorer.tsx diff --git a/src/components/editor/components/common/file-explorer/directory-node.tsx b/src/components/editor/components/file-explorer/directory-node.tsx similarity index 100% rename from src/components/editor/components/common/file-explorer/directory-node.tsx rename to src/components/editor/components/file-explorer/directory-node.tsx diff --git a/src/components/editor/components/common/file-explorer/file-input.tsx b/src/components/editor/components/file-explorer/file-input.tsx similarity index 100% rename from src/components/editor/components/common/file-explorer/file-input.tsx rename to src/components/editor/components/file-explorer/file-input.tsx diff --git a/src/components/editor/components/common/file-explorer/file-node.tsx b/src/components/editor/components/file-explorer/file-node.tsx similarity index 100% rename from src/components/editor/components/common/file-explorer/file-node.tsx rename to src/components/editor/components/file-explorer/file-node.tsx diff --git a/src/components/editor/components/common/file-explorer/file-tree.tsx b/src/components/editor/components/file-explorer/file-tree.tsx similarity index 100% rename from src/components/editor/components/common/file-explorer/file-tree.tsx rename to src/components/editor/components/file-explorer/file-tree.tsx diff --git a/src/components/editor/components/common/terminal.css b/src/components/editor/components/terminal.css similarity index 100% rename from src/components/editor/components/common/terminal.css rename to src/components/editor/components/terminal.css diff --git a/src/components/editor/components/common/terminal.tsx b/src/components/editor/components/terminal.tsx similarity index 100% rename from src/components/editor/components/common/terminal.tsx rename to src/components/editor/components/terminal.tsx diff --git a/src/components/editor/components/tutorial/toolbar.tsx b/src/components/editor/components/tutorial/toolbar.tsx deleted file mode 100644 index be0a113e3..000000000 --- a/src/components/editor/components/tutorial/toolbar.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from "react" -import { SolveButton } from "./solve-button" - -export declare namespace Toolbar { - export interface Props {} -} - -export const Toolbar: React.FC = () => { - return ( -
-
-
- -
-
-
- ) -} - -Toolbar.displayName = "Toolbar" \ No newline at end of file diff --git a/src/components/editor/rx.ts b/src/components/editor/rx.ts index 77a5b9417..0343f132f 100644 --- a/src/components/editor/rx.ts +++ b/src/components/editor/rx.ts @@ -1,13 +1,5 @@ import { Rx } from "@effect-rx/rx-react" -import { - Console, - Effect, - Option, - Layer, - Schedule, - Stream, - pipe -} from "effect" +import { Effect, Option, Layer, Schedule, Stream, pipe } from "effect" import { themeRx } from "@/rx/theme" import { File, FullPath } from "@/workspaces/domain/workspace" import { RxWorkspaceHandle } from "@/workspaces/rx" diff --git a/src/tutorials/incremental-adoption/100/repo.ts b/src/tutorials/incremental-adoption/100/repo.ts index ad723a28a..9d706c810 100644 --- a/src/tutorials/incremental-adoption/100/repo.ts +++ b/src/tutorials/incremental-adoption/100/repo.ts @@ -25,14 +25,13 @@ export class TodoRepository { } create(text: string): Effect.Effect { - const self = this - return Effect.gen(function*() { - const todos = yield* Effect.promise(() => self.getAll()) + return Effect.gen(this, function*() { + const todos = yield* Effect.promise(() => this.getAll()) const maxId = todos.reduce((max, todo) => todo.id > max ? todo.id : max, 0) const newTodo = { id: maxId + 1, text, completed: false } - self.todos.push(newTodo) + this.todos.push(newTodo) return newTodo }) } diff --git a/src/tutorials/incremental-adoption/200/main.initial.txt b/src/tutorials/incremental-adoption/200/main.initial.txt new file mode 100644 index 000000000..6f6a101d0 --- /dev/null +++ b/src/tutorials/incremental-adoption/200/main.initial.txt @@ -0,0 +1,23 @@ +import Express from "express" +import { Effect } from "effect" +import { TodoRepository } from "./repo" + +const app = Express() + +app.use(Express.json() as Express.NextFunction) + +const repo = new TodoRepository() + +// Create Todo +app.post("/todos", (req, res) => { + repo.create(req.body.text).pipe( + /* Your Code Here */ + Effect.runPromise + ) +}) + +/* snip */ + +app.listen(3000, () => { + console.log("Server listing on port 3000...") +}) diff --git a/src/tutorials/incremental-adoption/200/main.ts b/src/tutorials/incremental-adoption/200/main.ts new file mode 100644 index 000000000..556a56196 --- /dev/null +++ b/src/tutorials/incremental-adoption/200/main.ts @@ -0,0 +1,31 @@ +import Express from "express" +import { Effect } from "effect" +import { TodoRepository } from "./repo" + +const app = Express() + +app.use(Express.json() as Express.NextFunction) + +const repo = new TodoRepository() + +// Create Todo +app.post("/todos", (req, res) => { + repo.create(req.body.text).pipe( + Effect.andThen((todo) => res.json(todo)), + Effect.catchTag("CreateTodoError", (error) => + Effect.sync(() => { + res.status(500).json({ + type: error._tag, + text: error.text + }) + }) + ), + Effect.runPromise + ) +}) + +/* snip */ + +app.listen(3000, () => { + console.log("Server listing on port 3000...") +}) diff --git a/src/tutorials/incremental-adoption/200/repo.ts b/src/tutorials/incremental-adoption/200/repo.ts new file mode 100644 index 000000000..2b22ecfcc --- /dev/null +++ b/src/tutorials/incremental-adoption/200/repo.ts @@ -0,0 +1,66 @@ +import { Data, Effect, Random } from "effect" + +export interface Todo { + readonly id: number + readonly text: string + readonly completed: boolean +} + +export class CreateTodoError extends Data.TaggedError("CreateTodoError")<{ + readonly text: string +}> {} + +export class TodoRepository { + readonly todos: Array = [ + { id: 1, text: "Finish homework", completed: false }, + { id: 2, text: "Buy groceries", completed: false }, + { id: 3, text: "Write report", completed: false }, + { id: 4, text: "Clean house", completed: false }, + { id: 5, text: "Pay bills", completed: false } + ] + + get(id: number): Promise { + const todo = this.todos.find((todo) => todo.id === id) + return Promise.resolve(todo) + } + + getAll(): Promise> { + return Promise.resolve(this.todos) + } + + create(text: string): Effect.Effect { + return Effect.gen(this, function*() { + if ((yield* Random.next) > 0.5) { + return yield* new CreateTodoError({ text }) + } + const todos = yield* Effect.promise(() => this.getAll()) + const maxId = todos.reduce((max, todo) => + todo.id > max ? todo.id : max, + 0) + const newTodo = { id: maxId + 1, text, completed: false } + this.todos.push(newTodo) + return newTodo + }) + } + + async update( + id: number, + props: Partial> + ): Promise { + const todo = await this.get(id) + if (todo) { + Object.assign(todo, props) + return Promise.resolve(todo) + } + return Promise.resolve(undefined) + } + + delete(id: number): Promise { + const index = this.todos.findIndex((todo) => todo.id === id) + if (index !== -1) { + this.todos.splice(index, 1) + return Promise.resolve(true) + } + return Promise.resolve(false) + } +} diff --git a/src/tutorials/incremental-adoption/300/main.ts b/src/tutorials/incremental-adoption/300/main.ts new file mode 100644 index 000000000..9d94fb1ef --- /dev/null +++ b/src/tutorials/incremental-adoption/300/main.ts @@ -0,0 +1,42 @@ +import Express from "express" +import { Effect } from "effect" +import { TodoRepository } from "./repo" + +const app = Express() + +app.use(Express.json() as Express.NextFunction) + +const repo = new TodoRepository() + + +// Create Todo +app.post("/todos", (req, res) => { + repo.create(req.body.text).then((todo) => res.json(todo)) +}) + +// Read Todo +app.get("/todos/:id", (req, res) => { + const id = Number.parseInt(req.params.id) + repo.get(id).then((todo) => res.json(todo)) +}) + +// Read Todos +app.get("/todos", (_, res) => { + repo.getAll().then((todos) => res.json(todos)) +}) + +// Update Todo +app.patch("/todos/:id", (req, res) => { + const id = Number.parseInt(req.params.id) + repo.update(id, req.body).then((todo) => res.json(todo)) +}) + +// Delete Todo +app.delete("/todos/:id", (req, res) => { + const id = Number.parseInt(req.params.id) + repo.delete(id).then((deleted) => res.json(deleted)) +}) + +app.listen(3000, () => { + console.log("Server listing on port 3000...") +}) diff --git a/src/tutorials/incremental-adoption/300/repo.ts b/src/tutorials/incremental-adoption/300/repo.ts new file mode 100644 index 000000000..9d706c810 --- /dev/null +++ b/src/tutorials/incremental-adoption/300/repo.ts @@ -0,0 +1,59 @@ +import { Effect } from "effect" + +export interface Todo { + readonly id: number + readonly text: string + readonly completed: boolean +} + +export class TodoRepository { + readonly todos: Array = [ + { id: 1, text: "Finish homework", completed: false }, + { id: 2, text: "Buy groceries", completed: false }, + { id: 3, text: "Write report", completed: false }, + { id: 4, text: "Clean house", completed: false }, + { id: 5, text: "Pay bills", completed: false } + ] + + get(id: number): Promise { + const todo = this.todos.find((todo) => todo.id === id) + return Promise.resolve(todo) + } + + getAll(): Promise> { + return Promise.resolve(this.todos) + } + + create(text: string): Effect.Effect { + return Effect.gen(this, function*() { + const todos = yield* Effect.promise(() => this.getAll()) + const maxId = todos.reduce((max, todo) => + todo.id > max ? todo.id : max, + 0) + const newTodo = { id: maxId + 1, text, completed: false } + this.todos.push(newTodo) + return newTodo + }) + } + + async update( + id: number, + props: Partial> + ): Promise { + const todo = await this.get(id) + if (todo) { + Object.assign(todo, props) + return Promise.resolve(todo) + } + return Promise.resolve(undefined) + } + + delete(id: number): Promise { + const index = this.todos.findIndex((todo) => todo.id === id) + if (index !== -1) { + this.todos.splice(index, 1) + return Promise.resolve(true) + } + return Promise.resolve(false) + } +} From 0e735333465792845f365817f3dbe3335c7bea7f Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Thu, 13 Jun 2024 15:12:49 -0400 Subject: [PATCH 2/9] add section on managing requirements to incremental adoption tutorial --- .../200-dealing-with-errors.mdx | 2 +- .../300-handling-requirements.mdx | 108 ++++++++++++++++ src/tutorials/common.ts | 3 +- src/tutorials/incremental-adoption/0/main.ts | 2 +- .../incremental-adoption/200/main.ts | 4 +- .../incremental-adoption/300/main.initial.txt | 40 ++++++ .../incremental-adoption/300/main.ts | 54 ++++++-- .../incremental-adoption/300/repo.ts | 121 +++++++++++------- 8 files changed, 276 insertions(+), 58 deletions(-) create mode 100644 content/tutorials/incremental-adoption/300-handling-requirements.mdx create mode 100644 src/tutorials/incremental-adoption/300/main.initial.txt diff --git a/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx b/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx index c22582a67..5a043561c 100644 --- a/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx +++ b/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx @@ -167,7 +167,7 @@ The team in charge of the `TodoRepository` has continued to refactor the `create Using what we have learned above, your tasks for this exercise include: - If creation of a `Todo` results in a `CreateTodoError` - - Set the response status code to `500` + - Set the response status code to `404` - Return a JSON response that conforms to the following `{ "type": "CreateTodoError", "text": }` In the editor to the right, modify the code to restore functionality to our server. Please note that there is no one "correct" answer, as there are multiple ways to achieve the desired outcome. diff --git a/content/tutorials/incremental-adoption/300-handling-requirements.mdx b/content/tutorials/incremental-adoption/300-handling-requirements.mdx new file mode 100644 index 000000000..c472162b4 --- /dev/null +++ b/content/tutorials/incremental-adoption/300-handling-requirements.mdx @@ -0,0 +1,108 @@ +--- +title: Handling Requirements +excerpt: Learn how to incrementally adopt Effect into your application +section: Incremental Adoption +workspace: express +--- + +### Handling Requirements + +Utilizing `Effect.runPromise` to interop with your existing application is fine when you are just getting started with adopting Effect. However, it will quickly become apparent that this approach does not scale, especially once you start using Effect to manage the requirements of your application and `Layer`s to compose the dependency graph between services. + + +For a detailed walkthrough of how to manage requirements within your Effect applications, take a look at the Requirements Management section of the documentation. + + +### Understanding the Problem + +To understand the problem, let's take a look at a simple example where we create a `Layer` which logs `"Hello, World"` when constructed. The layer is then provided to two Effect programs which are executed at two separate execution boundaries. + +```ts twoslash +import { Console, Effect, Layer } from "effect" + +const HelloWorldLive = Layer.effectDiscard( + Console.log("Hello, World!") +) + +async function main() { + // Execution Boundary #1 + await Effect.succeed(1).pipe( + Effect.provide(HelloWorldLive), + Effect.runPromise + ) + + // Execution Boundary #2 + await Effect.succeed(2).pipe( + Effect.provide(HelloWorldLive), + Effect.runPromise + ) +} + +main() +/** + * Output: + * Hello, World! + * Hello, World! + */ +``` + +As you can see from the output, the message `"Hello, World!"` is logged twice. This is because each call to `Effect.provide` will fully construct the dependency graph specified by the `Layer` and then provide it to the Effect program. This can create problems when your layers are meant to encapsulate logic that is only meant to be executed **once** (for example, creating a database connection pool) or when layer construction is **expensive** (for example, fetching a large number of remote assets and caching them in memory). + +To solve this problem, we need some sort of top-level, re-usable Effect `Runtime` which contains our fully constructed dependency graph, and then use that `Runtime` to execute our Effect programs instead of the default `Runtime` used by the `Effect.run*` methods. + +### Managed Runtimes + +The `ManagedRuntime` data type in Effect allows us to create a top-level, re-usable Effect `Runtime` which encapsulates a fully constructed dependency graph. In addition, `ManagedRuntime` gives us explicit control over when resources acquired by the runtime should be disposed. + +```ts twoslash +import { Console, Effect, Layer, ManagedRuntime } from "effect" + +const HelloWorldLive = Layer.effectDiscard( + Console.log("Hello, World!") +) + +// Create a managed runtime from our layer +const runtime = ManagedRuntime.make(HelloWorldLive) + +async function main() { + // Execution Boundary #1 + await Effect.succeed(1).pipe( + runtime.runPromise + ) + + // Execution Boundary #2 + await Effect.succeed(2).pipe( + runtime.runPromise + ) + + // Dispose of resources when no longer needed + await runtime.dispose() +} + +main() +/** + * Output: + * Hello, World! + */ +``` + +Some things to note about the program above include: + + - `"Hello, World!"` is only logged to the console once + - We no longer have to provide `HelloWorldLive` to each Effect program + - Resources acquired by the `ManagedRuntime` must be manually disposed of + +### Exercise + +The team in charge of the `TodoRepository` has been hard at work and has managed to convert the `TodoRepository` into a completely Effect-based service complete with a `Layer` for service construction. + +Using what we have learned above, your tasks for this exercise include: + + - Create a `ManagedRuntime` which takes in the `TodoRepository` layer + - Use the `ManagedRuntime` to run the Effect programs within the Express route handlers + - For any Effect program which may result in a `TodoNotFoundError`: + - Set the response status code to `404` + - Return a JSON response that conforms to the following `{ "type": "TodoNotFound", "id": }` + - **BONUS**: properly dispose of the `ManagedRuntime` when the server shuts down + +In the editor to the right, modify the code to restore functionality to our server. Please note that there is no one "correct" answer, as there are multiple ways to achieve the desired outcome. diff --git a/src/tutorials/common.ts b/src/tutorials/common.ts index f2bd99a01..3543877b3 100644 --- a/src/tutorials/common.ts +++ b/src/tutorials/common.ts @@ -23,7 +23,8 @@ export const tutorialWorkspaces: ReadonlyRecord< name: "express", dependencies: { ...packageJson.dependencies, - express: "latest" + express: "latest", + "@types/express": "latest" }, shells: [ new WorkspaceShell({ label: "Server", command: "../run src/main.ts" }), diff --git a/src/tutorials/incremental-adoption/0/main.ts b/src/tutorials/incremental-adoption/0/main.ts index bb0c9cc93..f84534c2a 100644 --- a/src/tutorials/incremental-adoption/0/main.ts +++ b/src/tutorials/incremental-adoption/0/main.ts @@ -32,7 +32,7 @@ app.patch("/todos/:id", (req, res) => { // Delete Todo app.delete("/todos/:id", (req, res) => { const id = Number.parseInt(req.params.id) - repo.delete(id).then((deleted) => res.json(deleted)) + repo.delete(id).then((deleted) => res.json({ deleted })) }) app.listen(3000, () => { diff --git a/src/tutorials/incremental-adoption/200/main.ts b/src/tutorials/incremental-adoption/200/main.ts index 556a56196..65ff23480 100644 --- a/src/tutorials/incremental-adoption/200/main.ts +++ b/src/tutorials/incremental-adoption/200/main.ts @@ -14,9 +14,9 @@ app.post("/todos", (req, res) => { Effect.andThen((todo) => res.json(todo)), Effect.catchTag("CreateTodoError", (error) => Effect.sync(() => { - res.status(500).json({ + res.status(404).json({ type: error._tag, - text: error.text + text: error.text ?? "" }) }) ), diff --git a/src/tutorials/incremental-adoption/300/main.initial.txt b/src/tutorials/incremental-adoption/300/main.initial.txt new file mode 100644 index 000000000..f84534c2a --- /dev/null +++ b/src/tutorials/incremental-adoption/300/main.initial.txt @@ -0,0 +1,40 @@ +import Express from "express" +import { TodoRepository } from "./repo" + +const app = Express() + +app.use(Express.json() as Express.NextFunction) + +const repo = new TodoRepository() + +// Create Todo +app.post("/todos", (req, res) => { + repo.create(req.body.text).then((todo) => res.json(todo)) +}) + +// Read Todo +app.get("/todos/:id", (req, res) => { + const id = Number.parseInt(req.params.id) + repo.get(id).then((todo) => res.json(todo)) +}) + +// Read Todos +app.get("/todos", (_, res) => { + repo.getAll().then((todos) => res.json(todos)) +}) + +// Update Todo +app.patch("/todos/:id", (req, res) => { + const id = Number.parseInt(req.params.id) + repo.update(id, req.body).then((todo) => res.json(todo)) +}) + +// Delete Todo +app.delete("/todos/:id", (req, res) => { + const id = Number.parseInt(req.params.id) + repo.delete(id).then((deleted) => res.json({ deleted })) +}) + +app.listen(3000, () => { + console.log("Server listing on port 3000...") +}) diff --git a/src/tutorials/incremental-adoption/300/main.ts b/src/tutorials/incremental-adoption/300/main.ts index 9d94fb1ef..623411825 100644 --- a/src/tutorials/incremental-adoption/300/main.ts +++ b/src/tutorials/incremental-adoption/300/main.ts @@ -1,42 +1,78 @@ import Express from "express" -import { Effect } from "effect" +import { Effect, ManagedRuntime } from "effect" import { TodoRepository } from "./repo" const app = Express() app.use(Express.json() as Express.NextFunction) -const repo = new TodoRepository() - +const runtime = ManagedRuntime.make(TodoRepository.Live) // Create Todo app.post("/todos", (req, res) => { - repo.create(req.body.text).then((todo) => res.json(todo)) + TodoRepository.create(req.body.text).pipe( + Effect.andThen((todo) => res.json(todo)), + runtime.runPromise + ) }) // Read Todo app.get("/todos/:id", (req, res) => { const id = Number.parseInt(req.params.id) - repo.get(id).then((todo) => res.json(todo)) + TodoRepository.get(id).pipe( + Effect.andThen((todo) => res.json(todo)), + Effect.catchTag("TodoNotFoundError", () => + Effect.sync(() => { + res.status(404).json({ type: "TodoNotFound", id }) + }) + ), + runtime.runPromise + ) }) // Read Todos app.get("/todos", (_, res) => { - repo.getAll().then((todos) => res.json(todos)) + TodoRepository.getAll.pipe( + Effect.andThen((todos) => res.json(todos)), + runtime.runPromise + ) }) // Update Todo app.patch("/todos/:id", (req, res) => { const id = Number.parseInt(req.params.id) - repo.update(id, req.body).then((todo) => res.json(todo)) + TodoRepository.update(id, req.body).pipe( + Effect.andThen((todo) => res.json(todo)), + Effect.catchTag("TodoNotFoundError", () => + Effect.sync(() => { + res.status(404).json({ type: "TodoNotFound", id }) + }) + ), + runtime.runPromise + ) }) // Delete Todo app.delete("/todos/:id", (req, res) => { const id = Number.parseInt(req.params.id) - repo.delete(id).then((deleted) => res.json(deleted)) + TodoRepository.delete(id).pipe( + Effect.andThen((deleted) => res.json({ deleted })), + runtime.runPromise + ) }) -app.listen(3000, () => { +const server = app.listen(3000, () => { console.log("Server listing on port 3000...") }) + +// Graceful Shutdown +process.on("SIGTERM", shutdown) +process.on("SIGINT", shutdown) + +function shutdown() { + server.close(() => { + runtime.dispose().then(() => { + process.exit(0) + }) + }) +} \ No newline at end of file diff --git a/src/tutorials/incremental-adoption/300/repo.ts b/src/tutorials/incremental-adoption/300/repo.ts index 9d706c810..01d4ef4cb 100644 --- a/src/tutorials/incremental-adoption/300/repo.ts +++ b/src/tutorials/incremental-adoption/300/repo.ts @@ -1,59 +1,92 @@ -import { Effect } from "effect" +import { Array, Data, Effect, Layer, Option, Ref } from "effect" -export interface Todo { +export class Todo extends Data.TaggedClass("Todo")<{ readonly id: number readonly text: string readonly completed: boolean -} +}> {} -export class TodoRepository { - readonly todos: Array = [ - { id: 1, text: "Finish homework", completed: false }, - { id: 2, text: "Buy groceries", completed: false }, - { id: 3, text: "Write report", completed: false }, - { id: 4, text: "Clean house", completed: false }, - { id: 5, text: "Pay bills", completed: false } - ] - - get(id: number): Promise { - const todo = this.todos.find((todo) => todo.id === id) - return Promise.resolve(todo) - } +export class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{ + readonly id: number +}> {} + +const initialTodos: ReadonlyArray = [ + new Todo({ id: 1, text: "Finish homework", completed: false }), + new Todo({ id: 2, text: "Buy groceries", completed: false }), + new Todo({ id: 3, text: "Write report", completed: false }), + new Todo({ id: 4, text: "Clean house", completed: false }), + new Todo({ id: 5, text: "Pay bills", completed: false }) +] + +export const make = Effect.gen(function* () { + const todosRef = yield* Ref.make(initialTodos) - getAll(): Promise> { - return Promise.resolve(this.todos) + const getAllTodos = Ref.get(todosRef) + + function getTodo(id: number) { + return Ref.get(todosRef).pipe( + Effect.andThen(Array.findFirst((todo) => todo.id === id)), + Effect.catchTag( + "NoSuchElementException", + () => new TodoNotFoundError({ id }) + ) + ) } - create(text: string): Effect.Effect { - return Effect.gen(this, function*() { - const todos = yield* Effect.promise(() => this.getAll()) - const maxId = todos.reduce((max, todo) => - todo.id > max ? todo.id : max, - 0) - const newTodo = { id: maxId + 1, text, completed: false } - this.todos.push(newTodo) - return newTodo + function createTodo(text: string) { + return Ref.modify(todosRef, (todos) => { + const maxTodoId = Array.reduce(todos, 0, (max, todo) => + todo.id > max ? todo.id : max + ) + const newTodo = new Todo({ id: maxTodoId + 1, text, completed: false }) + return [newTodo, Array.append(todos, newTodo)] as const }) } - async update( - id: number, - props: Partial> - ): Promise { - const todo = await this.get(id) - if (todo) { - Object.assign(todo, props) - return Promise.resolve(todo) - } - return Promise.resolve(undefined) + function updateTodo(id: number, props: Partial>) { + return Ref.modify(todosRef, (todos) => { + const updated: Array = [] + let newTodo: Todo | undefined = undefined + for (const todo of todos) { + if (todo.id === id) { + newTodo = new Todo({ ...todo, ...props }) + updated.push(newTodo) + } else { + updated.push(todo) + } + } + return [Option.fromNullable(newTodo), updated] as const + }).pipe( + Effect.flatten, + Effect.catchTag( + "NoSuchElementException", + () => new TodoNotFoundError({ id }) + ) + ) } - delete(id: number): Promise { - const index = this.todos.findIndex((todo) => todo.id === id) - if (index !== -1) { - this.todos.splice(index, 1) - return Promise.resolve(true) - } - return Promise.resolve(false) + function deleteTodo(id: number) { + return Ref.modify(todosRef, (todos) => { + const index = Array.findFirstIndex(todos, (todo) => todo.id === id) + return Option.match(index, { + onNone: () => [false, todos] as const, + onSome: (index) => [true, Array.remove(todos, index)] as const + }) + }) } + + return { + get: getTodo, + getAll: getAllTodos, + create: createTodo, + update: updateTodo, + delete: deleteTodo + } as const +}) + +export class TodoRepository extends Effect.Tag("app/TodoRepository")< + TodoRepository, + Effect.Effect.Success +>() { + static Live = Layer.effect(this, make) } From b9a896840ba81354de306c8fd26fe98ff5bc8f5c Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Fri, 14 Jun 2024 11:34:30 -0400 Subject: [PATCH 3/9] add landing page for effect tutorials --- .../basics/100-your-first-effect.mdx | 2 - .../tutorials/basics/200-returning-values.mdx | 1 - .../basics/300-combining-effects.mdx | 1 - .../tutorials/basics/400-using-generators.mdx | 1 - content/tutorials/basics/index.mdx | 8 +- .../100-getting-started.mdx | 2 - .../200-dealing-with-errors.mdx | 2 - .../300-handling-requirements.mdx | 2 - .../tutorials/incremental-adoption/index.mdx | 8 +- package.json | 4 + pnpm-lock.yaml | 111 +++++++++++ .../[...slug]/components/Navigation.tsx | 1 + src/app/tutorials/[...slug]/page.tsx | 33 ++-- .../components/DifficultySelector.tsx | 68 +++++++ .../tutorials/components/TutorialsDisplay.tsx | 151 +++++++++++++++ src/app/tutorials/layout.tsx | 6 +- src/app/tutorials/page.tsx | 24 +++ src/components/icons/chevron-down.tsx | 4 +- src/components/ui/card.tsx | 83 +++++++++ src/components/ui/checkbox.tsx | 29 +++ src/components/ui/form.tsx | 176 ++++++++++++++++++ src/components/ui/select.tsx | 164 ++++++++++++++++ src/contentlayer/schema/tutorial.ts | 13 +- 23 files changed, 860 insertions(+), 34 deletions(-) create mode 100644 src/app/tutorials/components/DifficultySelector.tsx create mode 100644 src/app/tutorials/components/TutorialsDisplay.tsx create mode 100644 src/app/tutorials/page.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/select.tsx diff --git a/content/tutorials/basics/100-your-first-effect.mdx b/content/tutorials/basics/100-your-first-effect.mdx index ed85e9573..36f919207 100644 --- a/content/tutorials/basics/100-your-first-effect.mdx +++ b/content/tutorials/basics/100-your-first-effect.mdx @@ -1,7 +1,5 @@ --- title: Your first Effect -excerpt: Learn the basics of Effect -section: Learn the basics --- ### What is an Effect? diff --git a/content/tutorials/basics/200-returning-values.mdx b/content/tutorials/basics/200-returning-values.mdx index 902f106a5..cae054c4d 100644 --- a/content/tutorials/basics/200-returning-values.mdx +++ b/content/tutorials/basics/200-returning-values.mdx @@ -1,6 +1,5 @@ --- title: Returning values -excerpt: Learn the basics of Effect --- Congratulations on running your first Effect! diff --git a/content/tutorials/basics/300-combining-effects.mdx b/content/tutorials/basics/300-combining-effects.mdx index 201d8115c..c3983d72c 100644 --- a/content/tutorials/basics/300-combining-effects.mdx +++ b/content/tutorials/basics/300-combining-effects.mdx @@ -1,6 +1,5 @@ --- title: Combining Effects -excerpt: Learn the basics of Effect --- Now that you can create and run effects, let's take a look at how you can diff --git a/content/tutorials/basics/400-using-generators.mdx b/content/tutorials/basics/400-using-generators.mdx index 366a5ca57..ec2b5db17 100644 --- a/content/tutorials/basics/400-using-generators.mdx +++ b/content/tutorials/basics/400-using-generators.mdx @@ -1,6 +1,5 @@ --- title: Using generators -excerpt: Learn the basics of Effect --- To make using Effect more approachable, we can use generators to write our diff --git a/content/tutorials/basics/index.mdx b/content/tutorials/basics/index.mdx index ff83b1e1a..e72485d49 100644 --- a/content/tutorials/basics/index.mdx +++ b/content/tutorials/basics/index.mdx @@ -1,7 +1,11 @@ --- title: Welcome -excerpt: Learn the basics of Effect -section: Learn the basics +excerpt: Learn the basics of Effect by exploring fundamental concepts and core data types. +section: The Basics of Effect +difficulty: beginner +prerequisites: + - Familiarity with asynchronous programming in JavaScript + - Basic understanding of TypeScript syntax and features --- Welcome to the Effect tutorials! diff --git a/content/tutorials/incremental-adoption/100-getting-started.mdx b/content/tutorials/incremental-adoption/100-getting-started.mdx index 60b1bbe1f..c7916365a 100644 --- a/content/tutorials/incremental-adoption/100-getting-started.mdx +++ b/content/tutorials/incremental-adoption/100-getting-started.mdx @@ -1,7 +1,5 @@ --- title: Getting Started -excerpt: Learn how to incrementally adopt Effect into your application -section: Incremental Adoption workspace: express --- diff --git a/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx b/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx index 5a043561c..19c14da04 100644 --- a/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx +++ b/content/tutorials/incremental-adoption/200-dealing-with-errors.mdx @@ -1,7 +1,5 @@ --- title: Dealing with Errors -excerpt: Learn how to incrementally adopt Effect into your application -section: Incremental Adoption workspace: express --- diff --git a/content/tutorials/incremental-adoption/300-handling-requirements.mdx b/content/tutorials/incremental-adoption/300-handling-requirements.mdx index c472162b4..95042f4cd 100644 --- a/content/tutorials/incremental-adoption/300-handling-requirements.mdx +++ b/content/tutorials/incremental-adoption/300-handling-requirements.mdx @@ -1,7 +1,5 @@ --- title: Handling Requirements -excerpt: Learn how to incrementally adopt Effect into your application -section: Incremental Adoption workspace: express --- diff --git a/content/tutorials/incremental-adoption/index.mdx b/content/tutorials/incremental-adoption/index.mdx index 76296b71a..1a09544d7 100644 --- a/content/tutorials/incremental-adoption/index.mdx +++ b/content/tutorials/incremental-adoption/index.mdx @@ -1,8 +1,14 @@ --- title: Introduction -excerpt: Learn how to incrementally adopt Effect into your application +excerpt: Explore how Effect can be adopted incrementally into existing applications. section: Incremental Adoption +difficulty: intermediate workspace: express +prerequisites: + - Completion of The Basics of Effect + - Understanding of how effects are executed + - Ability to handle errors with Effect + - Comfort managing requirements with Effect --- ### Incrementally Adopting Effect diff --git a/package.json b/package.json index db9659946..92c02f5cc 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,16 @@ "@effect/rpc-http": "^0.28.44", "@effect/schema": "^0.67.22", "@headlessui/react": "1.7.17", + "@hookform/resolvers": "^3.6.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", @@ -55,6 +58,7 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.51.5", "react-instantsearch": "^7.4.1", "react-resizable-panels": "^2.0.19", "react-tweet": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e91a4c1d7..ece502133 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,18 @@ importers: '@headlessui/react': specifier: 1.7.17 version: 1.7.17(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@hookform/resolvers': + specifier: ^3.6.0 + version: 3.6.0(react-hook-form@7.51.5(react@18.0.0)) '@radix-ui/react-accordion': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) '@radix-ui/react-alert-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-checkbox': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) @@ -53,6 +59,9 @@ importers: '@radix-ui/react-radio-group': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-select': + specifier: ^2.0.0 + version: 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.3.2)(react@18.0.0) @@ -134,6 +143,9 @@ importers: react-dom: specifier: ^18 version: 18.0.0(react@18.0.0) + react-hook-form: + specifier: ^7.51.5 + version: 7.51.5(react@18.0.0) react-instantsearch: specifier: ^7.4.1 version: 7.4.1(algoliasearch@4.23.3)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) @@ -841,6 +853,11 @@ packages: react: ^16 || ^17 || ^18 react-dom: ^16 || ^17 || ^18 + '@hookform/resolvers@3.6.0': + resolution: {integrity: sha512-UBcpyOX3+RR+dNnqBd0lchXpoL8p4xC21XP8H6Meb8uve5Br1GCnmg0PcBoKKqPKgGu9GHQ/oygcmPrQhetwqw==} + peerDependencies: + react-hook-form: ^7.0.0 + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -1273,6 +1290,9 @@ packages: typescript: optional: true + '@radix-ui/number@1.0.1': + resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} + '@radix-ui/primitive@1.0.1': resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} @@ -1315,6 +1335,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.0.4': + resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collapsible@1.0.3': resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} peerDependencies: @@ -1555,6 +1588,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.0.0': + resolution: {integrity: sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.0.2': resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -4504,6 +4550,12 @@ packages: peerDependencies: react: ^18.0.0 + react-hook-form@7.51.5: + resolution: {integrity: sha512-J2ILT5gWx1XUIJRETiA7M19iXHlG74+6O3KApzvqB/w8S5NQR7AbU8HVZrMALdmDgWpRPYiZJl0zx8Z4L2mP6Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + react-instantsearch-core@7.4.1: resolution: {integrity: sha512-Nk47IJaCrIecoAfH1vdLStvdOflN+O+TiHqUoLU2NMlQ6cK4OoCQTHG5Vyc1DaiO4uctBkvjcCQhhzZOdk0tRg==} peerDependencies: @@ -5996,6 +6048,10 @@ snapshots: react: 18.0.0 react-dom: 18.0.0(react@18.0.0) + '@hookform/resolvers@3.6.0(react-hook-form@7.51.5(react@18.0.0))': + dependencies: + react-hook-form: 7.51.5(react@18.0.0) + '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -6435,6 +6491,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@radix-ui/number@1.0.1': + dependencies: + '@babel/runtime': 7.24.6 + '@radix-ui/primitive@1.0.1': dependencies: '@babel/runtime': 7.24.6 @@ -6482,6 +6542,23 @@ snapshots: '@types/react': 18.3.2 '@types/react-dom': 18.3.0 + '@radix-ui/react-checkbox@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)': + dependencies: + '@babel/runtime': 7.24.6 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.2)(react@18.0.0) + react: 18.0.0 + react-dom: 18.0.0(react@18.0.0) + optionalDependencies: + '@types/react': 18.3.2 + '@types/react-dom': 18.3.0 + '@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)': dependencies: '@babel/runtime': 7.24.6 @@ -6761,6 +6838,36 @@ snapshots: '@types/react': 18.3.2 '@types/react-dom': 18.3.0 + '@radix-ui/react-select@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0)': + dependencies: + '@babel/runtime': 7.24.6 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.2)(react@18.0.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.0.0(react@18.0.0))(react@18.0.0) + aria-hidden: 1.2.4 + react: 18.0.0 + react-dom: 18.0.0(react@18.0.0) + react-remove-scroll: 2.5.5(@types/react@18.3.2)(react@18.0.0) + optionalDependencies: + '@types/react': 18.3.2 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slot@1.0.2(@types/react@18.3.2)(react@18.0.0)': dependencies: '@babel/runtime': 7.24.6 @@ -10371,6 +10478,10 @@ snapshots: react: 18.0.0 scheduler: 0.21.0 + react-hook-form@7.51.5(react@18.0.0): + dependencies: + react: 18.0.0 + react-instantsearch-core@7.4.1(algoliasearch@4.23.3)(react@18.0.0): dependencies: '@babel/runtime': 7.24.6 diff --git a/src/app/tutorials/[...slug]/components/Navigation.tsx b/src/app/tutorials/[...slug]/components/Navigation.tsx index f19bfcb90..fb099c49a 100644 --- a/src/app/tutorials/[...slug]/components/Navigation.tsx +++ b/src/app/tutorials/[...slug]/components/Navigation.tsx @@ -16,6 +16,7 @@ export declare namespace Navigation { } export const Navigation: React.FC = async ({ tutorial }) => { + console.log(groupedTutorials) const group = groupedTutorials[tutorialSection(tutorial)] const index = group.children.indexOf(tutorial) const previous = group.children[index - 1] diff --git a/src/app/tutorials/[...slug]/page.tsx b/src/app/tutorials/[...slug]/page.tsx index 7df1948f3..086a14d6b 100644 --- a/src/app/tutorials/[...slug]/page.tsx +++ b/src/app/tutorials/[...slug]/page.tsx @@ -3,7 +3,10 @@ import { notFound } from "next/navigation" import * as FS from "node:fs/promises" import * as Path from "node:path" import { MDX } from "@/components/atoms/mdx" -import { groupedTutorials, tutorialSection } from "@/workspaces/domain/tutorial" +import { + groupedTutorials, + tutorialSection +} from "@/workspaces/domain/tutorial" import { Navigation } from "./components/Navigation" import { Tutorial } from "./components/Tutorial" @@ -70,19 +73,21 @@ export default async function Page({ ) return ( - } - next={ - next && { - title: next.title, - url: next.urlPath +
+ } + next={ + next && { + title: next.title, + url: next.urlPath + } } - } - > - - + > + + +
) } diff --git a/src/app/tutorials/components/DifficultySelector.tsx b/src/app/tutorials/components/DifficultySelector.tsx new file mode 100644 index 000000000..1872784ea --- /dev/null +++ b/src/app/tutorials/components/DifficultySelector.tsx @@ -0,0 +1,68 @@ +"use client" + +import { useCallback, useState } from "react" +import type { Tutorial } from "contentlayer/generated" +import type { CheckedState } from "@radix-ui/react-checkbox" +import { + AccordionContent, + AccordionItem, + AccordionTrigger +} from "@/components/ui/accordion" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" + +export type Difficulty = Tutorial["difficulty"] + +const levels: ReadonlyArray<{ + readonly id: Difficulty + readonly label: string +}> = [ + { id: "beginner", label: "Beginner" }, + { id: "intermediate", label: "Intermediate" }, + { id: "advanced", label: "Advanced" } +] + +export function DifficultySelector({ + onSelect, + onUnselect +}: { + readonly onSelect?: (choice: Difficulty) => void + readonly onUnselect?: (choice: Difficulty) => void +}) { + const handleCheckedChange = useCallback( + (checked: CheckedState, difficulty: Difficulty) => { + if (checked) { + onSelect?.(difficulty) + } else { + onUnselect?.(difficulty) + } + }, + [onSelect, onUnselect] + ) + + return ( + + + Difficulty + + +
+ {levels.map((choice) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/tutorials/components/TutorialsDisplay.tsx b/src/app/tutorials/components/TutorialsDisplay.tsx new file mode 100644 index 000000000..787faf564 --- /dev/null +++ b/src/app/tutorials/components/TutorialsDisplay.tsx @@ -0,0 +1,151 @@ +"use client" + +import { useReducer } from "react" +import { Data } from "effect" +import type { Tutorial } from "contentlayer/generated" +import { Accordion } from "@/components/ui/accordion" +import { + Card, + CardHeader, + CardDescription, + CardContent, + CardFooter, + CardTitle +} from "@/components/ui/card" +import { Icon } from "@/components/icons" +import { groupedTutorials, TutorialGroup } from "@/workspaces/domain/tutorial" +import { DifficultySelector } from "./DifficultySelector" + +export type Difficulty = Tutorial["difficulty"] + +interface State { + readonly difficulties: ReadonlyArray +} + +type Action = Data.TaggedEnum<{ + readonly SelectDifficulty: { readonly difficulty: Difficulty } + readonly UnselectDifficulty: { readonly difficulty: Difficulty } +}> +const Action = Data.taggedEnum() + +const initialState: State = { + difficulties: [] +} + +function reducer(state: State, action: Action): State { + switch (action._tag) { + case "SelectDifficulty": + return { + ...state, + difficulties: [...state.difficulties, action.difficulty] + } + case "UnselectDifficulty": + return { + ...state, + difficulties: state.difficulties.filter( + (value) => value !== action.difficulty + ) + } + } +} + +function applyDifficultyFilter( + tutorials: ReadonlyArray, + difficulties: ReadonlyArray +): ReadonlyArray { + if (difficulties.length === 0) { + return tutorials + } + return tutorials.filter(({ index }) => + difficulties.includes(index.difficulty) + ) +} + +export function TutorialsDisplay() { + const [state, dispatch] = useReducer(reducer, initialState) + + const groups = Object.values(groupedTutorials) + const byDifficulty = applyDifficultyFilter(groups, state.difficulties) + const tutorials = byDifficulty.map(({ index }) => index) + + return ( +
+
+ + + dispatch(Action.SelectDifficulty({ difficulty })) + } + onUnselect={(difficulty) => + dispatch(Action.UnselectDifficulty({ difficulty })) + } + /> + +
+ + +
+ ) +} diff --git a/src/app/tutorials/layout.tsx b/src/app/tutorials/layout.tsx index ad9aef5cc..bef44e50f 100644 --- a/src/app/tutorials/layout.tsx +++ b/src/app/tutorials/layout.tsx @@ -1,12 +1,12 @@ import { ReactNode } from "react" import { Toaster } from "@/components/ui/toaster" +import { Navigation } from "@/components/layout/navigation" export default function RootLayout({ children }: { children: ReactNode }) { return ( <> -
- {children} -
+ + {children} ) diff --git a/src/app/tutorials/page.tsx b/src/app/tutorials/page.tsx new file mode 100644 index 000000000..7a34335d2 --- /dev/null +++ b/src/app/tutorials/page.tsx @@ -0,0 +1,24 @@ +"use server" + +import { TutorialsDisplay } from "./components/TutorialsDisplay" + +export default async function TutorialsPage() { + return ( +
+
+
+
+

+ Explore Our Tutorials +

+

+ Find the perfect learning path to level-up your understanding of + Effect! Browse our curated selection of educational materials. +

+
+
+
+ +
+ ) +} diff --git a/src/components/icons/chevron-down.tsx b/src/components/icons/chevron-down.tsx index fb71b2495..50971b64f 100644 --- a/src/components/icons/chevron-down.tsx +++ b/src/components/icons/chevron-down.tsx @@ -17,8 +17,8 @@ export const ChevronDownIcon: React.FC = ({ ) diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 000000000..af6a1a8dd --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,83 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..56338aafb --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,29 @@ +"use client" + +import React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Icon } from "@/components/icons" +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 000000000..281e56ffe --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import React from "react" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext +} from "react-hook-form" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { Label } from "@/components/ui/label" +import { cn } from "@/lib/utils" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +