Elixir API and Elm SPA - Part 3

Part 3: Elm App creation and Routing setup

Now we're going to create the Elm application. We're going to use the fantastic create-elm-app because it allows us to create a production ready Elm project with webpack and Hot Module Replacement.

Series

  1. Part 1 - Elixir App creation
  2. Part 2 - Adds Guardian Authentication
  3. Part 3 - Elm App creation and Routing setup
  4. Part 4: Adding Login and Register pages
  5. Part 5: Persisting session data to localStorage

Install the create-elm-app

npm install -g create-elm-app

Create and start the app

create-elm-app toltec-web
cd toltec-web
elm-app start

You should see the Elm logo in a browser tab. Let's now to build our app.

Setting up Tachions

For this project I am going to use the tachyons project to create the interfaces. So let's add that to the head section of index.html

<!-- index.html -->

<link rel="stylesheet" href="https://unpkg.com/tachyons@4.9.1/css/tachyons.min.css" />

Setting up Routing

This app is completely based on Richard Feldman excellent Elm SPA Example. This app shares a lot of the low level infrastructure, but removes a few of the high level features that are not needed for our app. It also uses a different directory structure that is vertically organised instead of horizontally spread along the Data/, Requests/, Page/, and View/ directories. You'll find that the routing, Model shape, Messages, and Coders/Decoders are very much, if not completely, the same. Thanks Richard, for all the hard work :)

Before adding Elm code, we need to add some dependencies that we are going to use to setup the routing and navigation.

elm-app install elm-lang/navigation
elm-app install evancz/url-parser

For this part we'll have only a few routes:

  • Home: #/
  • Login: #/login
  • Logout: #/logout
  • Register: #/register

Let's create a new file named Route.elm in the src/ directory

-- src/Route.elm

module Route exposing (Route(..), fromLocation, href, modifyUrl)

import Html exposing (Attribute)
import Html.Attributes as Attr
import Navigation exposing (Location)
import UrlParser as Url exposing ((</>), Parser, oneOf, parseHash, s, string)


type Route
    = Home
    | Root
    | Login
    | Logout
    | Register


route : Parser (Route -> a) a
route =
    oneOf
        [ Url.map Home (s "")
        , Url.map Login (s "login")
        , Url.map Logout (s "logout")
        , Url.map Register (s "register")
        ]


routeToString : Route -> String
routeToString page =
    let
        pieces =
            case page of
                Home ->
                    []

                Root ->
                    []

                Login ->
                    [ "login" ]

                Logout ->
                    [ "logout" ]

                Register ->
                    [ "register" ]
    in
        "#/" ++ String.join "/" pieces


href : Route -> Attribute msg
href route =
    Attr.href (routeToString route)


modifyUrl : Route -> Cmd msg
modifyUrl =
    routeToString >> Navigation.modifyUrl


fromLocation : Location -> Maybe Route
fromLocation location =
    if String.isEmpty location.hash then
        Just Root
    else
        parseHash route location

This creates a Route type with the possible routes and a route function that will be passed to the parseHash function to help it determine which route corresponds to the current location in the browser url bar. The fromLocation function is the responsible of mapping between browser location bar and our app Routes.

Next we add a single message to set a route in our app. Create a Messages.elm file:

-- src/Messages.elm

module Messages exposing (Msg(..))

import Route exposing (Route)


type Msg
    = SetRoute (Maybe Route)

For now we only have this message to set a new Route in our app.

And before going further, add this Util.elm copied from Richard's app:

-- src/Util.elm

module Util exposing ((=>), pair)


(=>) : a -> b -> ( a, b )
(=>) =
    (,)


{-| infixl 0 means the (=>) operator has the same precedence as (<|) and (|>),
meaning you can use it at the end of a pipeline and have the precedence work out.
-}
infixl 0 =>


{-| Useful when building up a Cmd via a pipeline, and then pairing it with
a model at the end.
session.user
|> User.Request.foo
|> Task.attempt Foo
|> pair { model | something = blah }
-}
pair : a -> b -> ( a, b )
pair first second =
    first => second

Our initial Model.elm is like this:

-- src/Model.elm

module Model exposing (Model, initialModel, Page(..), PageState(..), getPage)

import Json.Decode as Decode exposing (Value)


type Page
    = Blank
    | NotFound
    | Home
    | Login
    | Register


type PageState
    = Loaded Page
    | TransitioningFrom Page


type alias Model =
    { pageState : PageState
    }


initialModel : Value -> Model
initialModel val =
    { pageState = Loaded Blank
    }


getPage : PageState -> Page
getPage pageState =
    case pageState of
        Loaded page ->
            page

        TransitioningFrom page ->
            page

Initially the app will manage a tiny set of pages as you can see. Following Richards app we'll have either a fully loaded page or we'll be in a state of transition from the current page to a new one. The Model therefore, holds the current PageState, that is initially a Blank page.

The initial app is slowly taking shape. Let's add now the Update.elm file:

-- src/Update.elm

module Update exposing (update, init)

import Json.Decode as Decode exposing (Value)
import Model exposing (Model, initialModel, Page(..), PageState(..), getPage)
import Messages exposing (Msg(..))
import Navigation exposing (Location)
import Route exposing (Route)
import Util exposing ((=>))


init : Value -> Location -> ( Model, Cmd Msg )
init val location =
    updateRoute (Route.fromLocation location) (initialModel val)


updateRoute : Maybe Route -> Model -> ( Model, Cmd Msg )
updateRoute maybeRoute model =
    case maybeRoute of
        Nothing ->
            { model | pageState = Loaded NotFound } => Cmd.none

        Just Route.Home ->
            { model | pageState = Loaded Home } => Cmd.none

        Just Route.Root ->
            model => Route.modifyUrl Route.Home

        Just Route.Login ->
            { model | pageState = Loaded Login } => Cmd.none

        Just Route.Logout ->
            model => Cmd.none

        Just Route.Register ->
            { model | pageState = Loaded Register } => Cmd.none


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    updatePage (getPage model.pageState) msg model


updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    case ( msg, page ) of
        ( SetRoute route, _ ) ->
            updateRoute route model

This module exposes two functions, the first one is init that takes an initial value and a location and returns the first tuple (Model, Cmd Msg) that will bootstrap our app state. As you can see, when the app starts, the state will depend only on two things the Value (that we'll see later) and the Location in the url.

The other exposed function is the update function. This is very simple because it delegates the real job to the updatePage. This last one will be where the app logic reside. Right now it doesn't do much, it only knows about one single message, the SetRoute message and when gets one, it updates the app route.

Finally, the updateRoute is where the logic specific to route changes is declared. Depending on the route passed, it establishes a specific page as loaded. Right now, the Logout route does nothing.

The View.elm file is the following:

-- src/View.elm

module View exposing (..)

import Html exposing (..)
import Model exposing (Model, Page(..), PageState(..))
import Messages exposing (Msg(..))
import Page.Page as Page exposing (ActivePage)
import Page.Home as Home
import Page.NotFound as NotFound
import Session.Login as Login
import Session.Register as Register


view : Model -> Html Msg
view model =
    case model.pageState of
        Loaded page ->
            viewPage True page

        TransitioningFrom page ->
            viewPage False page


viewPage : Bool -> Page -> Html Msg
viewPage isLoading page =
    let
        frame =
            Page.frame isLoading
    in
        case page of
            NotFound ->
                NotFound.view
                    |> frame Page.Other

            Blank ->
                Html.text "Loading Maya!"

            Home ->
                Home.view
                    |> frame Page.Home

            Login ->
                Login.view
                    |> frame Page.Login

            Register ->
                Register.view
                    |> frame Page.Register

This is quite simple, but uses a lot of new modules. Essentially, what it does is to retrieve the page from the model and then render the appropriate view. The viewPage function receives the page and then renders a view chosen among several ones.

This is how the views are defined:


-- src/Page/NotFound.elm

module Page.NotFound exposing (view)

import Html exposing (Html, h1, div, text)


view : Html msg
view =
    div []
        [ h1 [] [ text "The page you requested was not found!" ] ]

-- src/Page/Home.elm

module Page.Home exposing (view)

import Html exposing (..)


view : Html msg
view =
    div []
        [ h1 [] [ text "Toltec" ]
        , p [] [ text "Welcome to Toltec!" ]
        ]

-- src/Session/Login.elm

module Session.Login exposing (view)

import Html exposing (..)


view : Html msg
view =
    div []
        [ h1 [] [ text "Login page" ] ]

-- src/Session/Register.elm

module Session.Register exposing (view)

import Html exposing (..)


view : Html msg
view =
    div []
        [ h1 [] [ text "Register page" ] ]

They are quite simple, and only render some text so that we can check that the routing works. Notice that we created two new directories: Session/ and Page/.

The missing part is the Page.frame function that takes come content and puts a above around it.

-- src/Page/Page.elm

module Page.Page exposing (ActivePage(..), frame)

import Html exposing (..)
import Html.Attributes exposing (..)
import Route exposing (Route)


type ActivePage
    = Other
    | Home
    | Login
    | Register


frame : Bool -> ActivePage -> Html msg -> Html msg
frame isLoading activePage content =
    div []
        [ viewHeader activePage isLoading
        , content
        ]


viewHeader : ActivePage -> Bool -> Html msg
viewHeader activePage isLoading =
    nav [ class "dt w-100 border-box pa3 ph5-ns" ]
        [ a [ class "dtc v-mid mid-gray link dim w-25", Route.href Route.Home, title "Home" ]
            [ text "Maya" ]
        , div [ class "dtc v-mid w-75 tr" ] <|
            [ navbarLink activePage Route.Home [ text "Home" ]
            , navbarLink activePage Route.Login [ text "Login" ]
            , navbarLink activePage Route.Register [ text "Register" ]
            ]
        ]


navbarLink : ActivePage -> Route -> List (Html msg) -> Html msg
navbarLink activePage route linkContent =
    let
        active =
            case isActive activePage route of
                True ->
                    "black"

                False ->
                    "gray"
    in
        a [ Route.href route, class "link hover-black f6 f5-ns dib mr3 mr4-ns", class active ] linkContent


isActive : ActivePage -> Route -> Bool
isActive activePage route =
    case ( activePage, route ) of
        ( Home, Route.Home ) ->
            True

        ( Login, Route.Login ) ->
            True

        ( Register, Route.Register ) ->
            True

        _ ->
            False

As Richard explains, the frame function adds a header to the content we pass to it. It also takes care of detecting if the page we're showing matches any of the links on the nav bar. For the time being we're ignoring the isLoading parameter.

Having all this in place, we can code the main function. Change Main.elm to this:

-- src/Main.elm

module Main exposing (main)

import Json.Decode as Decode exposing (Value)
import Navigation
import Model exposing (Model)
import Messages exposing (Msg(..))
import Route exposing (Route)
import Subscriptions exposing (subscriptions)
import Update exposing (update, init)
import View exposing (view)


main : Program Value Model Msg
main =
    Navigation.programWithFlags (Route.fromLocation >> SetRoute)
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

Super simple. It uses the Navigation.programWithFlags to start our app. This function takes as the first parameter a (Location -> msg) function, that converts a Location into a message. In our case, the message is SetRoute. The Navigation.programWithFlags function will use this function to obtain a message each time the url changes and will call the update function passing to it this message. In essence, it observes for changes in the URL and triggers an Elm cycle by calling the update function. Of course, additionally, it can receive a initial value from JavaScript land when the app is bootstraped, but we're not using that right now.

The subscriptions function is defined in the Subscriptions.elm file:

-- src/Subscriptions.elm

module Subscriptions exposing (subscriptions)

import Model exposing (Model, Page(..), PageState(..), getPage)
import Messages exposing (Msg(..))


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ pageSubscriptions (getPage model.pageState)
        ]


pageSubscriptions : Page -> Sub Msg
pageSubscriptions page =
    case page of
        Blank ->
            Sub.none

        NotFound ->
            Sub.none

        Home ->
            Sub.none

        Login ->
            Sub.none

        Register ->
            Sub.none

Now we have all the pieces together and we can test our app. The elm-app start command should have hot reloaded all these changes and the app should be ready. Check the terminal that you don't have any error in the output. It should be like this:

Compiled successfully!

You can now view toltec-web in the browser.  Local:            http://localhost:3000/
  On Your Network:  http://192.168.1.108:3000/
Note that the development build is not optimized.
To create a production build, use elm-app build.

If you check your browser you should see something like this:

Toltec Home page

If you click on the links in the navbar, you'll see that they correctly swap the view rendered according to the page corresponding to the url in the browser.

You can find the source code, with tests, in the repo here in the branch part-03.

Nice, let's wrap it here for now. We have created the Elm app and configured the routing.

In part 4 we're going to add the login and register pages and connect them to our backend Elixir API.