This article is a part of a series, you should read part 1 if you haven’t already. Alternatively you can get the code from the end of the last article here and continue along. You can view the finished app here and all of the source code is available here.
Implement the main list of posts view
Getting a list of posts is all well and good, but not particularly exciting if we’re just going to show a count of the number of posts. We’ll build the UI for the main list of posts now, and as you might have guessed, most of the work for this step will be in View.elm. First we’ll get some house-keeping out of the way. We want more control over index.html so add or replace your index.html with this:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Elmstagram</title>
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div id="app"></div>
<script src="js/app.js"></script>
<script>
Elm.App.embed(document.getElementById('app'));
</script>
</body>
</html>
Download and save the final CSS from here to css/styles.css, then download all of the fonts from here to fonts/*. Next, move posts.json to data/posts.json and update the URL in Rest.getPosts
to "data/posts.json"
.
We’re no longer inlining the compiled JavaScript code, so we need to manually bootstrap it after the script loads. We’ll need to build js/app.js and with elm make
:
$ elm make App.elm --output js/app.js
You should be able to start or restart your static HTTP server and see that everything is still working.
OK, onto the view changes. The View.rootView
is, as the name suggests, going to be the root view. When we have multiple pages the structure defined here will be common to all pages. Change it to look as follows then download and save this logo to img/logo.svg.
-- View.elm
import Html.Attributes exposing (..)
rootView : Model -> Html Msg
rootView model =
div [ id "app-root" ]
[ main_ []
[ div [ class "photo-list" ] <|
List.map (viewPost model) model.posts
]
, nav []
[ div [ class "nav-inner" ]
[ a [ href "./", class "nav-logo" ]
[ img [ src "./img/logo.svg" ] []
, text "Elmstagram"
]
]
]
, footer []
[ div [ class "footer-inner" ]
[ p []
[ a [ href "https://github.com/bkbooth/Elmstagram.git" ] [ text "View Source" ]
, text "|"
, a [ href "./" ] [ text "Elmstagram" ]
]
]
]
]
We’ve added Html.Attributes
as another import where we expose everything, this is because similar to Http
we will end up using a large number of the functions. This should all be fairly self-explanatory, each DOM node takes a list of attributes followed by a list of child nodes. The only other item of interest is where we List.map
with a view function over our list of Post
s in model.post
. We’ll define View.viewPost
now, we want viewPost
to be reusable for each post in the main list and for when we click through to a single post. The function will look like this (don’t forget to add Post
to the import Types
line).
-- View.elm
import Types exposing (Model, Msg(..), Post)
viewPost : Model -> Post -> Html Msg
viewPost model post =
figure [ class "photo-figure" ]
[ div [ class "photo-wrap" ]
[ a [ href "#" ]
[ img [ src post.media, alt post.text, class "photo" ] []
]
]
, figcaption []
[ div [ class "caption-button" ]
[ button [ class "like-button" ] [ text "♡" ]
]
, div [ class "caption-content" ]
[ div [ class "photo-stats" ]
[ strong [] [ text <| toString post.likes ]
, text " likes, "
, strong [] [ text <| toString post.comments ]
, text " comments"
]
, p [ class "photo-caption" ] [ text post.text ]
]
]
]
Again, this is fairly straight-forward building of a DOM tree, note where we access the properties of the Post
that is passed into the function (this function is called for every Post
in model.posts
). At this stage you should be able to rebuild js/app.js with elm make App.elm --output js/app.js
, start your static HTTP server if it isn’t still running and reload http://localhost:8080 in your browser to see the layout and list of posts.
An optimisation that we can and should make is to use Html.Keyed
to turn each node in our list of Post
s into a keyed node to help with Elm’s rendering. This is important for lists where items are getting added, moved, removed, etc. but also when we add navigation between views that are using similar elements. In an early version of this app before adding Html.Keyed
, whenever I navigated back to the main list view from a single post view, the first item in the list was always the post that I’d just been looking at momentarily. Using Html.Keyed
fixes problems like these, so lets use it now. Because we want to make use of the current View.viewPost
implementation on the single post view, we’ll keep it as is and create a wrapper function that returns a keyed node. Add View.viewKeyedPost
like this:
-- View.elm
import Html.Keyed
viewKeyedPost : Model -> Post -> (String, Html Msg)
viewKeyedPost model post =
( post.id
, viewPost model post
)
As you can see, a keyed node is a (String, Html Msg)
tuple instead of just a Html Msg
node, the string is the unique identifier for that node, in this case the post.id
. Change div.photo-list
to a keyed node which uses viewKeyedPost
like this View.rootView
-- View.elm
Html.Keyed.node "div"
[ class "photo-list" ]
<| List.map (viewKeyedPost model) model.posts
Recompile and reload to see that everything should still be working.
Implement the “Like” button
Now let’s implement the functionality for the “like” button which is the heart next to the number of likes and comments on each post. It would be fairly easy to implement double-click to like similar to Instagram, but I’ve decided to reserve clicking on the image for navigating to the single post view. A general procedure which we’ll follow now for adding a new action is to:
- add the new action to the
Types.Msg
union type - handle the action in
State.update
- use/call the action somewhere in the
View
Firstly, update Types.Msg
to look like this:
-- Types.elm
type Msg
= FetchPosts (Result Http.Error (List Post))
| IncrementLikes String
The String
parameter after IncrementLikes
will be a Post.id
. Next add this to the State.update
case statement after the two FetchPosts
cases:
-- State.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case action of
-- ...
IncrementLikes postId ->
let
incrementPostLikes : String -> Post -> Post
incrementPostLikes postId post =
if post.id == postId then
{ post | likes = post.likes + 1 }
else
post
in
{ model
| posts = List.map (incrementPostLikes postId) model.posts
} ! []
This looks significantly more complex than the previous State.update
cases, but most of it is the definition of the incrementPostLikes
function. We could move this function out but this is the only place where the function is used, so I find it simpler to just keep it defined here. The function itself just takes a String
representing a Post.id
and a Post
, if the passed in postId
matches the passed in post.id
we increment the likes
property and return the updated post
, otherwise we just return the post
as is. We use this function in the model
update to List.map
over the model.posts
. Basically all this does is return a new model
, with an updated posts
property, where any Post
with the same Post.id
will have it’s likes
property incremented.
Finally, we need to update View.viewPost
. In View.elm, you should still have import Html.Events exposing (onClick)
from the starting example, if not add it back in now. Find button.like-button
in View.viewPost
and add an onClick
handler like this:
-- View.elm
button [ onClick <| IncrementLikes post.id, class "like-button" ] [ text "♡" ]
Now you should be able to recompile and test the app in the browser. Click any of the heart icon “like” buttons and the likes counter for the corresponding post will increment, exciting!
Add a second page and navigation
Just about any reasonably complex web application will contain multiple views and manage the navigation between those views by using the URL. Storing some of the application state in the URL also makes it easier to share a link to a particular page or view. There are two libraries provided by Elm for handling changes in the URL, namely elm-lang/navigation
and evancz/url-parser
. Go ahead and install both of them now with elm package install elm-lang/navigation
and elm package install evancz/url-parser
. It’s worth noting that Elm has been deliberate about calling this set of behaviours “navigation” and not “routing” as you would see in most front-end libraries and frameworks.
The first change required to introduce navigation to an Elm app is right at the top in App.elm, elm-lang/navigation
provides Navigation.program
which we use to create App.main
instead of Html.app
. Replace import Html
with import Navigation
, then replace Html.program
with Navigation.program
and pass State.hashParser
is the first parameter before the record definition, we’ll define State.hashParser
shortly. App.main
should now look like this.
-- App.elm
import Navigation
main : Program Never Model Msg
main =
Navigation.program State.hashParser
{ init = State.init
, update = State.update
, subscriptions = State.subscriptions
, view = View.rootView
}
Next we need to update Types.elm, we’ll need Model
to keep track of the current page. Firstly define a new union type Page
with the pages that you need, in this case ListOfPosts
for the main list of posts, and SinglePost String
for the single post view where the String
will be a Post.id
. We then need to add a new page
property to the Model
record. We’ll also need to update Types.initialModel
to set the initial Page
, but we’ll take the initial Page
as a parameter because we’ll parse the initial URL to work out what the initial Page
should be in State.init
. We also need to add a new action to the Msg
union type to handle navigation changes, we’ll call it NavigatedTo
and it will take a single Maybe Page
parameter.
-- Types.elm
type alias Model =
{ posts : List Post
, page : Page
}
initialModel : Page -> Model
initialModel page =
Model [] page
type Msg
= -- ...
| NavigatedTo (Maybe Page)
type Page
= ListOfPosts
| SinglePost String
Next up is State.elm and we’ve got a few changes that we need to make. We need to define some utility functions that map URL’s to a Page
and vice-versa:
-- State.elm
import Navigation
import UrlParser exposing (..)
hashParser : Navigation.Location -> Msg
hashParser location =
NavigatedTo <|
UrlParser.parseHash pageParser location
pageParser : Parser (Page -> a) a
pageParser =
oneOf
[ map ListOfPosts <| s ""
, map SinglePost <| s "view" </> string
]
toUrl : Page -> String
toUrl page =
case page of
ListOfPosts ->
"/"
SinglePost postId ->
"/view/" ++ postId
State.hashParser
is the function that we passed as the first argument to App.main
earlier. It’s role is to translate changes in Navigation.Location
to messages that State.update
can handle, in this case NavigatedTo
which we defined in the last step. To do this it uses UrlParser.parseHash
which takes a UrlParser.Parser
and a Navigation.Location
record. UrlParser
exports a number of functions to help with building an UrlParser.Parser
which matches the expected URL’s in the app and maps them to Page
s. UrlParser.parseHash
actually returns a Maybe Page
because it might not be able to map the URL to one of the known states of the app, you’ll need to handle this case too. Note that there is also a UrlParser.parsePath
function that will run a UrlParser.Parser
against the path part of the URL instead of the hash. I’ve actually used UrlParser.parsePath
in the repository, but using URL paths with a single-page web application requires some server-side support, UrlParser.parseHash
is easier for examples because we can just use a simple HTTP server. Finally State.toUrl
just returns a URL String
based on a Page
. Now we need to replace State.init
with this:
-- State.elm
init : Navigation.Location -> (Model, Cmd Msg)
init location =
case UrlParser.parseHash pageParser location of
Just page ->
initialModel page
! [ Rest.getPosts
]
Nothing ->
initialModel ListOfPosts
! [ Rest.getPosts
, Navigation.modifyUrl <| toUrl ListOfPosts
]
Because we’re using Navigation.program
, our State.init
function now gets passed a Navigation.Location
which is the URL at the time that the application is initialised. We parse this to a Maybe Page
using UrlParser.parseHash
and State.pageParser
. If it matches a page we pass the page to Types.initialModel
, otherwise we explicitly set the page to ListOfPosts
and update the URL with Navigation.modifyUrl
and State.toUrl
. You might want to redirect to a 404 Not Found page instead. In each case we still call Rest.getPosts
to load the post data. To finish up the changes in State.elm, we need to add the NavigatedTo
case to State.update
:
-- State.elm
update msg model =
case msg of
-- ...
NavigatedTo maybePage ->
case maybePage of
Just page ->
{ model | page = page }
! []
Nothing ->
model
! [ Navigation.newUrl <| toUrl ListOfPosts
]
This is pretty similar to what we just did in State.init
, except that we don’t need to trigger another action if maybePage
is a known Page
, just update model.page
. If it’s not a known Page
, we redirect to the ListOfPosts
page using Navigation.newUrl
. Navigation.newUrl
updates the state and write a new URL into the browser history, whereas Navigation.modifyUrl
updates the URL without adding a new state to the browser history.
We’ll add the proper UI for the single post view in the next part, for now we’ll just show the Post.postId
. We’ll create a new View.viewPage
function to change the view based on the current page. Replace Html.Keyed.node ...
in View.rootView
with just viewPage model
and move the Html.Keyed.node ...
block to View.viewPage
under the ListOfPosts
case. We’ll just put a dummy view in for the SinglePost
case for now. Don’t forget to expose Page(..)
from the Types
import, or you might want to just expose everything from Types
now.
-- View.elm
import Types exposing (..)
rootView : Model -> Html Msg
rootView model =
div [ id "app-root" ]
[ main_ []
[ viewPage model
]
-- ...
viewPage : Model -> Html Msg
viewPage model =
case model.page of
ListOfPosts ->
Html.Keyed.node "div"
[ class "photo-list" ]
<| List.map (viewKeyedPost model) model.posts
SinglePost postId ->
div [ class "photo-single" ]
[ text ("Post: " ++ postId) ]
Finally we need to update the links in View.elm to use State.toUrl
. Import State
and then replace both of the href "./"
’s in View.rootView
with href (State.toUrl ListOfPosts)
, then replace the href "#"
in View.viewPost
with href (State.toUrl SinglePost post.id)
. You should be able to recompile js/app.js now, start your static HTTP server and view the app in the browser.
That’s all (for now)
You can view the code that we’ve built so far here. We’ll continue building the app in Part 3: