We're going to make a more concrete set of mock data, and we'll do this by introducing a json decoder so that we can use our need for mocks to also satisfy the need to write decoders for data from the backend. Then we'll define a query function on our Page modules to fetch the data they need from the model.

It's a lot to cover. Let's get started.

Project

We're starting with the dailydrip/firestorm_elm repo tagged before this episode.

We're going to be writing JSON decoders, so let's bring in a couple of packages:

elm-package install -y NoRedInk/elm-decode-pipeline
elm-package install -y elm-community/json-extra

Next, let's introduce a decoder to the Category module:

vim src/Data/Category.elm
module Data.Category
    exposing
        ( -- ...
        , idDecoder
        , decoder
        -- ...
        )

-- ...
import Date exposing (Date)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Extra
import Json.Decode.Pipeline as Pipeline exposing (custom, decode, hardcoded, required)
-- ...
-- insertedAt and updatedAt should have always been Dates rather than Times
type alias Category =
    { -- ...
    , insertedAt : Date
    , updatedAt : Date
    }
-- ...
-- We'll add an idDecoder and export it, because the decoder for Thread will
-- need it.
idDecoder : Decoder Id
idDecoder =
    Decode.map Id Decode.int
-- We'll write a quick decoder. Json.Decode.Extra.date is super helpful here.
decoder : Decoder Category
decoder =
    decode Category
        |> required "id" idDecoder
        |> required "title" Decode.string
        |> required "slug" (Decode.map Slug Decode.string)
        |> required "inserted_at" Json.Decode.Extra.date
        |> required "updated_at" Json.Decode.Extra.date

-- We'll also remove the mockCategory function and remove it from the exports

Next, let's introduce a Store.Mocks module to allow us to set up some mocks for the store. We won't need this once we wire in a backend.

vim src/Store/Mocks.elm
module Store.Mocks exposing (store)

import Data.Category as Category exposing (Category)
import Data.Post as Post exposing (Post)
import Data.Thread as Thread exposing (Thread)
import Json.Decode as Decode exposing (decodeValue)
import Json.Encode as Encode exposing (..)
import Store exposing (Store)


-- We'll start with an empty store, inserting a couple of categories
store : Store
store =
    Store.empty
        |> insertCategory elixir
        |> insertCategory elm


-- insertCategory will attempt to insert a category decode result
insertCategory : Result String Category -> Store -> Store
insertCategory result store =
    case result of
        Err err ->
            store

        Ok category ->
            Store.insertCategory category store


-- We'll introduce a category function that decodes a JSON encoded value using
-- the Category.decoder. This way we can easily create our mock data without
-- constantly producing the encoded value object.
category : Int -> String -> String -> String -> String -> Result String Category
category id title slug insertedAt updatedAt =
    decodeValue Category.decoder
        (object
            [ ( "id", int id )
            , ( "title", string title )
            , ( "slug", string slug )
            , ( "inserted_at", string insertedAt )
            , ( "updated_at", string updatedAt )
            ]
        )


-- Then we introduce our categories
elixir : Result String Category
elixir =
    category
        1
        "Elixir"
        "elixir"
        "2017-06-01T18:25:43.511Z"
        "2017-06-01T18:25:43.511Z"


elm : Result String Category
elm =
    category
        2
        "Elm"
        "elm"
        "2017-06-01T18:25:43.511Z"
        "2017-06-01T18:25:43.511Z"

Next, let's wire up the model to use the mock store.

vim src/Model.elm
module Model exposing (Model, init)
-- ...
import Store.Mocks
-- ...
init : Route -> Model
init initialRoute =
    { -- ...
    , store = Store.Mocks.store
    }

We'll also clean up the drawer since we won't end up with our mockCategory, mockThread, or mockPost functions any longer:

vim src/Page/Layout.elm
module Page.Layout exposing (view)
-- ...
drawer : Html msg
drawer =
    div
        [ class "side-drawer" ]
        [ homeLink
        , categoriesLink
        ]


homeLink : Html msg
homeLink =
    a [ Route.href Home ] [ text "Home" ]


categoriesLink : Html msg
categoriesLink =
    a [ Route.href Categories ] [ text "Categories" ]
-- and remove the categoryLink, etc. functions

With this, the app should be mostly working, using our mock data. Let's do the same thing for threads and posts:

vim src/Data/Thread.elm
module Data.Thread
    exposing
        ( -- ...
        , decoder
        , idDecoder
        -- ...
        )

import Data.Category as Category
import Date exposing (Date)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Extra
import Json.Decode.Pipeline as Pipeline exposing (custom, decode, hardcoded, required)
-- ...
type alias Thread =
    { -- ...
    , insertedAt : Date
    , updatedAt : Date
    }
-- ...
idDecoder : Decoder Id
idDecoder =
    Decode.map Id Decode.int


decoder : Decoder Thread
decoder =
    decode Thread
        |> required "id" idDecoder
        |> required "title" Decode.string
        |> required "slug" (Decode.map Slug Decode.string)
        |> required "category_id" Category.idDecoder
        |> required "inserted_at" Json.Decode.Extra.date
        |> required "updated_at" Json.Decode.Extra.date
vim src/Data/Post.elm
module Data.Post
    exposing
        ( -- ...
        , decoder
        )

import Data.Thread as Thread
import Date exposing (Date)
import Html exposing (Html)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Extra
import Json.Decode.Pipeline as Pipeline exposing (custom, decode, hardcoded, required)
-- ...
type alias Post =
    { id : Id
    , body : Body
    , threadId : Thread.Id
    , insertedAt : Date
    , updatedAt : Date
    }
-- ...
decoder : Decoder Post
decoder =
    decode Post
        |> required "id" (Decode.map Id Decode.int)
        |> required "body" (Decode.map Body Decode.string)
        |> required "thread_id" Thread.idDecoder
        |> required "inserted_at" Json.Decode.Extra.date
        |> required "updated_at" Json.Decode.Extra.date
vim src/Store/Mocks.elm
module Store.Mocks exposing (store)

import Data.Category as Category exposing (Category)
import Data.Post as Post exposing (Post)
import Data.Thread as Thread exposing (Thread)
import Json.Decode as Decode exposing (decodeValue)
import Json.Encode as Encode exposing (..)
import Store exposing (Store)


store : Store
store =
    Store.empty
        |> insertCategory elixir
        |> insertCategory elm
        |> insertThread otpIsFun
        |> insertPost otpIsFunFirstPost


insertCategory : Result String Category -> Store -> Store
insertCategory result store =
    case result of
        Err err ->
            store

        Ok category ->
            Store.insertCategory category store


insertThread : Result String Thread -> Store -> Store
insertThread result store =
    case result of
        Err err ->
            store

        Ok thread ->
            Store.insertThread thread store


insertPost : Result String Post -> Store -> Store
insertPost result store =
    case result of
        Err err ->
            store

        Ok post ->
            Store.insertPost post store


elm : Result String Category
elm =
    category
        2
        "Elm"
        "elm"
        "2017-06-01T18:25:43.511Z"
        "2017-06-01T18:25:43.511Z"


elixir : Result String Category
elixir =
    category
        1
        "Elixir"
        "elixir"
        "2017-06-01T18:25:43.511Z"
        "2017-06-01T18:25:43.511Z"


otpIsFun : Result String Thread
otpIsFun =
    thread
        1
        1
        "OTP is Fun!"
        "otp-is-fun"
        "2017-06-01T18:25:43.511Z"
        "2017-06-01T18:25:43.511Z"


otpIsFunFirstPost : Result String Post
otpIsFunFirstPost =
    post
        1
        1
        "I know, right?"
        "2017-06-01T18:25:43.511Z"
        "2017-06-01T18:25:43.511Z"


category : Int -> String -> String -> String -> String -> Result String Category
category id title slug insertedAt updatedAt =
    decodeValue Category.decoder
        (object
            [ ( "id", int id )
            , ( "title", string title )
            , ( "slug", string slug )
            , ( "inserted_at", string insertedAt )
            , ( "updated_at", string updatedAt )
            ]
        )


thread : Int -> Int -> String -> String -> String -> String -> Result String Thread
thread id categoryId title slug insertedAt updatedAt =
    decodeValue Thread.decoder
        (object
            [ ( "id", int id )
            , ( "category_id", int categoryId )
            , ( "title", string title )
            , ( "slug", string slug )
            , ( "inserted_at", string insertedAt )
            , ( "updated_at", string updatedAt )
            ]
        )


post : Int -> Int -> String -> String -> String -> Result String Post
post id threadId body insertedAt updatedAt =
    decodeValue Post.decoder
        (object
            [ ( "id", int id )
            , ( "thread_id", int threadId )
            , ( "body", string body )
            , ( "inserted_at", string insertedAt )
            , ( "updated_at", string updatedAt )
            ]
        )

With this, our store has some mock data placed into it and it's easy and obvious how we can introduce further mock data. Next, we'll get to my favorite part - colocating queries with pages. Let's look at the Page.Categories module first:

-- We'll be introducing a `query` function that can be passed our model and
-- return a `ViewModel` for our view function
module Page.Categories exposing (query, view)

import Data.Category as Category exposing (Category)
-- ...
import Date exposing (Date)
import Model exposing (Model)
import Route exposing (Route)
import Store exposing (Store)


-- Our ViewModel captures all of the data that the view wants access to
type alias ViewModel =
    { categories : List Category
    , currentDate : Date
    }


-- The query receives the Model and returns the ViewModel.
-- we'll return all of the categories from the store for now.
query : Model -> ViewModel
query model =
    let
        categories =
            model.store
                |> Store.categories
    in
    { categories = categories
    , currentDate = Date.fromTime model.currentTime
    }


-- Now our viewmodel wraps up all of the data that we need
view : ViewModel -> Html msg
view { categories, currentDate } =
    div [ class "page-categories" ]
        [ h2 [] [ text "Categories" ]
        , ol [ class "category-list" ]
            (List.map (categoryView currentDate) categories)
        ]
-- ...

We'll update the View module to use this, now:

vim src/View.elm
module View exposing (view)
-- ...
view : Model -> Html Msg
view model =
    let
        currentDate =
            Date.fromTime model.currentTime
    in
    Page.Layout.view <|
        case model.currentRoute of
            -- ...
            Categories ->
                model
                    |> Page.Categories.query
                    |> Page.Categories.view
            -- ...

Doesn't that read really nicely? I love that all of the logic for fetching data from the Store is now encapsulated into the Page. Let's do the same thing for each page:

vim src/Page/Category.elm
module Page.Category exposing (query, view)
-- ...
import Date exposing (Date)
-- ...
import Store exposing (Store)


type alias ViewModel =
    { category : Maybe Category
    , threads : List Thread
    , currentDate : Date
    }


-- This query takes a `Category.Slug` and a `Model` and returns a `ViewModel`
query : Category.Slug -> Model -> ViewModel
query categorySlug model =
    let
        category =
            model.store
                |> Store.getCategoryBySlug categorySlug

        threads =
            category
                |> Maybe.map (\c -> Store.threads c.id model.store)
                |> Maybe.withDefault []
    in
    { category = category
    , threads = threads
    , currentDate = Date.fromTime model.currentTime
    }


view : ViewModel -> Html msg
view { category, threads, currentDate } =
    case category of
        Nothing ->
            text "No such category"

        Just category ->
            div [ class "page-category" ]
                [ h2 [] [ text category.title ]
                , ol [ class "thread-list" ]
                    (List.map (threadView currentDate category) threads)
                ]
-- ...
vim src/Page/Thread.elm
module Page.Thread exposing (query, view)
-- ...
import Date exposing (Date)
-- ...
import Store


type alias ViewModel =
    { category : Maybe Category
    , thread : Maybe Thread
    , posts : List Post
    , currentDate : Date
    }


-- And with this one we take in both the `Category.Slug` and the `Thread.Slug`
-- in addition to the `Model`
query : Category.Slug -> Thread.Slug -> Model -> ViewModel
query categorySlug threadSlug model =
    let
        category =
            model.store
                |> Store.getCategoryBySlug categorySlug

        thread =
            model.store
                |> Store.getThreadBySlug threadSlug

        posts =
            thread
                |> Maybe.map (\t -> Store.posts t.id model.store)
                |> Maybe.withDefault []
    in
    { category = category
    , thread = thread
    , posts = posts
    , currentDate = Date.fromTime model.currentTime
    }


view : ViewModel -> Html msg
view { currentDate, posts, category, thread } =
    case category of
        Nothing ->
            text "No such category"

        Just category ->
            case thread of
                Nothing ->
                    text "No such thread"

                Just thread ->
                    viewThread currentDate category thread posts


viewThread : Date -> Category -> Thread -> List Post -> Html msg
viewThread currentDate category thread posts =
    div []
        [ div [ class "thread-header" ]
            [ h2 []
                [ text thread.title ]
            , itemMetadata
                [ a [ href "#", class "username" ]
                    [ text "@someuser" ]
                , timeAbbr currentDate thread.updatedAt
                ]
            , itemMetadata
                [ categoryPills [ category ] ]
            ]
        , ol
            [ class "post-list" ]
            (List.map (postView currentDate) posts)
        ]
-- ...

And we'll wire them up in the View:

vim src/View.elm
module View exposing (view)

import Data.Category as Category exposing (Category)
import Data.Thread as Thread exposing (Thread)
import Date exposing (Date)
import Html exposing (Html, div, img, text)
import Model exposing (Model)
import Msg exposing (Msg)
import Page.Categories
import Page.Category
import Page.Home
import Page.Layout
import Page.Thread
import Route exposing (Route(..))
import Store exposing (Store)


view : Model -> Html Msg
view model =
    let
        currentDate =
            Date.fromTime model.currentTime
    in
    Page.Layout.view <|
        case model.currentRoute of
            Home ->
                Page.Home.view

            Categories ->
                model
                    |> Page.Categories.query
                    |> Page.Categories.view

            Category categorySlug ->
                model
                    |> Page.Category.query categorySlug
                    |> Page.Category.view

            Thread categorySlug threadSlug ->
                model
                    |> Page.Thread.query categorySlug threadSlug
                    |> Page.Thread.view

            NotFound ->
                text "Not found"

With this, everything's wired up and we've extracted all of our queries into the corresponding Page modules.

Summary

Today's episode was long, but it's also one of my favorite things. I only really know of one other person that's building their Elm apps this way, and he's using Phoenix channels as well. That's where we're heading. I hope you enjoyed it. See you soon!

Resources