From 5d32c7befbfb019f0bcb5c49473f625b396b0f41 Mon Sep 17 00:00:00 2001 From: Joost Date: Thu, 21 Dec 2023 15:01:05 +0000 Subject: [PATCH 1/3] Copy v4 of routing with UseElmish --- docs/recipes/ui/routing-with-elmish.md | 282 +++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 283 insertions(+) create mode 100644 docs/recipes/ui/routing-with-elmish.md diff --git a/docs/recipes/ui/routing-with-elmish.md b/docs/recipes/ui/routing-with-elmish.md new file mode 100644 index 00000000..41c1d352 --- /dev/null +++ b/docs/recipes/ui/routing-with-elmish.md @@ -0,0 +1,282 @@ +# How do I create multi-page applications with routing and the useElmish hook? + +*Written for SAFE template version 4.2.0* + +[UseElmish](https://zaid-ajaj.github.io/Feliz/#/Hooks/UseElmish) is a powerful package that allows you to write standalone components using Elmish. A component built around the `UseElmish` hook has its own view, state and update function. + +In this recipe we add routing to a safe app, and implement the todo list page using the `UseElmish` hook. + +## 1. Installing dependencies + +!!! warning "Pin Fable.Core to V3" + At the time of writing, the published version of the SAFE template does not have the version of `Fable.Core` pinned; this can create problems when installing dependencies. + + If you are using version v.4.2.0 of the template, pin `Fable.Core` to version 3 in `paket.depedencies` at the root of the project + + ```.diff title="paket.dependencies" + ... + -nuget Fable.Core + +nuget Fable.Core ~> 3 + ... + ``` + + +Install Feliz.Router in the Client project + +```bash +dotnet paket add Feliz.Router -p Client -V 3.8 +``` + +!!! Warning "Feliz.Router versions" + At the time of writing, the current version of the SAFE template (4.2.0) does not work well with the latest version of Feliz.Router (4.0). + To work around this, we install Feliz.Router 3.8, the latest version that works with SAFE template version 4.2.0. + + If you are working with a newer version of the SAFE template, it might be worth trying to install the newest version of Feliz.Router. + To see the installed version of the SAFE template, run in the command line: + + ```bash + dotnet new --list + ``` + +Install Feliz.UseElmish in the Client project + +```bash +dotnet paket add Feliz.UseElmish -p client +``` + +Open the router in the client project + +```fsharp title="Index.fs" +open Feliz.Router +``` + +## 2. Extracting the todo list module + +Create a new Module `TodoList` in the client project. Move the following functions and types to the TodoList Module: + +* Model +* Msg +* todosApi +* init +* update +* containerBox + +Also open `Shared`, `Fable.Remoting.Client`, `Elmish`, `Feliz.Bulma` and `Feliz`. + +```fsharp title="TodoList.fs" +module TodoList + +open Shared +open Fable.Remoting.Client +open Elmish + +open Feliz.Bulma +open Feliz + +type Model = { Todos: Todo list; Input: string } + +type Msg = + | GotTodos of Todo list + | SetInput of string + | AddTodo + | AddedTodo of Todo + +let todosApi = + Remoting.createApi () + |> Remoting.withRouteBuilder Route.builder + |> Remoting.buildProxy + +let init () : Model * Cmd = + let model = { Todos = []; Input = "" } + let cmd = Cmd.OfAsync.perform todosApi.getTodos () GotTodos + + model, cmd + +let update (msg: Msg) (model: Model) : Model * Cmd = + match msg with + | GotTodos todos -> { model with Todos = todos }, Cmd.none + | SetInput value -> { model with Input = value }, Cmd.none + | AddTodo -> + let todo = Todo.create model.Input + + let cmd = Cmd.OfAsync.perform todosApi.addTodo todo AddedTodo + + { model with Input = "" }, cmd + | AddedTodo todo -> { model with Todos = model.Todos @ [ todo ] }, Cmd.none + +let containerBox (model: Model) (dispatch: Msg -> unit) = + Bulma.box [ + Bulma.content [ + Html.ol [ + for todo in model.Todos do + Html.li [ prop.text todo.Description ] + ] + ] + Bulma.field.div [ + field.isGrouped + prop.children [ + Bulma.control.p [ + control.isExpanded + prop.children [ + Bulma.input.text [ + prop.value model.Input + prop.placeholder "What needs to be done?" + prop.onChange (fun x -> SetInput x |> dispatch) + ] + ] + ] + Bulma.control.p [ + Bulma.button.a [ + color.isPrimary + prop.disabled (Todo.isValid model.Input |> not) + prop.onClick (fun _ -> dispatch AddTodo) + prop.text "Add" + ] + ] + ] + ] + ] +``` + +## 4. Add the UseElmish hook to the TodoList Module + +open Feliz.UseElmish in the TodoList Module + +```fsharp title="TodoList.fs" +open Feliz.UseElmish +... +``` + +In the todoList module, rename `containerBox` to `view`. +On the first line, call `React.useElmish` passing it the `init` and `update` functions. Bind the output to `model` and `dispatch` + +=== "Code" + ```fsharp title="TodoList.fs" + let view (model: Model) (dispatch: Msg -> unit) = + let model, dispatch = React.useElmish(init, update, [||]) + ... + ``` + +=== "Diff" + ```.diff title="TodoList.fs" + -let containerBox (model: Model) (dispatch: Msg -> unit) = + +let view (model: Model) (dispatch: Msg -> unit) = + + let model, dispatch = React.useElmish(init, update, [||]) + ... + ``` + +Replace the arguments of the function with unit, and add the `ReactComponent` attribute to it + +=== "Code" + ```fsharp title="Index.fs" + [] + let view () = + ... + ``` +=== "Diff" + ```.diff title="Index.fs" + + [] + - let view (model: Model) (dispatch: Msg -> unit) = + + let view () = + ... + ``` + +## 5. Add a new model to the Index module + +In the `Index module`, create a model that holds the current page + +```fsharp title="Index.fs" +type Page = + | TodoList + | NotFound + +type Model = + { CurrentPage: Page } +``` +## 6. Initializing the application + +Create a function that initializes the app based on an url + +```fsharp title="Index.fs" +let initFromUrl url = + match url with + | [ "todo" ] -> + let model = { CurrentPage = TodoList } + + model, Cmd.none + | _ -> + let model = { CurrentPage = NotFound } + + model, Cmd.none +``` + +Create a new `init` function, that fetches the current url, and calls initFromUrl. + +```fsharp title="Index.fs" +let init () = + Router.currentUrl () + |> initFromUrl +``` +## 7. Updating the Page + +Add a `Msg` type, with an PageChanged case + +```fsharp title="Index.fs" +type Msg = + | PageChanged of string list +``` +Add an `update` function, that reinitializes the app based on an URL + +```fsharp title="Index.fs" +let update (msg: Msg) (model: Model) : Model * Cmd = + match msg with + | PageChanged url -> + initFromUrl url +``` + +## 8. Displaying pages + +Add a containerBox function to the `Index` module, that returns the appropriate page content + +```fsharp title="Index.fs" +let containerBox (model: Model) (dispatch: Msg -> unit) = + match model.CurrentPage with + | NotFound -> Bulma.box "Page not found" + | TodoList -> TodoList.view () +``` +## 9. Add the router to the view + +Wrap the content of the view method in a `React.Router` element's router.children property, and add a `router.onUrlChanged` property to dispatch the urlChanged message + +=== "Code" + ```fsharp title="Index.fs" + let view (model: Model) (dispatch: Msg -> unit) = + React.router [ + router.onUrlChanged ( PageChanged>>dispatch ) + router.children [ + Bulma.hero [ + ... + ] + ] + ] + ``` +=== "Diff" + ```diff title="Index.fs" + let view (model: Model) (dispatch: Msg -> unit) = + + React.router [ + + router.onUrlChanged ( PageChanged>>dispatch ) + + router.children [ + Bulma.hero [ + ... + ] + + ] + + ] + ``` + +## 10. Try it out + +The routing should work now. Try navigating to [localhost:8080](http://localhost:8080/); you should see a page with "Page not Found". If you go to [localhost:8080/#/todo](http://localhost:8080/#/todo), you should see the todo app. + +!!! info "# sign" + You might be surprised to see the hash sign as part of the URL. It enables React to react to URL changes without a full page refresh. + There are ways to omit this, but getting this to work properly is outside of the scope of this recipe. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 606dbb73..7db20e32 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,6 +86,7 @@ nav: - Migrate from a CDN stylesheet to an NPM package: "recipes/ui/cdn-to-npm.md" - Add routing with state shared between pages: "recipes/ui/add-routing.md" - Add routing with separate models per page: "recipes/ui/add-routing-with-separate-models.md" + - Add Routing with UseElmish: "recipes/ui/routing-with-elmish.md" - Storage: - Quickly add a database: "recipes/storage/use-litedb.md" - JavaScript: From 8d019938925709ea65b229d31dddfa45f0c4de3e Mon Sep 17 00:00:00 2001 From: Joost Date: Thu, 21 Dec 2023 17:18:13 +0000 Subject: [PATCH 2/3] Remove unneeded warnings about pinned versions; install UseElmish using Femto; align with new Todo structure --- docs/recipes/ui/routing-with-elmish.md | 173 +++++++++++++------------ 1 file changed, 91 insertions(+), 82 deletions(-) diff --git a/docs/recipes/ui/routing-with-elmish.md b/docs/recipes/ui/routing-with-elmish.md index 41c1d352..e65778bb 100644 --- a/docs/recipes/ui/routing-with-elmish.md +++ b/docs/recipes/ui/routing-with-elmish.md @@ -8,40 +8,17 @@ In this recipe we add routing to a safe app, and implement the todo list page us ## 1. Installing dependencies -!!! warning "Pin Fable.Core to V3" - At the time of writing, the published version of the SAFE template does not have the version of `Fable.Core` pinned; this can create problems when installing dependencies. - - If you are using version v.4.2.0 of the template, pin `Fable.Core` to version 3 in `paket.depedencies` at the root of the project - - ```.diff title="paket.dependencies" - ... - -nuget Fable.Core - +nuget Fable.Core ~> 3 - ... - ``` - - Install Feliz.Router in the Client project ```bash -dotnet paket add Feliz.Router -p Client -V 3.8 +dotnet paket add Feliz.Router -p Client ``` -!!! Warning "Feliz.Router versions" - At the time of writing, the current version of the SAFE template (4.2.0) does not work well with the latest version of Feliz.Router (4.0). - To work around this, we install Feliz.Router 3.8, the latest version that works with SAFE template version 4.2.0. - - If you are working with a newer version of the SAFE template, it might be worth trying to install the newest version of Feliz.Router. - To see the installed version of the SAFE template, run in the command line: - - ```bash - dotnet new --list - ``` - Install Feliz.UseElmish in the Client project ```bash -dotnet paket add Feliz.UseElmish -p client +cd src/Client +dotnet femto install Feliz.UseElmish ``` Open the router in the client project @@ -58,10 +35,10 @@ Create a new Module `TodoList` in the client project. Move the following functio * Msg * todosApi * init -* update -* containerBox +* todoAction +* todoList -Also open `Shared`, `Fable.Remoting.Client`, `Elmish`, `Feliz.Bulma` and `Feliz`. +Also open `Shared`, `Fable.Remoting.Client`, `Elmish` and `Feliz`. ```fsharp title="TodoList.fs" module TodoList @@ -70,7 +47,6 @@ open Shared open Fable.Remoting.Client open Elmish -open Feliz.Bulma open Feliz type Model = { Todos: Todo list; Input: string } @@ -86,13 +62,12 @@ let todosApi = |> Remoting.withRouteBuilder Route.builder |> Remoting.buildProxy -let init () : Model * Cmd = +let init () = let model = { Todos = []; Input = "" } let cmd = Cmd.OfAsync.perform todosApi.getTodos () GotTodos - model, cmd -let update (msg: Msg) (model: Model) : Model * Cmd = +let update msg model = match msg with | GotTodos todos -> { model with Todos = todos }, Cmd.none | SetInput value -> { model with Input = value }, Cmd.none @@ -102,43 +77,57 @@ let update (msg: Msg) (model: Model) : Model * Cmd = let cmd = Cmd.OfAsync.perform todosApi.addTodo todo AddedTodo { model with Input = "" }, cmd - | AddedTodo todo -> { model with Todos = model.Todos @ [ todo ] }, Cmd.none - -let containerBox (model: Model) (dispatch: Msg -> unit) = - Bulma.box [ - Bulma.content [ - Html.ol [ - for todo in model.Todos do - Html.li [ prop.text todo.Description ] + | AddedTodo todo -> + { + model with + Todos = model.Todos @ [ todo ] + }, + Cmd.none + +let private todoAction model dispatch = + Html.div [ + prop.className "flex flex-col sm:flex-row mt-4 gap-4" + prop.children [ + Html.input [ + prop.className + "shadow appearance-none border rounded w-full py-2 px-3 outline-none focus:ring-2 ring-teal-300 text-grey-darker" + prop.value model.Input + prop.placeholder "What needs to be done?" + prop.autoFocus true + prop.onChange (SetInput >> dispatch) + prop.onKeyPress (fun ev -> + if ev.key = "Enter" then + dispatch AddTodo) + ] + Html.button [ + prop.className + "flex-no-shrink p-2 px-12 rounded bg-teal-600 outline-none focus:ring-2 ring-teal-300 font-bold text-white hover:bg-teal disabled:opacity-30 disabled:cursor-not-allowed" + prop.disabled (Todo.isValid model.Input |> not) + prop.onClick (fun _ -> dispatch AddTodo) + prop.text "Add" ] ] - Bulma.field.div [ - field.isGrouped - prop.children [ - Bulma.control.p [ - control.isExpanded - prop.children [ - Bulma.input.text [ - prop.value model.Input - prop.placeholder "What needs to be done?" - prop.onChange (fun x -> SetInput x |> dispatch) - ] - ] - ] - Bulma.control.p [ - Bulma.button.a [ - color.isPrimary - prop.disabled (Todo.isValid model.Input |> not) - prop.onClick (fun _ -> dispatch AddTodo) - prop.text "Add" - ] + ] + +[] +let todoList model dispatch = + Html.div [ + prop.className "bg-white/80 rounded-md shadow-md p-4 w-5/6 lg:w-3/4 lg:max-w-2xl" + prop.children [ + Html.ol [ + prop.className "list-decimal ml-6" + prop.children [ + for todo in model.Todos do + Html.li [ prop.className "my-1"; prop.text todo.Description ] ] ] + + todoAction model dispatch ] ] ``` -## 4. Add the UseElmish hook to the TodoList Module +## 4. Add the UseElmish hook to the TodoList view function open Feliz.UseElmish in the TodoList Module @@ -147,21 +136,21 @@ open Feliz.UseElmish ... ``` -In the todoList module, rename `containerBox` to `view`. -On the first line, call `React.useElmish` passing it the `init` and `update` functions. Bind the output to `model` and `dispatch` +In the todoList module, rename the function `todoList` to `view`, and remove the `private` access modifier. +On the first line, call `React.useElmish`, passing it the `init` and `update` functions. Bind the output to `model` and `dispatch` === "Code" ```fsharp title="TodoList.fs" - let view (model: Model) (dispatch: Msg -> unit) = - let model, dispatch = React.useElmish(init, update, [||]) + let view model dispatch = + let model, dispatch = React.useElmish (init, update, [||]) ... ``` === "Diff" ```.diff title="TodoList.fs" - -let containerBox (model: Model) (dispatch: Msg -> unit) = - +let view (model: Model) (dispatch: Msg -> unit) = - + let model, dispatch = React.useElmish(init, update, [||]) + -let containerBox model dispatch = + +let view model dispatch = + + let model, dispatch = React.useElmish (init, update, [||]) ... ``` @@ -176,7 +165,7 @@ Replace the arguments of the function with unit, and add the `ReactComponent` at === "Diff" ```.diff title="Index.fs" + [] - - let view (model: Model) (dispatch: Msg -> unit) = + - let view model dispatch = + let view () = ... ``` @@ -213,9 +202,7 @@ let initFromUrl url = Create a new `init` function, that fetches the current url, and calls initFromUrl. ```fsharp title="Index.fs" -let init () = - Router.currentUrl () - |> initFromUrl +let init () = Router.currentUrl () |> initFromUrl ``` ## 7. Updating the Page @@ -228,33 +215,55 @@ type Msg = Add an `update` function, that reinitializes the app based on an URL ```fsharp title="Index.fs" -let update (msg: Msg) (model: Model) : Model * Cmd = +let update msg model = match msg with - | PageChanged url -> - initFromUrl url + | PageChanged url -> initFromUrl url ``` ## 8. Displaying pages -Add a containerBox function to the `Index` module, that returns the appropriate page content +Add a pageContent function to the `Index` module, that returns the appropriate page content ```fsharp title="Index.fs" -let containerBox (model: Model) (dispatch: Msg -> unit) = +let pageContent model = match model.CurrentPage with - | NotFound -> Bulma.box "Page not found" + | NotFound -> Html.text "Page not found" | TodoList -> TodoList.view () ``` + +In the `view` function, replace the call to `todoList` with a call to `pageContent` + +=== "Code" + ``` + let view model dispatch = + Html.section [ + ... + pageContent model + ... + ] + ``` +=== "Diff" +``` + let view model dispatch = + Html.section [ + ... + - todoList view model + + pageContent model + ... + ] +``` + ## 9. Add the router to the view Wrap the content of the view method in a `React.Router` element's router.children property, and add a `router.onUrlChanged` property to dispatch the urlChanged message === "Code" ```fsharp title="Index.fs" - let view (model: Model) (dispatch: Msg -> unit) = + let view model dispatch = React.router [ router.onUrlChanged ( PageChanged>>dispatch ) router.children [ - Bulma.hero [ + Html.section [ ... ] ] @@ -266,7 +275,7 @@ Wrap the content of the view method in a `React.Router` element's router.childre + React.router [ + router.onUrlChanged ( PageChanged>>dispatch ) + router.children [ - Bulma.hero [ + Html.section [ ... ] + ] From 4eb5407bd5ff4beb4faff1eed322147bddd8fda3 Mon Sep 17 00:00:00 2001 From: Joost Date: Thu, 21 Dec 2023 17:27:28 +0000 Subject: [PATCH 3/3] Small fixes --- docs/recipes/ui/routing-with-elmish.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/recipes/ui/routing-with-elmish.md b/docs/recipes/ui/routing-with-elmish.md index e65778bb..3f3a82a2 100644 --- a/docs/recipes/ui/routing-with-elmish.md +++ b/docs/recipes/ui/routing-with-elmish.md @@ -1,7 +1,5 @@ # How do I create multi-page applications with routing and the useElmish hook? -*Written for SAFE template version 4.2.0* - [UseElmish](https://zaid-ajaj.github.io/Feliz/#/Hooks/UseElmish) is a powerful package that allows you to write standalone components using Elmish. A component built around the `UseElmish` hook has its own view, state and update function. In this recipe we add routing to a safe app, and implement the todo list page using the `UseElmish` hook. @@ -234,7 +232,7 @@ let pageContent model = In the `view` function, replace the call to `todoList` with a call to `pageContent` === "Code" - ``` + ```fsharp title="Index.fs" let view model dispatch = Html.section [ ... @@ -243,7 +241,7 @@ In the `view` function, replace the call to `todoList` with a call to `pageConte ] ``` === "Diff" -``` + ```diff title="Index.fs" let view model dispatch = Html.section [ ... @@ -251,7 +249,7 @@ In the `view` function, replace the call to `todoList` with a call to `pageConte + pageContent model ... ] -``` + ``` ## 9. Add the router to the view