Controlled hallucination

Learning Elm Part 3 - Add Transaction Form

Published: - Reading time: 12 minutes

In this part of the series, we will prepare our little webapp for creating new transactions. We will add a new “edit view” containing a simple HTML form.

You can find a deployed version here, with the Elm Debugger here and the source code here.

Table of Contents

I’ve read the chapter Single-page applications of Richard’s book. He ends up having a Main module, and a module for each page, but for the time being I’ll stick with my single file. There is a nice talk from the creator of Elm, Evan Czaplicki - The life of a file about growing your Elm code.

Building the UI

At this point in time, this is what Firefox tells me the webpage looks like on an iPhone 12:

List View

Not much going on, to be honest. Given that I have gotten used to the FAB buttons (floating action buttons) from Material Design, I’ll add a similar one for our webapp. After reading the documentation about buttons for Semantic UI, I settled on a big round button with a plus icon:

view : Model -> Html Msg
view model =
    div [ class "ui container" ]
        [ viewListItems model.listItems
        , button [ class "massive circular ui blue icon button fab" ]
            [ i [ class "plus icon" ] []
            ]
        ]

The last CSS class, fab, will be the one making it an actual floating button. You can again see that Semantic UI CSS classes are very clean, they read like normal English. Regarding Elm code, I must say I prefer HTML markup better, so in that respect, maybe React is nicer. I might be biased because I have looked at a lot of HTML code in my life. This seems clearer:

<button class="massive circular ui blue icon button">
  <i class="icon plus"></i>
</button>

The CSS is pretty straight forward, courtesy of ChatGPT:

.fab {
  position: fixed;
  bottom: 20px;
  right: 20px;
}

The result, not surprisingly, is a massive circular button with a plus icon:

List View With FAB

Creating a new View

Our application will now have two states:

  1. Rendering the list of transactions
  2. Rendering the edit form

So will need to keep track of that state. I chose to add a new type, and a new attribute to my model. I will also need to switch between those two pages, so I’ll go ahead and add that new message too:

---- MODEL ----
type alias Model =
    { transactions : List Transaction
    , listItems : List ListItem
    , currentPage : Page
    }

type Page
    = List
    | Edit

---- UPDATE ----
type Msg
    = GotTransactions (Result Json.Decode.Error (List Transaction))
    | SetPage Page

The next step is to send that message when a user clicks the button. That is done with the onClick function:

button [ class "massive(...)", onClick (SetPage Edit) ]
    [ i [ class "plus icon" ] []
    ]

And sure enough, if you click that button, you’ll see the events in the Elm Debugger:

Elm Debugger Edit Button

Great! Now we’ll need our new form view, and we need to react to that event. We will start with an empty div with a cancel button, so we can switch back. We will move the Add Button to the list view, so it won’t be rendered when we are adding a transaction.

Our main view will call a function that will render the current page, and our initial viewForm will be empty:

view : Model -> Html Msg
view model =
    div [ class "ui container" ]
        [ renderPage model ]


renderPage : Model -> Html Msg
renderPage model =
    case model.currentPage of
        List ->
            viewListItems model

        Edit ->
            viewForm model


viewForm : Model -> Html Msg
viewForm model =
    div []
        [ div [ class "ui button", onClick (SetPage List) ]
            [ text "Cancel" ]
        ]

Reacting to the new SetPage message

Now we only have to react to the event. We’ll add it as the first pattern-match:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SetPage page ->
            ( { model | currentPage = page }, Cmd.none )

        GotTransactions (Ok transactions) ->
            let
                listItems =
                    buildListItems transactions
            in
            ( { model | transactions = transactions, listItems = listItems }, Cmd.none )

        _ ->
            ( model, Cmd.none )

And now clicking the button actually does something! We can even play with the Elm Debugger:

Scaffolding the Edit Form

Now comes the boring part of data-entry, which is what this small webapp is all about. I’ll just use the most standard features from Semantic UI’s form documentation.

This is our empty form:

viewForm : Model -> Html Msg
viewForm model =
    div []
        [ form [ class "ui large form" ]
            [ div [ class "field" ]
                [ label [] [ text "Date" ]
                , input [ name "date", type_ "date" ] []
                ]
            , div [ class "field" ]
                [ label [] [ text "Description" ]
                , input [ name "description", placeholder "Supermarket" ] []
                ]
            , div [ class "field" ]
                [ label [] [ text "Expense" ]
                , select [ class "ui fluid dropdown" ] []
                ]
            , div [ class "field" ]
                [ label [] [ text "Source" ]
                , select [ class "ui fluid dropdown" ] []
                ]
            , div [ class "field" ]
                [ label [] [ text "Amount" ]
                , input
                    [ type_ "number"
                    , step "0.01"
                    , placeholder "Amount"
                    , attribute "inputmode" "decimal"
                    , lang "en-US"
                    , placeholder "10.99"
                    ]
                    []
                ]
            , div [ class "ui button", onClick (SetPage List) ]
                [ text "Cancel" ]
            , div [ class "blue ui button right floated" ]
                [ text "Submit" ]
            ]
        ]

That results in this:

Empty Form

Ok, now on to the not-so-boring part!

Getting the current date

In part 1 of the series, I mentioned this particular example on purpose. I followed Elm’s justinmimbs/date package documentation and I was good to go.

We need to:

  1. Create the new Msg variant, we’ll copy the name from the documentation mentioned above.
  2. Add that date to our model.
  3. Call that command.

type alias Model =
    { transactions : List Transaction
    , listItems : List ListItem
    -- NEW STATE ATTRIBUTE
    , currentDate : Date
    , currentPage : Page
    }

initialModel : Model
initialModel =
    { transactions = []
    , listItems = []
    -- Instead of using Maybe Date, just hardcode a default one
    , currentDate = Date.fromCalendarDate 2024 Jan 1
    , currentPage = List
    }

type Msg
    = GotTransactions (Result Json.Decode.Error (List Transaction))
    -- NEW MESSAGE VARIANT
    | ReceiveDate Date
    | SetPage Page

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- HANDLE THE NEW MESSAGE
        ReceiveDate date ->
            ( { model | currentDate = date }, Cmd.none )
-- (...)


-- OUR NEW INIT
initialCmd : Cmd Msg
initialCmd =
    Date.today |> Task.perform ReceiveDate


init : () -> ( Model, Cmd Msg )
init _ =
    ( initialModel, initialCmd )

For this to work, we have to elm install elm/time so we can access the Jan type to create the default date.

If we reload the app, no date is rendered yet, but you should see the ReceiveDate message in the Elm Debugger.

The only thing missing is setting the value to the input:

-- NOT THE FINAL CODE!!
input
    [ name "date"
    , type_ "date"
    , value (Date.toIsoString model.currentDate)
    ]
    []

This will render the updated date input. This is not, however, how the form will work, but it helped me check everything was working ok.

Working with HTML forms

In The Elm Guide, under the The Elm Architecture, you can see the Forms section. It more or less suggests using what in React is a controlled input. I must confess that I hate(d?) this idea, when I first implemented it in React. It boils down to really getting into the innards of the browser’s events, and synchronizing every key-stroke / input with a JavaScript variable. My main issue was that as a beginner, I used my “validated” model as the source of the value, and it results in some bad user-experience, especially when dealing with negative numbers and backspaces. I won’t get into that tangent.

So this time around, I opted for sanity and decided I would follow The Elm Architecture, but have my input state separated from my model. Not all browsers handle input the same way, but even for <input type="number"> the actual value is usually a string, so I decided that my form would be a record of String values:

type alias FormInput =
    { date : String
    , description : String
    , source : String
    , destination : String
    , currency : String
    , amount : String
    }

emptyFormInput : FormInput
emptyFormInput =
    { date = ""
    , description = ""
    , source = ""
    , destination = ""
    , currency = ""
    , amount = ""
    }

This will hold the state of my form, and our Model will have a new formInput : FormInput attribute, set to emptyFormInput by default. Not only that, but every time we enter the Edit state, we will set it again to a “default” state. We will have a small function to calculate this default:

defaultFormInput : Model -> FormInput
defaultFormInput model =
    { date = Date.toIsoString model.currentDate
    , description = ""
    , destination = ""
    , source = ""
    , amount = ""
    , currency = "USD"
    }

And we will separate our Edit pattern-match to its own case, so the code is clearer:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
--- (...)
        SetPage Edit ->
            ( { model | currentPage = Edit, formInput = defaultFormInput model }, Cmd.none )

        SetPage page ->
            ( { model | currentPage = page }, Cmd.none )
--- (...)

We will also change the form, to use that as the source of values:

viewForm : Model -> Html Msg
viewForm model =
    let
        f : FormInput
        f = model.formInput
    in
--- (....)
    input[ name "date", type_ "date", value f.date ][]
--- (....)

And now… it looks like nothing has changed! But the initial value is set from our new form state. The next step is making the inputs “controlled”, in React lingo. For this, we need to use the onInput event handler, create a message, etc:

  1. Add the new EditDate String Msg variant
  2. Handle our new EditDate String Msg variant
  3. Dispatch the event
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
-- (...)
        EditDate date ->
            let
                f =
                    model.formInput

                formInput =
                    { f | date = date }
            in
            ( { model | formInput = formInput }, Cmd.none )
-- (...)


viewForm : Model -> Html Msg
viewForm model =
-- (...)
        input [ name "date", type_ "date", value f.date, onInput EditDate ]
        []

And after that, yes, our date input is controlled. Every time you modify the date, you’ll see the message flying in the Elm Debugger and your model.formInput being updated.

I’ll do the same for our other four inputs (description, expense account, source account and amount). I’ll create the same 4 new messages, the same 4 new update cases, for example:

EditAmount amount ->
    let
        f =
            model.formInput

        formInput =
            { f | amount = amount }
    in
    ( { model | formInput = formInput }, Cmd.none )

And set all the controlled values in the form view. This seems like a lot of repetition, and it is! I think we might refactor this later.

A new Settings model

Our Expense and Source dropdowns are now empty. This will be the “simplified” edit form. I plan to have an “advanced edit mode” later. So for the time being, I’ll assume the user decides exactly which accounts go in each dropdown. I’ll store this in a Settings model that the user will later be able to modify.

type alias Settings =
    { destinationAccounts : List String
    , sourceAccounts : List String
    , defaultCurrency : String
    }


defaultSettings : Settings
defaultSettings =
    { destinationAccounts = [ "Expenses:Groceries", "Expenses:Eat Out & Take Away" ]
    , sourceAccounts = [ "Assets:Cash", "Assets:Bank:Checking", "Liabilities:CreditCard" ]
    , defaultCurrency = "USD"
    }

Nothing fancy here, we will use our defaultSettings for our initialModel. Now we will create two small helpers to generate the <option> list, destinationOptions and sourceOptions. We will also use the first of those list as the pre-selected option, and remove the hard-coded “USD” currency, by modifying our defaultFormInput function:

viewForm : Model -> Html Msg
viewForm model =
-- (...)
    select [ class "ui fluid dropdown", value f.destination, onInput EditDestination ] (destinationOptions model)
-- (...)

destinationOptions : Model -> List (Html Msg)
destinationOptions model =
    let
        options : List String
        options =
            model.settings.destinationAccounts

        selectedOpt : String
        selectedOpt =
            model.formInput.destination
    in
    options
        |> List.map (\opt -> option [ value opt, selected (selectedOpt == opt) ] [ text opt ])

-- (THE SAME FOR sourceOptions)

defaultFormInput : Model -> FormInput
defaultFormInput model =
    { date = Date.toIsoString model.currentDate
    , description = ""
    , destination = List.head model.settings.destinationAccounts |> Maybe.withDefault ""
    , source = List.head model.settings.sourceAccounts |> Maybe.withDefault ""
    , amount = ""
    , currency = model.settings.defaultCurrency
    }

Final Thoughts

I think this is a good place to end this post. We’ve covered quite a lot! But it could be summarized as two main actions:

  1. We’ve added the new Page type to store which view should be rendered.
  2. We’ve added our initial form with controlled inputs

We can now see a more interesting use of the Elm Debugger, which lets you go back in time and step through each of the state transitions:

I found that updating nested records in Elm is not that ergonomic. My first instinct was just to write it like this, but it does not compile:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
-- (...)
        EditDate date ->
            ( { model.formInput | date = date }, Cmd.none )
---(...)

I searched the web a bit about best practices on this, but did not find much. I’ll dig on.

There are several Elm packages for dealing with forms, but I wanted to write it myself to have the experience.

In the next post, we’ll be adding form validation, and getting ready to persist some data.

See you next time!