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
- Part 1 - Elixir App creation
- Part 2 - Adds Guardian Authentication
- Part 3 - Elm App creation and Routing setup
- Part 4: Adding Login and Register pages
- 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:
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.