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:
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.
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.
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
]
]
]
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 )
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.
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:
Html.map
and Cmd.map
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.