Search code examples
f#fable-f#elmish

How can I do a simple elmish router?


Sorry, but I'm a newbie with Fable and F#. I started a boilerplate from SAFE project, and I created a SPA with two pages. However, all the logic is inside a single file. My question is. How can I implement a router putting each view in one file?

I would something like that:

...
Client
  |_Client.fs
  |_Pages
      |_ Home.fs
      |_ About.fs
Server
  |_Server.fs
...

Below is my Client.fs file

src/Client/Client.fs

(**
 - title: Navigation demo
 - tagline: The router sample ported from Elm
*)
module App

open Fable.Core
open Fable.Import
open Elmish
open Fable.Import.Browser
open Fable.PowerPack
open Elmish.Browser.Navigation
open Elmish.Browser.UrlParser


JsInterop.importAll "whatwg-fetch"

// Types
type Page = Home | Blog of int | Search of string

type Model =
  { page : Page
    query : string
    cache : Map<string,string list> }

let toHash =
    function
    | Blog id -> "#blog/" + (string id)
    | _ -> "#home"

/// The URL is turned into a Page option.
let pageParser : Parser<Page->_,_> =
  oneOf
    [ map Home (s "home")
      map Blog (s "blog" </> i32) ]


type Msg =
  | Query of string
  | Enter
  | FetchFailure of string*exn
  | FetchSuccess of string*(string list)


type Place = { ``place name``: string; state: string; }

(* If the URL is valid, we just update our model or issue a command.
If it is not a valid URL, we modify the URL to whatever makes sense.
*)
let urlUpdate (result:Option<Page>) model =
  match result with
  | Some page ->
      { model with page = page; query = "" }, []

  | None ->
      Browser.console.error("Error parsing url")
      ( model, Navigation.modifyUrl (toHash model.page) )

let init result =
  urlUpdate result { page = Home; query = ""; cache = Map.empty }


(* A relatively normal update function. The only notable thing here is that we
are commanding a new URL to be added to the browser history. This changes the
address bar and lets us use the browser&rsquo;s back button to go back to
previous pages.
*)
let update msg model =
  match msg with
  | Query query ->
      { model with query = query }, []

  | FetchFailure (query,_) ->
      { model with cache = Map.add query [] model.cache }, []

  | FetchSuccess (query,locations) ->
      { model with cache = Map.add query locations model.cache }, []


// VIEW

open Fable.Helpers.React
open Fable.Helpers.React.Props


let viewLink page description =
  a [ Style [ Padding "0 20px" ]
      Href (toHash page) ]
    [ str description]

let internal centerStyle direction =
    Style [ Display "flex"
            FlexDirection direction
            AlignItems "center"
            unbox("justifyContent", "center")
            Padding "20px 0" ]

let words size message =
  span [ Style [ unbox("fontSize", size |> sprintf "%dpx") ] ] [ str message ]

let internal onEnter msg dispatch =
    function
    | (ev:React.KeyboardEvent) when ev.keyCode = 13. ->
        ev.preventDefault()
        dispatch msg
    | _ -> ()
    |> OnKeyDown

let viewPage model dispatch =
  match model.page with
  | Home ->
      [ words 60 "Welcome!"
        str "Play with the links and search bar above. (Press ENTER to trigger the zip code search.)" ]

  | Blog id ->
      [ words 20 "This is blog post number"
        words 100 (string id) ]

open Fable.Core.JsInterop

let view model dispatch =
  div []
    [ div [ centerStyle "row" ]
        [ viewLink Home "Home"
          viewLink (Blog 42) "Cat Facts"
          viewLink (Blog 13) "Alligator Jokes"
          viewLink (Blog 26) "Workout Plan" ]
      hr []
      div [ centerStyle "column" ] (viewPage model dispatch)
    ]

open Elmish.React
open Elmish.Debug

// App
Program.mkProgram init update view
|> Program.toNavigable (parseHash pageParser) urlUpdate
|> Program.withReact "elmish-app"
|> Program.withDebugger
|> Program.run

Solution

  • in general, all the Elmish "components" (you can understand it as "file") have:

    • a Model representing their state
    • a Msg representing the possible action supported in the component
    • an update function reacting to a Msg and generating a new Model from the previous Model state
    • a view function to generate the view from the current Model state

    In my application, I use the following structure which allows me to scale (indefinitely) the application.

    A Router.fs file responsible to handle to represents the different routes and the parsing function.

    let inline (</>) a b = a + "/" + string b
    
    type Route =
        | Home
        | Blog of int
    
    let toHash (route : Route) =
        match route with
        | Home -> "home"
        | Blog id -> "blog" </> id
    
    open Elmish.Browser.Navigation
    open Elmish.Browser.UrlParser
    
    let routeParser : Parser<Route -> Route, Route> =
        oneOf [ // Auth Routes
                map (fun domainId -> Route.Blog domainId) (s "blog" </> i32)
                map Route.Home (s "home")
                // Default Route
                map Route.Home top ]
    

    A Main.fs file responsible to create the Elmish program and handling how to react to the route changes.

    open Elmish
    open Fable.Helpers.React
    open Fable.Import
    
    type Page =
        | Home of Home.Model
        | Blog of Blog.Model
        | NotFound
    
    type Model =
        { ActivePage : Page
          CurrentRoute : Router.Route option }
    
    type Msg =
        | HomeMsg of Home.Msg
        | BlogMsg of Blog.Msg
    
    let private setRoute (optRoute: Router.Route option) model =
        let model = { model with CurrentRoute = optRoute }
    
        match optRoute with
        | None ->
            { model with ActivePage = Page.NotFound }, Cmd.none
    
        | Some Router.Route.Home ->
            let (homeModel, homeCmd) = Home.init ()
            { model with ActivePage = Page.Home homeModel }, Cmd.map HomeMsg homeCmd
    
        | Some (Router.Route.Blog blogId) ->
            let (blogModel, blogCmd) = Blog.init blogId
            { model with ActivePage = Page.Blog blogModel }, Cmd.map BlogMsg blogCmd
    
    let init (location : Router.Route option) =
        setRoute location
            { ActivePage = Page.NotFound
              CurrentRoute = None }
    
    let update (msg : Msg) (model : Model) =
        match model.ActivePage, msg with
        | Page.NotFound, _ ->
            // Nothing to do here
            model, Cmd.none
    
        | Page.Home homeModel, HomeMsg homeMsg ->
            let (homeModel, homeCmd) = Home.update homeMsg homeModel
            { model with ActivePage = Page.Home homeModel }, Cmd.map HomeMsg homeCmd
    
        | Page.Blog blogModel, BlogMsg blogMsg ->
            let (blogModel, blogCmd) = Blog.update blogMsg blogModel
            { model with ActivePage = Page.Blog blogModel }, Cmd.map BlogMsg blogCmd
    
        | _, msg ->
            Browser.console.warn("Message discarded:\n", string msg)
            model, Cmd.none
    
    
    let view (model : Model) (dispatch : Dispatch<Msg>) =
        match model.ActivePage with
        | Page.NotFound ->
            str "404 Page not found"
    
        | Page.Home homeModel ->
            Home.view homeModel (HomeMsg >> dispatch)
    
        | Page.Blog blogModel ->
            Blog.view blogModel (BlogMsg >> dispatch)
    
    open Elmish.Browser.UrlParser
    open Elmish.Browser.Navigation
    open Elmish.React
    
    // App
    Program.mkProgram init update view
    |> Program.toNavigable (parseHash Router.routeParser) setRoute
    |> Program.withReactUnoptimized "elmish-app"
    |> Program.run
    

    So in your case, I would have the following files:

    • Router.fs
    • Home.fs
    • Blog.fs
    • Main.fs