Learning Elm Part 8 - Advanced Edit Mode
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
- Creating the Account type
- Frequent descriptions
- HTML <datalist>
- Advanced Edit Mode
- Frequent descriptions auto-complete
- Final product
- End-to-end tests
- Final thoughts
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:
Transactionswill hold theTransactiontype, its JSON decoders, etc.EditTransactionwill 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:
- The new
Msgvariant:EditTransactionMsg EditTransaction.Msg - Handle the new message type, and “close” the view accordingly
- Hold the new
editTransactionState : EditTransaction.Statein the main model - Render the view from the new
EditTransactionmodule
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
}
}
- First, we transform each transaction into a two-item list of the accounts involved. In our example above:
["Assets:Cash", "Expenses:Dining"] - Now we have a list of lists:
[ [s1, d1], [s2, d2], ... ], so we flatten it with List.concat - We get the uniques with List.Extra.unique
- And now that we have our unique accounts, we map them to our
(key, value) = ("Assets:Cash", "A:Cash") - We create the
Dictinstance
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
- We map the list of transactions into a list of
(description, {dst="<destination>", src="<source>", cnt=1}) - We reduce that list by using the description as key, and either incrementing the
cntor just inserting. This means we keep the first combination we find (more on that later) - We sort by the count (ascending!)
- We reverse
- We keep the first 50
- We build our
(key, value)list - We build our
Dictinstance
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:
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:

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 )
-- (...)
- We lookup our description in our
Dict String FrequentDescriptiondictionary - We map the
Maybeto its destination component or fallback to the current destination - We map the
Maybeto its source component or fallback to the current source - If the destination or source are NOT in the “extra accounts” from where the simple mode dropdowns are rendered from, we append them there.
- 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:
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!