This post belongs to the Phoenix and Elm, a real use case series.
- Introduction to creating a SPA with Phoenix and Elm
- Rendering the initial contact list
- Adding full text search and pagination navigation to the contact list
- Better state with union types, search resetting and keyed nodes.
- Implementing Elm routing
- Poenix and Elm communication through WebSockets
Implementing Elm routing
In the previous part, we did some enhancements to our contact list application. These changes include union types in the model to represent more precisely what is the current state of the application, resetting the search result and using keyed Html nodes for a more efficient rendering. Today we are going to go a step further and implement the contact detail page, which a user can visit by clicking on any of the contact cards. Let's do this!
Phoenix changes
In regards to the backend, we need our API to have a new route like /api/contacts/:id
which
returns the JSON representation of the contact corresponding to that id. Let's start by adding
the new show action to the router.ex
file:
# web/router.ex
defmodule PhoenixAndElm.Router do
use PhoenixAndElm.Web, :router
# ...
scope "/api", PhoenixAndElm do
pipe_through :api
resources "/contacts", ContactController, only: [:index, :show]
end
# ...
scope "/", PhoenixAndElm do
pipe_through :browser # Use the default browser stack
get "/*path", PageController, :index
end
end
Take note that we are handling any route that doesn't belong to the API pipe with the PageController
.
The reason for this is that we want to handle all the URLs from the frontend. Once this is ready,
let's update the ContactController
module to add the action:
# web/controllers/contact_controller.ex
defmodule PhoenixAndElm.ContactController do
use PhoenixAndElm.Web, :controller
# ...
def show(conn, %{"id" => id}) do
contact = Repo.get(Contact, id)
render conn, contact: contact
end
end
We also need to update the ContactView
module to handle the corresponding response:
# web/views/contact_view.ex
defmodule PhoenixAndElm.ContactView do
use PhoenixAndElm.Web, :view
# ...
def render("show.json", %{contact: contact}), do: contact
end
With these changes our backend is ready, so if we visit http://localhost:4000/api/contacts/id where id corresponds to an existing contact id, we should see the following JSON response in the browser:
{
picture: "http://api.randomuser.me/portraits/women/1.jpg",
phone_number: "761/266-1174",
location: "Denmark",
last_name: "Heaney",
id: 180,
headline: "Est repellat omnis.",
gender: 1,
first_name: "Axel",
email: "axel@green.org",
birth_date: "1975-11-03"
}
The Routing module
To implement routing in Elm, we are going to need two additional packages for handling browser location changes and routes matching. These packages are Elm Navigation and UrlParser, so let's install them:
elm package install elm-lang/navigation -y
elm package install evancz/url-parser -y
Next, we are going to define the Routing
Elm module, with all the functionality in regards to parsing
the browser location and matching the routes of our application:
-- web/elm/Routing.elm
module Routing exposing (..)
import Navigation
import UrlParser exposing (..)
type Route
= HomeIndexRoute
| NotFoundRoute
matchers : Parser (Route -> a) a
matchers =
oneOf
[ map HomeIndexRoute <| s ""
]
parse : Navigation.Location -> Route
parse location =
case UrlParser.parsePath matchers location of
Just route ->
route
Nothing ->
NotFoundRoute
We start by creating a new union type named Route
, which contains all of the possible routes of our application:
HomeIndexRoute
, for the contact list.NotFoundRoute
, for any other route.
Next we define the matchers function which matches the current browser's location with our previously described routes,
and for the time being, we only need to map /
to HomeIndexRoute
.
Finally, the parse
function takes the location and returns the corresponding route using the matchers
function, returning NotFoundRoute
when the location does not correspond to any of the matched routes.
Handling Url changes
To handle these changes, we have to make some refactoring in our existing modules. The first of these changes
is in the Main module, where instead o using Html.program
we have to wrap our initial application in a Navigation.program
:
-- web/elm/Main.elm
module Main exposing (..)
import Messages exposing (Msg(..))
import Model exposing (..)
import Navigation
import Routing exposing (parse)
import Update exposing (..)
import View exposing (view)
init : Navigation.Location -> ( Model, Cmd Msg )
init location =
let
currentRoute =
parse location
model =
initialModel currentRoute
in
urlUpdate model
main : Program Never Model Msg
main =
Navigation.program UrlChange
{ init = init
, view = view
, update = update
, subscriptions = always <| Sub.none
}
Navigation.program
takes a new UrlChange
parameter which is a new message triggered every time the URL changes,
and the init function takes the current location, parses it to a known route and sets it in the model using the initialModel
function,
returning the urlUpdate
function response. These are many changes so let's start by updating the Model
to add the current route:
-- web/elm/Model.elm
module Model exposing (..)
import Routing exposing (Route)
-- ...
type alias Model =
{ contactList : RemoteData String ContactList
, search : String
, route : Route
}
-- ...
initialModel : Route -> Model
initialModel route =
{ contactList = NotRequested
, search = ""
, route = route
}
Now let's move on to the Messages
module to add the UrlChange
message type:
-- web/elm/Messages.elm
module Messages exposing (..)
-- ...
import Navigation
type Msg
= FetchResult (Result Http.Error ContactList)
-- ...
| UrlChange Navigation.Location
Finally, we need to implement the UrlChange
case in the Update
module:
-- web/elm/Update.elm
module Update exposing (..)
-- ...
import Routing exposing (Route(..), parse)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- ...
UrlChange location ->
let
currentRoute =
parse location
in
urlUpdate { model | route = currentRoute }
urlUpdate : Model -> ( Model, Cmd Msg )
urlUpdate model =
case model.route of
HomeIndexRoute ->
model ! [ fetch 1 "" ]
_ ->
model ! []
In addition to setting the current route every time the location changes, we have added the
urlUpdate
function as well. This function is critical, as it returns any route-specific command
we need to run. This means that everytime a user visits the HomeIndexRoute
path, the application
will automatically fetch the first page of contacts (like we were doing before in the init
function of the Main
module). The following chart illustrates, more or less, how the
Navigation.Program
flow looks like:
- The browser sends a location change event to the main
Navigation.Program
. - The
Navigation.Program
triggers theUrlChange
message, handled by theupdate
function of theUpdate
module. - This calls the
parse
function of theRouting
module, which returns the matchedRoute
. - The
update
function returns the new update model and the specific commands for that route (if any). - The
Navigation.Program
uses theview
function along with the receivedmodel
to render the Html.
Route specific views
Our routes are going to render different Html, so we still need to update the View
module to implement this:
-- web/elm/View.elm
module View exposing (..)
import ContactList.View exposing (indexView)
-- ...
view : Model -> Html Msg
view model =
section
[]
[ headerView
, div []
[ page model ]
]
-- ...
page : Model -> Html Msg
page model =
case model.route of
HomeIndexRoute ->
indexView model
NotFoundRoute ->
notFoundView
notFoundView : Html Msg
notFoundView =
text "Route not found"
The page
function patterns match against the current route of the model, calling the specific view function for that route. That notFoundView
is very simple,
so let's create a nicer one and make it look like the other warning messages we already have in the contact list search. We are probably going to
need this function in other places, therefore instead of using it directly from the ContactList.View
, let's move it to a different module called Common.View
:
-- web/elm/Common/View.elm
module Common.View exposing (warningMessage)
import Html exposing (..)
import Html.Attributes exposing (class)
import Messages exposing (Msg(..))
warningMessage : String -> String -> Html Msg -> Html Msg
warningMessage iconClasses message content =
div
[ class "warning" ]
[ span
[ class "fa-stack" ]
[ i [ class iconClasses ] [] ]
, h4
[]
[ text message ]
, content
]
Don't forget to remove it from the ContactList.View
and add the necessary import in it:
-- web/elm/ContactList/View.elm
module ContactList.View exposing (indexView)
import Common.View exposing (warningMessage)
-- ...
Now we can refactor the previously created notFoundView
function in order to make it look how we want:
-- web/elm/View.elm
module View exposing (..)
import Common.View exposing (warningMessage)
-- ...
notFoundView : Html Msg
notFoundView =
warningMessage
"fa fa-meh-o fa-stack-2x"
"Page not found"
backToHomeLink
Cool! We do not only want to show the message but also give the user the chance to go back to
the index route and display the contact list, that is why we have also added that convenient
backToHomeLink
function call as the last parameter of warningMessage
. Let's add its implementation
in the Common.View
module:
-- web/elm/Common/View.elm
module Common.View exposing (warningMessage, backToHomeLink)
-- ...
backToHomeLink : Html Msg
backToHomeLink =
a
[ onClick <| NavigateTo HomeIndexRoute ]
[ text "← Back to contact list" ]
As you can see, it is just a basic link that triggers the NavigateTo
message passing it the route to navigate to,
in this case, the HomeIndexRoute
. Let's add its definition to the Messages
module:
-- web/elm/Messages.elm
module Messages exposing (..)
import Routing exposing (Route)
-- ...
type Msg
= FetchResult (Result Http.Error ContactList)
-- ...
| NavigateTo Route
Furthermore, we have to add it's implementation in the Update
module:
-- web/elm/Upate.elm
module Update exposing (..)
import Navigation
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- ...
NavigateTo route ->
model ! [ Navigation.newUrl <| toPath route ]
-- ...
In this case, it is returning the model and a command created by the newUrl
function from the Navigation
module, which receives an URL string and adds it to the browser history, creating a location change and
triggering all the flow we have seen earlier again. However, how can we get a string URL having only a Route
?
That is where toPath function comes into play. Let's create it in the Routing
module:
-- web/elm/Routing.elm
module Routing exposing (..)
-- ...
toPath : Route -> String
toPath route =
case route of
HomeIndexRoute ->
"/"
NotFoundRoute ->
"/not-found"
Using pattern matching against the received route, it returns an URL.As easy as pie! If we are not missing anything and after the compiler ends compiling, this is what happens if we visit an incorrect route like http://localhost:4000/foo:
Show contact route
Now that we have covered the HomeIndexRoute
and NotFoundRoute
routes, let's update the Routing
module to add the changes we need
to add a new route, which shows a contact's detail page:
-- web/elm/Routing.elm
module Routing exposing (..)
-- ...
type Route
= HomeIndexRoute
-- ...
| ShowContactRoute Int
toPath : Route -> String
toPath route =
case route of
-- ...
ShowContactRoute id ->
"/contacts/" ++ toString id
-- ...
matchers : Parser (Route -> a) a
matchers =
oneOf
[ -- ...
, map ShowContactRoute <| s "contacts" </> int
]
-- ...
So when the user visits a path like /contacts/id
, we need to retrieve that contact's data from the API
endpoint we created at the beginning of this post, and store it somewhere in our program model.
Let's update the Model
module:
-- web/elm/Model.elm
module Model exposing (..)
-- ...
type alias Model =
{ -- ...
, contact : RemoteData String Contact
}
-- ...
initialModel : Route -> Model
initialModel route =
{
, contact = NotRequested
}
Following the same pattern we set up in the previous part for handling remote data, we have added a new
contact to the Model
record, initialized with NotRequested
. Now that we know how the flow works, next step
is creating the command which is returned along with the model once the new route is visited.
This command sends the Http request asking for the given user's data:
-- web/elm/Commands.elm
module Commands exposing (..)
import Decoders exposing (contactListDecoder, contactDecoder)
-- ...
fetchContact : Int -> Cmd Msg
fetchContact id =
let
apiUrl =
"/api/contacts/" ++ toString id
request =
Http.get apiUrl contactDecoder
in
Http.send FetchContactResult request
The response gets parsed with the contactDecoder
, and the result handled with the FetchContactResult
message,
which we need to add to the Messages
module:
-- web/elm/Messages.elm
module Messages exposing (..)
import Model exposing (ContactList, Contact)
-- ...
type Msg
-- ...
| FetchContactResult (Result Http.Error Contact)
Next, we need to edit the Update
module to implement these results:
-- web/elm/Update.elm
module Update exposing (..)
import Commands exposing (fetch, fetchContact)
-- ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- ...
FetchContactResult (Ok response) ->
{ model | contact = Success response } ! []
FetchContactResult (Err error) ->
{ model | contact = Failure "Contact not found" } ! []
If the result of the Http request and decoding is Ok
then it sets the current contact in the model.
On the other hand, if it fails, it establishes a friendly message to show to the user. However, there's
something we are missing here. How is the fetchContact
going to be triggered? Well, we have to do it
whenever a user visits the show contact route, and this gets done in two different ways:
- By clicking on a contact's card in the contact list.
- Visiting the Url directly from the browser.
In any of these cases we have to fetch the contact when the Url corresponds to the ShowContactRoute
, so let's update once more the Update
module
to implement this:
-- web/elm/Update.elm
module Update exposing (..)
import Commands exposing (fetch, fetchContact)
-- ...
urlUpdate : Model -> ( Model, Cmd Msg )
urlUpdate model =
case model.route of
HomeIndexRoute ->
model ! [ fetch 1 "" ]
ShowContactRoute id ->
{ model | contact = Requesting } ! [ fetchContact id ]
_ ->
model ! []
Let's move on to the Contact.View
and add the onClick
handler to the card:
-- web/elm/Contact/View.elm
module Contact.View exposing (..)
-- ...
contactView : Contact -> ( String, Html Msg )
contactView model =
let
-- ...
in
( toString model.id
, div
[ classes
, onClick <| NavigateTo <| ShowContactRoute model.id
]
-- ...
Remember that int the main View
module we are using route-specific view functions, so let's add the handler for the
ShowContactRoute
route:
-- web/elm/View.elm
module View exposing (..)
import Contact.View exposing (showContactView)
-- ...
page : Model -> Html Msg
page model =
case model.route of
-- ...
ShowContactRoute id ->
showContactView model
-- ...
As you can see, we are using a showContactView
function from the Contact.View
module that we need to implement:
-- web/elm/Contact/View.elm
module Contact.View exposing (..)
-- ...
showContactView : Model -> Html Msg
showContactView model =
case model.contact of
Success contact ->
let
classes =
classList
[ ( "person-detail", True )
, ( "male", contact.gender == 0 )
, ( "female", contact.gender == 1 )
]
( _, content ) =
contactView contact
in
div
[ id "contacts_show" ]
[ header []
[ h3
[]
[ text "Person detail" ]
]
, backToHomeLink
, div
[ classes ]
[ content ]
]
Requesting ->
warningMessage
"fa fa-spin fa-cog fa-2x fa-fw"
"Fetching contact"
(text "")
Failure error ->
warningMessage
"fa fa-meh-o fa-stack-2x"
error
backToHomeLink
NotRequested ->
text ""
Following the same RemoteData pattern that in the ContactList.View
main function, we handle all the
possible different values of the model's contact. The Success
branch wraps the existing contactView
function in an Html div with its header and back-to-home link, to navigate back to the HomeIndexRoute
route.
After the compilation ends, we can refresh our browser, click on any of the contact cards and see
how the list disappears to show only our selected contact:
I's working like a charm. But if you paginate or make a search, click on a contact and then
return back to the contact list, you will notice that the current search and pagination is lost.
This is becacuse we are always fetching the first page in the urlUpdate
function, so let's do a little refactor to solve this:
-- web/elm/Update.elm
module Update exposing (..)
-- ...
urlUpdate : Model -> ( Model, Cmd Msg )
urlUpdate model =
case model.route of
HomeIndexRoute ->
case model.contactList of
NotRequested ->
model ! [ fetch 1 "" ]
_ ->
model ! []
-- ...
With this little change, we are only resetting the pagination and search only when the contact list has not been requested previously. Let's get back to the browser and see what happens now:
We have our Elm routes completely working, yay! This is all for now, but in the next part of the series, we are going to add support for one of the features that makes Phoenix so awesome, WebSockets, removing the API controller and replacing it with a Phoenix channel, seeing how to connect to it from Elm and send messages through it. In the meantime, here's the source code of what we have done so far.
Happy coding!