How UICards work: UI within UI in Elm
2018-12-20

I’ve created a package called UICards which allows you to lay out UI elements in interactive cards. Combined with live reloading, this provides a kind of live development environment for UI which makes it easier for you to create and test a consistent UI. Instead of navigating around your app to see your UI in a particular state, you can put things up in cards in any states you’re interested in.

I wrote an introduction to UICards earlier. It shows what the cards look like — you might find it easier to follow this post if you see that first.

Implementing UICards was a fun project because I had to figure out how to deal with the fact that:

  1. UICards have their own UI (header, card frames and the menu to switch between decks)
  2. Each card is itself a chunk of the application’s UI.

I also wanted to support interactive cards where you can click and type, exposing triggering states of UI controls, which means handling messages and model changes. So what I have is UIs within my UI.

But we have to go deeper still! Take these two cards for example:

Both are showing the same registration form, but in different states corresponding to different model values. But of course, in Elm we’re limited to a single model. How can I manage it then?

For context, I should preface with an explanation for how cards are created. The entry point for creating cards is the show function which returns a Program. You can use it like this:

main = 
    show App.update
            [ deck "Forms"
                [ card "Menu button" initialMenuModel <|
                    \model ->
                        Main.registrationForm model
                , ...
                ]
            , ...
            ]

show takes the application update function as its argument, and each card takes a function Model -> Html msg which generates its contents.

So how does this work under the covers?

Let’s start with the model. I obviously need some internal state – for example, to track whether the deck list is open or closed. I also need to keep track of the state of UI in each of the cards. It translates into this code:

type alias InternalModel =
    { isMenuOpen : Bool
    , navKey : Nav.Key
    , selectedDeckIndex : Maybe Int
    }

type alias Model msg model =
    { internal : InternalModel
    , decks : CardModel msg model
    }

CardModel is an array of decks, each of which contains an array of card records:

type alias CardModel msg model =
    Array (FullDeck msg model)

type alias FullDeck msg model =
    { name : String, cards : Array (FullCard msg model) }

type alias FullCard msg model =
    { name : String, model : model, view : model -> Html msg, index : CardIndex }

type alias CardIndex =
    ( Int, Int )

Each card has a model field and an index field which identifies each card and allows me to apply updates to the correct model.

What about messages?

Each card is producing messages of the same type, but the updates they imply need to be applied to different models. This means that I have to label each message with some kind of card ID that can be matched to the model – that’s the index consisting of deck number and card number within the deck.

Moreover, I have two types of messages: those that are internal to UICards, like ToggleMenu, and those coming from the cards. With this, I can start defining the Msg type:

type Msg msg
    = CardMsg (CardMsg msg)
    | InternalMsg InternalMsg

Internal messages are simple:

type InternalMsg
    = ToggleMenu
    | ClickLink UrlRequest
    | ChangeUrl Url

What about card messages? The definition of CardMsg by itself is straightforward:

type alias CardMsg msg =
    ( CardIndex, msg )

We’ve already seen CardIndex - it’s a pair of integer indexes identifying the given card. But how do I produce these messages when cards produce Html AppMsg values (where AppMsg is the application message type)?

I have to perform the conversion msg -> CardMsg msg in my view code. First of all, I have to do a bit of work to construct a FullCard record for each card:

type alias Card msg model =
    { name : String, model : model, view : model -> Html msg }

type alias FullCard msg model =
    { name : String, model : model, view : model -> Html msg, index : CardIndex }

card : String -> model -> (model -> Html msg) -> Card msg model
card = 
    Card

toFullCard : Int -> Int -> Card msg model -> FullCard msg model
toFullCard givenDeckIndex givenCardIndex givenCard =
    FullCard givenCard.name givenCard.model givenCard.view ( givenDeckIndex, givenCardIndex )

Then, in the UICards view function, for each of these records, I convert the messages with Html.map:

viewCard : FullCard msg model -> Html (CardMsg msg)
viewCard givenCard =
    div (toAttrs cardWrapperStyles)
        [ h2 (toAttrs cardHeadingStyles) [ text <| String.toUpper givenCard.name ]
        , div (toAttrs cardStyles)
            [ div (toAttrs cardLinerStyles)
                [ Html.map (\givenMsg -> CardMsg ( givenCard.index, givenMsg )) <|
                    givenCard.view givenCard.model
                ]
            ]
        ]

What about side effects?

The application update function might generate a command to trigger some side effect, which will eventually result in a message and a subsequent model change — just for that card. Once again, this means that I need to associate that message with a particular card it relates to, so that the appropriate model is updated.

This is very similar to translating messages in the view function, which I can do with Cmd.map:

updateCard givenCard =
    let
        ( newModel, cmd ) =
            givenAppUpdate cardMsg givenCard.model
    in
    ( { givenCard | model = newModel }, Cmd.map (\cmdMsg -> ( ( deckIndex, cardIndex ), cmdMsg )) cmd )

Tying it together in the update function

The update function has to handle both internal UICards messages as well as card messages, and possibly produce commands from either source. This is what the implementation looks like:

update : AppUpdate msg model -> Msg msg -> Model msg model -> ( Model msg model, Cmd (Msg msg) )
update givenAppUpdate givenMsg givenModel =
    case givenMsg of
        InternalMsg internalMsg ->
            let
                ( internalModel, internalCmd ) =
                    internalUpdate internalMsg givenModel.internal
            in
            ( { givenModel | internal = internalModel }, Cmd.map InternalMsg internalCmd )

        CardMsg cardMsg ->
            let
                ( cardModel, cardCmd ) =
                    cardUpdate givenAppUpdate cardMsg givenModel.decks
            in
            ( { givenModel | decks = cardModel }, Cmd.map CardMsg cardCmd )

The first argument (givenAppUpdate) is the application update function that’s passed to the show function together with a list of decks/cards:

type alias AppUpdate msg model =
    msg -> model -> ( model, Cmd msg )

The internalUpdate function straightforwardly handles the UICards messages:

internalUpdate : InternalMsg -> InternalModel -> ( InternalModel, Cmd InternalMsg )
internalUpdate givenMsg givenModel =
    case givenMsg of
        ChangeUrl url ->
            ( { givenModel | selectedDeckIndex = url.fragment |> Maybe.andThen String.toInt }, Cmd.none )

        ClickLink givenUrlRequest ->
            case givenUrlRequest of
                Internal url ->
                    ( givenModel, Nav.pushUrl givenModel.navKey <| Url.toString url )

                External url ->
                    ( givenModel, Nav.load url )

        ToggleMenu ->
            ( { givenModel | isMenuOpen = not givenModel.isMenuOpen }, Cmd.none )

It’s not concerned with the application update function so it only receives the internal message and internal model as its arguments.

The cardUpdate function is somewhat more involved:

cardUpdate : AppUpdate msg model -> CardMsg msg -> CardModel msg model -> ( CardModel msg model, Cmd (CardMsg msg) )
cardUpdate givenAppUpdate givenCardMsg givenCardModel =
    let
        ( ( deckIndex, cardIndex ), cardMsg ) =
            givenCardMsg

        updateCard givenCard =
            let
                ( newModel, cmd ) =
                    givenAppUpdate cardMsg givenCard.model
            in
            ( { givenCard | model = newModel }, Cmd.map (\cmdMsg -> ( ( deckIndex, cardIndex ), cmdMsg )) cmd )
    in
    case Array.get deckIndex givenCardModel of
        Nothing ->
            ( givenCardModel, Cmd.none )

        Just givenDeck ->
            case Array.get cardIndex givenDeck.cards of
                Nothing ->
                    ( givenCardModel, Cmd.none )

                Just givenCard ->
                    let
                        ( newCard, cmd ) =
                            updateCard givenCard

                        updatedDeck =
                            { givenDeck | cards = Array.set cardIndex newCard givenDeck.cards }

                        newCardModel =
                            Array.set deckIndex updatedDeck givenCardModel
                    in
                    ( newCardModel, cmd )

In the let clause, I first extract the different bits of information from the card message, and then update the card’s model using the application update function. update also produces a command which I have to transform from Cmd msg to Cmd (CardMsg msg) in the same way as I convert the Html values.

Finally, I assemble the new CardModel value and return it in a tuple with a command.

Summary

The implementation turned out to be a bit finicky, mainly due to having to keeping track of which updates to apply and how to transform and route messages. But ultimately, it wasn’t that complicated.

The key takeaways are:

  • Each card has to have its own model in order to allow interactive cards
  • In order to associate messages with a particular card, messages have to be translated using Html.map and Cmd.map
  • Internal messages are differentiated from card messages with the help of a custom type.

Next steps

If you’d like to try out UICards, you can install the package with elm install alexkorban/uicards. You can read the documentation for details on how to set things up.

If you’re just interested in learning more about Elm, I’ve written a non-beginner book called Practical Elm — give it a look.

Would you like to dive further into Elm?
📢 My book
Practical Elm
skips the basics and gets straight into the nuts-and-bolts of building non-trivial apps.
🛠 Things like building out the UI, communicating with servers, parsing JSON, structuring the application as it grows, testing, and so on.
Practical Elm