Elm
(ish) is an Elm
-inspired JavaScript
(ES5)
fully functional front-end micro-framework from scratch.1
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.
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.
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!
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
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 M
odel, C
ontroller and V
iew
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
.
It's essential to ask: "Where do I start (my TDD quest)?"
The answer is: create two new files:
examples/todo-list/elmish.js
and test/elmish.test.js
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,
'../examples/todo-list/index.html')); // sample HTML file to initialise JSDOM.
require('jsdom-global')(html); // https://github.com/rstacruz/jsdom-global
const elmish = require('../examples/todo-list/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!)
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.
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 examples/todo-list/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.
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
andcounter-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
Now that we have the test for our empty
function written,
we can add the empty
function to examples/todo-list/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);
}
}
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 examples/todo-list/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:
Boom! our first test is passing! (the test has 3 assertions, that's why Tape says "tests 3. pass 3").
The mount
function is the "glue" or "wiring" function that
connects the model
, update
and view
; we can generalise it.
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 examples/todo-list/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
*/
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('../examples/counter-reset/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
Add the following code to the mount
function body to make the test pass
in examples/todo-list/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)
}
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 suite:
node test/elmish.test.js
You should expect to see: (tests passing)
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".
In our quest to analyse the required functionality of a Todo List, the easiest way is to observe a functioning TodoMVC Todo List.
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
If you are unable to run the TodoMVC locally, you can always view it online: http://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.
A todo list has only 2 basic functions:
- Add a
new
item to the list (when the[Enter]
key is pressed) - 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.
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.
below the main interface there is a <footer>
with a count, 3 view toggles and one action:
- "{cont} item(s) left":
{store.items.filter(complete==false)}
item{store.items.length > 1 ? 's' : '' }
left - Show
All
- Show
Active
- Show
Completed
- Clear
Completed
Finally, if you click around the <footer>
toggle menu,
you will notice that the Web Bowser Address bar
changes to reflect the chosen view.
Thinking about a task or challenge from "first principals" is
a greatthe best way to understand it.
This is the "physics" approach. see: https://youtu.be/L-s_3b5fRd8?t=22m37s
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:
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: #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.
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
orclass
.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).
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:
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:
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: /examples/todo-list/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 CSSclass
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 DOMnode
, 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 theadd_attributes
function is called. see: https://en.wikipedia.org/wiki/Idempotence
For reference, the Elm HTML Attributes function on Elm package is: http://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:
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
:
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:
If you get "stuck", checkout the complete example: /examples/todo-list/elmish.js
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":
@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. 💔
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
:
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.
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:
Now the Coverage should be 100% when you run npm test
:
In your terminal, type/run the follwoing command: open coverage/lcov-report/index.html
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!
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:
<input>
attributes: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Attributes- https://caniuse.com/#feat=autofocus (unavailable on iOS Safari!)
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
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
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
.
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
<input>
attributetype
: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Attributes
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: http://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/learn-elm-architecture-in-javascript/tree/master/examples/todo-list/elmish.js
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 usingchecked=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
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:
- What: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#Attributes
- Why: https://stackoverflow.com/questions/4855168/what-is-href-and-why-is-it-used
- How: https://stackoverflow.com/questions/4689344/how-can-i-add-href-attribute-to-a-link-dynamically-using-javascript
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:
https://github.com/dwyl/learn-elm-architecture-in-javascript/tree/master/examples/todo-list/elmish.js
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:
https://github.com/dwyl/learn-elm-architecture-in-javascript/tree/master/examples/todo-list/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".
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:
http://package.elm-lang.org/packages/elm-lang/html/2.0.0/Html
Demo: https://ellie-app.com/LTtNVQjfWVa1
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: /examples/todo-list/elmish.js
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: /examples/todo-list/elmish.js
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: /examples/todo-list/elmish.js
Routing is how we use the browser URL/Address to keep track of what should be displayed in the browser window.
- 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.
Routing uses two web browser APIs:
- Location: https://developer.mozilla.org/en-US/docs/Web/API/Location
- History: https://developer.mozilla.org/en-US/docs/Web/API/History_API
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
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:
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!
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');
*/
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();
});
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:
/examples/todo-list/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.
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 avalue
(String
) to the borwser/device'slocalStorage
with a specifickey
getItem
- retrieve thevalue
String
fromlocalStorage
bykey
Example:
localStorage.setItem('key', "World");
console.log("Hello " + localStorage.getItem('key')); // Hello World
-
model
is retrieved fromlocalStorage
if it has been (previously) set whenmount
is invoked - Initial
model
is saved tolocalStorage
whenmount
is invoked - Updated
model
is saved tolocalStorage
whenupdate
is called. (thuslocalStorage
always has the latest version)
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:
- Further reading & discussion:
https://stackoverflow.com/questions/2010892/storing-objects-in-html5-localStorage
- Spec: https://www.w3.org/TR/webstorage/#the-localstorage-attribute
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('../examples/counter-reset/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:
- Require the
view
andupdate
from our counter reset example. mount
the counter reset app- test that the
model
(7) is being saved tolocalStorage
bymount
function. - Attempt to "re-mount" the counter app with an initial model of
42
should not work becausemount
will "read" the initial model fromlocalStorage
if it is defined. update
the model using theinc
(increment) action- test that
localStorage
was updated to reflect the increment (8) empty
the DOM. (to simulate a page being refreshed)- 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:
/examples/todo-list/elmish.js
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
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:
- https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onclick
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof
- https://stackoverflow.com/questions/6956258/adding-onclick-event-to-dynamically-added-button
- https://stackoverflow.com/questions/14569320/simulating-button-click-in-javascript
Try to make the test pass by yourself or with your pairing partner.
If you get "stuck", checkout:
elmish.js > add_attributes
In Elm, when we want to "listen" for an event or "external input"
we use subscriptions
.
Examples include:
- Keyboard events
- Mouse movements & clicks
- Browser location changes (Navigation)
- Websocket events (messages)
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!
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.
Background reading: https://webaim.org/techniques/keyboard
Let's start by making a "copy" of the code in /examples/counter-reset
:
mkdir examples/counter-reset-keyboard
cp examples/counter-reset/* examples/counter-reset-keyboard/
First step is to re-factor the code in
examples/counter-reset-keyboard/counter.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 examples/counter-reset-keyboard/counter.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('./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,
}
}
Without touching the code/tests
in examples/counter-reset-keyboard/test.js
,
You should just be able to run the "liveserver" on your localhost
:
npm start
and when you open: http://127.0.0.1:8000/examples/counter-reset-keyboard
should see the Qunit (Broweser) Tests passing:
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
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
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
examples/counter-reset-keyboard/counter.js
,
Your tests should pass:
Well done!
That's it for now! Elm
(ish) is "ready" to be used
for our TodoMVC App!
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
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".