In the last episode, we implemented a much more full-featured "users show" view (though there's still quite a bit of static data presently in use). Today, we'll add the ability to switch tabs and implement a view for the tab that show's the user's friends. Let's get started.

Project

I've tagged the time-tracker project with before_episode_029.3. You can see the mockup of what we'll be building here.

We'll be mocking up the data that drives this view for now, and I'll add an API endpoint to fill it in later.

For now, though, we need to be able to change the views. Ultimately I'll want to modify our routes to be a bit smarter, so you can deep link to a particular tab here as well as to deep link to a given page in our resource tables, specify the sort order in the URL, etc. We'll leave that for later, and instead just track the selected tab in our model and add a message to let us change it.

Adding the user show view's tab to the model

Let's go ahead and add something to the model to track this:

module Model exposing (Model, UsersModel, ProjectsModel, OrganizationsModel, initialModel)
-- ...
type alias UsersModel =
    { users : RemotePersistentPaginated User
    , usersListView : UsersListView
    , newUserForm : FormWithErrors User
    , shownUser : Maybe User
    , usersSort : Maybe ( Sorted, UserSortableField )
    , userSearchQuery : String
    , userShowTab : Int
    }
-- ...
initialModel : Maybe Route.Location -> Model
initialModel location =
    { -- ...
    , usersModel =
        { -- ...
        , userShowTab = 3
        }
    -- ...
    }

This should result in the last tab being shown initially, which corresponds to the view we've already implemented. Next, let's use this data to specify the selected tab in our user details tabs:

module View.Users.Show exposing (view, header)
-- ...
detailsCard : Model -> User -> Html Msg
detailsCard model user =
    let
        -- ...
    in
        Card.view
            [ -- ...
            ]
            [ -- ...
            , Card.actions
                [ Options.css "padding" "0" ]
                [ Tabs.render Mdl
                    [ 10, 0 ]
                    model.mdl
                    [ -- ...
-- Here we just dig into the model to get our active tab index
                    , Tabs.activeTab model.usersModel.userShowTab
                    -- ...
                    ]
                    [ -- ...
                    ]
                    []
                ]
            ]

That's easy enough. Next, we want to add a message that can set the active tab. We'll call this SelectUserShowTab Int:

module Msg exposing (Msg(..), UserMsg(..), ProjectMsg(..), OrganizationMsg(..), LoginMsg(..))
-- ...
type UserMsg
    -- ...
    | SelectUserShowTab Int

And we'll handle it in our update:

module Update exposing (update)
-- ...
updateUserMsg : Model -> UserMsg -> UsersModel -> ( UsersModel, Cmd Msg, Maybe ( String, String ) )
updateUserMsg model msg usersModel =
    case msg of
        -- ...
        SelectUserShowTab tabIndex ->
            ( { usersModel | userShowTab = tabIndex }
            , Cmd.none
            , Nothing
            )

All that's left is to wire up the tabs to send this message:

module View.Users.Show exposing (view, header)
-- ...
detailsCard : Model -> User -> Html Msg
detailsCard model user =
    let
        -- ...
    in
        Card.view
            [ -- ...
            ]
            [ -- ...
            , Card.actions
                [ Options.css "padding" "0" ]
                [ Tabs.render Mdl
                    [ 10, 0 ]
                    model.mdl
                    [ -- ...
-- We'll wire up the `onSelectTab` action
                    , Tabs.onSelectTab <| UserMsg' << SelectUserShowTab
                    ]
                    [ -- ...
                    ]
                    []
                ]
            ]

Switching our content based on the selected tab

That gives us the ability to flip between our tabs. Now we need to switch out our content based on the selected tab. That's easy enough:

module View.Users.Show exposing (view, header)
-- ...
showUser : Model -> User -> Html Msg
showUser model user =
    div [ style [ ( "width", "80%" ), ( "margin", "0 auto" ) ] ]
        [ detailsCard model user
        , showTab model user
        ]


showTab : Model -> User -> Html Msg
showTab model user =
    case model.usersModel.userShowTab of
        0 ->
            timeline model user

        1 ->
            connections model user

        2 ->
            projects model user

        3 ->
            information model user

        _ ->
            text "This shouldn't ever happen :("


timeline : Model -> User -> Html Msg
timeline model user =
    text "timeline"


connections : Model -> User -> Html Msg
connections model user =
    text "connections"


projects : Model -> User -> Html Msg
projects model user =
    text "projects"

Something that comes up at this point is that the other tabs have a different bit of margin beneath the details card, so let's go ahead and add the same margin to the bottom of it that we have above each of the info panels:

detailsCard : Model -> User -> Html Msg
detailsCard model user =
    let
        -- ...
    in
        Card.view
            [ -- ...
            , Options.css "margin-bottom" "2em"
            ]
            -- ...

That's a bit nicer. Next, we'll want to actually dig in and implement this list of cards for our friends. We'll co-opt the usersModel's users field and pretend it's our list of friends, so we don't have to implement mocks for it specifically.

Connections view

We already have a usersCards function on our View.Users module, so we'll just copy it over. Eventually I would expect we'd extract a few helpers for this sort of thing, but for now we'll duplicate code.

module View.Users.Show exposing (view, header)

import Model exposing (Model, UsersModel)
-- ...
import Html exposing (Html, text, h2, div, a, span, h3)
import Html.Events exposing (onClick)
-- ...
import RemoteData exposing (RemoteData(..))
-- ...
connections : Model -> User -> Html Msg
connections model user =
    usersCards model
-- ...
usersCards : Model -> Html Msg
usersCards model =
    case model.usersModel.users.current of
        NotAsked ->
            text "Initialising..."

        Loading ->
            text "Loading..."

        Failure err ->
            text <| "There was a problem fetching the users: " ++ toString err

        Success paginatedUsers ->
            grid [] <|
                List.map
                    (\user -> cell [ size All 3 ] [ userCard user ])
                    paginatedUsers.items


userCard : User -> Html Msg
userCard user =
    let
        userPhotoUrl =
            "https://api.adorable.io/avatars/400/" ++ user.name ++ ".png"
    in
        Card.view
            [ Options.css "width" "100%"
            , Options.css "cursor" "pointer"
            , Options.attribute <| onClick <| NavigateTo <| Maybe.map ShowUser user.id
            , Elevation.e2
            ]
            [ Card.title
                [ Options.css "background" ("url('" ++ userPhotoUrl ++ "') center / cover")
                , Options.css "min-height" "250px"
                , Options.css "padding" "0"
                ]
                []
            , Card.text []
                [ h3 [] [ text user.name ]
                , text "Software Zealot"
                ]
            ]

Alright, so to see this you have to view the users view first and then click through. I'll actually update our Util module to also fetch the users on this route just so we don't have to do this regularly, but it's a bit screwy and definitely shouldn't stick around.

module Util exposing (cmdsForModelRoute, MaterialTableHeader, onEnter)
-- ...
cmdsForModelRoute : Model -> List (Cmd Msg)
cmdsForModelRoute model =
    case model.route of
        -- ...
        Just (ShowUser id) ->
            [ API.fetchUser model id (always NoOp) <| UserMsg' << GotUser
              -- FIXME: We shouldn't fetch users here, just doing this while
              -- we've mocked out the connections tab
            , API.fetchUsers model <| UserMsg' << GotUsers
            ]
        -- ...

So now we won't have to constantly be visiting the users route and coming back in to see our connections. Next, we'll want to tweak this view to match our mockups - it's close though. We need to add the little bits of iconText in the card and the set of icons at the bottom, as well as fix the padding and styles on the user's name and title:

module View.Users.Show exposing (view, header)
-- ...
import Material.Button as Button
-- ...
userCard : Model -> User -> Html Msg
userCard model user =
    let
        userPhotoUrl =
            "https://api.adorable.io/avatars/400/" ++ user.name ++ ".png"
    in
        Card.view
            [ Options.css "width" "100%"
            , Options.css "cursor" "pointer"
            , Options.attribute <| onClick <| NavigateTo <| Maybe.map ShowUser user.id
            , Elevation.e2
            ]
            [ Card.title
                [ Options.css "background" ("url('" ++ userPhotoUrl ++ "') center / cover")
                , Options.css "min-height" "250px"
                , Options.css "padding" "0"
                ]
                []
            , Card.text []
                [ h3
                    [ style [ ( "margin", "0" ) ] ]
                    [ text user.name ]
                , Options.styled p
                    [ Color.text Color.accent ]
                    [ text "Software Zealot" ]
                , iconText "email" "user@example.com"
                , iconText "history" "7h 34m"
                , iconText "access_time" "48h 20m"
                ]
            , Card.actions
                [ Options.css "text-align" "right"
                , Color.text Color.accent
                ]
                [ Button.render Mdl
                    [ 10, 1, 0 ]
                    model.mdl
                    [ Button.icon, Button.ripple ]
                    [ Icon.i "phone" ]
                , Button.render Mdl
                    [ 10, 1, 1 ]
                    model.mdl
                    [ Button.icon, Button.ripple ]
                    [ Icon.i "message" ]
                , Button.render Mdl
                    [ 10, 1, 2 ]
                    model.mdl
                    [ Button.icon, Button.ripple ]
                    [ Icon.i "star_border" ]
                ]
            ]

That's got it looking pretty close. I'd like to add one more tweak. Right now if you make your browser really skinny, the grid looks pretty bad. Let's use the grid options to make it drop to 2 per row on a tablet and 1 per row on mobile:

usersCards : Model -> Html Msg
usersCards model =
    case model.usersModel.users.current of
        NotAsked ->
            text "Initialising..."

        Loading ->
            text "Loading..."

        Failure err ->
            text <| "There was a problem fetching the users: " ++ toString err

        Success paginatedUsers ->
            grid [] <|
                List.map
                    (\user ->
                        cell
                            [ size Desktop 3
                            , size Tablet 4
                            , size Desktop 3
                            ]
                            [ userCard model user ]
                    )
                    paginatedUsers.items

And that's that extra little bit of polish that makes me pretty happy. Honestly, I'd prefer to have a sort of WideDesktop option for the grid that is still 12 cells wide but lets me change the cell width at a wider breakpoint, perhaps around 1500 pixels wide. I'd really prefer to go 3 cards per row on normal desktop sizes but scale up to 4 cards wide on the extra-wide side. I'm sure there's a way to do this with just CSS breakpoints, but I'd prefer to do it declaratively in the code here. Anyway, I'm not worried about it for now.

Summary

In today's episode, we implemented the ability to switch tabs in our users show view, and we implemented the "Connections" tab to show a list of our friends. We're using the list of all users rather than just the provided user's friends, just as a means to get the design down before we've implemented the corresponding backend bits. We might ultimately want to implement a more detailed bit of state to manage our users show view, since our usersModel is getting a little disjointed at present, but we'll keep that sub-model flat for now until we really can't handle it any more.

I hope you enjoyed it. See you soon!

Resources