Skip to content

Latest commit

Β 

History

History
2051 lines (1634 loc) Β· 74.3 KB

elmish.md

File metadata and controls

2051 lines (1634 loc) Β· 74.3 KB

Elm(ish)

elmlogo-ish

Elm(ish) is an Elm-inspired JavaScript (ES5) fully functional front-end micro-framework from scratch.1


Why?

The purpose of building Elm(ish) is not to "replace" Elm or to create yet another front-end JS framework!

The purpose of separating the Elm(ish) functions into a "micro framework" is to:
a) abstract the "plumbing" so that we can simplify the Todo List application code to just "application logic".
b) demo a re-useable (fully-tested) "micro-framework" that allows us to practice using The Elm Architecture ("TEA").
c) promote the mindset of writing tests first and then the least amount of code necessary to pass the test (while meeting the acceptance criteria).

Test & Document-Driven Development is easy and it's easily one of the best habits to form in your software development "career". This walkthrough shows how you can do it the right way; from the start of a project.


What?

A walkthrough of creating a fully functional front-end "micro framework" from scratch.

By the end of this exercise you will understand The Elm Architecture (TEA) much better because we will be analysing, documenting, testing and writing each function required to architect and render our Todo List (TodoMVC) App.



Who?

People who want to gain an in-depth understanding of The Elm Architecture ("TEA") and thus intrinsically grok Redux/React JavaScript apps.

This tutorial is intended for beginners with modest JavaScript knowledge (variables, functions, DOM methods & TDD).
If you have any questions or get "stuck", please open an issue: https://github.com/dwyl/learn-elm-architecture-in-javascript/issues
@dwyl is a "safe space" and we are all here to help don't be shy/afraid;
the more questions you ask, the more you are helping yourself and others!


How?

Before diving into writing functions for Elm(ish), we need to consider how we are going to test it.
By ensuring that we follow TDD from the start of an project, we avoid having to "correct" any "bad habits" later.

We will be using Tape & JSDOM for testing the functions. Tape is a minimalist testing library that is fast and has everything we need. JSDOM is a JavaScript implementation of the WHATWG DOM & HTML standards, for use with node.js.
If either of these tools is unfamiliar to you, please see: https://github.com/dwyl/learn-tape and front-end-with-tape.md

What Can We Generalise ?

Our first step in creating Elm(ish) is to re-visit the functions we wrote for the "counter app" and consider what can be generalised into an application-independent re-useable framework.

Our rule-of-thumb is: anything that creates (or destroys) a DOM element or looks like "plumbing" (that which is common to all apps, e.g: "routing" or "managing state") is generic and should thus be abstracted into the Elm(ish) framework.

Recall that there are 3 parts to the Elm Architecture: model, update and view.
These correspond to the Model, Controller and View of "MVC pattern", which is the most widely used "software architecture pattern".

Aside: "software architecture" is just a fancy way of saying "how code is organised" and/or how "data flows" through a system. Whenever you see the word "pattern" it just means "a bunch of experienced people have concluded that this works well, so as beginners, we don't have to think too hard (up-front)."

The reason Elm refers to the "Controller" as "Update" is because this name more accurately reflects what the function does: it updates the state (Model) of the application.

Our update and view functions will form the "domain logic" of our Todo List App,
(i.e. they are "specific" to the Todo List) so we cannot abstract them.
The model will be a JavaScript Object where the App's data (todo list items) will be stored.

The update function is a simple switch statement that "decides" how to to update the app's model each case will call a function that belongs to the Todo List App.

The view function invokes several "helper" functions which create HTML ("DOM") elements e.g: <section>, <div> & <button>; these can (will) be generalised (below).

Let's start with a couple of "familiar" generic functions (which we used in the "counter-reset" example): empty and mount.


Start by Creating the Files

It's essential to ask: "Where do I start (my TDD quest)?"
The answer is: create two new files: lib/elmish.js and test/elmish.test.js

Test Setup

In order to run our test, we need some "setup" code that "requires" the libraries/files so we can execute the functions.

In the test/elmish.test.js file, type the following code:

const test = require('tape');       // https://github.com/dwyl/learn-tape
const fs = require('fs');           // to read html files (see below)
const path = require('path');       // so we can open files cross-platform
const html = fs.readFileSync(path.resolve(__dirname,
  '../index.html')); // sample HTML file to initialise JSDOM.
require('jsdom-global')(html);      // https://github.com/rstacruz/jsdom-global
const elmish = require('../lib/elmish.js'); // functions to test
const id = 'test-app';              // all tests use 'test-app' as root element

Most of this code should be familiar to you if you have followed previous tutorials. If anything is unclear please revisit https://github.com/dwyl/learn-tape and

If you attempt to run the test file: node test/elmish.test.js you should see no output. (this is expected as we haven't written any tests yet!)

empty the DOM

Start by describing what the empty function does.
This is both to clarify our own understanding as the people writing the code
and to clearly communicate with the humans reading the code.

empty Function Description

The empty function deletes all the DOM elements from within a specific "root" element.
it is used to erase the DOM before re-rendering the app.

Following "Document(ation) Driven Development", we create a JSDOC comment block in the lib/elmish.js file with just the function description:

/**
 * `empty` deletes all the DOM elements from within a specific "root" element.
 * it is used to erase the DOM before re-rendering the app.
 */

Writing out the function documentation first allows (our subconscious) time to think about the functionality and how to test for the "acceptance criteria". Even if you know exactly what code needs to be written, resist the temptation to write the code until it is documented. Even if you are writing code alone, always imagine that you are "pairing" with someone who does not (already) "know the solution" and you are explaining it to them.

empty Function Test

We previously used the empty function in our counter, counter-reset and multiple-counters examples (in the "basic" TEA tutorial) so we have a "head start" on writing the test.

In the test/elmish.test.js file, append the following code:

test('empty("root") removes DOM elements from container', function (t) {
  // setup the test div:
  const text = 'Hello World!'
  const root = document.getElementById(id);
  const div = document.createElement('div');
  div.id = 'mydiv';
  const txt = document.createTextNode(text);
  div.appendChild(txt);
  root.appendChild(div);
  // check text of the div:
  const actual = document.getElementById('mydiv').textContent;
  t.equal(actual, text, "Contents of mydiv is: " + actual + ' == ' + text);
  t.equal(root.childElementCount, 1, "Root element " + id + " has 1 child el");
  // empty the root DOM node:
  elmish.empty(root); // <-- exercise the `empty` function!
  t.equal(root.childElementCount, 0, "After empty(root) has 0 child elements!");
  t.end();
});

Note: if any line in this file is unfamiliar to you, please first go back over the previous example(s): counter-basic and counter-reset, then do bit of "googling" for any words or functions you don't recognise e.g: childElementCount, and if you are still "stuck", please open an issue! It's essential that you understand each character in the code before continuing to avoid "confusion" later.

Run the test:

node test/elmish.test.js

You should see the following: tests-fail

empty Function Implementation

Now that we have the test for our empty function written, we can add the empty function to lib/elmish.js:

/**
 * `empty` deletes all the DOM elements from within a specific "root" element.
 * it is used to erase the DOM before re-rendering the app.
 * This is the *fastest* way according to: stackoverflow.com/a/3955238/1148249
 * @param  {Object} node the exact ("parent") DOM node you want to empty
 * @example
 * // returns true (once the 'app' node is emptied)
 * empty(document.getElementById('app'));
 */
function empty(node) {
  while (node.lastChild) {
    node.removeChild(node.lastChild);
  }
}

Add module.exports statement to "export" the empty function

Adding the function to the elmish.js file is a good start, but we need to export it to be able to invoke it in our test.
Add the following code at the end of lib/elmish.js:

/* module.exports is needed to run the functions using Node.js for testing! */
/* istanbul ignore next */
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    empty: empty // export the `empty` function so we can test it.
  }
} else { init(document); }

When you run the test in your terminal with the command node test/elmish.test.js you should see something similar to this:

empty-function-tests-pass

Boom! our first test is passing! (the test has 3 assertions, that's why Tape says "tests 3. pass 3").

mount the App

The mount function is the "glue" or "wiring" function that connects the model, update and view; we can generalise it.

mount function Documentation

Think about what the mount function does; it "mounts" ("renders") the App in the "root" DOM element. It also tells our app to "re-render" when a signal with an action is received.

In lib/elmish.js add the following JSDOC comment:

/**
 * `mount` mounts the app in the "root" DOM Element.
 * @param  {Object} model store of the application's state.
 * @param  {Function} update how the application state is updated ("controller")
 * @param  {Function} view function that renders HTML/DOM elements with model.
 * @param  {String} root_element_id root DOM element in which the app is mounted
 */

mount function Test

In the test/elmish.test.js file, append the following code:

// use view and update from counter-reset example
// to invoke elmish.mount() function and confirm it is generic!
const { view, update } = require('./counter.js');

test('elmish.mount app expect state to be Zero', function (t) {
  const root = document.getElementById(id);
  elmish.mount(7, update, view, id);
  const actual = document.getElementById(id).textContent;
  const actual_stripped = parseInt(actual.replace('+', '')
    .replace('-Reset', ''), 10);
  const expected = 7;
  t.equal(expected, actual_stripped, "Inital state set to 7.");
  // reset to zero:
  const btn = root.getElementsByClassName("reset")[0]; // click reset button
  btn.click(); // Click the Reset button!
  const state = parseInt(root.getElementsByClassName('count')[0]
    .textContent, 10);
  t.equal(state, 0, "State is 0 (Zero) after reset."); // state reset to 0!
  elmish.empty(root); // clean up after tests
  t.end()
});

Note: we have "borrowed" this test from our previous example. see: test/counter-reset.test.js

mount Function Implementation

Add the following code to the mount function body to make the test pass in lib/elmish.js:

/**
 * `mount` mounts the app in the "root" DOM Element.
 * @param  {Object} model store of the application's state.
 * @param  {Function} update how the application state is updated ("controller")
 * @param  {Function} view function that renders HTML/DOM elements with model.
 * @param  {String} root_element_id root DOM element in which the app is mounted
 */
function mount(model, update, view, root_element_id) {
  var root = document.getElementById(root_element_id); // root DOM element
  function signal(action) {                     // signal function takes action
    return function callback() {                // and returns callback
      var updatedModel = update(action, model); // update model for the action
      empty(root);                              // clear root el before rerender
      view(signal, updatedModel, root);         // subsequent re-rendering
    };
  };
  view(signal, model, root);                    // render initial model (once)
}

Add mount to module.exports Object

Recall that in order to test the elmish functions we need to export them. Your module.exports statement should now look something like this:

if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    empty: empty,
    mount: mount
  }
} else { init(document); }

Re-Run the Test(s)

Re-run the test suite:

node test/elmish.test.js

You should expect to see: (tests passing) image

Now that we have started creating the elmish generic functions, we need to know which other functions we need.
Let's take a look at the TodoMVC App to "analyse the requirements".

Analyse the TodoMVC App to "Gather Requirements"

In our quest to analyse the required functionality of a Todo List, the easiest way is to observe a functioning TodoMVC Todo List.

Recommended Background Reading: TodoMVC "Vanilla" JS

By far the best place to start for understanding TodoMVC's layout/format, is the "Vanilla" JavaScript (no "framework") implementation: https://github.com/tastejs/todomvc/tree/gh-pages/examples/vanillajs

Run it locally with:

git clone https://github.com/tastejs/todomvc.git
cd todomvc/examples/vanillajs
python -m SimpleHTTPServer 8000

Open your web browser to: http://localhost:8000

vanillajs-localhost

If you are unable to run the TodoMVC locally, you can always view it online: https://todomvc.com/examples/vanillajs

Play with the app by adding a few items, checking-off and toggling the views in the footer.

Note: having read through the the "Vanilla" JS implementation we feel it is quite complex and insufficiently documented (very few code comments and sparse README.md), so don't expect to understand it all the first time without "study". Don't worry, we will walk through building each feature in detail.

Todo List Basic Functionality

A todo list has only 2 basic functions:

  1. Add a new item to the list (when the [Enter] key is pressed)
  2. Check-off an item as "completed" (done/finished)

Add item and "Check-off" is exactly the "functionality" you would have in a paper-based Todo List.

TodoMVC "Advanced" Functionality

In addition to these basic functions, TodoMVC has the ability to:

  • Un-check an item as to make it "active" (still to be done)
  • Double-click/tap on todo item description to edit it.
  • Mark all as complete
  • Click X on item row to remove from list.

<footer> Menu

below the main interface there is a <footer> with a count, 3 view toggles and one action: image

  • "{cont} item(s) left":
    {store.items.filter(complete==false)} item{store.items.length > 1 ? 's' : '' } left
  • Show All
  • Show Active
  • Show Completed
  • Clear Completed

Routing / Navigation

Finally, if you click around the <footer> toggle menu, you will notice that the Web Bowser Address bar changes to reflect the chosen view.

tea-todomvc-routing

Thinking about a task or challenge from "first principals" is a great the best way to understand it.
This is the "physics" approach. see: https://youtu.be/L-s_3b5fRd8?t=22m37s

HTML Elements (Functions)

The requirements for the HTML elements we need for a Todo List can be gathered by viewing the source code of the VanillaJS TodoMVC in a web browser:

todomvc-elements-browser-devtools

This is a "copy-paste" of the generated code including the Todo items:

<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <input class="new-todo" placeholder="What needs to be done?" autofocus="">
  </header>
  <section class="main" style="display: block;">
    <input id="toggle-all" class="toggle-all" type="checkbox">
    <label for="toggle-all">Mark all as complete</label>
    <ul class="todo-list">
      <li data-id="1531397960010" class="completed">
        <div class="view">
          <input class="toggle" type="checkbox" checked="">
          <label>Learn The Elm Architecture ("TEA")</label>
          <button class="destroy"></button>
        </div>
      </li>
      <li data-id="1531397981603" class="">
        <div class="view">
          <input class="toggle" type="checkbox">
          <label>Build TEA Todo List App</label>
          <button class="destroy">
          </button>
        </div>
      </li>
    </ul>
  </section>
  <footer class="footer" style="display: block;">
    <span class="todo-count"><strong>1</strong> item left</span>
    <ul class="filters">
      <li>
        <a href="#/" class="selected">All</a>
      </li>
      <li>
        <a href="#/active" class="">Active</a>
      </li>
      <li>
        <a href="#/completed">Completed</a>
      </li>
    </ul>
    <button class="clear-completed" style="display: block;">Clear completed</button>
  </footer>
</section>

Let's split each one of these elements into it's own function (with any necessary "helpers") in the order they appear.

For a "checklist" of these features see: dwyl/learn-elm-architecture-in-javascript#44

When building a House we don't think "build house" as our first action.
Instead we think: what are the "foundations" that need to be in place before we lay the first "brick"?

In our Todo List App we need a few "Helper Functions" before we start building the App.

HTML / DOM Creation Generic Helper Functions

All "grouping" or "container" HTML elements e.g: <div>, <section> or <span> will be called with two arguments: e.g: var sec = section(attributes, childnodes)

  • attributes - a list (Array) of HTML attributes/properties e.g: id or class.
  • childnodes - a list (Array) of child HTML elements (nested within the <section> element)

Each of these function arguments will be "applied" to the HTML element. We therefore need a pair of "helper" functions (one for each argument).

add_attributes

The JSDOC comment for our add_attributes function is:

/**
* add_attributes applies the desired attributes to the desired node.
* Note: this function is "impure" because it "mutates" the node.
* however it is idempotent; the "side effect" is only applied once
* and no other nodes in the DOM are "affected" (undesirably).
* @param {Array.<String>} attrlist list of attributes to be applied to the node
* @param {Object} node DOM node upon which attribute(s) should be applied
* @example
* // returns node with attributes applied
* div = add_attributes(["class=item", "id=mydiv", "active=true"], div);
*/

This should give you a good idea of what code needs to be written.

But let's write the test first! Add the following test to the test/elmish.test.js file:

test('elmish.add_attributes applies class HTML attribute to a node', function (t) {
  const root = document.getElementById(id);
  let div = document.createElement('div');
  div.id = 'divid';
  div = elmish.add_attributes(["class=apptastic"], div);
  root.appendChild(div);
  // test the div has the desired class:
  const nodes = document.getElementsByClassName('apptastic');
  t.equal(nodes.length, 1, "<div> has 'apptastic' class applied");
  t.end();
});

If you (attempt to) run this test (and you should), you will see something like this:

image

Test is failing because the elmish.add_attributes function does not exist.

Go ahead and create the elmish.add_attributes function (just the function without passing the test) and export it in elmish.js:

/**
* add_attributes applies the desired attributes to the desired node.
* Note: this function is "impure" because it "mutates" the node.
* however it is idempotent; the "side effect" is only applied once
* and no other nodes in the DOM are "affected" (undesirably).
* @param {Array.<String>} attrlist list of attributes to be applied to the node
* @param {Object} node DOM node upon which attribute(s) should be applied
* @example
* // returns node with attributes applied
* div = add_attributes(["class=item", "id=mydiv", "active=true"], div);
*/
function add_attributes (attrlist, node) {
  if(attrlist && attrlist.length) {
    attrlist.forEach(function (attr) { // apply each prop in array
      var a = attr.split('=');
      switch(a[0]) {
        // code to make test pass goes here ...
        default:
          break;
      }
    });
  }
  return node;
}
// ... at the end of the file, "export" the add_attributes funciton:
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    add_attributes: add_attributes, // export the function so we can test it!
    empty: empty,
    mount: mount
  }
}

When you re-run the test you will see something like this: image The function exists but it does not make the tests pass. Your quest is to turn this 0 into a 1.

Given the JSDOC comment and test above, take a moment to think of how you would write the add_attributes function to apply a CSS class to an element.

If you can, make the test pass by writing the add_attributes function.
(don't forget to export the function at the bottom of the file).

If you get "stuck", checkout the complete example: /lib/elmish.js

Note 0: we have "seen" the code before in the counter example: counter.js#L51
The difference is this time we want it to be "generic"; we want to apply a CSS class to any DOM node.

Note 1: it's not "cheating" to look at "the solution", the whole point of having a step-by-step tutorial is that you can check if you get "stuck", but you should only check after making a good attempt to write the code yourself.

Note 2: The add_attributes function is "impure" as it "mutates" the target DOM node, this is more of a "fact of life" in JavaScript, and given that the application of attributes to DOM node(s) is idempotent we aren't "concerned" with "side effects"; the attribute will only be applied once to the node regardless of how many times the add_attributes function is called. see: https://en.wikipedia.org/wiki/Idempotence

For reference, the Elm HTML Attributes function on Elm package is: https://package.elm-lang.org/packages/elm-lang/html/2.0.0/Html-Attributes

Once you make the test pass you should see the following in your Terminal: image


Input placeholder Attribute

The <input> form element (where we create new Todo List items) has a helpful placeholder attribute prompting us with a question: "What needs to be done?"

Add the following test to the test/elmish.test.js file:

test('elmish.add_attributes set placeholder on <input> element', function (t) {
  const root = document.getElementById(id);
  let input = document.createElement('input');
  input.id = 'new-todo';
  input = elmish.add_attributes(["placeholder=What needs to be done?"], input);
  root.appendChild(input);
  const placeholder = document.getElementById('new-todo')
    .getAttribute("placeholder");
  t.equal(placeholder, "What needs to be done?", "paceholder set on <input>");
  t.end();
});

Run the test node test/elmish.test.js:

image

You know "the drill"; write the necessary code in the add_attributes function of elmish.js to add a placeholder to an <input> element and make this test pass:

image

If you get "stuck", checkout the complete example: /lib/elmish.js


default case ("branch") test?

At this point in our Elm(ish) quest, all our tests are passing, which is good, however that is not the "full picture" ...

If you use Istanbul to check the "test coverage" (the measure of which lines/branches of code are being executed during tests), you will see that only 98.5% of lines of code is being "covered":

image

@dwyl we are "keen" on having "100% Test Coverage" ... anything less than 100% is guaranteed to result in "regressions", disappointment and a lonely loveless life. πŸ’”

87% Test Coverage

See: https://github.com/dwyl/learn-istanbul

This means that if we have a switch statement as in the case of the add_attributes function we need to add a test, that "exercises" that "branch" of the code. Add the following test code to your test/elmish.test.js file:

/** DEFAULT BRANCH Test **/
test('test default case of elmish.add_attributes (no effect)', function (t) {
  const root = document.getElementById(id);
  let div = document.createElement('div');
  div.id = 'divid';
  // "Clone" the div DOM node before invoking elmish.attributes to compare
  const clone = div.cloneNode(true);
  div = elmish.add_attributes(["unrecognised_attribute=noise"], div);
  t.deepEqual(div, clone, "<div> has not been altered");
  t.end();
});

By definition this test will pass without adding any additional code because we already added the default: break; lines above (which is "good practice" in switch statements).
Run the test(s) node test/elmish.test.js: image

So "why bother" adding a test if it's always going to pass? Two reasons:
First: It won't "always pass". if someone decides to remove the "default" case from add_attributes function (people do "strange things" all the time!) it will fail so by having a test, we will know that the switch is "incomplete".
Second: Having "full coverage" of our code from the start of the project, and not having to"debate" or "discuss" the "merits" of it means we can have confidence in the code.

Test null Attribute Argument (attrlist) in add_attributes Function

Since JavaScript is not statically/strictly typed we need to consider the situation where someone might accidentally pass a null value.

Thankfully, this is easy to write a test for. Add the following test to test/elmish.test.js:

test('test elmish.add_attributes attrlist null (no effect)', function (t) {
  const root = document.getElementById(id);
  let div = document.createElement('div');
  div.id = 'divid';
  // "Clone" the div DOM node before invoking elmish.attributes to compare
  const clone = div.cloneNode(true);
  div = elmish.add_attributes(null, div); // should not "explode"
  t.deepEqual(div, clone, "<div> has not been altered");
  t.end();
});

This test should also pass without the addition of any code:

image

Now the Coverage should be 100% when you run npm test:

image

In your terminal, type/run the follwoing command: open coverage/lcov-report/index.html

image

Check-Coverage Pre-Commit Hook

Once you achieve 100% test coverage, there is no reason to "compromise" by going below this level. Let's add a pre-commit check to make sure we maintain our desired standard.

We wrote a detailed guide to git pre-commit hooks with npm: [https://github.com/dwyl/learn-pre-commit]https://github.com/dwyl/learn-pre-commit

Install the pre-commit module:

npm install pre-commit istanbul --save-dev

In your package.json file add:

{
  "scripts": {
    "check-coverage": "istanbul check-coverage --statements 100 --functions 100 --lines 100 --branches 100",
    "test": "istanbul cover tape ./test/*.test.js | tap-spec"
  },
  "pre-commit": [
    "test",
    "check-coverage"
  ]
}

Now whenever you commit your code, your tests will run and istanbul will check the test coverage level for you.

Let's get back to our add_attributes function!


Input autofocus

In order to "guide" the person using our Todo List app to create their first Todo List item, we want the <input> field to be automatically "active" so that they can just start typing as soon as the app loads.

This is achieved using the autofocus attribute.

Add the following test to the test/elmish.test.js file:

test.only('elmish.add_attributes add "autofocus" attribute', function (t) {
  document.getElementById(id).appendChild(
    elmish.add_attributes(["class=new-todo", "autofocus", "id=new"],
      document.createElement('input')
    )
  );
  // document.activeElement via: https://stackoverflow.com/a/17614883/1148249
  t.equal(document.getElementById('new'), document.activeElement,
    '<input autofocus> is "activeElement"');
  elmish.empty(document);
  t.end();
});

Write the necessary code to make this test pass as a case in add_attributes in elmish.js.

Relevant reading:

Note: while all our other HTML attributes follow the key="value" syntax, according to the W3C specification, simply adding the attribute key in the element is "valid" e.g: <input placeholder="What needs to be done?" autofocus> see: https://stackoverflow.com/questions/4445765/html5-is-it-autofocus-autofocus-or-autofocus

add data-id attribute to <li>

data-* attributes allow us to store extra information on standard, semantic HTML elements without affecting regular attributes. For example in the case of a Todo List item, we want to store a reference to the "item id" in the DOM for that item, so that we know which item to check-off when the checkbox is clicked/tapped. However we don't want to use the "traditional" id attribute, we can use data-id to keep a clear separation between the data and presentation.

See: "Using data attributes" https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes

In the TodoMVC HTML code there are two <li> (list elements) which have the data-id attribute (see above).

Add the following test to the test/elmish.test.js file:

test('elmish.add_attributes set data-id on <li> element', function (t) {
  const root = document.getElementById(id);
  let li = document.createElement('li');
  li.id = 'task1';
  li = elmish.add_attributes(["data-id=123"], li);
  root.appendChild(li);
  const data_id = document.getElementById('task1').getAttribute("data-id");
  t.equal(data_id, '123', "data-id successfully added to <li> element");
  t.end();
});

Write the "case" in to make this test pass in elmish.js.

Tip: use setAttribute() method: https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute

label for attribute

Apply the for attribute to a <label> e.g: <label for="toggle-all">

HTML <label> attributes for: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label#Attributes

Add the following test to the test/elmish.test.js file:

test.only('elmish.add_attributes set "for" attribute <label> element', function (t) {
  const root = document.getElementById(id);
  let li = document.createElement('li');
  li.id = 'toggle';
  li = elmish.add_attributes(["for=toggle-all"], li);
  root.appendChild(li);
  const label_for = document.getElementById('toggle').getAttribute("for");
  t.equal(label_for, "toggle-all", '<label for="toggle-all">');
  t.end();
});

Add the "case" in the add_attributes function's switch statement to make this test pass in elmish.js.


<input> attribute type

In order to use a Checkbox in our Todo List UI, we need to set the type=checkbox on the <input> element.

Add the following test to the test/elmish.test.js file:

test('elmish.add_attributes type="checkbox" on <input> element', function (t) {
  const root = document.getElementById(id);
  let input = document.createElement('input');
  input = elmish.add_attributes(["type=checkbox", "id=toggle-all"], input);
  root.appendChild(input);
  const type_atrr = document.getElementById('toggle-all').getAttribute("type");
  t.equal(type_atrr, "checkbox", '<input id="toggle-all" type="checkbox">');
  t.end();
});

Write the "case" in add_attributes to make this test pass in elmish.js.

Relevant reading


Add style attribute to HTML element?

In TodoMVC there are three instances of in-line CSS styles. they are all style="display: block;". It's unclear why setting inline styles is necessary; we prefer to be consistent and either use CSS classes with an external stylesheet (which TodoMVC already does!) or go full "inline styles" e.g: https://package.elm-lang.org/packages/mdgriffith/style-elements/latest

For now, let's add the style attribute to our add_attributes function for "completeness".

see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style

Add the following test to the test/elmish.test.js file:

test.only('elmish.add_attributes apply style="display: block;"', function (t) {
  const root = document.getElementById(id);
  elmish.empty(root);
  let sec = document.createElement('section');
  root.appendChild(
    elmish.add_attributes(["id=main", "style=display: block;"], sec)
  );
  const style = window.getComputedStyle(document.getElementById('main'));
  t.equal(style._values.display, 'block', 'style="display: block;" applied!')
  t.end();
});

Write the "case" in to make this test pass in elmish.js.

If you get "stuck", checkout: https://github.com/dwyl/todomvc-vanilla-javascript-elm-architecture-example/blob/master/lib/elmish.js


checked=true attribute for "complete"/"done" items

Todo List items that have been marked as "done" will have the checked=true attribute applied to them.

Add the following test to the test/elmish.test.js file:

test('elmish.add_attributes checked=true on "done" item', function (t) {
  const root = document.getElementById(id);
  elmish.empty(root);
  let input = document.createElement('input');
  input = elmish.add_attributes(["type=checkbox", "id=item1", "checked=true"],
    input);
  root.appendChild(input);
  const checked = document.getElementById('item1').checked;
  t.equal(checked, true, '<input type="checkbox" checked=true>');
  let input2
  t.end();
});

Write the code to make the test pass!

Implementation note: while the VanillaJS TodoMVC view has checked="" (just an attribute with no value), we find this "unfriendly" to beginners so instead we are using checked=true instead because it's clearer. See: https://stackoverflow.com/a/10650302/1148249 "Use true as it is marginally more efficient and is more intention revealing to maintainers."

For more detail on the <input type="checkbox"> see: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox


Set href on <a> (anchor) element

The "filters" in the <footer> of TodoMVC contain 3 links ("anchors") <a> each of which have an href attribute indicating where clicking/tapping on the link (filter) should "route" to.

We will return to routing later (below), for now we simply need to set the href attribute.

Add the following test to the test/elmish.test.js file:

test('elmish.add_attributes <a href="#/active">Active</a>', function (t) {
  const root = document.getElementById(id);
  elmish.empty(root);
  root.appendChild(
    elmish.add_attributes(["href=#/active", "class=selected", "id=active"],
      document.createElement('a')
    )
  );
  // note: "about:blank" is the JSDOM default "window.location.href"
  console.log('JSDOM window.location.href:', window.location.href);
  // so when an href is set *relative* to this it becomes "about:blank#/my-link"
  // so we *remove* it before the assertion below, but it works fine in browser!
  const href = document.getElementById('active').href.replace('about:blank', '')
  t.equal(href, "#/active", 'href="#/active" applied to "active" link');
  t.end();
});

Write the code to make the test pass!

Useful knowledge:


append_childnodes

The append_childnodes functionality is a "one-liner":

childnodes.forEach(function (el) { parent.appendChild(el) });

It's easy to think: "why bother to create a function...?"
The reasons to create small functions are:
a) Keep the functionality "DRY" https://en.wikipedia.org/wiki/Don%27t_repeat_yourself which means we can easily track down all instances of function invocation.
b) If we ever need to modify the function, e.g: to performance optimise it, there is a single definition.
c) It makes unit-testing the functionality easy; that's great news for reliability!

With that in mind, let's write a test for the childnodes function! Add the following code to the test/elmish.test.js file:

test.only('elmish.append_childnodes appends child DOM nodes to parent', function (t) {
  const root = document.getElementById(id);
  elmish.empty(root); // clear the test DOM before!
  let div = document.createElement('div');
  let p = document.createElement('p');
  let section = document.createElement('section');
  elmish.append_childnodes([div, p, section], root);
  t.equal(root.childElementCount, 3, "Root element " + id + " has 3 child els");
  t.end();
});

Now, based on the following JSDOC comment:

/**
 * `append_childnodes` appends an array of HTML elements to a parent DOM node.
 * @param  {Array.<Object>} childnodes array of child DOM nodes.
 * @param  {Object} parent the "parent" DOM node where children will be added.
 * @return {Object} returns parent DOM node with appended children
 * @example
 * // returns the parent node with the "children" appended
 * var parent = elmish.append_childnodes([div, p, section], parent);
 */

Implement this function to make the test pass. It should be the easiest one so far. (see above for "one-liner" clue...).

Don't forget to remove the .only from the test, once you finish.

If you get "stuck", checkout: lib/elmish.js

<section> HTML Element

The first HTML element we encounter in the TodoMVC app is <section>.
<section> represents a standalone section β€” which doesn't have a more specific semantic element to represent it β€” it's an alternative way to group elements to a <div>.

info: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/section
difference: https://stackoverflow.com/questions/6939864/what-is-the-difference-between-section-and-div

We want to make our view function "declarative", this means our view should contain no "control flow" (i.e. if statements). The function invocations should reflect the final DOM quite closely see: https://en.wikipedia.org/wiki/Declarative_programming

Example view:

elmish.append_childnodes([
  section(["class=todoapp"], [ // array of "child" elements
    header(["class=header"], [
      h1([], [
        text("todos")
      ]), // </h1>
      input([
        "class=new-todo",
        "placeholder=What needs to be done?",
        "autofocus"
      ]) // <input> is "self-closing"
    ]) // </header>
  ])
], document.getElementById('my-app'));

Add the following test to your test/elmish.test.js file:

test('elmish.section creates a <section> HTML element', function (t) {
  const p = document.createElement('p');
  p.id = 'para';
  const text = 'Hello World!'
  const txt = document.createTextNode(text);
  p.appendChild(txt);
  // create the `<section>` HTML element using our section function
  const section = elmish.section(["class=new-todo"], [p])
  document.getElementById(id).appendChild(section); // add section with <p>
  // document.activeElement via: https://stackoverflow.com/a/17614883/1148249
  t.equal(document.getElementById('para').textContent, text,
    '<section> <p>' + text + '</p></section> works as expected!');
  elmish.empty(document.getElementById(id));
  t.end();
});

Based on the following JSDOC comment:

/**
 * section creates a <section> HTML element with attributes and childnodes
 * @param {Array.<String>} attrlist list of attributes to be applied to the node
 * @param {Array.<Object>} childnodes array of child DOM nodes.
 * @return {Object} returns the <section> DOM node with appended children
 * @example
 * // returns <section> DOM element with attributes applied & children appended
 * var section = elmish.section(["class=todoapp"], [h1, input]);
 */

Attempt to create the section function using the add_attributes and append_childnodes "helper" functions.

If you get "stuck", checkout: lib/elmish.js

Note: in our "solution" we created a "helper" function called create_element to "DRY" the HTML element creation code; this is a recommended* "best practice" improves maintainability.

The JSDOC comment for our create_element function is:

/**
 * create_element is a "helper" function to "DRY" HTML element creation code
 * creat *any* element with attributes and childnodes.
 * @param {String} type of element to be created e.g: 'div', 'section'
 * @param {Array.<String>} attrlist list of attributes to be applied to the node
 * @param {Array.<Object>} childnodes array of child DOM nodes.
 * @return {Object} returns the <section> DOM node with appended children
 * @example
 * // returns the parent node with the "children" appended
 * var div = elmish.create_element('div', ["class=todoapp"], [h1, input]);
 */

try to write it for yourself before looking at the "answer".

For reference, the section function in Elm: https://package.elm-lang.org/packages/elm-lang/html/2.0.0/Html
Demo: https://ellie-app.com/LTtNVQjfWVa1 ellie-elm-section

Create a view using HTML Element Functions!

Once we know how to create one HTML element, it's easy to create all of them! Consider the following HTML for the <header> section of the TodoMVC App:

<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <input class="new-todo" placeholder="What needs to be done?" autofocus="">
  </header>
</section>

There are five HTML elements: <section>, <header>, <h1> (which has a text element) and <input>. We need a function to represent (create) each one of these HTML elements.

Here is a test that creates the "real" header view: (notice how the "shape" of the "elmish" functions matches the HTML)

test('elmish create <header> view using HTML element functions', function (t) {
  const { append_childnodes, section, header, h1, text, input } = elmish;
  append_childnodes([
    section(["class=todoapp"], [ // array of "child" elements
      header(["class=header"], [
        h1([], [
          text("todos")
        ]), // </h1>
        input([
          "id=new",
          "class=new-todo",
          "placeholder=What needs to be done?",
          "autofocus"
        ], []) // <input> is "self-closing"
      ]) // </header>
    ])
  ], document.getElementById(id));

  const place = document.getElementById('new').getAttribute('placeholder');
  t.equal(place, "What needs to be done?", "placeholder set in <input> el");
  t.equal(document.querySelector('h1').textContent, 'todos', '<h1>todos</h1>');
  elmish.empty(document.getElementById(id));
  t.end();
});

We can define the required HTML element creation functions in only a few lines of code.

Create (and export) the necessary functions to make the test pass: header, h1, input and text.

Tip: each one of these HTML creation functions is a "one-liner" function body that invokes the create_element function defined above. Except the text function, which is still a "one-liner", but has only one argument and invokes a native method.

If you get stuck trying to make this test pass, refer to the completed code: /lib/elmish.js

Create the "main" view functions

Once you have the code to pass the above test(s), you will be ready to tackle something a bit bigger. Our next view is the main App:

<section class="main" style="display: block;">
  <input id="toggle-all" class="toggle-all" type="checkbox">
  <label for="toggle-all">Mark all as complete</label>
  <ul class="todo-list">
    <li data-id="1531397960010" class="completed">
      <div class="view">
        <input class="toggle" type="checkbox" checked="">
        <label>Learn The Elm Architecture ("TEA")</label>
        <button class="destroy"></button>
      </div>
    </li>
    <li data-id="1531397981603" class="">
      <div class="view">
        <input class="toggle" type="checkbox">
        <label>Build TEA Todo List App</label>
        <button class="destroy">
        </button>
      </div>
    </li>
  </ul>
</section>

The corresponding test for the above view is:

test.only('elmish create "main" view using HTML DOM functions', function (t) {
  const { section, input, label, ul, li, div, button, text } = elmish;
  elmish.append_childnodes([
    section(["class=main", "style=display: block;"], [
      input(["id=toggle-all", "class=toggle-all", "type=checkbox"], []),
      label(["for=toggle-all"], [ text("Mark all as complete") ]),
      ul(["class=todo-list"], [
        li(["data-id=123", "class=completed"], [
          div(["class=view"], [
            input(["class=toggle", "type=checkbox", "checked=true"], []),
            label([], [text('Learn The Elm Architecture ("TEA")')]),
            button(["class=destroy"])
          ]) // </div>
        ]), // </li>
        li(["data-id=234"], [
          div(["class=view"], [
            input(["class=toggle", "type=checkbox"], []),
            label([], [text("Build TEA Todo List App")]),
            button(["class=destroy"])
          ]) // </div>
        ]) // </li>
      ]) // </ul>
    ])
  ], document.getElementById(id));
  const done = document.querySelectorAll('.completed')[0].textContent;
  t.equal(done, 'Learn The Elm Architecture ("TEA")', 'Done: Learn "TEA"');
  const todo = document.querySelectorAll('.view')[1].textContent;
  t.equal(todo, 'Build TEA Todo List App', 'Todo: Build TEA Todo List App');
  elmish.empty(document.getElementById(id));
  t.end();
});

Add the test to your test/elmish.test.js file.

To make this test pass you will need to write (and export) 5 new functions: label, ul, li, div and button.

These five functions are all almost identical so you should be able to get these done in under 5 minutes. (don't over-think it!) Just make the tests pass and try to keep your code maintainable.

Again, if you get stuck trying to make this test pass, refer to the completed code: /lib/elmish.js

Create the <footer> view functions

The final view we need functions for is <footer>:

<footer class="footer" style="display: block;">
  <span class="todo-count"><strong>1</strong> item left</span>
  <ul class="filters">
    <li>
      <a href="#/" class="selected">All</a>
    </li>
    <li>
      <a href="#/active" class="">Active</a>
    </li>
    <li>
      <a href="#/completed">Completed</a>
    </li>
  </ul>
  <button class="clear-completed" style="display: block;">
    Clear completed
  </button>
</footer>

This view introduces 4 new tags: <footer>, <span>, <strong> and <a> (in the order they appear).

Add the following test for this view to your test/elmish.test.js file:
:

test.only('elmish create <footer> view using HTML DOM functions', function (t) {
  const { footer, span, strong, text, ul, li, a, button } = elmish;
  elmish.append_childnodes([
    footer(["class=footer", "style=display: block;"], [
      span(["class=todo-count", "id=count"], [
        strong("1"),
        text("item left")
      ]),
      ul(["class=filters"], [
        li([], [
          a(["href=#/", "class=selected"], [text("All")])
        ]),
        li([], [
          a(["href=#/active"], [text("Active")])
        ]),
        li([], [
          a(["href=#/completed"], [text("Completed")])
        ])
      ]), // </ul>
      button(["class=clear-completed", "style=display:block;"],
        [text("Clear completed")]
      )
    ])
  ], document.getElementById(id));

  const left = document.getElementById('count').textContent;
  t.equal(left, "item left", 'there is 1 todo item left');
  const clear = document.querySelectorAll('button')[1].textContent;
  t.equal(clear, "Clear completed", '<button> text is "Clear completed"');
  const selected = document.querySelectorAll('.selected')[1].textContent;
  t.equal(selected, "All", "All is selected by default");
  elmish.empty(document.getElementById(id));
  t.end();
});

Add the 4 functions footer, span, strong and a to elmish.js and export them so the test will pass.

if you get stuck trying to make this test pass, refer to the completed code: /lib/elmish.js


Routing

Routing is how we use the browser URL/Address to keep track of what should be displayed in the browser window.

Acceptance Criteria

  • URL (hash) should change to reflect navigation in the app
  • History of navigation should be preserved
    • Browser "back button" should work.
  • Pasting (or Book-marking) a URL should display the desired content when the "page" is loaded.

Background reading

Routing uses two web browser APIs:

location allows us to "get" and "set" the URL (href) and history lets us set the page history (before changing the href) so that the user can use their browser's "back button" (or other native browser navigation to go "back" through the history).

Note: Internet Explorer <11 does not support history.pushState: https://caniuse.com/#search=pushstate

Try it!

Open a web browser window, open the "Developer Tools" then type (or copy-paste) the following code into the Console:

setTimeout(function () { // delay for 1 second then run:
  console.log('window.location.href:', window.location.href);
  var base = window.location.href.split('#')[0];
  var active = '#/active';
  console.log('Setting the window.location.href to:', base + active);
  window.location.href = base + active;
  console.log('window.location.href:', window.location.href, 'updated!');
  console.log('window.history.length:', window.history.length);
  window.history.pushState(null, 'Active', active);
  console.log('window.history.length:', window.history.length);
}, 1000)

You should see something like this: browser-routing-example

The values for window.history.length will be different (depending on how many times you run the code).

But that's "all" there is to it! Now let's define some "helper functions" so that we can use routing in our Todo List App!

Implementation

JSDOC

We are huge proponents of "document driven development" this includes writing both markdown and code comments.

Consider the following JSDOC for the route function:

/**
 * route sets the hash portion of the URL in a web browser
 * and sets the browser history so that the "back button" works.
 * @param {Object} state - the current state of the application.
 * @param {String} title - the title of the "page" being navigated to
 * @param {String} hash - the hash (URL) to be navigated to.
 * @return {Object} new_state - state with hash updated to the *new* hash.
 * @example
 * // returns the state object with updated hash value:
 * var new_state = elmish.route(state, 'Active', '#/active');
 */

route Test!

Add the following test to your test/elmish.test.js file:

test.only('elmish.route updates the url hash and sets history', function (t) {
  const initial_hash = window.location.hash
  console.log('START window.location.hash:', initial_hash, '(empty string)');
  const initial_history_length = window.history.length;
  console.log('START window.history.length:', initial_history_length);
  // update the URL Hash and Set Browser History
  const state = { hash: '' };
  const new_hash = '#/active'
  const new_state = elmish.route(state, 'Active', new_hash);
  console.log('UPDATED window.history.length:', window.history.length);
  console.log('UPDATED state:', new_state);
  console.log('UPDATED window.location.hash:', window.location.hash);
  t.notEqual(initial_hash, window.location.hash, "location.hash has changed!");
  t.equal(new_hash, new_state.hash, "state.hash is now: " + new_state.hash);
  t.equal(new_hash, window.location.hash, "window.location.hash: "
    + window.location.hash);
  t.equal(initial_history_length + 1, window.history.length,
    "window.history.length increased from: " + initial_history_length + ' to: '
    + window.history.length);
  t.end();
});

route Implementation (to make test(s) pass)

The code to make these tests pass is only 3 or 4 lines. (depending on your implementation ...)
Provided the tests pass and you haven't "hard coded" the return, there is no "wrong answer". Try and figure it out for yourself before checking a solution.

if you get stuck trying to make this test pass, refer to the completed code: /lib/elmish.js

Note: do not "worry" about how to render the "right" content on the "page" in response to the URL (hash) changing, we will come to that when writing the "business logic" of the Todo List Application, because it will "make more sense" in context.

Elm(ish) Store > Save Model (Data) to localStorage

The final piece in the "Elm(ish)" puzzle is saving data on the device so that the Todo List items (and history) is not "lost" when when the user refreshes the browser or navigates away (and back).

The relevant Web Browser API is localStorage: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage

We are only using two methods of the localStorage API:

  • setItem - save a value (String) to the borwser/device's localStorage with a specific key
  • getItem - retrieve the value String from localStorage by key

Example:

localStorage.setItem('key', "World");
console.log("Hello " + localStorage.getItem('key')); // Hello World

Acceptance Criteria

  • model is retrieved from localStorage if it has been (previously) set when mount is invoked
  • Initial model is saved to localStorage when mount is invoked
  • Updated model is saved to localStorage when update is called. (thus localStorage always has the latest version)

Try it!

As always, the best way to familiarise yourself with a DOM API is to try it in your web browser! Open a browser tab, open Dev Tools and type the following code:

var model = { 'one': 1, 'two': 2, 'three': 3 };

// save the model (data) into storage as a stringified object:
localStorage.setItem('elmish_store', JSON.stringify(model));

// Retrieve the stringified object from localStorage:
var retrieved_model = localStorage.getItem('elmish_store');

console.log('Retrieved model: ', JSON.parse(retrieved_model));

You should see something like this:

localStorage-example-run-in-browser

Implementation

Given that saving and retrieving the Todo List model to/from localStorage uses two "native" DOM API functions, we can avoid writing our own functions which are just going to "wrap" setItem and getItem.

We can simply use the setItem and getItem where we need them! The best place to handle the "set" and "get" logic is in the mount function. You will recall from earlier (above) that the Elm(ish) mount function looks like this:

/**
 * `mount` mounts the app in the "root" DOM Element.
 * @param  {Object} model store of the application's state.
 * @param  {Function} update how the application state is updated ("controller")
 * @param  {Function} view function that renders HTML/DOM elements with model.
 * @param  {String} root_element_id root DOM element in which the app is mounted
 */
function mount(model, update, view, root_element_id) {
  var root = document.getElementById(root_element_id); // root DOM element
  function signal(action) {                     // signal function takes action
    return function callback() {                // and returns callback
      var updatedModel = update(action, model); // update model for the action
      empty(root);                              // clear root el before rerender
      view(signal, updatedModel, root);         // subsequent re-rendering
    };
  };
  view(signal, model, root);                    // render initial model (once)
}

We are going to make 3 adjustments to this code to use setItem and getItem, but first let's write a test for the desired outcome!

Add the following test code to your test/elmish.test.js file:

// Testing localStorage requires a "polyfil" because it's unavailable in JSDOM:
// https://github.com/jsdom/jsdom/issues/1137 Β―\_(ツ)_/Β―
global.localStorage = { // globals are bad! but a "necessary evil" here ...
  getItem: function(key) {
   const value = this[key];
   return typeof value === 'undefined' ? null : value;
 },
 setItem: function (key, value) {
   this[key] = value;
 }
}
localStorage.setItem('hello', 'world!');
console.log('localStorage (polyfil) hello', localStorage.getItem('hello'));

// Test mount's localStorage using view and update from counter-reset example
// to confirm that our elmish.mount localStorage works and is "generic".

test.only('elmish.mount sets model in localStorage', function (t) {
  const { view, update } = require('./counter.js');

  const root = document.getElementById(id);
  elmish.mount(7, update, view, id);
  // the "model" stored in localStorage should be 7 now:
  t.equal(JSON.parse(localStorage.getItem('elmish_store')), 7,
    "elmish_store is 7 (as expected). initial state saved to localStorage.");
  // test that mount still works as expected (check initial state of counter):
  const actual = document.getElementById(id).textContent;
  const actual_stripped = parseInt(actual.replace('+', '')
    .replace('-Reset', ''), 10);
  const expected = 7;
  t.equal(expected, actual_stripped, "Inital state set to 7.");
  // attempting to "re-mount" with a different model value should not work
  // because mount should retrieve the value from localStorage
  elmish.mount(42, update, view, id); // model (42) should be ignored this time!
  t.equal(JSON.parse(localStorage.getItem('elmish_store')), 7,
    "elmish_store is 7 (as expected). initial state saved to localStorage.");
  // increment the counter
  const btn = root.getElementsByClassName("inc")[0]; // click increment button
  btn.click(); // Click the Increment button!
  const state = parseInt(root.getElementsByClassName('count')[0]
    .textContent, 10);
  t.equal(state, 8, "State is 8 after increment.");
  // the "model" stored in localStorage should also be 8 now:
  t.equal(JSON.parse(localStorage.getItem('elmish_store')), 8,
    "elmish_store is 8 (as expected).");
  elmish.empty(root); // reset the DOM to simulate refreshing a browser window
  elmish.mount(5, update, view, id); // 5 ignored! model read from localStorage.
  // clearing DOM does NOT clear the localStorage (this is desired behaviour!)
  t.equal(JSON.parse(localStorage.getItem('elmish_store')), 8,
    "elmish_store still 8 from increment (above) saved in localStorage");
  t.end()
});

There is quite a lot to "unpack" in this test but let's walk through the steps:

  1. Require the view and update from our counter reset example.
  2. mount the counter reset app
  3. test that the model (7) is being saved to localStorage by mount function.
  4. Attempt to "re-mount" the counter app with an initial model of 42 should not work because mount will "read" the initial model from localStorage if it is defined.
  5. update the model using the inc (increment) action
  6. test that localStorage was updated to reflect the increment (8)
  7. empty the DOM. (to simulate a page being refreshed)
  8. test that localStorage still contains our saved data.

Based on this test, try to add the necessary lines to the mount function, to make the test pass.

if you get stuck trying to make this test pass, refer to the completed code: /lib/elmish.js


onclick attribute to invoke the "dispatcher" when element clicked

In order to allow click/tap interactions with buttons, we need to add an onclick attribute which then invokes the desired update.

Add the following test code to your test/elmish.test.js file:

test.only('elmish.add_attributes onclick=signal(action) events!', function (t) {
  const root = document.getElementById(id);
  elmish.empty(root);
  let counter = 0; // global to this test.
  function signal (action) { // simplified version of TEA "dispatcher" function
    return function callback() {
      switch (action) {
        case 'inc':
          counter++; // "mutating" ("impure") counters for test simplicity.
          break;
      }
    }
  }

  root.appendChild( // signal('inc') should be applied as "onclick" function:
    elmish.add_attributes(["id=btn", signal('inc')],
      document.createElement('button'))
  );

  // "click" the button!
  document.getElementById("btn").click()
  // confirm that the counter was incremented by the onclick being triggered:
  t.equal(counter, 1, "Counter incremented via onclick attribute (function)!");
  elmish.empty(root);
  t.end();
});

Run the test:

node test/elmish.test.js

onclick-test-failing

Making this test pass requires a little knowledge of how JavaScript does "type checking" and the fact that we can "pass around" functions as variables.

The amount of code required to make this test pass is minimal, you could even get it down to 1 line. The key is thinking through what the test is doing and figuring out how to apply an onclick function to a DOM node.

Relevant/useful reading:

Try to make the test pass by yourself or with your pairing partner.

If you get "stuck", checkout: elmish.js > add_attributes


subscriptions for Event Listeners

In Elm, when we want to "listen" for an event or "external input" we use subscriptions.
Examples include:

In order to listen for and respond to Keyboard events, specifically the Enter and [Escape] key press, we need a way of "attaching" event listeners to the DOM when mounting our App.

To demonstrate subscriptions, let's briefly re-visit the Counter Example and consider an alternative User Interaction/Experience: Keyboard!

Use-case: Use Up/Down Keyboard (Arrow) Keys to Increment/Decrement Counter

As a user
I would like to use the keyboard [↑] (Up) and [↓] (Down) arrow keys
to signal the Increment and Decrement action (respectively) of the Counter.
So that I don't have to use a mouse to click a button.

up-down-arrrow-keys

Background reading: https://webaim.org/techniques/keyboard

Baseline Example Code Without Subscription

Let's start by making a "copy" of the code in /examples/counter-reset:

cp test/counter.js test/counter-reset-keyboard.js

First step is to re-factor the code in test/counter-reset-keyboard.js to use the "DOM" functions we've been creating for Elm(ish). This will simplify the counter.js down to the bare minimum.

In your test/counter-reset-keyboard.js file, type the following code:

/* if require is available, it means we are in Node.js Land i.e. testing!
 in the broweser, the "elmish" DOM functions are loaded in a <script> tag */
/* istanbul ignore next */
if (typeof require !== 'undefined' && this.window !== this) {
  var { button, div, empty, h1, mount, text } = require('../lib/elmish.js');
}

function update (action, model) {    // Update function takes the current state
  switch(action) {                   // and an action (String) runs a switch
    case 'inc': return model + 1;    // add 1 to the model
    case 'dec': return model - 1;    // subtract 1 from model
    case 'reset': return 0;          // reset state to 0 (Zero) git.io/v9KJk
    default: return model;           // if no action, return curent state.
  }                                  // (default action always returns current)
}

function view(model, signal) {
  return div([], [
    button(["class=inc", "id=inc", signal('inc')], [text('+')]), // increment
    div(["class=count", "id=count"], [text(model.toString())]), // count
    button(["class=dec", "id=dec", signal('dec')], [text('-')]), // decrement
    button(["class=reset", "id=reset", signal('reset')], [text('Reset')])
  ]);
}

/* The code block below ONLY Applies to tests run using Node.js */
/* istanbul ignore else */
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    view: view,
    update: update,
  }
}

How do We Test for Subscription Events?

As described above in our "use case" we want to create event listeners, for the [↑] (Up) and [↓] (Down) arrow keyboard keys, so the only way to test for these is to "Trigger" the event(s). Thankfully, this is easy in JavaScript. Let's write the test!

Add the following test code to your test/elmish.test.js file:

test here!

Run the test (watch it fail!):

node test/elmish.test.js

subscriptions-test-failing

Hopefully it's clear from reading the test why the assertion is failing. The counter is not being incremented. The last assertion passes because "even a broken clock is right twice a day" ... since the counter is never incremented, the count is 0 (zero) throughout the test, so the last assertion always passes. (this will not be the case once you have the [Up] arrow event listener working).

Recommended reading: https://stackoverflow.com/questions/596481/is-it-possible-to-simulate-key-press-events-programmatically

subscriptionsImplementation: Keyboard Keys Increment/Decrement Counter!

Once again, try to think of how you would implement a subscriptions function and attempt to write the code.

Don't be disheartened if you have "no idea" how to solve this one. If you are relatively recent to JavaScript, it is unlikely that you have come across event listeners.

It's "OK" to "take a peek" at the sample code: examples/counter-reset-keyboard/counter.js

Once you add the subscriptions function to test/counter-reset-keyboard.js, Your tests should pass:

counter-reset-keyboard-subscriptions-tests-passing

Well done!


That's it for now! Elm(ish) is "ready" to be used for our TodoMVC App!


Why Not use HTML5 <template> Element ??

Templates are an awesome feature in HTML5 which allow the creation of reusable markup!

Sadly, they are unavailable in Internet Explorer. https://caniuse.com/#feat=template
If you don't need to "cater" for Internet Explorer, then checkout: https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro

Notes

1The reason for calling the micro-framework Elm(ish) is to emphasize that it is "inspired by" Elm. The only things Elm(ish) shares with Elm are the "MUV" architecture "pattern" and function naming/argument similarity. In all other respects Elm(ish) is a "poor imitation" and should only be used for learning purposes! To truly appreciate the awesome elegance, simplicity, power and personal effectiveness of using Elm, there is no substitute for the "real thing".