Building a basic UI-clone of Instagram using Elm - Part 3

This article is a part of a series, if you haven't read the first or second parts yet you can read them here and here. 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 single post view

Now that we can navigate to a new page/view, let's start building out the view properly. If you recall where we left off, the SinglePost case in View.viewPage was just rendering the postId that was parsed from the URL. We need to use this postId to find the right Post in model.posts. We'll define a View.getPost helper function to do this now.

-- View.elm

getPost : String -> List Post -> Maybe Post  
getPost postId posts =  
    let
        postsById = List.filter (\post -> post.id == postId) posts
    in
        List.head postsById

We need to return a Maybe Post because the postId string parsed from the URL might not be a valid Post.id, or there might just not be a Post in model.posts that matches the postId. We approach the problem by filtering the list to Posts with a Post.id matching postId which should leave 1 or 0 Posts in the postsById list (we're trusting the data source here that Post.id is unique), then we take the first item from the filtered list, which will return a Maybe Post. Now let's update the SinglePost case of View.viewPage to use View.getPost and View.viewPost to render a single post.

-- View.elm

viewPage : Model -> Html Msg  
viewPage model =  
    case model.page of
        ListOfPosts ->
            -- ...

        SinglePost postId ->
            case getPost postId model.posts of
                Just post ->
                    div [ class "photo-single" ]
                        [ viewPost model post ]

                Nothing ->
                    div []
                        [ text ("Post " ++ postId ++ " not found.") ]

You should be able to recompile and open the app in the browser now and the SinglePost view will render a single post, which is fun, but no more useful than the ListOfPosts view. We'll load and display the list of comments (if any) on the SinglePost view next.

Load and display comments

We've got a bit of work to do in order to load the comments, but thankfully it's all derivative of things we've already done. Firstly, download all of the *.json files from here and save them to data/*.json. If you inspect one of the files you'll notice that it's filename is a {Post.id}.json and the JSON data looks like this:

[
  ...,
  {
    "id": "17856770641073526",
    "username": "tiktoktikkdotcom",
    "time": 1478756236,
    "text": "This is definitely first rated!"
  },
  ...
]

Basically for each Post we have a {Post.id}.json with the list of comments for that post. We only care about the "username" and "text" properties for now, let's firstly define a type alias in Types.elm and a decoder in Rest.elm.

-- Types.elm

type alias Comment =  
    { username : String
    , text : String
    }
-- Rest.elm

import Types exposing (Msg(..), Post, Comment)

decodeComments : Json.Decoder (List Comment)  
decodeComments =  
    list <|
        map2 Comment
            (field "username" string)
            (field "text" string)

Most of this is pretty similar to what we already did with with the Post type and decoder. Notice that we've used Json.Decoder.map2 to extract two properties from each JSON object. While we're in Rest.elm let's also add a function that takes a Post.id and loads it's associated JSON comments file.

-- Rest.elm

getPostComments : String -> Cmd Msg  
getPostComments postId =  
    Http.send (FetchComments postId) <|
        Http.get ("data/" ++ postId ++ ".json") decodeComments

Again this is pretty similar to Rest.getPosts that we defined previously, except that we're taking a postId parameter, and we also pass the postId through to the FetchComments action. We should now add FetchComments to the Types.Msg union type and while we're in Types.elm we should add the lists of Comments to Types.Model and update Types.initialModel to initialise it. We'll store the lists of Comments in a Dict which is provided by elm-lang/core, but we need to import it before we can use it. The keys in our Dict will be Post.ids and the values will be List Comment. Note that Dict.empty just initialises an empty Dict.

-- Types.elm

import Dict exposing (Dict)

type alias Model =  
    { posts : List Post
    , comments : Dict String (List Comment)
    , page : Page
    }

initialModel : Page -> Model  
initialModel page =  
    Model [] Dict.empty page

type Msg  
    = FetchPosts (Result Http.Error (List Post))
    | FetchComments String (Result Http.Error (List Comment))
    | -- ...

Now we should move onto State.elm to handle the FetchComments action in State.update, don't forget to import Dict here too.

-- State.elm

import Dict exposing (Dict)

update : Msg -> Model -> (Model, Cmd Msg)  
update msg model =  
    case msg of
        -- ...

        FetchComments postId (Ok comments) ->
            { model
                | comments = Dict.insert postId comments model.comments
                , posts = List.map (setPostComments postId <| List.length comments) model.posts
            } ! []

        FetchComments postId (Err _) ->
            update (FetchComments postId <| Ok []) model

setPostComments : String -> Int -> Post -> Post  
setPostComments postId numberOfComments post =  
    if post.id == postId then
        { post | comments = numberOfComments }
    else
        post

Just like with FetchPosts, we actually get a Result and need to handle the Err case as well as the Ok case. I decided that for the Err case we'd just call update again but this time passing Ok [], which as we'll see next will initialise the Dict entry for the given postId to an empty list of Comments. The Ok case needs to do two things, firstly we insert the list of Comments into the Dict with Dict.insert, then we need to update the counter in the Post.comments property of the Post with a Post.id matching postId. Unlike the IncremementLikes case where we defined a helper function inside the case block, we define setPostComments separately because we'll be reusing this function when we add and remove comments. setPostComments takes a Post.id, an updated number of Comments and a Post, we use it in the FetchComments case to List.map over model.posts.

The final change that we need to make in State.elm is to actually load the Comments by calling Rest.getPostComments. In both State.init and State.update we'll split the Just page case to have slightly different behaviour for Just ListOfPosts and Just SinglePost. Just ListOfPosts will basically be the same as the current Just page case in both cases, but for Just SinglePost we'll want to call Rest.getPostComments in both cases.

-- State.elm

init : Navigation.location -> (Model, Cmd Msg)  
init location =  
    case UrlParser.parseHash pageParser location of
        Just ListOfPosts ->
            initialModel ListOfPosts
                ! [ Rest.getPosts
                  ]

        Just (SinglePost postId) ->
            initialModel (SinglePost postId)
                ! [ Rest.getPosts
                  , Rest.getPostComments postId
                  ]

        Nothing ->
            -- ...

update : Msg -> Model -> (Model, Cmd Msg)  
update msg model =  
    case msg of
        -- ...

        NavigatedTo maybePage ->
            case maybePage of ->
                Just ListOfPosts ->
                    { model | page = ListOfPosts }
                        ! []

                Just (SinglePost postId) ->
                    { model | page = SinglePost postId }
                        ! [ Rest.getPostComments postId
                          ]

                Nothing ->
                    -- ...

Now we need to actually show the comments. Firstly we'll define a getPostComments helper function to return a List Comment from model.comments. Because Dict.get returns a Maybe we need to handle the Nothing case, we'll just return an empty List. Don't forgot to import Dict here too.

-- View.elm

import Dict exposing (Dict)

getPostComments : String -> Dict String (List Comment) -> List Comment  
getPostComments postId comments =  
    case (Dict.get postId comments) of
        Just postComments ->
            postComments

        Nothing ->
            []

Next we need to make some changes to the existing View.viewPost function. We'll determine if we should show the list of Comments by inspecting model.page, we only want to show them for the SinglePost case, so we'll just show an empty div for the ListOfPosts case. We'll add the comments directly after p.photo-caption.

-- View.elm

viewPost : Model -> Post -> Html Msg  
viewPost model post =  
    let
        displayComments =
            case model.page of
                SinglePost postId ->
                    viewComments model post

                ListOfPosts ->
                    div [] []
    in
        -- ...
        , p [ class "photo-caption" ] [ text post.text ]
        , displayComments
        -- ...

Finally we'll define the viewComments and viewComment functions. viewComment defines the bulk of the DOM structure, but returns a (String, Html Msg) which if you recall is used by Html.Keyed.node. In viewComments we'll use List.indexedMap to pass an Int index as well as the Comment through to viewComment. viewComments then just returns a Html.Keyed.node using the list of DOM returned by the viewComment calls for each Comment.

-- View.elm

viewComments : Model -> Post -> Html Msg  
viewComments model post =  
    let
        listOfComments =
            List.indexedMap (viewComment post) <|
                getPostComments post.id model.comments
    in
        Html.Keyed.node "div"
            [ class "comments" ] <|
            listOfComments


viewComment : Post -> Int -> Comment -> (String, Html Msg)  
viewComment post index comment =  
    ( toString index
    , div [ class "comment" ]
        [ p []
            [ strong [] [ text comment.username ]
            , text comment.text
            ]
        ]
    )

At this stage you should be able to recompile and test the app in the browser again. When you click through to any of the SinglePost views you should see a list of Comments! Let's remove some of those comments next.

Implement the remove comment button

We'll add the ability to remove comments by adding a little inline "x" button after each comment. Using our established pattern we'll firstly add the RemoveComment action to Types.Msg.

-- Types.elm

type Msg  
    = -- ...
    | RemoveComment String Int

The String will be a Post.id and the Int will be an index representing the location of the Comment in the List. Let's add the handler for this action in State.update now. We're going to use a function from List.Extra which is provided by elm-community/list-extra, so install it now with elm package install elm-community/list-extra and import List.Extra.

-- State.elm

import List.Extra

update : Msg -> Model -> (Model, Cmd Msg)  
update msg model =  
    case msg of
        -- ...

        RemoveComment postId index ->
            let
                removePostComment : Maybe (List Comment) -> Maybe (List Comment)
                removePostComment comments =
                    case comments of
                        Just comments ->
                            Just <| List.Extra.removeAt index comments

                        Nothing ->
                            Nothing

                numberOfPostComments =
                    getNumberOfPostComments postId model.comments
            in
                { model
                    | comments = Dict.update postId removePostComment model.comments
                    , posts = List.map (setPostComments postId <| numberOfPostComments - 1) model.posts
                } ! []

getNumberOfPostComments : String -> Dict String (List Comment) -> Int  
getNumberOfPostComments postId comments =  
    case Dict.get postId comments of
        Just postComments ->
            List.length postComments

        Nothing ->
            0

We need the handler to firstly remove the Comment from the right location in model.comments, which is determined by the index in the List Comment where the Dict key matches the passed in postId, then it needs to update the comments counter for the right Post in model.posts. We've defined a couple of helper functions for this, firstly getNumberOfPostComments which we've defined independently because we'll use it again soon when we want to add a new Comment, it just gets the List Comment by using Dict.get with the passed in postId, then either returns the length of the list, or 0 if the Dict doesn't have an entry matching the postId. removePostComment is defined in the RemoveComment handler because it's only ever used here. We use List.Extra.removeAt to attempt to remove from the provided List of Comments the item at the provided index, it returns the updated List. In the model record update statement we use Dict.update with the removePostComment function, then similar to the FetchComments handler we use List.map with setPostComments to update the number of Comments for the given Post.

Finally let's add the actual "x" button into the view, just add the following line to View.viewComment immediately after , text comment.text:

-- View.elm

viewComment : Post -> Int -> Comment -> (String, Html Msg)  
viewComment post index comment =  
    -- ...
    , text comment.text
    , button [ onClick <| RemoveComment post.id index, class "remove-comment" ] [ text "×" ]
    -- ...

Recompile and open the app in your browser, click through to one of the SinglePost views and remove comments to your heart's content by clicking the little "x" button next to each comment. You might notice that if you remove some comments, navigate back to the ListOfPosts view and then back to the same SinglePost view, the comments that you just removed will all be back! This is because we always load the list of Comments when we navigate to the SinglePost view. To fix it we'll update the Ok case of FetchComments in State.update to check if there's an existing entry in Dict for the postId and only update the model if it's a new entry in the Dict.

-- State.elm

update : Msg -> Model -> (Model, Cmd Msg)  
update msg model =  
    case msg of
        -- ...

        FetchComments postId (Ok comments) ->
            case Dict.get postId model.comments of
                Just existingComments ->
                    model ! []

                Nothing ->
                    { model
                        | comments = Dict.insert postId updatedComments model.comments
                        , posts = List.map (setPostComments postId <| List.length updatedComments) model.posts
                    } ! []

Recompile and test it in the browser. Removed comments now stay removed! ...until you refresh the page, but that's another story. Let's move onto the final piece of this puzzle, adding a new comment.

Implement the add comment form

This last step will require updating text in some input fields. Starting in Types.elm, we will add and initialise a newComment property to the Model record and we'll need 3 new actions in Msg, one for each of the two input fields, and one for actually adding the comment.

--Types.elm

type alias Model =  
    { posts : List Post
    , comments : Dict String (List Comment)
    , page : Page
    , newComment : Comment
    }

initialModel : Page -> Model  
initialModel page =  
    Model [] Dict.empty page (Comment "" "")

type Msg  
    = -- ...
    | UpdateCommentUsername String
    | UpdateCommentText String
    | AddComment String Comment

Moving to State.elm, the two UpdateComment... actions are quite simple and basically the same, they just update the corresponding property in model.newComment.

-- State.elm

update : Msg -> Model -> (Model, Cmd Msg)  
update msg model =  
    case msg of
        -- ...

        UpdateCommentUsername username ->
            let
                comment = model.newComment
            in
                { model
                    | newComment = { comment | username = username }
                } ! []

        UpdateCommentText text ->
            let
                comment = model.newComment
            in
                { model
                    | newComment = { comment | text = text }
                } ! []

The AddComment action is more complex than these, but quite similar to the RemoveComment action. The addPostComment helper function that we'll define is also used by Dict.update, but either concatenates the new Comment onto the existing List of Comments or returns a new List containing just the new Comment. We also need to reset the two input fields after adding the comment.

-- State.elm

update : Msg -> Model -> (Model, Cmd Msg)  
update msg model =  
    case msg of
        -- ...

        AddComment postId comment ->
            let
                addPostComment : Maybe (List Comment) -> Maybe (List Comment)
                addPostComment comments =
                    case comments of
                        Just comments ->
                            Just <| comments ++ [ comment ]

                        Nothing ->
                            Just [ comment ]

                numberOfPostComments =
                    getNumberOfPostComments postId model.comments
            in
                { model
                    | comments = Dict.update postId addPostComment model.comments
                    , posts = List.map (setPostComments postId <| numberOfPostComments + 1) model.posts
                    , newComment = Comment "" ""
                } ! []

Finishing up with View.elm we need to define View.viewCommentForm to build the actual form DOM, then use it in View.viewComments. We actually append the comment form to the list of comments, so it also needs to return a (String, Html Msg) tuple for the Html.Keyed.node. Remember to expose onInput and onSubmit from the Html.Events import.

-- View.elm

import Html.Events exposing (onClick, onInput, onSubmit)

viewComments : Model -> Post -> Html Msg  
viewComments model post =  
    -- ...
        Html.Keyed.node "div"
            [ class "comments" ] <|
            listOfComments
                ++ [ viewCommentForm model post ]

viewCommentForm : Model -> Post -> (String, Html Msg)  
viewCommentForm model post =  
    ( "comment-form"
    , Html.form [ onSubmit <| AddComment post.id model.newComment, class "comment-form" ]
        [ input
            [ type_ "text"
            , value model.newComment.username
            , onInput UpdateCommentUsername
            , placeholder "author..."
            ]
            []
        , input
            [ type_ "text"
            , value model.newComment.text
            , onInput UpdateCommentText
            , placeholder "comment..."
            ]
            []
        , input
            [ type_ "submit"
            , hidden True
            ]
            []
        ]
    )

By now this should all look pretty straight-forward. We bind the value of each input to properties in model.newComment and we trigger UpdateComment... actions when either of the inputs change. We trigger the AddComment action with the forms onSubmit event.

Recompile and open the app in your browser and you should now be able to leave any nasty comments that you want anonymously without anybody else ever seeing them!

The end!

You can view the code that we've built here. We've covered quite a lot in building this example:

  • Bootstrap an Elm application
  • Define and handle changes to the model state object
  • Define a DOM structure which responds to changes in the model
  • Get data with HTTP requests and decode a JSON response
  • Add navigation with multiple pages/views, run commands after navigating to a new page
  • Define and handle changes to forms

This doesn't solve all of the problems that you'll face trying to build a front-end web application with Elm, but these foundations should give you enough to build most of what you require. Hopefully this has been an interesting exercise for you and encourages you to try and build something interesting with Elm.

If you missed one of the earlier parts, feel free to go back and read them too:

  • Part 1 - Setup an Elm app and load posts from a JSON file
  • Part 2 - Build the main list view and add navigation