diff --git a/README.md b/README.md index d7ce536e..efd71957 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ When we encounter this type of "_what is the **right way**_?" question
we always follow [***Occam's Razor***](https://en.wikipedia.org/wiki/Occam%27s_razor) and _ask_: what is the ***simplest way***?
-In the case of web application organization, +In the case of web application organization, the ***answer*** is: the "**Elm _Architecture_**". @@ -63,13 +63,13 @@ When compared to _other_ ways of organizing your code, + Easier to _understand_ what is going on in more advanced apps because there is no complex logic, only one basic principal and the "_flow_" is _always_ the same. -+ ***Uni-directional data-flow*** means "state" ++ ***Uni-directional data-flow*** means "state" of the app is always _predictable_; given a specific starting "state" and sequence of update actions the output/end state will _always_ be the same. This makes testing/testability very easy! + There's **no** "***middle man***" to complicate things -(_the way there is in other application architectures +(_the way there is in other application architectures such as [Model-view-Presenter](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93presenter) or "Model-View-ViewModel" (MVVM) which is "overkill" for most apps_.) @@ -85,12 +85,12 @@ their code/app in a _sane_, predictable and testable way. ![all-you-need-is-less](https://cloud.githubusercontent.com/assets/194400/25772135/a4230490-325b-11e7-9f12-da19fa4eb5e9.png) -+ _Basic_ JavaScript Knowledge. -see: [github.com/iteles/**Javascript**-the-**Good-Parts**-notes](https://github.com/iteles/Javascript-the-Good-Parts-notes) -+ _Basic_ Understanding of TDD. If you are _completely_ new to TDD, -please see: https://github.com/dwyl/learn-tdd -+ A computer -+ 30 minutes. ++ **_Basic_ JavaScript Knowledge**. +see: [github.com/dwyl/**Javascript**-the-**Good-Parts**-notes](https://github.com/iteles/Javascript-the-Good-Parts-notes) ++ _Basic_ Understanding of **TDD**. If you are _completely_ new to TDD, +please see: [github.com/dwyl/**learn-tdd**](https://github.com/dwyl/learn-tdd) ++ A computer with a Web Browser. ++ **30 minutes**. > No other knowledge is assumed or implied. If you have **_any_ questions**, ***please ask***:
@@ -105,17 +105,34 @@ If you have **_any_ questions**, ***please ask***:
Start with a few definitions: -+ **M**odel - or "data model" is the place where all data -is often referred to as the application's `state`. ++ **M**odel - or "data model" is the place where all data stored; +often referred to as the application's `state`. + **U**pdate - how the app handles `actions` performed by people and `update` the `state`. -+ **V**iew - what the people using the app can _see_; -a way to `view` the Model (counter) as `HTML` -rendered by the web browser. ++ **V**iew - what people using the app can _see_; +a way to `view` the Model (counter) as `HTML` +rendered in a web browser. ![elm-muv-architecture-diagram](https://cloud.githubusercontent.com/assets/194400/25773775/b6a4b850-327b-11e7-9857-79b6972b49c3.png) -Don't worry if you don't understand this diagram (_yet_), +
+If you're not into flow diagrams, don't worry, there not everyone is,
+a _much_ more "user friendly" _explanation_ +of **The Elm Architecture** ("TEA")
+is +[**Kolja Wilcke**'s](https://twitter.com/01k/status/986528602635358208?s=20) _fantastic_ +["View Theater" diagram](https://github.com/w0rm/creating-a-fun-game-with-elm/blob/001baf05b3879d12c0ff70075e9d25e8cc7c4656/assets/the-elm-architecture1.jpg): + +
+ +![elm-architecture-puppet-show](https://user-images.githubusercontent.com/194400/41206474-62d1a6a4-6cfc-11e8-8029-e27b7aa7f069.jpg) + +Creative Commons License: +[Attribution 4.0 International (CC BY 4.0)](https://twitter.com/01k/status/986528602635358208?s=20) + +
+
+If this diagram is not clear (_yet_), again, don't panic, it will all become clear when you start seeing it in _action_ (_below_)! @@ -666,6 +683,34 @@ button('Reset', signal, Res) ``` ![reset-counter](https://cloud.githubusercontent.com/assets/194400/25822128/82eb7a8e-342f-11e7-9cd0-1a69d95ee878.gif) +
+ +### 10. _Next Level: Multiple Counters_! + +Now that you have _understood_ the Elm Architecture +by following the basic (_single_) counter example, +it's time to take the example to the next level: +multiple counters on the same page! + +#### Multiple Counters Exercise + +Follow your _instincts_ and `try` to the following: + +**1.** **Refactor** the "reset counter" example +to use an `Object` for the `model` (_instead of an_ `Integer`)
+**e.g**: `var model = { counters: [0] }`
+where the value of the first element in the `model.counters` Array +is the value for the _single_ counter example.
+ +**2.** **Display _multiple_ counters** on the **_same_ page** +using the `var model = { counters: [0] }` approach.
+ +**3.** **Write tests** for the scenario where there +are multiple counters on the same page. + +Once you have had a go, checkout our solutions: `examples/multiple-counters` +and corresponding writeup: +[**multiple-counters.md**](https://github.com/dwyl/learn-elm-architecture-in-javascript/blob/master/multiple-counters.md)

diff --git a/examples/counter-reset/counter.js b/examples/counter-reset/counter.js index 9df71bec..dc0944ee 100644 --- a/examples/counter-reset/counter.js +++ b/examples/counter-reset/counter.js @@ -72,7 +72,8 @@ function init(doc){ /* The code block below ONLY Applies to tests run using Node.js */ /* istanbul ignore next */ -if (typeof module !== 'undefined' && module.exports) { module.exports = { +if (typeof module !== 'undefined' && module.exports) { + module.exports = { view: view, mount: mount, update: update, diff --git a/examples/multiple-counters-instances/index.html b/examples/multiple-counters-instances/index.html new file mode 100644 index 00000000..54a78080 --- /dev/null +++ b/examples/multiple-counters-instances/index.html @@ -0,0 +1,52 @@ + + + + + + Elm Architecture in JS - Counter Reset + + +

Elm Architecture in JS - Counter Reset

+ + + + + + + + +

AltStyle によって変換されたページ (->オリジナル) /

+ diff --git a/examples/multiple-counters/counter.js b/examples/multiple-counters/counter.js new file mode 100644 index 00000000..a28d87c4 --- /dev/null +++ b/examples/multiple-counters/counter.js @@ -0,0 +1,89 @@ +// Define the Component's Actions: +var Inc = 'inc'; // increment the counter +var Dec = 'dec'; // decrement the counter +var Res = 'reset'; // reset counter: git.io/v9KJk + +function update(model, action) { // Update function takes the current state + var parts = action ? action.split('-') : []; // e.g: inc-0 where 0 is the counter "id" + var act = parts[0]; + var index = parts[1] || 0; + var new_model = JSON.parse(JSON.stringify(model)) // "clone" the model + switch(act) { // and an action (String) runs a switch + case Inc: + new_model.counters[index] = model.counters[index] + 1; + break; + case Dec: + new_model.counters[index] = model.counters[index] - 1; + break; + case Res: // use ES6 Array.fill to create a new array with values set to 0: + new_model.counters[index] = 0; + break; + default: return model; // if action not defined, return curent state. + } + return new_model; +} + +function view(signal, model, root) { + empty(root); // clear root element before re-rendering the App (DOM). + model.counters.map(function(counter, index) { + return container(index, [ // wrap DOM nodes in an "container" + button('+', signal, Inc + '-' + index), // append index to action + div('count', counter), // create div w/ count as text + button('-', signal, Dec + '-' + index), // decrement counter + button('Reset', signal, Res + '-' + index) // reset counter + ]); + }).forEach(function (el) { root.appendChild(el) }); // forEach is ES5 so IE9+ +} + +// Mount Function receives all MUV and mounts the app in the "root" DOM Element +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 + model = update(model, action); // update model according to action + view(signal, model, root); // subsequent re-rendering + }; + }; + view(signal, model, root); // render initial model (once) +} + +// The following are "Helper" Functions which each "Do ONLY One Thing" and are +// used in the "View" function to render the Model (State) to the Browser DOM: + +// empty the contents of a given DOM element "node" (before re-rendering) +function empty(node) { + while (node.firstChild) { + node.removeChild(node.firstChild); + } +} // Inspired by: stackoverflow.com/a/3955238/1148249 + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/section +function container(index, elements) { + var con = document.createElement('section'); + con.id = index; + con.className = 'counter'; + elements.forEach(function(el) { con.appendChild(el) }); + return con; +} + +function button(text, signal, action) { + var button = document.createElement('button'); + var text = document.createTextNode(text); // human-readable button text + button.appendChild(text); // text goes *inside* not attrib + button.className = action.split('-')[0]; // use action as CSS class + button.id = action; + // console.log(signal, ' action:', action) + button.onclick = signal(action); // onclick tells how to process + return button; // return the DOM node(s) +} // how to create a button in JavaScript: stackoverflow.com/a/8650996/1148249 + +function div(divid, text) { + var div = document.createElement('div'); + div.id = divid; + div.className = divid; + if(text !== undefined) { // if text is passed in render it in a "Text Node" + var txt = document.createTextNode(text); + div.appendChild(txt); + } + return div; +} diff --git a/examples/multiple-counters/index.html b/examples/multiple-counters/index.html new file mode 100644 index 00000000..3d78a4d9 --- /dev/null +++ b/examples/multiple-counters/index.html @@ -0,0 +1,29 @@ + + + + + + Elm Architecture in JS - Counter Reset + + + + + + + + + + + + + + + + + + + + diff --git a/examples/multiple-counters/test.js b/examples/multiple-counters/test.js new file mode 100644 index 00000000..7768095c --- /dev/null +++ b/examples/multiple-counters/test.js @@ -0,0 +1,74 @@ +var id = 'test-app'; + +test('update({counters:[0]}) returns {counters:[0]} (current state unmodified)', + function(assert) { + var result = update({counters:[0]}); + assert.equal(result.counters[0], 0); +}); + +test('Test Update increment: update(1, "inc") returns 2', function(assert) { + var result = update({counters: [1] }, "inc"); + console.log('result', result); + assert.equal(result.counters[0], 2); +}); + + +test('Test Update decrement: update(1, "dec") returns 0', function(assert) { + var result = update({counters: [1] }, "dec"); + assert.equal(result.counters[0], 0); +}); + +test('Test negative state: update(-9, "inc") returns -8', function(assert) { + var result = update({counters: [-9] }, "inc"); + assert.equal(result.counters[0], -8); +}); + +test('mount({model: 7, update: update, view: view}, "' + + id +'") sets initial state to 7', function(assert) { + mount({counters:[7]}, update, view, id); + var state = document.getElementById(id) + .getElementsByClassName('count')[0].textContent; + assert.equal(state, 7); +}); + +test('empty("test-app") should clear DOM in root node', function(assert) { + empty(document.getElementById(id)); + mount({counters:[7]}, update, view, id); + empty(document.getElementById(id)); + var result = document.getElementById(id).innerHtml + assert.equal(result, undefined); +}); + +test('click on "+" button to re-render state (increment model by 1)', +function(assert) { + document.body.appendChild(div(id)); + mount({counters:[7]}, update, view, id); + document.getElementById(id).getElementsByClassName('inc')[0].click(); + var state = document.getElementById(id) + .getElementsByClassName('count')[0].textContent; + assert.equal(state, 8); // model was incremented successfully + empty(document.getElementById(id)); // clean up after tests +}); + +// Reset Functionality + +test('Test reset counter when model/state is 6 returns 0', function(assert) { + var result = update({counters:[7]}, "reset"); + assert.equal(result.counters[0], 0); +}); + +test('reset button should be present on page', function(assert) { + var reset = document.getElementsByClassName('reset'); + assert.equal(reset.length, 3); +}); + +test('Click reset button resets state to 0', function(assert) { + mount({counters:[7]}, update, view, id); + var root = document.getElementById(id); + assert.equal(root.getElementsByClassName('count')[0].textContent, 7); + var btn = root.getElementsByClassName("reset")[0]; // click reset button + btn.click(); // Click the Reset button! + var state = root.getElementsByClassName('count')[0].textContent; + assert.equal(state, 0); // state was successfully reset to 0! + empty(root); // clean up after tests +}); diff --git a/examples/style.css b/examples/style.css index b52d7184..8ddfa8da 100644 --- a/examples/style.css +++ b/examples/style.css @@ -2,6 +2,7 @@ body{ font-family: Courier, "Lucida Console", monospace; font-size: 4em; text-align: center; + background-color: white; } button { font-size: 0.5em; color:white; border:5px solid; border-radius: 0.5em; @@ -18,6 +19,23 @@ button { background-color: #f39c12; border-color: #e67e22; } +#qunit { + padding-top: 0.5em; + width: 100%; + clear: both; +} + #qunit-header { /* just cause the default style makes the header HUGE!! */ font-size: 0.4em !important; } + +/* specific to multiple counters */ +section { + background-color: white; + float: left; + padding-right: 1%; + width: 32%; +} +section button { + width: 100%; +} diff --git a/multiple-counters.md b/multiple-counters.md new file mode 100644 index 00000000..afb9cf53 --- /dev/null +++ b/multiple-counters.md @@ -0,0 +1,204 @@ +# _Multiple_ Counters Exercise! + +There are (_at least_) two ways +of displaying multiple counters on the same page. + +The _easy_ way is to "_instantiate_" several counters +each within their own "container" (DOM) element. e.g:
+```html + + + + + +``` + +![elm-arch-multiple-counters-naive](https://user-images.githubusercontent.com/194400/41302789-5299bd5e-6e63-11e8-8006-84313c54a24c.png) +see: [link to multiple counter instances code] + + +This "_works_" and "_satisfies_ the _requirement_" +of having multiple counters on the same "page". +_However_, it's not a "sustainable" way of "extending" an app for the long term. +Almost no "_real_" web application uses an `Integer` as the `model`. + +We could leave the counter example `model` as an `Integer` +and move on to the _next_ example (_Todo List_), +but as a "_thought experiment_", +let's try to implement _multiple counters_ using an `Array` of `Integers`, +this is a good "**refactoring**" exercise. + + + +## 1. Refactor Model from `Integer` to `Object` with `Array` + +Using the code from +[`example/counter-reset`](https://github.com/dwyl/learn-elm-architecture-in-javascript/tree/master/examples/counter-reset) +as a starting point, +refactor the `model` from `Integer` to an `Object` with an `Array` +called `counters`:
+```js +mount({counters:[0]}, update, view, 'app'); +``` + +That will "_break_" the existing tests: +![counter-tests-broken](https://user-images.githubusercontent.com/194400/41207245-f49b8caa-6d09-11e8-9fb9-ee9509e8b56b.png) + +(_I **temporarily commented out** all the other failing tests + to reduce noise, but by the time we are done refactoring, + all tests will pass!_) + +### 1.1 Make Tests Pass Again? + +When refactoring the _convention_ is to ***not touch the tests***, +_However_ the _first_ test in our `test.js` file checks the `state` +of the `model` if no _action_ is passed into the `update` function: +```js +test('Test Update update(0) returns 0 (current state)', function(assert) { + var result = update(0); + assert.equal(result, 0); +}); +``` +This test is still _relevant_ because the Elm Architecture _always_ +returns the `model` _unchanged_ if no `action` is given. +We need to _update_ this test to reflect the change in the `model` signature: +```js +test('update({counters:[0]}) returns {counters:[0]} (current state unmodified)', function(assert) { + var result = update({counters:[0]}); + assert.equal(result.counters[0], 0); +}); +``` + +Snapshot of the code/changes required to make tests pass again: +https://github.com/dwyl/learn-elm-architecture-in-javascript/pull/41/commits/c65d491d69d2d68964df36817ccbff9de3275f0b + + + +## 2. Render Multiple Counters using New Model + +Updating the `model` was the _start_ of our refactoring journey, +if we were to include multiple elements in the `counters` `Array` +now, before updating the `view` function, +we would still only see _one_ +counter on the page because our `view` +does not _yet_ "know" how to render multiple counters. + + +### 2.1 Update the `view` function + +Given that we have updated the `model` to be a an `Object` +with a `counters` `Array`, we need to update our `view` function +to render as many counters as we have elements +in the `counters` `Array`. + +_First_ create a "container" DOM element so each counter +(_the increment, decrement and reset buttons + and text display of the current counter value_) +can be "wrapped" together: + +```js +function container(index, elements) { + var con = document.createElement('section'); + con.id = index; + con.className = 'counter'; + elements.forEach(function(el) { con.appendChild(el) }); + return con; +} +``` + +This `container` function will be used +in the re-worked `view` function (_which we are modifying next!_) + +Let's modify the `view` function to accommodate + +#### Before: + +```js +function view(signal, model, root) { + empty(root); // clear root element before + [ // Store DOM nodes in an array + button('+', signal, Inc), // then iterate to append them + div('count', model), // create div with stat as text + button('-', signal, Dec), // decrement counter + button('Reset', signal, Res) // reset counter + ].forEach(function(el){ root.appendChild(el) }); // forEach is ES5 so IE9+ +} +``` + +#### After: + +```js +function view(signal, model, root) { + empty(root); // clear root element before re-rendering the App (DOM). + model.counters.map(function(counter, index) { // one counter for each + return container(index, [ // wrap DOM nodes in an "container" + button('+', signal, Inc + '-' + index), // append index to action + div('count', counter), // create div w/ count as text + button('-', signal, Dec + '-' + index), // decrement counter + button('Reset', signal, Res + '-' + index) // reset counter + ]); + }).forEach(function (el) { root.appendChild(el) }); // forEach is ES5 so IE9+ +} +``` +The key differences are: ++ Wrapping the counter in a "container" DOM element. ++ Appending the index (_in the `model.counters` Array_) +to each `action` e.g: `Inc + '-' + index` +such that each button is unique and we can derive the +_exact_ counter that needs to be Incremented. + + +### 2.2 Refactor the `update` function + +The `update` function needs to be updated to support + +#### Before: + +```js +function update(model, action) { // 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 Res: return 0; // reset state to 0 (Zero) git.io/v9KJk + default: return model; // if no action, return curent state. + } // (default action always returns current) +} +``` + +#### After: + +```js +function update(model, action) { + var parts = action ? action.split('-') : []; // e.g: inc-0 where 0 is the counter "id" + var act = parts[0]; + var index = parts[1] || 0; // default to 0 (assume only one counter) + var new_model = JSON.parse(JSON.stringify(model)) // "clone" the model + switch(act) { // and an action (String) runs a switch + case Inc: + new_model.counters[index] = model.counters[index] + 1; + break; + case Dec: + new_model.counters[index] = model.counters[index] - 1; + break; + case Res: // use ES6 Array.fill to create a new array with values set to 0: + new_model.counters[index] = 0; + break; + default: return model; // if action not defined, return current state. + } + return new_model; +} +``` + +Try it: http://127.0.0.1:8000/examples/multiple-counters/?coverage + +![image](https://user-images.githubusercontent.com/194400/41462055-7b7158a4-7089-11e8-829e-cc8f0d9ba74a.png) + + +If you can _simplify_ this code, +we're happy to receive a Pull Request! +Share your thoughts on: +https://github.com/dwyl/learn-elm-architecture-in-javascript/issues/40