Controlled hallucination

Learning Elm Part 7 - Edit Settings & BDD Tests

Published: - Reading time: 15 minutes

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

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:

  1. Unknown: This is the initial state of our Elm app.
  2. Loaded: We received our settings through our JavaScript ports.
  3. 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:

stateDiagram-v2 [*] --> Unknown Unknown --> FirstRun: No Settings found in DB Unknown --> Loaded: Settings found in DB FirstRun --> Loaded: Stored Settings in DB Loaded --> Loaded: Updated Settings 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:

  1. Add two new Msg variant
  2. Handle the happy-path
  3. Add the new JSON decoder
  4. 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:

Welcome With Edit Settings

Creating the Settings module

In the Settings module we will place:

  • Our Settings data type which holds the settings
  • Our defaultSettings
  • Our JSON decoders
  • Our saveSettings JavaScript 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 Cancel button if we are in the Welcome page, so we need that showCancelButton

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.Model holds 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 m message from Elm’s runtime, we call the update function from our settings module
  • Commands from our settings model have Settings.Msg type, which get wrapped in our EditSettingsMsg by Cmd.map
  • Messages from our settings Html have the Settings.Msg type, and they get wrapped in our EditSettingsMsg by 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:

  1. Go back to default settings
  2. Go back to NoSettings
  3. 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!