19 January 2016

Tags: haskell elm haskellelmspa

Any serious Single Page Application needs to have routing. Right ? So before we add any further pages it’s time to add routing support to the Elm frontend.

In episode 2, we implemented a Micky Mouse solution for page routing. Clearly that approach won’t scale. Now is a good time to implement something that can handle multiple pages, history navigation, direct linking etc. We could do it all from scratch, but lets opt for pulling in a library. In this episode we’ll introduce elm-transit-router to the Albums sample application.

Useful resources
  • Check out the other episodes in this blog series.

  • The accompanying Albums sample app is on github, and there is a tag for each episode

Introduction

I decided pretty early on to try out the elm-transit-router library. It seemed to cover most of what I was looking for. It even has some pretty cool support for animations when doing page transitions.

Static typing is supposed to be really helpful when doing refactoring. Introducing routing should be a nice little excercise to see if that holds. Remember, there still isn’t a single test in our sample app, so it better hold. The elm-transit-router library github repo contains a great example app that proved very helpful in getting it up and running for the Albums app.

Hop is an alternative routing library you might want to check out too.

Implementation changes

frontend/elm-package.json
  // (...
  "source-directories": [
        ".",
        "src/"                                             (1)
    ],

  // ...
  "dependencies": {
    //... others ommitted
    "etaque/elm-route-parser": "2.1.0 <= v < 3.0.0",       (2)
    "etaque/elm-transit-style": "1.0.1 <= v < 2.0.0",      (3)
    "etaque/elm-transit-router": "1.0.1 <= v < 2.0.0"      (4)

  }
  ...
1 We’ve moved all elm files but Main.elm to the a src sub directory. So we need to add src to the list of source directories
2 A typed route parser with a nice DSL in Elm: We use it for defining our routes
3 Html animations for elm-transit
4 Drop-in router with animated route transitions for single page apps in Elm. Drop in, as in fitting very nicely with elm start-app.
Album dependencies

Click for larger diagram

The addition of the 3 new dependencies also adds quite a few transitive dependencies. The diagram above is automatically generated by the elm-light plugin for Light Table.

Defining routes (frontend/src/Routes.elm)
type Route                                                   (1)
  = Home
  | ArtistListingPage
  | ArtistDetailPage Int
  | NewArtistPage
  | EmptyRoute


routeParsers : List (Matcher Route)
routeParsers =
  [ static Home "/"                                         (2)
  , static ArtistListingPage "/artists"
  , static NewArtistPage "/artists/new"
  , dyn1 ArtistDetailPage "/artists/" int ""                (3)
  ]


decode : String -> Route
decode path =                                               (4)
  RouteParser.match routeParsers path
    |> Maybe.withDefault EmptyRoute


encode : Route -> String
encode route =                                              (5)
  case route of
    Home -> "/"
    ArtistListingPage   -> "/artists"
    NewArtistPage       -> "/artists/new"
    ArtistDetailPage  i -> "/artists/" ++ toString i
    EmptyRoute -> ""
1 Union type that defines the different routes for the application
2 A static route matcher (static is a function from the RouteParser dsl)
3 Dynamic route matcher with one dynamic param
4 We try to match a given path with the route matchers defined above. Returns route of first successful match, or the EmptyRoute route if no match is found.
5 Encode a given route as a path
A few handy router utils (frontend/src/Routes.elm)
redirect : Route -> Effects ()
redirect route =                                       (1)
  encode route
    |> Signal.send TransitRouter.pushPathAddress
    |> Effects.task


clickAttr : Route -> Attribute
clickAttr route =                                     (2)
  on "click" Json.value (\_ ->  Signal.message TransitRouter.pushPathAddress <| encode route)


linkAttrs : Route -> List Attribute
linkAttrs route =                                     (3)
  let
    path = encode route
  in
    [ href path
    , onWithOptions
        "click"
        { stopPropagation = True, preventDefault = True }
        Json.value
        (\_ ->  Signal.message TransitRouter.pushPathAddress path)
    ]
1 This function allows us to perform routing through a redirect kind of effect. Comes in handy when we need to switch routes as a result of performing a task or doing an update action of some sort.
2 Helper function that creates a click handler attribute. When clicked the signal is forwarded to an address of the internal mailbox for the elm-transit-router library. By means of delegation the internal TransitRouter.Action type is wrapped into our app’s Action type. We’ll get back to this when we wire it all together !
3 Another helper function, similar to clickAttr, but this is more specific for links that also has a href attribute

Changes in Main.elm

Too hook in elm-transit-router we need to make a couple of changes to how we wire up our model, actions, view and update function. It’s also worth noting that from episode 2 have removed all direct update delegation from ArtistListing to ArtistDetail, this now all will happen through route transitions. An immediate benefit of that is that the ArtistDetail page becomes much more reusable.

Model, actions, transitions and initialization
type alias Model = WithRoute Routes.Route                                (1)
  { homeModel : Home.Model
  , artistListingModel : ArtistListing.Model
  , artistDetailModel : ArtistDetail.Model
  }


type Action =
    NoOp
  | HomeAction Home.Action
  | ArtistListingAction ArtistListing.Action
  | ArtistDetailAction ArtistDetail.Action
  | RouterAction (TransitRouter.Action Routes.Route)                    (2)


initialModel : Model
initialModel =
  { transitRouter = TransitRouter.empty Routes.EmptyRoute               (3)
  , homeModel = Home.init
  , artistListingModel = ArtistListing.init
  , artistDetailModel = ArtistDetail.init
  }


actions : Signal Action
actions =
  Signal.map RouterAction TransitRouter.actions                         (4)


mountRoute : Route -> Route -> Model -> (Model, Effects Action)
mountRoute prevRoute route model =                                      (5)
  case route of

    Home ->
      (model, Effects.none)

    ArtistListingPage ->                                                (6)
      (model, Effects.map ArtistListingAction (ServerApi.getArtists ArtistListing.HandleArtistsRetrieved))

    ArtistDetailPage artistId ->
      (model, Effects.map ArtistDetailAction (ServerApi.getArtist artistId ArtistDetail.ShowArtist))

    NewArtistPage ->
      ({ model | artistDetailModel = ArtistDetail.init } , Effects.none)

    EmptyRoute ->
      (model, Effects.none)


routerConfig : TransitRouter.Config Routes.Route Action Model
routerConfig =                                                          (7)
  { mountRoute = mountRoute
  , getDurations = \_ _ _ -> (50, 200)
  , actionWrapper = RouterAction
  , routeDecoder = Routes.decode
  }


init : String -> (Model, Effects Action)
init path =                                                             (8)
  TransitRouter.init routerConfig path initialModel
1 We extend our model using WithRoute for our Route type in routes. This extends our type with a transitRouter property
2 We add a RouteAction to our Action type. We will handle that explicitly in the update function we’ll cover in the next section
3 We define an initial model, which has the initial models for the various pages. In addition we initialize the transitRouter property with an empty state and EmptyRoute route (that didn’t read to well). Basically a route that shouldn’t render anything, because it will transition to an actual route. It’s just an intermediary
4 Transformer for mapping TransitRouter actions to our own RouterAction. This allows start-app to map external input signals to inputs with an action type our application can recognize and process.
5 mountRoute is a function that provides what we want to happen in our update when a new route is mounted. Currently we only pattern match on route to be mounted, but we could also match on the combination of previous route and new route to provide custom behaviour depending on where you came from and where your are going to. Very powerful !
6 When the ArtistListingPage route is mounted we return an effect to retrieve artists (when that effect returns the ArtistListing.HandleArtistRetrieved action is then eventually passed to the update function of ArtistListing)
7 routerConfig wires together the various bits that TransitRouter needs to do it’s thing
8 The init function now just initializes the TransitRouter with our config, and initial path (which we receive from a port) and our Initial model

There’s quite a bit going on here, but once this is all in place, adding new routes is quite a breeze. I’d recommend reading through the Readme for elm-transit-router to understand more about the details of each step

The update function
update : Action -> Model -> (Model, Effects Action)
update action model =
  case action of

    NoOp ->
      (model, Effects.none)

    HomeAction homeAction ->
      let (model', effects) = Home.update homeAction model.homeModel
      in ( { model | homeModel = model' }
         , Effects.map HomeAction effects )

    ArtistListingAction act ->                                                       (1)
      let (model', effects) = ArtistListing.update act model.artistListingModel
      in ( { model | artistListingModel = model' }
         , Effects.map ArtistListingAction effects )

    ArtistDetailAction act ->                                                        (2)
      let (model', effects) = ArtistDetail.update act model.artistDetailModel
      in ( { model | artistDetailModel = model' }
         , Effects.map ArtistDetailAction effects )

    RouterAction routeAction ->                                                      (3)
      TransitRouter.update routerConfig routeAction model
1 You should recognize this pattern from the previous episode. We delegate all actions tagged with ArtistListingAction to the update function for ArtistListing. The we update the model with the updated model from ArtistListing and map any effects returned.
2 If you remember from episode 2 this used to reside in ArtistListing, but has been moved here.
3 RouterAction action types are handled by the update function in TransitRouter. If you Debug.log this function you will see this is called repeadly when there is a transition from one route to the next. (To handle the animation effects most notably)
The main view/layout
menu : Signal.Address Action -> Model -> Html
menu address model =                                                       (1)
  header [class "navbar navbar-default"] [
    div [class "container"] [
        div [class "navbar-header"] [
          div [ class "navbar-brand" ] [
            a (linkAttrs Home) [ text "Albums galore" ]
          ]
        ]
      , ul [class "nav navbar-nav"] [
          li [] [a (linkAttrs ArtistListingPage) [ text "Artists" ]]       (2)
      ]
    ]
  ]



contentView : Signal.Address Action -> Model -> Html
contentView address model =                                                (3)
  case (TransitRouter.getRoute model) of
    Home ->
      Home.view (Signal.forwardTo address HomeAction) model.homeModel

    ArtistListingPage ->                                                   (4)
      ArtistListing.view (Signal.forwardTo address ArtistListingAction) model.artistListingModel

    ArtistDetailPage i ->
      ArtistDetail.view (Signal.forwardTo address ArtistDetailAction) model.artistDetailModel

    NewArtistPage  ->
      ArtistDetail.view (Signal.forwardTo address ArtistDetailAction) model.artistDetailModel

    EmptyRoute ->
      text "Empty WHAT ?"


view : Signal.Address Action -> Model -> Html
view address model =
  div [class "container-fluid"] [
      menu address model
    , div [ class "content"
          , style (TransitStyle.fadeSlideLeft 100 (getTransition model))]  (5)
          [contentView address model]
  ]
1 Menu view function for the app
2 Here we use the linkAttrs util function from Routes.elm to get a click handler. When the link is click a route transition to the given page will occur (with addressbar update, history tracking and the whole shebang)
3 We render the appropriate main content view based which route is current in our model.
4 Getting the view for a page is used in the typical start-app way. Call the view function of the sub component and make sure to provide a forwarding addres that main can handle in its update function !
5 We define the route transition animation using the style attribute (function) in elm-html. Here we use a transition style defined in elm-transit-style.

How to navigate from one page to another ?

Move from artistlisting to artistdetail (frontend/src/ArtistListing.elm)
artistRow : Signal.Address Action -> Artist -> Html
artistRow address artist =
  tr [] [
     td [] [text artist.name]
    ,td [] [button [ Routes.clickAttr <| Routes.ArtistDetailPage artist.id ] [text "Edit"]]  (1)
    ,td [] [button [ onClick address (DeleteArtist (.id artist))] [ text "Delete!" ]]
  ]


view : Signal.Address Action -> Model -> Html
view address model =
  div [] [
      h1 [] [text "Artists" ]
    , button [
            class "pull-right btn btn-default"
          , Routes.clickAttr Routes.NewArtistPage                                            (2)
        ]
        [text "New Artist"]
    , table [class "table table-striped"] [
          thead [] [
            tr [] [
               th [] [text "Name"]
              ,th [] []
              ,th [] []
          ]
        ]
        , tbody [] (List.map (artistRow address) model.artists)
    ]
  ]
1 For navigation using links we just use the util function Routes.clickAttr function we defined earlier. This will trigger the necessary route transition to the appropriate page (with params as necessary)
2 It’s worth noting that we since episode 2 have introduced a separate route for handling NewArtist (/artists/new). We are still using the same behaviour otherwise, so it’s just a minor modification to have a separate transition for a new artist (since that doesn’t have a numeric id as part of its route path)
Move to the artist listing after saving an artist (frontend/src/ArtistDetail.elm)
  -- ... inside update function

  HandleSaved maybeArtist ->
      case maybeArtist of
        Just artist ->
          ({ model | id = Just artist.id
                   , name = artist.name }
            , Effects.map (\_ -> NoOp) (Routes.redirect Routes.ArtistListingPage)   (1)
          )

        Nothing ->
          Debug.crash "Save failed... we're not handling it..."
1 We use the Routes.redirect function we defined earlier. When the task fro saving is completed we trigger an effect that will transtion route to the ArtistListing page. To allow the effect to work in our update function we need to map it to an action that ArtistDetail knows about (we don’t have access to the RouterAction in main here!). That’s why we map the effect to a NoOp action.

The final wiring

frontend/main.elm
app : StartApp.App Model
app =
  StartApp.start
    { init = init initialPath                  (1)
    , update = update
    , view = view
    , inputs = [actions]                       (2)
    }


main : Signal Html
main =
  app.html


port tasks : Signal (Task.Task Never ())
port tasks =
  app.tasks


port initialPath : String                      (3)
1 We call the init function previously defined with a initialPath (which we get from a port, see 3 below)
2 The inputs fields of the start-app config is for external signals. We wire it to our actions defintion defined earlier
3 We get the initialPath through a port from JavaScript. See the next section for how
Initially I forgot to wire up the inputs. The net result of that was that none of the links actually did anything. Was lost for a while there, but the author of elm-transit-router etaque was able to spot it easily when I reached out in the elm-lang slack channel
frontend/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Albums</title>
    <link rel="stylesheet" href="assets/css/bootstrap.min.css">
  </head>
  <body>
    <script type="text/javascript" src="main.js"></script>                 (1)
    <script type="text/javascript" src="/_reactor/debug.js"></script>      (2)

    <script type="text/javascript">
      var main = Elm.fullscreen(Elm.Main, {initialPath: "/"});             (3)
    </script>

  </body>
</html>
1 This is the transpiled elm to js for our frontend app
2 We don’t really need this one, but if reactor in debug mode had worked with ports this would be necessary for debug tracing etc
3 We start our elm app with an input param for our initialPath. This is sent to the port defined above. It’s currently hardcoded to / (home), but once we move to a proper web server we would probably use something like window.location.pathname to allow linking directly to a specific route within our Single Page App.

Summary and next steps

This was an all Elm episode. Hopefully I didn’t loose all Haskellites along the way because of that. We’ve added a crucial feature for any Single Page (Web) Application in this episode. The end result was pretty neat and tidy too.

So how was the refactoring experience this time ? Well the compiler was certainly my best buddy along the way. Obviously I also had to consult the documentation of elm-transit-router quite often. i had a few times where things appeared to be compiling fine in Light Table, but actually there was some error in a Module referred by Main. I’m not sure if it’s make’s fault or just that there is something missing in the elm-light plugin. I’ll certainly look into that. Always handy to have the command line available when you’re not sure about whether your IDE/Editor is tripping you up or not. I don’t think tests would have caught many of the issues I encountered. Forgetting to wire up inputs to startapp was probably my biggest blunder, and I’m sure no test would have covered that. I needed to know that this was something I had to wire up for it to work. RTFM etc.

Next up I think we will look at how much effort there is to add additional features. The hypothesis is that it should be fairly straighforward, but who knows !

comments powered by Disqus