Controlled hallucination

Learning Elm Part 8 - Advanced Edit Mode

Published: - Reading time: 12 minutes

In the previous post we made it possible for users to change the default settings, and we migrated our end-to-end tests to Gherkin scenarios.

In this iteration, I’ll add these new features:

  • Add transactions whose accounts are not in the pre-defined list, but still support auto-complete of known accounts
  • Add transactions whose currency is not the default currency
  • Auto-complete of the most frequent expense/source account, given a description

As always, you can find a deployed version here, with the Elm Debugger here and the source code here.

Table of contents

Extract the EditTransaction module

But first, we’ll continue our refactor in the same vein that we performed our Settings refactor. In this iteration, I’ll create two modules:

  • Transactions will hold the Transaction type, its JSON decoders, etc.
  • EditTransaction will hold the state/update/view for editing transactions

The Main module will continue to hold our “list transaction” functionality, and it will now depend on both the new modules.

To recap, in the main module we need to create:

  1. The new Msg variant: EditTransactionMsg EditTransaction.Msg
  2. Handle the new message type, and “close” the view accordingly
  3. Hold the new editTransactionState : EditTransaction.State in the main model
  4. Render the view from the new EditTransaction module

These steps are the same we did in the previous post so I won’t repeat them in detail here.

The most annoying part of the refactor was moving the Elm tests, and that the VSCode IDE I am using does not automatically import all the things when you copy/paste code from one module to another, so I had to reimport:

import Html exposing (Html, button, div, form, input, label, option, p, select, span, text)
import Html.Attributes exposing (attribute, checked, class, classList, for, id, lang, list, name, placeholder, selected, step, type_, value)
import Html.Events exposing (onClick, onInput, onSubmit)

Maybe I should just have used (..) to import everything, and move on!

Creating the Account type

Given that we will need to keep some kind of list of know accounts, we’ll take this opportunity to make a small improvement, and that is not to calculate our “account short name”. Assets:Bank:Checking gets rendered in the list view as A:B:Checking and this is calculated in each instance the account is rendered. Even though I certainly did not notice a difference after replacing this for a lookup, it seemed wasteful, so I created these new types:

type alias Account =
    { name : String
    , shortName : String
    }


type alias Accounts =
    Dict String Account

After we have that data structure, we can use Dict.keys to get the list needed to auto-complete later.

Given the list of transactions, I defined this function to build the dictionary of accounts:

buildAccounts : List Transaction -> Accounts
buildAccounts txns =
    let
        accounts : Dict String Account
        accounts =
            txns
                |> List.map (\t -> [ t.source.account, t.destination.account ])
                |> List.concat
                |> List.Extra.unique
                |> List.map (\account -> ( account, Account account (accountShortName account) ))
                |> Dict.fromList
    in
    accounts

Remember our Transaction has more or less this structure:

{
  "date": "2023-03-12",
  "description": "Pizza",
  "destination": {
    "account": "Expenses:Dining",
    "currency": "USD",
    "amount": 1999
  },
  "source": {
    "account": "Assets:Cash",
    "currency": "USD",
    "amount": -1999
  }
}
  1. First, we transform each transaction into a two-item list of the accounts involved. In our example above: ["Assets:Cash", "Expenses:Dining"]
  2. Now we have a list of lists: [ [s1, d1], [s2, d2], ... ], so we flatten it with List.concat
  3. We get the uniques with List.Extra.unique
  4. And now that we have our unique accounts, we map them to our (key, value) = ("Assets:Cash", "A:Cash")
  5. We create the Dict instance

I am sure there are several other ways… we could have used an empty dictionary and List.foldl for example.

We will store that in our model, under accounts: Accounts.

After a few refactors, where I needed to pass the Accounts to where it was used, finally, I could use it:

viewDescription : Transaction -> Accounts -> Html Msg
viewDescription txn accounts =
    let
        source =
            Dict.get txn.source.account accounts |> Maybe.map .shortName |> withDefault txn.source.account

        destination =
            Dict.get txn.destination.account accounts |> Maybe.map .shortName |> withDefault txn.destination.account
    in
    div [ class "left floated content" ]
        [ div [ class "header txn-description" ] [ text txn.description ]
        , div [ class "description" ] [ text (source ++ " ↦ " ++ destination) ]
        ]

So good! We exchanged CPU for memory, and some CPU. I won’t measure the improvement.

But at least now we can use Dict.keys to get our unique lists later.

Frequent descriptions

We will use the following data-structure to store frequent descriptions:

type alias FrequentDescription =
    { description : String
    , destination : String
    , source : String
    , count : Int
    }


type alias FrequentDescriptions =
    Dict String FrequentDescription

This state will live in our EditTransactions.State in the frequentDescriptions attribute. In JSON-like:

{
  "frequentDescriptions": {
    "Supermarket": {
      "description": "Supermarket",
      "destination": "Expenses:Groceries",
      "source": "Assets:Cash",
      "count": 23
    }
    // (...)
  }
}

As with our accounts, we will calculate this from the list of transactions, for the time being:

buildFrequentDescriptions : List Transaction -> FrequentDescriptions
buildFrequentDescriptions txns =
    let
        acc : ( String, { dst : String, src : String, cnt : Int } ) -> Dict String { dst : String, src : String, cnt : Int } -> Dict String { dst : String, src : String, cnt : Int }
        acc ( desc, rec ) dict =
            Dict.update desc
                (\exists ->
                    case exists of
                        Nothing ->
                            Just rec

                        Just current ->
                            Just { current | cnt = current.cnt + 1 }
                )
                dict
    in
    txns
        |> List.map (\t -> ( t.description, { dst = t.destination.account, src = t.source.account, cnt = 1 } ))
        |> List.foldl acc Dict.empty
        |> Dict.toList
        |> List.sortBy (\( _, rec ) -> rec.cnt)
        |> List.reverse
        |> List.take 50
        |> List.map (\( desc, rec ) -> ( desc, FrequentDescription desc rec.dst rec.src rec.cnt ))
        |> Dict.fromList
  1. We map the list of transactions into a list of (description, {dst="<destination>", src="<source>", cnt=1})
  2. We reduce that list by using the description as key, and either incrementing the cnt or just inserting. This means we keep the first combination we find (more on that later)
  3. We sort by the count (ascending!)
  4. We reverse
  5. We keep the first 50
  6. We build our (key, value) list
  7. We build our Dict instance

In step 2 we keep the first record we find, by description. As we have our transactions sorted descending by date (i.e. latest-first), it means that if you had this two transactions:

24/03: Supermarket:  Assets:Cash           => Expenses:Groceries
23/03: Supermarket:  Assets:Bank:Checking  => Expenses:Groceries

Our small app will auto-complete Expenses:Groceries / Assets:Cash, even if the previous 30 times you used Assets:Bank:Checking. You can consider this a bug or a feature, depending on your needs.

HTML <datalist>

We will use tags for our auto-completes.

For our descriptions, for example, the HTML will contain something like this:

<input list="descriptions" name="description" />

<datalist id="descriptions">
  <option value="Supermarket"></option>
  <option value="Lunch"></option>
  <option value="Gas"></option>
</datalist>

The <datalist> tag is not rendered, but the browser uses the list={element-id} in the input field to offer some options.

This is how they are rendered in my desktop firefox when clicking c then clicking the input box:

And in Safari, in my iPhone (where I plan to use this webapp), the browser adds the suggestions near the keyboard, and also as a dropdown:

List in iPhone 1 List in iPhone 2

I created a small helper function to render the lists:

viewDataList : String -> List String -> Html msg
viewDataList nodeId list =
    node "datalist" [ id nodeId ] (List.map (\a -> option [ value a ] []) list)

Advanced Edit Mode

I chose to use almost the same html form for both my advanced and simple mode. The advanced mode will let you type whatever you want in the accounts inputs, and whatever you want in the currency input. The simple mode will have dropdowns and no currency input.

To switch between them, I’ll use a new ToggleEditMode message that will live in the EditTransaction module. We will also need to keep the state, so I created a small type and a new attribute in the model of that module:

type EditMode
    = Simple
    | Advanced

update : Msg -> State -> ( State, Cmd Msg, Bool )
update msg model =
    case msg of
        ToggleEditMode ->
            let
                editMode =
                    if model.editMode == Simple then
                        Advanced

                    else
                        Simple
            in
            ( { model | editMode = editMode }, Cmd.none, False )
-- (...)

viewToggleEditModeButton : EditMode -> Html Msg
viewToggleEditModeButton editMode =
    let
        isChecked =
            editMode == Advanced
    in
    div [ class "fab" ]
        [ div [ class "ui toggle checkbox" ]
            [ input [ id "toggle-advanced", type_ "checkbox", cyAttr "toggle-advanced", checked isChecked, onClick ToggleEditMode ] []
            , label [ for "toggle-advanced" ] [ text "Advanced Edit" ]
            ]
        ]

I chose to use the toggle checkbox from Semantic UI, and to apply my fab CSS style to it.

This is what they look like, side by side:

Edit Transaction

I also moved the amount right after the description, because that is the two things I use the most… description, then amount. I wonder if doing some auto-focus to save me some milliseconds is considered bad ux…

Frequent descriptions auto-complete

To auto-complete the expense/source accounts, we need to hijack our EditDescription message, like so:

update : Msg -> State -> ( State, Cmd Msg, Bool )
update msg model =
    case msg of
        EditDescription description ->
            let
                maybeDstSrc =
                    Dict.get description model.descriptions

                destination =
                    maybeDstSrc |> Maybe.map .destination |> withDefault f.destination

                source =
                    maybeDstSrc |> Maybe.map .source |> withDefault f.source

                extraDestinations =
                    if destination /= f.destination then
                        destination :: f.extraDestinations

                    else
                        f.extraDestinations

                extraSources =
                    if source /= f.source then
                        source :: f.extraSources

                    else
                        f.extraSources

                f =
                    model.input

                input =
                    { f
                        | description = description
                        , destination = destination
                        , source = source
                        , extraDestinations = extraDestinations
                        , extraSources = extraSources
                    }
            in
            ( { model | input = input }, Cmd.none, False )
-- (...)
  1. We lookup our description in our Dict String FrequentDescription dictionary
  2. We map the Maybe to its destination component or fallback to the current destination
  3. We map the Maybe to its source component or fallback to the current source
  4. If the destination or source are NOT in the “extra accounts” from where the simple mode dropdowns are rendered from, we append them there.
  5. Update the model

Point 4 is important for this to work with dropdowns, because they might not be in the default accounts, so we need to modify the <option> list in the <select>.

Final product

This is what it looks like:

Remember you can try it out live here!

End-to-end tests

Now that we have our BDD setup, we can document what the new features are all about. We’ll add the following scenarios to our crud-transactions.feature:

    Scenario: I can switch to advanced edit mode
        When I go to add a transaction
        Then the advanced mode toggle is off
        When I toggle the advanced mode
        Then I don't see the expense account dropdown
        And I don't see the source account dropdown
        And I see a destination account text input
        And I see a source account text input
        And I see a currency text input
        And the advanced mode toggle is on


    Scenario: I can add transactions with new accounts and new currency
        When I go to add a transaction
        And I toggle the advanced mode
        And I enter the date "2024-02-28"
        And I enter the description "Car repair"
        And I enter the amount "690.90"
        And I enter the currency "EUR"
        And I enter the destination account "Expenses:Auto:Repair"
        And I enter the source account "Liabilities:Loans"
        And I save the transaction
        Then I see "Car repair"
        And I see "L:Loans ↦ E:A:Repair"
        And I see "EUR 690.90"

    Scenario: It auto-completes destination and source accounts in Sipmle Mode
        Given I have saved the following transactions:
            | date       | description  | destination       | source        | amount | currency |
            | 2024-02-29 | Fill-up tank | Expenses:Auto:Gas | Assets:PayPal | 1999   | USD      |
        When I go to add a transaction
        And I enter the description "Fill-up tank"
        Then the selected expense account is "Expenses:Auto:Gas"
        Then the selected source account is "Assets:PayPal"

    Scenario: It auto-completes destination and source accounts in Advanced Mode
        Given I have saved the following transactions:
            | date       | description  | destination       | source        | amount | currency |
            | 2024-02-29 | Fill-up tank | Expenses:Auto:Gas | Assets:PayPal | 1999   | USD      |
        When I go to add a transaction
        And I toggle the advanced mode
        And I enter the description "Fill-up tank"
        Then the destination account is "Expenses:Auto:Gas"
        Then the source account is "Assets:PayPal"

Final thoughts

Now the small webapp is shaping up to be a bit more useful!

I still need to figure out if this is the best way to structure the application. I need to pass the Settings, FrequentDescriptions and Accounts to my EditTransactions module, but it seems ok. I understand that that is the Elm way to do it: you pass the subset of things you need. In the elm-spa-example however, they seem to have a disjoint model:

src/Main.elm:

type Model
    = Redirect Session
    | NotFound Session
    | Home Home.Model
    | Settings Settings.Model
    | Login Login.Model
    | Register Register.Model
    | Profile Username Profile.Model
    | Article Article.Model
    | Editor (Maybe Slug) Editor.Model

I might look further into it, maybe it is disjoint in name only. Making a quick overview, I can see that the Profile.Model holds a Session, FeedTab, and feed : Status Feed.Model - and so does the Home.Model.

I think I’ll rename my State later to Model, because even though Richard uses State in Elm Europe 2017 - Richard Feldman - Scaling Elm Apps, he uses Model in the larger example.

Regarding our new features, there are some rough-edges to polish. For example, if you switch to the advanced mode, and you type an account that is not a default, and change the currency, go back to the simple mode… you wont notice that the input is holding that account because it is not in the dropdown, and the currency is not visible, which is weird. More work for future me!

Next up, encryption, maybe?

Stay tuned!