Learning Elm Part 7 - Edit Settings & BDD Tests
As always, you can find a deployed version here, with the Elm Debugger here and the source code here.
This change was quite large, so I will only describe a short part of it.
Table of contents
- Settings overview
- Loading our Settings
- Using the new Settings module
- Using Gherkin for end-to-end tests
- Final thoughts
Settings overview
In the previous post I added end-to-end tests using Cypress. Now that users can add, edit and delete transactions, it would be great if they could change the default settings. I’d like to use Argentine pesos as my default currency, so that’s already one unhappy user.
I will start by creating the edit settings form in pretty much the same way as the edit transactions form: a record to hold the form’s data, and a record to hold the validations of those inputs.
Now that I have actually finished watching Richard Feldman’s Elm Europe 2017 - Scaling Elm Apps talk, I’ll start moving code outside src/Main.elm.
However, before we do that, we need to face the problem of the first run, or blank state. The first time the user opens the application, there will be no persisted settings, but after that, there will be.
As far as settings are concerned, the application will have three states:
- Unknown: This is the initial state of our Elm app.
- Loaded: We received our settings through our JavaScript ports.
- First Run: No settings were found, indicating that this is the first time the application is opened in this browser.
I am delaying error management, but we could also have a corrupted settings state, and we should decide what to do then. But I am delaying error management, as I think I just mentioned.
We are going to use a different PouchDB to hold our settings. We could change this later, but for the time being, I am OK with having an unencrypted local settings storage, and a separate transactions storage that will be encrypted and replicated to a remote server. But I am getting ahead of myself.
In our JavaScript world we will:
- Open our settings DB
- Load the settings, and notify if we found them, or notify if we didn’t find them
In our Elm world, we will listen for those notifications to transition from Unknown to FirstRun | Loaded:
We will use a new type to represent this:
type SettingsStatus
= SettingsUnknown
| NoSettings
| SettingsLoaded
Our Model will hold a new settingsStatus : SettingsStatus attribute, with an initial value of SettingsUnknown.
Loading our Settings
We will need to:
- Add two new
Msgvariant - Handle the happy-path
- Add the new JSON decoder
- Add the new port and subscription
I’ll also formalize our “empty state” into its own Welcome Page variant
type Page
= Welcome
| List
| Edit
| EditSettings
type Msg
=
-- (...)
| GotSettings (Result Json.Decode.Error Settings)
| GotFirstRun
-- (...)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotFirstRun ->
( { model | settingsStatus = NoSettings, currentPage = Welcome }, Cmd.none )
GotSettings (Ok settings) ->
( { model | settings = settings, settingsStatus = SettingsLoaded, currentPage = List }, Cmd.none )
-- (...)
---- PORTS JS => ELM ----
port gotFirstRun : (Json.Decode.Value -> msg) -> Sub msg
port gotSettings : (Json.Decode.Value -> msg) -> Sub msg
-- (...)
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.batch
[ gotTransactions (decodeTransactions >> GotTransactions)
, gotSettings (decodeSettings >> GotSettings)
, gotFirstRun (\_ -> GotFirstRun)
]
-- (...)
decodeSettings : Json.Decode.Value -> Result Json.Decode.Error Settings
decodeSettings jsonData =
Json.Decode.decodeValue settingsDecoder jsonData
settingsDecoder : Json.Decode.Decoder Settings
settingsDecoder =
Json.Decode.succeed Settings
|> required "version" Json.Decode.string
|> required "destinationAccounts" (Json.Decode.list Json.Decode.string)
|> required "sourceAccounts" (Json.Decode.list Json.Decode.string)
|> required "defaultCurrency" Json.Decode.string
---- VIEW ----
view : Model -> Html Msg
view model =
div [ class "ui container" ]
[ renderPage model ]
renderPage : Model -> Html Msg
renderPage model =
case model.currentPage of
Welcome ->
viewEmptyState model
List ->
viewListItems model
Edit ->
viewForm model
EditSettings ->
viewSettings model
And in our JavaScript world:
// src/index.js
async function main() {
// ...
let settingsDb = new PouchDb("elm_expenses_settings");
async function loadSettings() {
try {
const settings = await settingsDb.get("settings");
await sendSettingsToElm(settings);
await sendTransactionsToElm();
} catch (e) {
if (e.name == "not_found") {
app.ports.gotFirstRun.send();
} else {
console.log("Error loading settings", e);
}
}
}
// ...
async function sendSettingsToElm(settings) {
settings.version = settings._rev;
delete settings._rev;
delete settings.id;
app.ports.gotSettings.send(settings);
}
await loadSettings();
}
With those changes in place, you can use the application, but as soon as you refresh, you’ll be presented again with the Welcome to Elm Expenses! screen, because we still cannot save our settings, and in our JavaScript world, we are only fetching transactions after we loaded our settings.
We will reuse our edit settings form as our Welcome page for now:

Creating the Settings module
In the Settings module we will place:
- Our
Settingsdata type which holds the settings - Our
defaultSettings - Our JSON decoders
- Our
saveSettingsJavaScript port - And our settings form model/update/views
The actual Settings instance will live in the Main.Model for now.
The state of our Edit Form is this:
type alias State =
{ inputs : Inputs
, results : Maybe Results
, showCancelButton : Bool
}
type alias Inputs =
{ version : String
, defaultCurrency : String
, destinationAccounts : String
, sourceAccounts : String
}
type alias Results =
{ defaultCurrency : Result String String
, destinationAccounts : Result String String
, sourceAccounts : Result String String
}
- Our “controlled” inputs (what the user is typing) is held in
Input - We may o may not have validated the form
- We don’t want to render the
Cancelbutton if we are in the Welcome page, so we need thatshowCancelButton
I also moved the “delete all data” and “import data” functionality to this module, so those two buttons are also hidden with the showCancelButton=False flag.
The Settings.Msg type has these variants:
type Msg
= EditDefaultCurrency String
| EditDestinationAccounts String
| EditSourceAccounts String
| Cancel
| SubmitForm
| ImportSample
| DeleteAllData
Which should be self-explanatory by now.
I followed Richard’s example for the update function, it returns an extra Bool argument to notify that we should “cancel”. I am not sure about this, to be honest. Because the parent already knows which messages are flying through, as we shall soon see. But anyway, this is the complete update function:
update : Msg -> State -> ( State, Cmd Msg, Bool )
update msg model =
case msg of
EditDefaultCurrency currency ->
let
f =
model.inputs
inputs =
{ f | defaultCurrency = currency }
in
( { model | inputs = inputs }, Cmd.none, False )
EditDestinationAccounts accounts ->
let
f =
model.inputs
inputs =
{ f | destinationAccounts = accounts }
in
( { model | inputs = inputs }, Cmd.none, False )
EditSourceAccounts accounts ->
let
f =
model.inputs
inputs =
{ f | sourceAccounts = accounts }
in
( { model | inputs = inputs }, Cmd.none, False )
Cancel ->
( model, Cmd.none, True )
SubmitForm ->
let
isValid =
validateForm model.inputs
( results, cmd, close ) =
case isValid of
Err e ->
( Just e, Cmd.none, False )
Ok (ValidSettings settings) ->
( Nothing, saveSettings settings, True )
in
( { model | results = results }, cmd, close )
ImportSample ->
( model, importSampleData (), True )
DeleteAllData ->
( model, deleteAllData (), True )
We notify that we should close the settings form in these situations:
- The cancel button was clicked
- We submitted a valid form
- We imported sample data
- We deleted all data
All in all, the Settings module is quite normal. Where things get a bit more weird is in the Main module.
Using the new Settings module
First, we need to have a new Msg variant, whose instances will hold the Settings.Msg instance:
import Settings as EditSettings exposing (Msg(..), Settings, decodeSettings)
type alias Model =
{ transactions : List Transaction
-- (...)
, editSettingsState : EditSettings.State
-- (...)
}
-- (...)
type Msg
= GotTransactions (Result Json.Decode.Error (List Transaction))
-- (...)
| EditSettingsMsg EditSettings.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotFirstRun ->
let
editSettingsState =
getSettingsFormInput model.settings
in
( { model
| settingsStatus = NoSettings
, currentPage = Welcome
, editSettingsState = { editSettingsState | showCancelButton = False }
}
, Cmd.none
)
-- (...)
EditSettingsMsg settingsMsg ->
let
( editSettingsState, cmd, cancel ) =
EditSettings.update settingsMsg model.editSettingsState
currentPage =
if cancel then
if settingsMsg == DeleteAllData then
Welcome
else
List
else
model.currentPage
( settings, settingsStatus, showCancelButton ) =
if settingsMsg == DeleteAllData then
( EditSettings.defaultSettings, NoSettings, False )
else
( model.settings, model.settingsStatus, editSettingsState.showCancelButton )
in
( { model
| editSettingsState = { editSettingsState | showCancelButton = showCancelButton }
, currentPage = currentPage
, settings = settings
, settingsStatus = settingsStatus
}
, cmd |> Cmd.map EditSettingsMsg
)
-- (...)
viewWelcome : Model -> Html Msg
viewWelcome model =
div [ class "container" ]
[ h2 [ class "ui icon header middle aligned" ]
[ i [ class "money icon" ] []
, div [ class "content" ] [ text "Welcome to Elm Expenses!" ]
, div [ class "sub header" ] [ text "This is a work in progress" ]
]
, EditSettings.viewForm model.editSettingsState |> Html.map EditSettingsMsg
]
- The
Main.Modelholds the state for the edit settings form - When we go to our Welcome page, and our Edit settings page, we generate a new state for our edit settings form
- Each time we receive an
EditSettings mmessage from Elm’s runtime, we call theupdatefunction from our settings module - Commands from our settings model have
Settings.Msgtype, which get wrapped in ourEditSettingsMsgby Cmd.map - Messages from our settings Html have the
Settings.Msgtype, and they get wrapped in ourEditSettingsMsgby Html.map
While handling the EditSettingsMsg we have some contrived logic that I hope will be cleaned up later. When we delete all data, we need to transition back to “first run”, so I “inspect” the message to see if it was a DeleteAllData message to calculate three things:
- Go back to default settings
- Go back to
NoSettings - We should not show the “Cancel” button
In imperative languages, I’d normally write an if without an else:
let something = "Pizza";
let another = "Water";
if (someCondition) {
something = "Hot Pizza";
another = "Cold Water";
}
But in Elm, you can’t do either of those things. You can’t mutate and you can’t have an if without an else. So I chose to condense the three IFs into one:
( settings, settingsStatus, showCancelButton ) =
if settingsMsg == DeleteAllData then
( EditSettings.defaultSettings, NoSettings, False )
else
( model.settings, model.settingsStatus, editSettingsState.showCancelButton )
I’ll have to grok through more Elm code to find what is more idiomatic.
Using Gherkin for end-to-end tests
I first created my tests in plain-old cypress, and they looked something like this:
describe("Users should be able to edit settings", () => {
beforeEach(() => {
cy.createDefaultSettings();
cy.addTransaction({
date: "2024-02-29",
description: "Fill-up tank",
destination: "Expenses:Auto:Gas",
source: "Assets:PayPal",
amount: 1999,
currency: "USD",
});
});
it("should be able to edit settings", () => {
cy.get('[data-cy="settings"]').click();
cy.get('[data-cy="cancel"]').should("exist");
cy.get('[data-cy="import-sample"]').should("exist");
cy.get('[data-cy="delete-all-data"]').should("exist");
cy.get('[data-cy="default-currency"]').clear().type("ARS");
cy.get('[data-cy="destination-accounts"]')
.clear()
.type("Expenses:Health & Beauty");
cy.get('[data-cy="source-accounts"]').clear().type("Assets:Bitcoin");
cy.get('[data-cy="save"]').click();
cy.get('[data-cy="add-transaction"]').click();
cy.get('[data-cy="destination"]').should(
"have.value",
"Expenses:Health & Beauty"
);
cy.get('[data-cy="destination"]')
.find("option:selected")
.should("have.text", "Expenses:Health & Beauty");
cy.get('[data-cy="source"]').should("have.value", "Assets:Bitcoin");
cy.get('[data-cy="source"]')
.find("option:selected")
.should("have.text", "Assets:Bitcoin");
});
it("should be able to import sample data", () => {
cy.get('[data-cy="settings"]').click();
cy.get('[data-cy="import-sample"]').click();
cy.contains("Holiday travel expenses").should("be.visible");
});
it("should be able to delete all data", () => {
cy.get('[data-cy="settings"]').click();
cy.get('[data-cy="delete-all-data"]').click();
cy.contains("Welcome to Elm Expenses!").should("be.visible");
});
});
You can use Custom Commands to replace some of the things above. Maybe cy.setDefaultCurrency("ARS") for example. But I wanted to give a BDD-style of testing a chance.
So I installed @badeball/cypress-cucumber-preprocessor. The documentation is actually very good. I thought that I would run into some issues, for example, when trying to use both reporters (one that looked pretty with plain-old cypress, and one that looked good with feature files) - but no. I was surprised into how smooth it all went.
I followed the Pretty output section of their documentation, and the cypress-multi-reporters part of it too.
I have this .cypress-cucumber-preprocessorrc.json file:
{
"pretty": {
"enabled": true
}
}
And my cypress.config.js looks like this:
const { defineConfig } = require("cypress");
const createBundler = require("@bahmutov/cypress-esbuild-preprocessor");
const {
addCucumberPreprocessorPlugin,
} = require("@badeball/cypress-cucumber-preprocessor");
const {
createEsbuildPlugin,
} = require("@badeball/cypress-cucumber-preprocessor/esbuild");
module.exports = defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
specPattern: "cypress/e2e/**/*{.cy.js,feature}",
async setupNodeEvents(on, config) {
await addCucumberPreprocessorPlugin(on, config);
on(
"file:preprocessor",
createBundler({
plugins: [createEsbuildPlugin(config)],
})
);
// Make sure to return the config object as it might have been modified by the plugin.
return config;
},
},
});
This is my “first run” feature test:
Feature: Users must choose their default settings to use the app
Scenario: A user opens the app for the first time
Then I see "Welcome to Elm Expenses!"
And the default currency is "USD"
And the expense accounts are:
| Expenses:Groceries |
| Expenses:Eat Out & Take Away |
And the source accounts are:
| Assets:Cash |
| Assets:Bank:Checking |
| Liabilities:CreditCard |
Scenario: A user saves the default settings
When I save my settings
Then I see the "Add Transaction" button
Before each of the cypress e2e tests, I am programmatically calling into my Elm/JavaScript to clear all data:
// cypress/support/e2e.js
beforeEach(() => {
cy.visit("/");
cy.deleteAllData();
});
So the above feature file looks weird - I could add a no-op “When I open the app” step.
This is the complete edit-settings.feature:
Feature: Users should be able to edit settings
Background: A user who already has transactions goes to settings
Given I have saved the default settings
And 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 settings
Scenario: I see more buttons
Then I see the "Cancel" button
And I see the "Import Sample" button
And I see the "Delete All Data" button
Scenario: I see my current settings
Then the default currency is "USD"
And the expense accounts are:
| Expenses:Groceries |
| Expenses:Eat Out & Take Away |
And the source accounts are:
| Assets:Cash |
| Assets:Bank:Checking |
| Liabilities:CreditCard |
Scenario: I change my settings
When I set the default currency to "ARS"
And I set the expense accounts to:
| Expenses:Health & Beauty |
And I set the source accounts to:
| Assets:Bitcoin |
And I save my settings
When I go to add a transaction
Then the selected expense account is "Expenses:Health & Beauty"
And the selected source account is "Assets:Bitcoin"
When I enter the date "2024-02-28"
And I enter the description "Spa"
And I enter the amount "199000"
And I save the transaction
Then I see "ARS 199,000.00"
Scenario: I can import sample data
When I click the "Import Sample" button
Then I see "Holiday travel expenses"
Scenario: I can delete all data
When I click the "Delete All Data" button
Then I see "Welcome to Elm Expenses!"
Scenario: I cannot store empty settings
When I set the default currency to an empty string
And I set the expense accounts to an empty string
And I set the source accounts to an empty string
And I save my settings
Then I see an error message "Default currency cannot be blank"
And I see an error message "Destination accounts cannot be blank"
And I see an error message "Source accounts cannot be blank"
These are part of the implementations:
// Manipulate our app state programmatically
Given("I have saved the default settings", () => {
cy.createDefaultSettings();
});
// Manipulate our app state programmatically
Given(/^I have saved the following transactions:$/, (table) => {
for (const txn of table.hashes()) {
txn.amount = parseInt(txn.amount);
cy.addTransaction(txn);
}
});
When('I set the default currency to "{word}"', (currency) => {
cy.get('[data-cy="default-currency"]').clear().type(currency);
});
When(/^I set the expense accounts to:$/, (table) => {
const accounts = table.raw().join("\n");
cy.get('[data-cy="destination-accounts"]').clear().type(accounts);
});
When(/^I set the source accounts to:$/, (table) => {
const accounts = table.raw().join("\n");
cy.get('[data-cy="source-accounts"]').clear().type(accounts);
});
When("I save my settings", () => cy.get('[data-cy="save"]').click());
// (...)
And this is what a test looks like in the pretty reporter:
✓ Clicking on a transaction opens up the editor (770ms)
Scenario: Editing a transaction persists the new changes # cypress/e2e/crud-transactions.feature:30
Given I have saved the default settings
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 click on "Fill-up tank"
And I enter the date "2024-02-28"
And I enter the description "Pizza"
And I enter the amount "19.90"
And I select the expense account "Expenses:Eat Out & Take Away"
And I select the source account "Assets:Bank:Checking"
And I save the transaction
Then I see "28 Feb"
And I see "Pizza"
And I see "A:B:Checking ↦ E:Eat Out & Take Away"
And I see "USD 19.90"
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ crud-transactions.feature 00:05 4 4 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ edit-settings.feature 00:06 6 6 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ empty-list.feature 00:01 3 3 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ first-run.feature 00:01 2 2 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! 00:15 15 15 - - -
Final thoughts
Creating the Settings module was a good experience, it helped me understand a bit more how to “scale” Elm applications. I still need to move some things around, because I notice some things feel weird.
Rewriting e2e tests to use cypress-cucumber-preprocessor was a great experience. I find that the intent of the tests are not lost in the details, and I really like using data tables.
I still need to add the “advanced edit” form, add encryption, add replication, add offline-mode and after that I can ditch my old React app. There is lots to do yet 😳 - oh, and export!
See you next time!