6.8

Routing

So far, we have learned how to retrieve posts from our local server and display them. It’s time for us to create a page for editing a post.

Creating an Edit Page

Here’s the plan: we’ll add a link that says Edit next to each row in the posts table. When that link is clicked, we’ll take users to a different page which contains a form for updating information associated with a post. Add a new cell to the bottom of viewPost in the Views/List.elm file.

viewPost : Post -> Html Msg
viewPost post =
    tr []
        .
        .
        , td []
            [ a [ href post.author.url ] [ text post.author.name ] ]
        , td []
            [ a [ href "" ] [ text "Edit" ] ]
        ]

Stop json-server if it’s running already by pressing Ctrl + c and restart it from the beginning-elm directory without adding a delay.

$ json-server --watch server/db.json -p 5019

Also run elm-reactor from the beginning-elm directory and go to this page in a browser: http://localhost:8000/PostApp/App.elm. You should see the Edit links.

We need to route users to a different page when Edit is clicked. Create a new file called Edit.elm in PostApp/Views directory and add the code below to it.

module Views.Edit exposing (view)

import Html exposing (..)
import Html.Attributes exposing (..)
import Types exposing (..)


view : Post -> Html Msg
view post =
    div []
        [ h3 [] [ text "Edit Post" ]
        , editForm post
        ]


editForm : Post -> Html Msg
editForm post =
    Html.form []
        [ div []
            [ text "Title"
            , br [] []
            , input
                [ type_ "text"
                , value post.title
                ]
                []
            ]
        , br [] []
        , div []
            [ text "Author Name"
            , br [] []
            , input
                [ type_ "text"
                , value post.author.name
                ]
                []
            ]
        , br [] []
        , div []
            [ text "Author URL"
            , br [] []
            , input
                [ type_ "text"
                , value post.author.url
                ]
                []
            ]
        , br [] []
        , div []
            [ button []
                [ text "Submit" ]
            ]
        ]

It’s a simple view with three input fields and a button for updating and submitting changes made to a post. We won’t be able to see how the edit view looks until we have routing in place. Speaking of which, let’s create a file called Routing.elm inside PostApp directory and add the code below to it.

module Routing exposing (..)

import Navigation exposing (Location)
import UrlParser exposing (..)
import Types exposing (..)


extractRoute : Location -> Route
extractRoute location =
    case (parsePath matchRoute location) of
        Just route ->
            route

        Nothing ->
            NotFoundRoute


matchRoute : Parser (Route -> a) a
matchRoute =
    oneOf
        [ map PostsRoute top
        , map PostsRoute (s "posts")
        , map PostRoute (s "posts" </> int)
        ]

Routing

Elm provides two modules — Navigation and UrlParser — to facilitate routing in an app. Install packages that include those modules by running the following commands from the beginning-elm directory in terminal.

$ elm-package install elm-lang/navigation
$ elm-package install evancz/url-parser

Answer y when asked to update elm-package.json and approve the installation plan.

Before we dive into the extractRoute and matchRoute functions in Routing module, we need to know which URLs will be used to access the list and edit pages. Back in the Creating a Local JSON Server section, we used the http://localhost:5019/posts URL to fetch all posts in our database file (server/db.json).

[
  {
    "id": 1,
    "title": "json-server",
    "author": {
      "name": "typicode",
      "url": "https://github.com/typicode"
    }
  },
  {
    "id": 2,
    "title": "http-server",
    "author": {
      "name": "indexzero",
      "url": "https://github.com/indexzero"
    }
  }
]

To retrieve an individual post, we appended its ID to the end like this: http://localhost:5019/posts/1.

{
  "id": 1,
  "title": "json-server",
  "author": {
    "name": "typicode",
    "url": "https://github.com/typicode"
  }
}

We’ll be using a similar URL pattern for accessing the list and edit pages.

Location

The Navigation module defines a type called Location to represent a full URL. Here’s how Location’s definition looks:

type alias Location =
    { href : String
    , host : String
    , hostname : String
    , protocol : String
    , origin : String
    , port_ : String
    , pathname : String
    , search : String
    , hash : String
    , username : String
    , password : String
    }

Don’t worry about understanding each and every field in the record above. All we need to know is that those fields represent the various information contained in a URL. The good news is we won’t have to directly access any of the fields in Location. The UrlParser module, which we will cover in a moment, provides various functions for extracting information from a location. If you are really curious, here is the breakdown of information contained in our edit page’s URL:

  • protocol : http
  • host : localhost
  • port_ : 8000
  • pathname : posts/1

We should never use a full URL to route users to a certain page. This is because our full URL will change as we move our code through different environments. Right now, we are building PostApp in a local environment with localhost:8000 in the URL. When it’s ready for production we’ll need to deploy it to some other environment whose address will be different. If we use the full URL for routing, everything will break when we move our app to a new environment. To avoid this, the right approach to navigate users is with paths.

Extracting Route from a Location

Paths are generally represented in strings. It would be great if we could convert them into union types. That way the compiler will warn us if we make a mistake while typing them. Let’s define a type called Route by adding the following code to the bottom of Types.elm.

type Route
    = PostsRoute
    | PostRoute Int
    | NotFoundRoute

Now let’s turn our attention back to the extractRoute function we defined in Routing module earlier.

extractRoute : Location -> Route
extractRoute location =
    case (parsePath matchRoute location) of
        Just route ->
            route

        Nothing ->
            NotFoundRoute

It uses parsePath — defined in UrlParser — to extract a path from the given location and translate it to one of the values in the Route type we defined above. parsePath, in turn, uses matchRoute to check if the given location contains one of these routes:

  • PostsRoute - represents all posts.
  • PostRoute Int - represents an individual post.

If yes, parsePath returns that route wrapped in Just. Otherwise it returns Nothing in which case we simply return NotFoundRoute to indicate that the given location doesn’t contain a route for any of the resources in our app.

Matching Routes

matchRoute defines parsers that know how to extract a given path from a location.

matchRoute : Parser (Route -> a) a
matchRoute =
    oneOf
        [ map PostsRoute top
        , map PostsRoute (s "posts")
        , map PostRoute (s "posts" </> int)
        ]
Parser
A parser is a component in a software program that takes some input data, checks for correct syntax, and builds a data structure which is easier to operate on compared to the original format. In our case the input data is a location. If the location is in correct format then the parser starts looking for a given path in it. If a match is found, it translates that path into one of the data constructors from the Route type defined in Types.elm.

The table below shows which parser is responsible for matching which path in a given location.

Location Path Parser Route
http://localhost:8000 top PostsRoute
http://localhost:8000/posts posts (s "posts") PostsRoute
http://localhost:8000/posts/1 posts/1 (s "posts" </> int) PostRoute 1

Notice how the first row doesn’t specify any path. If a path is missing, we want to take the user to the list view. top defines a parser that doesn’t look for a path. The s function also defines a parser, but it takes a path as an argument. Both top and s are defined in the UrlParser module. The parser for matching the path for an individual post uses the </> operator to combine two different parsers.

Finally, the oneOf function executes the parsers one at a time starting from the top. It stops as soon as a match is found for the entire path and not just a portion of it.

Storing Current Route

For routing to work properly, we need to know which page the user is currently on. Let’s store that information in our model. Add a new property to the Model record in Types.elm.

type alias Model =
    { posts : WebData (List Post)
    , currentRoute : Route
    }

We also need to update init in State.elm.

init : Route -> ( Model, Cmd Msg )
init route =
    ( { posts = RemoteData.Loading, currentRoute = route }, fetchPostsCommand )

It’s hard to read the code in init. Let’s extract the initial model into a separate function.

initialModel : Route -> Model
initialModel route =
    { posts = RemoteData.Loading
    , currentRoute = route
    }


init : ( Model, Cmd Msg )
init =
    ( initialModel, fetchPostsCommand )

Routing to the Correct Page

The overall process for routing users to the correct page can be summarized in the following steps.

Step 1. Enter a full URL in the browser’s address bar.

Step 2. Convert the URL from step 1 into Location type.

Step 3. Extract the route from location and store it in the currentRoute field in our Model.

Step 4. Determine which view (list or edit) to display based on currentRoute.

Step 5. Render the view from step 4.

We’ve already implemented steps 1, 2, and 3. Let’s implement step 4 by creating a new file called View.elm in the PostApp directory and adding the code below to it.

module View exposing (..)

import Html exposing (Html, h3, text)
import Types exposing (..)
import RemoteData exposing (WebData)
import Views.List
import Views.Edit


view : Model -> Html Msg
view model =
    case model.currentRoute of
        PostsRoute ->
            Views.List.view model

        PostRoute id ->
            case findPostById id model.posts of
                Just post ->
                    Views.Edit.view post

                Nothing ->
                    notFoundView

        NotFoundRoute ->
            notFoundView


findPostById : Int -> WebData (List Post) -> Maybe Post
findPostById postId posts =
    case RemoteData.toMaybe posts of
        Just posts ->
            posts
                |> List.filter (\post -> post.id == postId)
                |> List.head

        Nothing ->
            Nothing


notFoundView : Html msg
notFoundView =
    h3 [] [ text "Oops! The page you requested was not found!" ]

This is the view we will be passing to main in App.elm. It determines which view should be rendered based on the currentRoute value. If the route is PostsRoute, we simply take users to the list view. However, if the route is PostRoute id we need to first find a post with given ID using the findPostById function and pass that post to Views.Edit.view.

Remember, our model stores posts as WebData (List Post) type. One way we can extract values from it is by using the case expression. That’s exactly what we did in List.elm:

viewPostsOrError : Model -> Html Msg
viewPostsOrError model =
    case model.posts of
        RemoteData.NotAsked ->
            text ""

        RemoteData.Loading ->
            h3 [] [ text "Loading..." ]

        RemoteData.Success posts ->
            viewPosts posts

        RemoteData.Failure httpError ->
            viewError (createErrorMessage httpError)

The case expression is useful when we need to return some HTML based on what’s stored inside WebData. In findPostById, we don’t want to return HTML. We just need to find a post. A better alternative is to convert WebData to Maybe using the RemoteData.toMaybe function. If WebData contains a success value, toMaybe returns a Just, otherwise it returns Nothing.

findPostById : Int -> WebData (List Post) -> Maybe Post
findPostById postId posts =
    case RemoteData.toMaybe posts of
        Just posts ->
            posts
                |> List.filter (\post -> post.id == postId)
                |> List.head

        Nothing ->
            Nothing

We are now ready to look for a post with given ID. List.filter is just what we need. It’s possible that the post we’re looking for doesn’t exist. In that case List.head returns Nothing. That’s why we need to use one more case expression inside PostRoute id -> branch in the view function to make sure that we have really found a post before routing users to the edit view.

PostRoute id ->
    case findPostById id model.posts of
        Just post ->
            Views.Edit.view post

        Nothing ->
            notFoundView

If a post with given ID isn’t found or the given URL doesn’t contain a valid route we take the users to notFoundView.

notFoundView : Html msg
notFoundView =
    h3 [] [ text "Oops! The page you requested was not found!" ]

We used msg instead of Msg in notFoundView’s type annotation because none of the HTML elements inside it generates a message. It’s best practice not to limit a function’s type prematurely. When we do need to add an element that generates a message of type Msg such as button to notFoundView then we can change msg to Msg.

Wiring Everything Together

In addition to providing a type for representing a full URL, the Navigation module also coordinates routing by placing itself between the Elm Runtime and our code. The diagram below shows the interaction between Navigation, the Elm Runtime, and our code.

Modify init in State.elm so that it can accept a location from the Navigation module.

init : Location -> ( Model, Cmd Msg )
init location =
    let
        currentRoute =
            Routing.extractRoute location
    in
        ( initialModel currentRoute, fetchPostsCommand )

We used the extractRoute function defined in Routing.elm to determine the current route and store it in our model. Import Navigation and Routing modules in State.elm.

module State exposing (..)
.
.
import Rest exposing (fetchPostsCommand)
import Navigation exposing (Location)
import Routing

There are two ways a user can request a page:

  • Enter the URL of a page directly into the browser’s address bar. The above sequence diagram illustrates this flow.

  • Click a link somewhere in the app. Earlier, we implemented such a link in the list view.

To navigate users to the edit page when those links are clicked, we need to specify a path in the viewPost function in Views/List.elm. Add the following let expression, then replace the empty string with a real path for accessing a post.

viewPost : Post -> Html Msg
viewPost post =
    let
        postPath =
            "/posts/" ++ (toString post.id)
    in
        tr []
            .
            .
            , td []
                [ a [ href postPath ] [ text "Edit" ] ]
            ]

Now when an Edit link is clicked, the browser will create an event to notify the Elm Runtime that the URL has been changed. The following diagram shows how this event flows through various components in our app.

The step #5 in the diagram above suggests that the Navigation module will send the LocationChanged message to update. We haven’t defined that message yet. Let’s do that by adding it to the Msg type in Types.elm.

type Msg
    = FetchPosts
    | PostsReceived (WebData (List Post))
    | LocationChanged Location

We also need to import Navigation in Types.elm.

module Types exposing (..)

import RemoteData exposing (WebData)
import Navigation exposing (Location)
.
.

How does Navigation know that it needs to send the LocationChanged message to update when the Elm Runtime forwards a location changed event generated by the browser? Right now it doesn’t. We need to explicitly tell it to send that message. Modify main in App.elm to use the Navigation.program function instead of Html.program.

main : Program Never Model Msg
main =
    Navigation.program LocationChanged
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }

We need to import Navigation in App.elm as well.

module App exposing (main)
.
.
import Types exposing (..)
import Navigation

The Navigation.program function works just like Html.program. The only difference is that it will send the LocationChanged message to update whenever the URL changes. Next we need to handle that message by adding a branch to the update function in State.elm.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        .
        .
        LocationChanged location ->
            ( { model
                | currentRoute = Routing.extractRoute location
              }
            , Cmd.none
            )

The only thing remaining is to switch our main view from the list view to what’s defined in View.elm. Replace the line that imports Views.List in App.elm with this:

module App exposing (main)
.
.
import View exposing (view)
.
.

Using elm-live

Unfortunately, elm-reactor doesn’t know how to work with the Navigation module, so if we try to load the following URLs we’ll get an error.

What we need is a different development server called elm-live. It knows how to work with the Navigation module to route users to a proper page. An added advantage of using elm-live is that it also reloads pages whenever the underlying code is modified. As a result, we don’t have to refresh a page to see new changes.

Install elm-live globally using the -g option by running the following command from beginning-elm directory in terminal.

$ npm install elm-live -g

Stop elm-reactor by pressing Ctrl + c and run elm-live from the beginning-elm directory in terminal.

$ elm-live PostApp/App.elm --pushstate

The --pushstate option is what allows elm-live to work with the Navigation module. Now if you load either one of the following URLs, you should be routed to the list view.

Click Edit from the first row and you’ll be routed to the Edit Post page.

Going Back to the List Page

Right now there is no way for us to go back to the list page from the edit page unless we directly type the URL http://localhost:8000/posts in address bar. Let’s make this navigation easier by adding a Back link to the view function in Edit.elm.

view : Post -> Html Msg
view post =
    div []
        [ a [ href "/posts" ] [ text "Back" ]
        , h3 [] [ text "Edit Post" ]
        , editForm post
        ]

The page at http://localhost:8000/posts/1 should automatically refresh now that we’re using elm-live. Click the Back link located at the top and you should be routed to the list page.

Summary

In this section, we improved our app further by implementing routing. The Navigation module allowed us to route users to a different page when they click a link. It’s best practice to use paths instead of full URLs to determine which page the user should be taken to. We relied on the UrlParser module to extract paths from URLs and convert them to routes.

In the next section, we’ll learn how to actually update a post by sending a PATCH HTTP request to our sever.

Back to top

New chapters are coming soon!

Sign up for the Elm Programming newsletter to get notified!

* indicates required
Close