Building a single page application with Elm
Hi, its been a very long while since I last blogged, I promised this ages ago, its a partial sample chapter for a book I was planning on Elm but the publisher started to muck me about so it never happened.
I also have the table of contents for the rest of the book but I guess your not interested in a book Im not writing so I have omitted that. Anyway heres the sample chapter in its raw form.
Building a single page application #
In this chapter we will specify and build a single page application consisting of bootstrap based navbar
navigation with a selection of views that are shown based on the navigation option selected within the applications model.
Basic Structure #
The basic structure of the application we will be constructing comprises a home, about, contact, gallery and item view as follows:
Home View #
This will contain a basic navigation bar at the top with the contents reflecting the current navigation selection:
Navigation bar: [About|Gallery|Contact]
<Main content view, this is based on the current navigation selection>
<fixed footer>
About View #
The About view is simply a page displaying details, like a description and image.
<text>
<image>
<back>
Gallery View #
The gallery view is a list of categories showing an image and description for each. Clicking on the image result in navigating to the Item View, theres also a back button to navigate back to Home.
<fixed description text>
Category1 image
Category2 image
Category3 image
<back button>
Contact View #
The contact view contain some text and links to various social media, and a link to navigate back to Home.
<text>
<social media link1>
<social media link2>
<back button>
Item View #
The item view contains a image and descriptive text as well as a means to navigate back to the Gallery.
<text>
<image>
<back button>
The basic structure of this single page application is relatively simple and also follows The elm architecture as you would expect.
This single page application will be split into files loosely based on How i structure elm apps by Kris Jenkins.
├─ App.elm
├─ State.elm
├─ Types.elm
├─ View.elm
The main application startup will be hosted in App.elm. Application state and models will be contained within State.elm
. Types.elm will contain the various types that we will be using in the application. Finally the initial view visible to the user will be contained in View.elm
.
In addition each view in the application can be given its own module and file nested in the file structure as follows:
├─ About
│ └─ View.elm
├─ Category
│ └─ View.elm
├─ Contact
│ └─ View.elm
├─ Detail
│ └─ View.elm
├─ Gallery
│ └─ View.elm
└─ Home
└─ View.elm
Although this single page application is relatively simple and could be built by any one of the many static site engines like Ghost, Hugo or Jekyll it builds on the earlier chapters slowly adding complexity so you can see where things would lead to on a bigger site more complex site where you have additional requirements like web sockets etc.
Core structure #
First of all lets look at how we can construct the main entry point of the application, let’s create the following skeleton for App.elm
:
module Main exposing (..)
type alias Model =
{ page : string }
init =
{ page = "Home" }
view model =
div [] []
update msg model =
(model, Cmd.none)
subscription model =
Sub.None
main : Program Never
main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
All the main elements of the application are imported and a Html.program
is started with the appropriate init
, view
, update
, and subscriptions
.
Lets start to flesh things out further by further defining the model and other related types.
Model, Messages and types #
In this section we will be describing the model which represents the applications state as runtime, the messages used within the application and also any types that we might need to represent in the application too.
Model #
At the moment the model is really simple, its just a record with a single field page which is a string. Thats not a very robust way of defining the model so lets see about changing that now.
The current page of the application is fairly well defined its either going to be one of the following pages:
- Home - the default home page
- About - a page which displays information about the site
- Contact- a page which displays contact details after first exposing a captcha request
- Gallery - a page which shows the user different categories that are available to view
- Gallery Category - a page which shows a list of items available in a a category
- Item Detail - a page showing details on a single item
We can model the concept of a page really well with a union type:
type Page
= Home
| About
| Contact
| Gallery
| CategoryDetail String
| ItemDetail String
Home
, About
, Contact
and Gallery
are depicted by empty types with no specific shape but CategoryDetail
and ItemDetail
have the shape or type of a string. Come to think of it CategoryDetail
is very loosely typed being represented by a simple string, we can tighten that up too as the set of categories is also well defined. Lets just define just three categories for now:
type CategoryDetail
= Seasides
| IllustratedQuotes
| Architecture```
The same cannot be said about ItemDetail
as thats just going to be a key to the items name, it could equally be a number but lets keep that as a simple string for now. Lets update the Page type to take those new types into account:
type Page
= Home
| About
| Contact
| Gallery
| CategoryDetail CategoryDetail
| ItemDetail String
We can now also update the Model so that the field page is represented by the Page
type:
type alias Model =
{ page : Page }
Messages #
The primary messages that will be used will be either no navigate back a page or to navigate to a specific page. There will also be a message to indicate we want to use a captcha to view an email address to avoid an email address being exposed to crawlers. Lets define these three messages again using union types:
type Msg
= NavigateTo Page
| NavigateBack
| Captcha
- NavigateTo - navigates to the page detailed.
- NavigateBack - navigates back one page.
- Captcha - displays a captcha request which then shows an email address on success.
More types #
In the application we will also need types to represent the information about the categories detail and Item detail, we can use records to do this with simple string fields to represent textual information and images.
Category
will have a categoryType
, img
and description
:
type alias Category =
{categoryType : CategoryType, img : String, description : String}
Item will have an id, title, img, description and category:
type alias Item =
{id : String, title : String, img : String, description : String, category : CategoryType}
All of the field types are simple types apart from category which is also a CategoryType
which we defined above.
View #
The View is quite simple based on a standard Bootstrap 4 Navbar
navigation. We can define it by adapting some standard bootstrap html:
<nav class="navbar navbar-light bg-faded">
<a class="navbar-brand" href="#">Navbar</a>
<ul class="nav navbar-nav">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="http://example.com" id="supportedContentDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Dropdown</a>
<div class="dropdown-menu" aria-labelledby="supportedContentDropdown">
<a class="dropdown-item" href="#">Action</a>
<a class="dropdown-item" href="#">Another action</a>
<a class="dropdown-item" href="#">Something else here</a>
</div>
</li>
</ul>
</nav>
We can define the following rootView
function:
rootView model =
div [ class "container" ]
[ nav [ class "navbar navbar-light", attribute "role" "navigation" ]
[ a
[ class "pull-xs-left"
, href <| toHash Home
, onClick_ <| NavigateTo Home
]
[ img [ id "logo", class "img-fluid", src "/img/logogreen.png", srcset [ "/img/logogreen.png", "/img/logogreen@2x.png" ] ] [] ]
, button
[ attribute "aria-controls" "exCollapsingNavbar2"
, attribute "aria-expanded" "false"
, attribute "aria-label" "Toggle navigation"
, class "navbar-toggler hidden-sm-up flex-center"
, attribute "data-target" "#exCollapsingNavbar2"
, attribute "data-toggle" "collapse"
, type' "button"
]
[ text "☰" ]
, div [ class "collapse navbar-toggleable-xs", id "exCollapsingNavbar2" ]
[ ul [ class "nav navbar-nav pull-sm-right text-xs-center" ]
[ renderMenuItem model Home "Home"
, renderMenuItem model About "About"
, renderMenuItem model Gallery "Gallery"
, renderMenuItem model Contact "Contact"
]
]
]
, div [ class "content container-fluid" ] [ viewPage model ]
]
Theres are several functions here which we have not seen before: toHref
, onClick_
, srcset
, renderMenuItem
and viewPage
, lets go though them now.
Converting pages to hrefs #
When we want to navigate we can either use a href
node or send a command to navigate via the Elm architecture. If we want to navigate using href
then we need some way to convert our representation of a page into something that can be represented in the url in the browser navigation:
toHref : Page -> String
toHash page =
1case page of
Home ->
2"/home"
About ->
"/about"
Contact ->
"/contact"
Gallery ->
"/gallery"
CategoryDetail categoryType ->
3"/category/" ++ toString categoryType
ItemDetail name ->
"/item/" ++ name
The case statement is used to start pattern matching on the page type, we will have a separate statement to handle each case in the page union type.
For a simple page like About
, we return a string that represents a simple path.
For a more complex page like CategoryDetail
we have to combine elements from the union type to build a path. Here you can see that the categoryType
is concatenated not the string “category/”. The function toString
is used to convert categoryType
into a string as it is also a union type.
onClick event handling #
The astute reader may have noticed the onClick_
with a trailing underscore which is different to the normal definition of onClick
. The onClick event is also working hand in hand with the href
to allow us to navigate with our own event handler. We define a slightly different onClick
as follows:
onClick_ : a -> Attribute a
onClick_ msg =
onWithOptions "click" { stopPropagation = True, preventDefault = True} (succeed msg)
Whats happening here is we are using onWithOptions
to define an alternative onClick
thats stops the propagation of the event and also overrides the default browser behaviour. If we did not do this then clicking on the link would result in the browser trying to open the href
which would result in a page not found 404 error as the page does not actually exist, rather it is generated by the Elm architecture during the navigation and updating of the application.
Multiple images sources based on pixel density #
srcset
is an html 5 img
attribute that can be used to apply different images depending on the native resolution of the browser, [Mozilla.org][6] describes it as follows:
A list of one or more strings separated by commas indicating a set of possible image sources for the user agent to use. Each string is composed of: a URL to an image, optionally, whitespace followed by one of: a width descriptor, or a positive integer directly followed by ‘w’. The width descriptor is divided by the source size given in the sizes attribute to calculate the effective pixel density. a pixel density descriptor, which is a positive floating point number directly followed by ‘x’.
We can define srcset
is defined as follows:
srcset : List String -> Attribute a
srcset items =
let
maps =
1items |> List.indexedMap (\i item -> item ++ " " ++ toString (i + 1) ++ "x")
in
property "srcset" (2maps |> String.join "," >> Json.Encode.string)
An indexed map is applied to each of the strings in items, we use the index to define the pixel density descriptor. The mapped strings are joined back together with String.join and encoded as a Json string. So in the view where we see:
srcset ["/img/logogreen.png","/img/logogreen@2x.png" ]
we will get the following attribute:
<img srcset="/img/logogreen.png 1x,/img/logogreen@2x.png 2x”>
Defining navigation and changing the appearance #
renderMenuItem
is changing a navigations item’s style based on the current page and also defines the navigation to a specific page:
renderMenuItem : Model -> Page -> String -> Html.Html Msg
renderMenuItem model navigationPage txt =
let
liClass = //1
(if model.page == navigationPage
then "nav-item active"
else "nav-item")
textElement = //2
if model.page == menuItem
then [ text txt
, span [ class "sr-only" ]
[ text "(current)" ]
]
else [text txt]
in
li [class liClass]
[ a [ class "nav-link specialEliteFont"
, href (toHref navigationPage), onClick_ <| NavigateTo navigationPage //3
]
textElement
]
Here we define
liClass
to be eithernav-item
active if the current page is equal tomenuItem
if the current page is equal to
menuItem
We define atextElement
which will have it an extra span with the classsr-only
(Screen Reader only) defined and the text “(Current)”. If it is not the current page then we just use the text. This is so that screen readers will have an indication of what navigation option is active as an accessibility aid.Notice the
onClick_
event we defined along with theNavigateTo
message we defined the the Messages section.
Rendering the sub views #
The sub view is shown underneath the navigation menu:
______________
| Navigation |
|____________|
| |
| sub view |
|____________|
We render the subview with viewPage
, the view is updated depending on which page is current:
viewPage model =
case model.page of
Home -> getHomePage ()
About -> getAboutPage ()
Gallery -> getGalleryAsCards ()
Contact -> getContactPage ()
CategoryDetail category -> getCategoryPageCards category
ItemDetail item -> getItemPage item
You can see there is a separate view for each page which in return a list of nodes for that particular view.
For each of these sub views we can create a separate module and import the function into View.elm.
Any of the parameterless pages could be defined very simply, heres an example of what Home.View
could look like:
module Home.View exposing (..)
import Html exposing (br, div, Html, img, p, text)
import Html.Attributes exposing (class, src)
getAboutPage : () -> Html Msg
getAboutPage () =
div [ class "container-fluid" ] [ text “About" ]
We create that file in the Home folder and name the file View.Elm
. Remember from Chapter 3 than Elm enforces naming of modules and file names to coincide with the name of the file and module name. So we have to ensure we have a file named View.elm
in a folder named Home
. The module name also has to be Home.View
, don’t worry the elm compiler will call you out if you get anything wrong.
We can import the module and function into View.elm
by adding an import statement to the top of the file:
import Home.View exposing (getHomePage)
Heres a slightly more advanced example using one of the pages CategoryDetail
with parameters Category.View
:
module Category.View exposing (getCategoryPageCards)
import Html exposing (a, br, div, Html, img, p, text)
import Html.Attributes exposing (alt, class, href, name, src)
getCategoryPageCards category =
let
items =
List.filter (\c -> c.category == category) Data.items
colClass =
case List.length items of
1 -> "col-xs-12"
2 -> "col-xs-12 col-sm-6"
_ -> "col-xs-12 col-sm-6 col-md-4"
itemMapper item =
div [ class colClass ]
[ div [ class "card"]
[ a [ noContextMenu
, href (toHref <| ItemDetail item.id)
, onClick_ (NavigateTo <| ItemDetail item.id)
]
[ img [ noContextMenu, class "card-img-top img-fluid", src item.img ] [] ]
, div [ class "card-block" ]
[ p [ class "card-text" ] [ text item.title ] ]
]
]
in
div [ class "container-fluid" ]
[ div [ class "row" ]
(items |> List.map itemMapper)
, div [] [ backButton ]
]
The nodes returned from getCategoryPageCards
are returned as part of viewPage
. Data.items
are filtered by the current category and mapped into divs with onClick_
navigation to the ItemDetail
page.
Update #
The purpose of the update function is to update our model in relation to external events, in this instance its going to be mainly navigation oriented so the update is rather simple.
The update function is defined as follows:
update : Msg -> Model -> ( Model, Cmd b )
update msg model =
case msg of
1NavigateTo page ->
( model, (Navigation.newUrl <| pageToString page) )
2NavigateBack ->
model => (Navigation.back 1)
3Captcha ->
( model, captcha() )
We pattern match on the msg parameter and use the commands described in the Messages section.
For the NavigateTo
command we update the model to the new page.
For navigating back we use a command from the Navigation
package to navigate back exactly one page.
For the Captcha
command we run another function to do that work for us, the captcha function.
The captcha
function is defined as Port defined like this:
port captcha : () -> Cmd msg
We then need to add a little JavaScript subscription to the port which opens a new url with the captcha verification :
app.ports.captcha.subscribe(function () {
window.open('http://www.google.com/recaptcha/mailhide/...', '', 'toolbar=0,scrollbars=0,location=0,statusbar=0,menubar=0,resizable=0,width=500,height=300');
return false;
});
Whats happening here is that when the captcha message is received the JavaScript subscriber is notified and a a window is opened allowing the email address to be retrieved if the captcha is successful.
Navigation and Uri parsing #
Navigation or routing as it is sometimes called in single page applications is the managing of the browser address bar without creating new requests to servers etc. This is done internally via Html 5 push state.
Navigation in Elm is handled by the Elm Navigation package. This package provides an alternative program Navigation.program
which means the one we defined in Core structure now needs to be altered. The program function in Elm Navigation has been extended with an additional two extra arguments. The main program entry point needs to be modified to look like this:
main : Program Never
main =
Navigation.program (Navigation.makeParser pathParser)
{ init = nit
, view = view
, update = update
, urlUpdate = urlUpdate
, subscriptions = subscriptions
}
The first additional argument is a Parser
, there is a utility function called makeParser
in the Navigation package that allows us to define a function to turn a browser Location into whatever data we want to:
makeParser : (Location -> a) -> Parser a
In the Navigation.program
above you can see this used along with the pathParser
function below to parse a Location into a Page
:
Navigation.program` (Navigation.makeParser pathParser)
## Parsing
Parsing is handled with a parser combinator library defined in the url-parser package. There are other parser combinator libraries which add more functionality to url-parser but it has everything you need for most situations. We covered combinators in chapter 7 Interoperability and this is very much the same concept of combining small functions to create more complex functions and behaviour. We will only be using a small selection of combinators to parse the results:
oneOf
is a combinator that will try to match one of the parsers in a list.s
is a string combinator matching a particular string like “home”, “shop” etc.</>
is a combinator that matches a/
character in the location like item/myitem.UrlParser.string
matches any string.format
Is a combinator that allows you to customise or map another Parser, here it is used to Parsed output into the union types that represent them.
pathParser : Navigation.Location -> Result String Page
pathParser location =
parse identity pageParser (String.dropLeft 1 location.pathname)
The pathParser
functions first parameter is a function to map the successful parsing to another type here we are using the identity
function to leave the result as is. The String.dropLeft 1
function is removing the leading character from location.pathname
which is the leading forward slash.
pageParser : UrlParser.Parser (Page -> a) a
pageParser =
oneOf
[1format Home (oneOf [ s "home", s "" ])
,2 format About (s "about")
, format Shop (s "shop")
, format Gallery (s "gallery")
, format Contact (s "contact")
,3 format (stringToCategoryType >> CategoryDetail) (s "category" </> UrlParser.string)
, format ItemDetail (s "item" </> UrlParser.string)
]
The format
function is part of the navigation package and is simply a function to map the parsing result in if successful, in this instance we are using the Page
union type constructors to perform that map. The combinators oneOf
is used to choose between several in the preceding list, in this instance s
is used to match the strings home and an empty string.
Again format is used to construct a map to the About Page. The s
combinator is again use to match the string “about”
This time format is used with an extra function stringToCategoryType. As shown below, . The combinators used here are the s combinator to match the string “category”, the </>
the forward slash combinator and finally UrlParser.string
which matches any string. e,g, category/Seasides
stringToCategoryType : String -> CategoryType
stringToCategoryType category =
case category of
"Seasides" ->
Seasides
"IllustratedQuotes" ->
IllustratedQuotes
_ ->
Unknown
To match the category in the url back to a CategoryType
we match the corresponding string representation back into a CategoryType
.
Finally now that we know how parsing work we can look at the urlUpdate
to see how it works:
urlUpdate : Result a Page -> Model -> ( Model, Cmd c )
urlUpdate result model =
case result of
Err _ ->
( model, Navigation.modifyUrl (pageToString model.page) )
Ok page ->
{ model | page = page } => updateAnalytics (pageToString page)
We pattern match on the result witch is a Result
type and if its the Ok
case then we update the models page to the one passed in. If the result is an error (Err
) then we modify the url with Navigation.modifyUrl
just pointing it back to the previous page. pageToString
simply turns the Page
type back into a string. I’m going to strategically ignore updateAnalytics
for now as this will be covered in the next section.
Google Analytics integration #
Google analytics can be easily added to any web application be simply creating an account and including the following JavaScript in your html and replacing UA-12345678-1 with your own id:
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-12345678-1', 'auto');
ga('require', 'linkid');
ga('send', 'pageview');
</script>
The problem with this solution is that theres only one real page in the application, it would be really nice if the navigation in this application could show correctly. We need to provide a way to update Google analytics whenever the page navigation changes. Luckily Google provides a way to do this via the ga('set', 'page', page)
and ga('send', ‘pageview')
JavaScript functions. We can do this by defining another port. Remember the updateAnalytics
function from urlUpdate
above?
{ model | page = page } => updateAnalytics (pageToString page)
Well thats the port we are going to define now, it looks like this:
port updateAnalytics: String -> Cmd msg
Now all we need to do is wire up a little more JavaScript so that when the updateAnalytics
function is called the JavaScript subscriber is notified and Google analytics is updated correctly.
The JavaScript looks like this:
app.ports.updateAnalytics.subscribe(function (page) {
ga('set', 'page', page);
ga('send', 'pageview');
});
Summary #
We have covered quite a range of different aspects in this chapter:
- Setting up a project with a structure that supports a lot more expansion rather than having thousands of lines crammed into a single file.
- We used a model and type based approach to model the navigation and pages with the application
- We learned how Navigation works in single page applications
- Used Html 5
img
srcset
attribute to apply different images based on pixel density - Added custom events to override default browser behavior on navigation to a url
- Learned how to use parser combinators to parse url fragments
- Used ports to communicate with JavaScript
- Solved a problem with the correct navigation been shown in single page apps analytics
Final word #
I hope this blog post has been a useful read to someone, as I said I wrote it as partial sample chapter for a publisher but they mucked me about so I though it was better off being on my blog rather than gathering virtual dust.
Thanks for reading
Until next time!