Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

✅ A step-by-step complete beginner example/tutorial for building a Todo List App (TodoMVC) from scratch in JavaScript following Test Driven Development (TDD) best practice. 🌱

License

NotificationsYou must be signed in to change notification settings

dwyl/javascript-todo-list-tutorial

Repository files navigation

Astep-by-step tutorial showing you how tobuild aTodo List Appfrom scratch inJavaScript.

GitHub Workflow Statuscodecov.ioDependencies: Nonecontributions welcomeHitCount

Step one: learn JavaScript!

Before you continue, try the demo:https://dwyl.github.io/javascript-todo-list-tutorial/

Add a few items to the list. Double-click/tap the item to edit it.Check-off your todos and navigate the footer to filter for Active/Completed.Try and "break" it! Refresh the page and notice how your todo itemsare "still there" (they were saved tolocalStorage!).Once you have had a "play" with the demo, come back andbuild it!!


Why?

Thepurpose of thisTodo Listmini projectis topractice your "VanillaJS" skills andconsolidate your understanding of The Elm Architecture (TEA)by creating a real worlduseable App followingstrictDocumentation and Test Driven Development.

This willshow you that it's not onlypossibleto write docs and testsfirst,you will seefirst hand thatcode ismore concise,well-documented and thuseasier to maintainand you will get your work donemuch faster.

These arefoundational skills that willpayimmediate returns on the time invested,and willcontinue toreturn "interest"for as long as you write (and people use your) software!

It'simpossible to "over-state" howvital writing tests firstis to both yourpersonal effectiveness andlong-term sanity.Thankfully, by the end of this chapter, you will see howeasy it is.

What?

Build a fully functional "Todo List" Application!
Along the way we will cover:

  • Building an App using a pre-made CSS Styles/Framework!
  • The Document Object Model (DOM) + JSDOM
  • Browser Routing/Navigation
  • Local Storage for Offline Support
  • Keyboard event listeners for rapid todo list creation and editing!

We will be abstracting all "architecture" related ("generic") codeinto a "mini frontend framework" called "elmish".(elmish is inspired by Elm but only meant for educational purposes!)

The journey to creatingelmish is captured inelmish.mdand fully documented code is inelmish.js.This means our Todo List App can be as conciseand "declarative" as possible.

Todo List?

If you areunfamiliar with Todo lists, simply put:they are a way of keeping a list of the tasks that need to be done.
see:https://en.wikipedia.org/wiki/Time_management#Setting_priorities_and_goals

Todo Lists or "Checklists" are thebest way of tracking tasks.
Atul Gawande wrote asuperb book on this subject:
https://www.amazon.com/Checklist-Manifesto-How-Things-Right/dp/0312430000
Or if you don't have time to read,watch:https://www.youtube.com/results?search_query=checklist+manifesto

TodoMVC?

If you have not come across TodoMVC before,it's a website that showcases various "frontend" frameworksusing a common user interface (UI): a Todo List Application.TodoMVC-intro

Wehighly recommend checking out the following links:

For our purposes we will simply be re-using theTodoMVCCSSto make our TEA Todo Listlook good(not have to "worry" about styles so we canfocus on functionality).All the JavaScript code will be written "from scratch"to ensure that everything is clear.

Who?

This tutorial is for anyone/everyone who wantsto develop their "core" JavaScript skills (without using a framework/library)while building a "real world" (fully functional) Todo List Application.

As always, if you get "stuck",please open an issue:https://github.com/dwyl/javascript-todo-list-tutorial/issuesby opening a question you helpeveryone learn more effectively!

Prerequisites

Most beginners with basic JavaScript and HTML knowledgeshould be able to follow this example without any prior experience.The code is commented and the most "complex" function is an event listener.With that said, if you feel "stuck" at any point,please consult the recommend reading (and Google)and if you cannot find an answer,please open an issue!

Recommended reading:

How?

Start by cloning this repository to yourlocalhostso that you can follow the example/tutorial offline:

git clone https://github.com/dwyl/javascript-todo-list-tutorial.git

Install thedevDependencies so you can run the tests:

cd javascript-todo-list-tutorial&& npm install

Now you haveeverything you need to build a Todo List from scratch!

Elm(ish) ?

In order tosimplify the code for our Todo List App,weabstracted much of the "generic" codeinto a "front-end micro framework" calledElm(ish).The functions & functionality ofElm(ish) should befamiliar to youso youshould be able to build the Todo List using theElm(ish)helper functions e.g:mount,div,input androute.

You canopt toeither:

a) read theElm(ish) docs/tutorialelmish.mdbefore building the Todo List App -this will give you both TDD practiceand a deeper understanding of building a micro framework.i.e. "prospective learning"

b) refer theElm(ish) docs/tutorialelmish.mdwhile building the Todo List App when you "need to know"how one of the helper functions works. i.e. "contextual learning"

c)onlyconsult theElm(ish) docs/tutorialelmish.mdif you are "stuck"while building the Todo List App.i.e. "debug learning"

The choice is yours; there is no "right" way to learn.

Testing & Documentation?

Before diving intobuilding the Todo List App,we need to consider how we are going totest it.By ensuring that we followTDD from thestart of an App,we will have"no surprises"andavoid having to "correct" any"bad habits".

We will be usingTape andJSDOM for testingboth our functions and the final application.If you arenew to either of these tools,please see:github.com/dwyl/learn-tapeandfront-end-with-tape.md

We will be usingJSDOC for documentation.Please seeour tutorial if this is new to you.


Create Files

Create anew directory e.g:/todo-appSo that you can build the Todo List from scratch!

In your editor/terminal create the following files:

  • test/todo-app.test.js
  • lib/todo-app.js
  • index.html

These file names should be self-explanatory, but if unclear,todo-app.test.js is where we will write the tests for ourTodo List App.todo-app.js is where all the JSDOCs and functionsfor our Todo List App will be written.

Test Setup

In order to run our test(s), we need some "setup" codethat "requires" the libraries/files so we canexecute the functions.

In thetest/todo-app.test.js file, type the following code:

consttest=require('tape');// https://github.com/dwyl/learn-tapeconstfs=require('fs');// to read html files (see below)constpath=require('path');// so we can open files cross-platformconsthtml=fs.readFileSync(path.resolve(__dirname,'../index.html'));require('jsdom-global')(html);// https://github.com/rstacruz/jsdom-globalconstapp=require('../lib/todo-app.js');// functions to testconstid='test-app';// all tests use 'test-app' as root element

Most of this code should befamiliar to youif you have followed previous tutorials.If anything isunclear please revisithttps://github.com/dwyl/learn-tapeandfront-end-with-tape.md

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

model

Themodel for our Todo List App isboringly simple.All we need is anObject with atodos key which has an Array of Objects as it's value:

{todos:[{id:1,title:"Learn Elm Architecture",done:true},{id:2,title:"Build Todo List App",done:false},{id:3,title:"Win the Internet!",done:false}]}

todos is anArray ofObjects and each Todo (Array) itemhas 3 keys:

  • id: the index in the list.
  • title: the title/description of the todo item.
  • done: aboolean indicating if the item is complete or still "todo".

What about thecount of items ?

The TodoMVC Specification requires us to display acounterof the items in the Todo list:https://github.com/tastejs/todomvc/blob/main/app-spec.md#counter

javascript-todo-list-count

In order to display thecount of items in the Todo list,wecould store 3 values in the model:

  • total_items - the total number of items, in this case 3.
  • completed_items - the number of completed items. in this case 1.
  • incomplete_items - the number of items still to be done; 2.

Each time anew item is added to the listwe would need to updateboth thetotal_itemsand theincomplete_itemsvalues in themodel.And each time anitem gets checked off as "done",we would need to updateboth theincomplete_itemsand thecompleted_items.This isunnecessary effort we can avoid.We can simplycompute these values based on the data in thetodos Arrayand display them for the user without storing any additional data.

Instead ofstoring any additional data for acounter in the model(the count of active and completed Todo items),we willcompute the count and display the count at "runtime".We don'tneed to store any additional data in themodel.This may use a few CPU cycles computing thecounteach time the view is rendered but that's "OK"!Even on anancient Android devicethis will only take a millisecond to compute andwon't "slow down" the app or affect UX.

See below for how the three counts are computed.

e.g: in the model above there are 3 todo items in thetodos Array;2 items which are "active" (done=false)and 1 which is "done" (done=true).

modelTest

Given that themodel is "just data"(it hasno "methods" becauseElm(ish) is"Functional"not"Object Oriented"),there is nofunctionality to test.We are merely going to test for the "shape" of the data.

In thetest/todo-app.test.js file, append following test code:

test('todo `model` (Object) has desired keys',function(t){constkeys=Object.keys(app.model);t.deepEqual(keys,['todos','hash'],"`todos` and `hash` keys are present.");t.true(Array.isArray(app.model.todos),"model.todos is an Array")t.end();});

If yourun this test in your terminal:

node test/todo-app.test.js

You should seeboth assertionsfail:model-tests-failing

modelImplementation

Write theminimum code required topass this test intodo-app.js.e.g:

/** * initial_model is a simple JavaScript Object with two keys and no methods. * it is used both as the "initial" model when mounting the Todo List App * and as the "reset" state when all todos are deleted at once. */varinitial_model={todos:[],// empty array which we will fill shortlyhash:"#/"// the hash in the url (for routing)}/* module.exports is needed to run the functions using Node.js for testing! *//* istanbul ignore next */if(typeofmodule!=='undefined'&&module.exports){module.exports={model:initial_model}}

Once you save thetodo-app.js file and re-run the tests.

node test/todo-app.test.js

Youshould expect to see both assertionspassing:model-tests-passing

We're off to agreat start! Let's tackle some actualfunctionality next!


update

Theupdate function is the"brain"of the App.

update JSDOC

TheJSDOC for ourupdate function is:

/** * `update` transforms the `model` based on the `action`. *@param {String} action - the desired action to perform on the model. *@param {Object} model - the App's data ("state"). *@return {Object} new_model - the transformed model. */

update Test >default case

As with theupdate in ourcounter examplethe function body is aswitch statementthat "decides" how to handle a request based on theaction(also known as the "message").

Given that weknow that ourupdate function "skeleton"will be aswitch statement(because that is the "TEA" pattern)a good test tostart with is thedefault case.

Append the following test code intest/todo-app.test.js:

test('todo `update` default case should return model unmodified',function(t){constmodel=JSON.parse(JSON.stringify(app.model));constunmodified_model=app.update('UNKNOWN_ACTION',model);t.deepEqual(model,unmodified_model,"model returned unmodified");t.end();});

If yourun this test in your terminal:

node test/todo-app.test.js

You should see the assertionfail:update-default-branch-test-failing

update Function Implementation >default case

Write theminimum code necessary to pass the test.

Yes, we could just write:

functionupdate(action,model){returnmodel;}

And thatwould make the testpass.

But, in light of the fact that weknow theupdatefunction body will contain aswitch statement,make the test pass by returning themodelunmodified in thedefault case.

e.g:

/** * `update` transforms the `model` based on the `action`. *@param {String} action - the desired action to perform on the model. *@param {Object} model - the App's (current) model (or "state"). *@return {Object} new_model - the transformed model. */functionupdate(action,model){switch(action){// action (String) determines which casedefault:// if action unrecognised or undefined,returnmodel;// return model unmodified}// default? https://softwareengineering.stackexchange.com/a/201786/211301}

When you re-run the test(s) in your terminal:

node test/todo-app.test.js

You should see this assertion pass:update-default-branch-test-passing

Now that we have apassing testfor thedefault case in ourupdate function,we can move on tothinking about the first (and most fundamental) pieceoffunctionality in the Todo List App: Adding an item to the list.

ADD anitem to the Todo List

This is both thefirst "feature" a "user" will encounter andby far the mostused feature of a Todo List.
(bydefinition people add more items to their list than they finish,to finish everything we would have tolive forever!)

ADD itemAcceptance Criteria

Adding a new todo item's text shouldappend the todo itemObject to themodel.todos Array.
Such that themodel is transformed (data is added) in the following way:

BEFORE:

{todos:[],hash:"#/"}

AFTER:

{todos:[{id:1,"Add Todo List Item",done:false}],hash:"#/"}

Hold On, That Doesn't Seem "Right" How Does Todo ItemText Get Added?

sotp-sign-fail

While considering the "Acceptance Criteria"for adding an item to the Todo List,wenotice that ourupdateJSDOCand corresponding function "signature" (defined above) as:

/** * `update` transforms the `model` based on the `action`. *@param {String} action - the desired action to perform on the model. *@param {Object} model - the App's (current) model (or "state"). *@return {Object} updated_model - the transformed model. */functionupdate(action,model){switch(action){// action (String) determines which casedefault:// if action unrecognised or undefined,returnmodel;// return model unmodified}// default? https://softwareengineering.stackexchange.com/a/201786/211301}

does not have aparameter for passing in the Todo List item Text (title),i.e. how do we add "data" to themodel...?

That's "Oh kay"! (don't panic!)
If wetry to think about implementation up-front,we wouldinvariably be "over-thinking" thingsand get "stuck" in the"analysis paralysis"of"waterfall"

As you areabout to see, we caneasily change the function signature,in thenext testwithout affecting our exiting (passing) test!

As youpractice "DDD" & "TDD" you will begin toappreciateand evenembrace themental agility that comes fromnot "over-thinking" things.

Whenever you encounter a "New Requirement"(or realise that you didn'tfully consider theoriginal requirements),you know that yoursuite of tests has"got yourback".
You can "refactor" a function'simplementation to your heart's content,safe in the knowledge that all yourexisting tests still pass.i.e. therest of the app "still works"exactly as expected.

We don't want to "mess with" either of the other two (existing) parameters,bothaction andmodel have clearly defined purposes,but weneed a way to pass "data" into theupdate function!

With that in mind, let'samend theupdateJSDOC commentand function signature to:

/** * `update` transforms the `model` based on the `action`. *@param {String} action - the desired action to perform on the model. *@param {Object} model - the App's (current) model (or "state"). *@param {String} data - data we want to "apply" to the item. e.g: item Title. *@return {Object} updated_model - the transformed model. */functionupdate(action,model,data){switch(action){// action (String) determines which casedefault:// if action unrecognised or undefined,returnmodel;// return model unmodified}// default? https://softwareengineering.stackexchange.com/a/201786/211301}

Without makingany other changes, re-run the tests:

node test/todo-app.test.js

Everything should still pass:update-default-branch-test-passing

Congratulations! You justextended a function (signature)without affecting anyexisting tests.

ADD itemTest

Append the following test code to yourtest/todo-app.test.js file:

test('`ADD` a new todo item to model.todos Array via `update`',function(t){constmodel=JSON.parse(JSON.stringify(app.model));// initial statet.equal(model.todos.length,0,"initial model.todos.length is 0");constupdated_model=app.update('ADD',model,"Add Todo List Item");constexpected={id:1,title:"Add Todo List Item",done:false};t.equal(updated_model.todos.length,1,"updated_model.todos.length is 1");t.deepEqual(updated_model.todos[0],expected,"Todo list item added.");t.end();});

If yourun this test in your terminal:

node test/todo-app.test.js

You should see the assertionfail:

update-add-item-test-failing

ADD itemImplementation

With the above test as your "guide",write thebare minimum code necessary to make all assertions pass.

Sample implementation:

/** * `update` transforms the `model` based on the `action`. *@param {String} action - the desired action to perform on the model. *@param {Object} model - the App's (current) model (or "state"). *@param {String} data - the data we want to "apply" to the item. *@return {Object} updated_model - the transformed model. */functionupdate(action,model,data){varnew_model=JSON.parse(JSON.stringify(model))// "clone" the modelswitch(action){// and an action (String) runs a switchcase'ADD':new_model.todos.push({id:model.todos.length+1,title:data,done:false});break;default:// if action unrecognised or undefined,returnmodel;// return model unmodified}// see: https://softwareengineering.stackexchange.com/a/201786/211301returnnew_model;}

thecase 'ADD' is therelevant code.

Wasyour implementationsimilar...?
If you were able to make itsimpler,please share!

Once you have the test(s)passing e.g:todo-add-item-tests-passing

Let's move on to thenext functionality!


TOGGLE a Todoitem todone=true

todomvc-two-items-1-done

Checking off a todo item involves changing the value of thedone propertyfromfalse totrue. e.g:

FROM:

{todos:[{id:1,"Toggle a todo list item",done:false}]}

TO:

{todos:[{id:1,"Toggle a todo list item",done:true}]}

Given that we have already defined ourupdate function above,we can dive straight into writing atest:

TOGGLE itemTest

Append the following test code to yourtest/todo-app.test.js file:

test('`TOGGLE` a todo item from done=false to done=true',function(t){constmodel=JSON.parse(JSON.stringify(app.model));// initial stateconstmodel_with_todo=app.update('ADD',model,"Toggle a todo list item");constitem=model_with_todo.todos[0];constmodel_todo_done=app.update('TOGGLE',model_with_todo,item.id);constexpected={id:1,title:"Toggle a todo list item",done:true};t.deepEqual(model_todo_done.todos[0],expected,"Todo list item Toggled.");t.end();});

execute the test:

node test/todo-app.test.js

You should see somethingsimilar to the following:toggle-todo-list-item

TOGGLE itemImplementation

With the above test as your "guide",write theminimum code necessary to make the test pass.(ensure that you continue to make a "copy" of themodelrather than "mutate" it)

Once you make itpass you should see:

todo-item-toggled

Try to make the test pass alone (or with your pairing partner).If you get "stuck" see:todo-app.js

Hold On, Does This WorkBoth Ways?

Yes, youguessed it!Choosing to name theaction as "TOGGLE"isprecisely because we don'tneedto have aseparate functionto "undo" an item if it has been "checked off".

Append the following test code to yourtest/todo-app.test.js file:

test('`TOGGLE` (undo) a todo item from done=true to done=false',function(t){constmodel=JSON.parse(JSON.stringify(app.model));// initial stateconstmodel_with_todo=app.update('ADD',model,"Toggle a todo list item");constitem=model_with_todo.todos[0];constmodel_todo_done=app.update('TOGGLE',model_with_todo,item.id);constexpected={id:1,title:"Toggle a todo list item",done:true};t.deepEqual(model_todo_done.todos[0],expected,"Toggled done=false >> true");// add another item before "undoing" the original one:constmodel_second_item=app.update('ADD',model_todo_done,"Another todo");t.equal(model_second_item.todos.length,2,"there are TWO todo items");// Toggle the original item such that: done=true >> done=falseconstmodel_todo_undone=app.update('TOGGLE',model_second_item,item.id);constundone={id:1,title:"Toggle a todo list item",done:false};t.deepEqual(model_todo_undone.todos[0],undone,"Todo item Toggled > undone!");t.end();});

You should notneed to modify any of the code in theupdate function.The above test should justpass based on the code you wrote above.If it doesnot, thenrevise your implementationof theTOGGLE case inupdate untilall tests pass:

undo-a-todo-item

view Function

It won't have "escaped" you thatso far we have not writtenany codethat auser can actuallyinteract with.

So far we havesuccessfully added twocase blocks in theswitch statementof ourupdate function. We now have the twobasic functions requiredto bothADD a new todo list item to themodel.todos Arrayand check-off a todo list item as "done" using theTOGGLE action.This is "enough" functionality to startusing the todo list (ourselves)andUX-testing it withprospective "users".

If you followed through the "Elm(ish)" tutorialelmish.mdyou will have seen that we created asampleview in the last fewteststo "exercise" the DOM element creation functions.This means that wealready know how to build aview for our Todo List App!We "just" need toadapt theview we made inElm(ish) to displaythe data in ourmodel.

Samplemodel to Render in Ourview

Let's return to the samplemodel from above:

{todos:[{id:1,title:"Learn Elm Architecture",done:true},{id:2,title:"Build Todo List App",done:false},{id:3,title:"Win the Internet!",done:false}],hash:'#/'// the "route" to display}

The model containsthree items in thetodos Array.
The first is complete (done=true)whereas the second and third items are still "todo" (done=false).

This is what thismodel looks like in the "VanillaJS"TodoMVC:

todomvc-3-items-1-done

Ourquest in the next "pomodoro" is to re-create thisusing the DOM functions we created inElm(ish)!

Focus on Rendering TheList First

For now,ignore the<footer> (below the Todo List)andjust focus on rendering thelist itself.

todomvc-3-items-1-done

In your web browser, openDeveloperToolsandinspect the HTML for the Todo list:https://todomvc.com/examples/vanillajs/

todomvc-main-section-todo-list-html

This is the HTML copied directly from the browser:

<sectionclass="main"style="display: block;"><inputclass="toggle-all"type="checkbox"><labelfor="toggle-all">Mark all as complete</label><ulclass="todo-list"><lidata-id="1533501855500"class="completed"><divclass="view"><inputclass="toggle"type="checkbox"><label>Learn Elm Architecture</label><buttonclass="destroy"></button></div></li><lidata-id="1533501861171"class=""><divclass="view"><inputclass="toggle"type="checkbox"><label>Build Todo List App</label><buttonclass="destroy"></button></div></li><lidata-id="1533501867123"class=""><divclass="view"><inputclass="toggle"type="checkbox"><label>Win the Internet!</label><buttonclass="destroy"></button></div></li></ul></section>

Note: there is "redundant" markup in this HTML in the form of a<div>inside the<li>, for now we are just replicating the HTML "faithfully",we can "prune" it later.

From this HTMl we can write our"Technical Acceptance Criteria":

  • Todo List items should be displayed as list items<li>in anunordered list<ul>.
  • Each Todo List item<li> should contain a<div>with aclass="view" which "wraps":
    • <input type="checkbox"> - the "checkbox"that people can "Toggle" to change the "state"of the Todo item from "active" to "done"(which updates the modelFrom:model.todos[id].done=falseTo:model.todos[id].done=true)
    • <label> - the text content ("title") of the todo list item
    • <button> - the button the personcan click/tap todelete a Todo item.

Todo Listview Test Assertions

Given themodel (above),

  • There is a<ul> with 3<li> (list items)rendered in theview.
  • Thefirst<li> has an<input type="checkbox">which ischecked (done=true)
  • Theremaining<li>'s have<input type="checkbox">that areunchecked (done=false)

Let's "tackle" thefirst assertionfirst:

Render aSingle Todo List Item Usingrender_list Test

It'salways a good idea to "break apart" a test into smaller testsbecause it means we will write smaller(and thusmore maintainable) "composable" functions.With that in mind, let's add the followingtest totest/todo-app.test.js:

test.only('render_item HTML for a single Todo Item',function(t){constmodel={todos:[{id:1,title:"Learn Elm Architecture",done:true},],hash:'#/'// the "route" to display};// render the ONE todo list item:document.getElementById(id).appendChild(app.render_item(model.todos[0]))constdone=document.querySelectorAll('.completed')[0].textContent;t.equal(done,'Learn Elm Architecture','Done: Learn "TEA"');constchecked=document.querySelectorAll('input')[0].checked;t.equal(checked,true,'Done: '+model.todos[0].title+" is done=true");elmish.empty(document.getElementById(id));// clear DOM ready for next testt.end();});

After saving thetest/todo-app.test.js file, if you attempt to run it:

node test/todo-app.test.js

you will see something like this:

render_item-test-failing

render_list Implementation

Given the test above, I added the following code to mytodo-app.js file:

/* if require is available, it means we are in Node.js Land i.e. testing! *//* istanbul ignore next */if(typeofrequire!=='undefined'&&this.window!==this){var{ a, button, div, empty, footer, input, h1, header, label, li, mount,    route, section, span, strong, text, ul}=require('./elmish.js');}/** * `render_item` creates an DOM "tree" with a single Todo List Item * using the "elmish" DOM functions (`li`, `div`, `input`, `label` and `button`) * returns an `<li>` HTML element with a nested `<div>` which in turn has the: *   `<input type=checkbox>` which lets users to "Toggle" the status of the item *   `<label>` which displays the Todo item text (`title`) in a `<text>` node *   `<button>` lets people "delete" a todo item. * see: https://github.com/dwyl/learn-elm-architecture-in-javascript/issues/52 *@param  {Object} item the todo item object *@return {Object} <li> DOM Tree which is nested in the <ul>. *@example * // returns <li> DOM element with <div>, <input>. <label> & <button> nested * var DOM = render_item({id: 1, title: "Build Todo List App", done: false}); */functionrender_item(item){return(li(["data-id="+item.id,"id="+item.id,item.done ?"class=completed" :""],[div(["class=view"],[input(["class=toggle","type=checkbox",(item.done ?"checked=true" :"")],[]),label([],[text(item.title)]),button(["class=destroy"])])// </div>])// </li>)}

Add therender_item to themodule.exports at the end of the file:

if(typeofmodule!=='undefined'&&module.exports){module.exports={model:initial_model,update:update,render_item:render_item,// export so that we can unit test}}

This will make the test pass:image

Now that we have arender_item functionthat renders asingle<li> (todo list item),we can create another function whichuses therender_item in a "loop",to createseveral<li> nested in a<ul>.

render_main Test

Append the following test code to yourtest/todo-app.test.js file:

test('render "main" view using (elmish) HTML DOM functions',function(t){constmodel={todos:[{id:1,title:"Learn Elm Architecture",done:true},{id:2,title:"Build Todo List App",done:false},{id:3,title:"Win the Internet!",done:false}],hash:'#/'// the "route" to display};// render the "main" view and append it to the DOM inside the `test-app` node:document.getElementById(id).appendChild(app.render_main(model));// test that the title text in the model.todos was rendered to <label> nodes:document.querySelectorAll('.view').forEach(function(item,index){t.equal(item.textContent,model.todos[index].title,"index #"+index+" <label> text: "+item.textContent)})constinputs=document.querySelectorAll('input');// todo items are 1,2,3[true,false,false].forEach(function(state,index){t.equal(inputs[index+1].checked,state,"Todo #"+index+" is done="+state)})elmish.empty(document.getElementById(id));// clear DOM ready for next testt.end();});

If you attempt to run this test:

node test/todo-app.test.js

you will see something like this:main-test-failing

Given your knowledge of implementing therender_item function above,and your skills with JavaScript loops, create yourrender_main function,to make the tests pass.

If you get "stuck" there is areference implementation in:todo-app.js

All our tests passand we have100% test coverage:

render_main-tests-pass-100-coverage

This means we are writing the "bare minimum" code necessaryto meet all acceptance criteria (requirements),which isbothfaster andmore maintainable!
Onwards!


<footer> Elementissues/53

Referring again to therendered HTMLonhttps://todomvc.com/examples/vanillajs as our "guide":

footer-screenshot

Dev Tools > Elements (inspector)

todo-list-mvc-

Copy-paste therendered HTML

"copy-pasted" of therendered HTML from the Dev Tools:todo-list-mvc-copy-html

<footerclass="footer"style="display: block;"><spanclass="todo-count"><strong>2</strong> items left</span><ulclass="filters"><li><ahref="#/"class="selected">All</a></li><li><ahref="#/active">Active</a></li><li><ahref="#/completed">Completed</a></li></ul><buttonclass="clear-completed"style="display: block;">    Clear completed</button></footer>

Technical Acceptance Criteria

These are the criteria (checklist) as described inissues/53:

  • render_footer returns a<footer> DOM element which can be rendered directly to thedocument ornested in another DOM element.
  • <footer> contains:
    • <span> which contains
      • atext node with:"{count} item(s) left".pseudocode:{model.todos.filter( (i) => { i.done==false })}item{model.todos.length > 1 ? 's' : '' } left
    • <ul> containing 3<li> with the following links (<a>):
      • ShowAll:<a href="#/">All</a>
        • class="selected" should only appear on the selected menu/navigation item.this should be "driven" by themodel.hash property.
      • ShowActive: <a href="#/active">Active</a>
      • ShowCompleted:<a href="#/completed">Completed</a>
    • <button>willClear allCompleted items.sample code:
      new_model.todos = model.todos.filter(function(item) { return item.done === false })

Estimate Time Required to Writerender_footer Function

"armed" with the acceptance criteriachecklistand the"informative prior"(theexperience we havealready gained)from building the previous view functionsrender_item andrender_mainweestimate withreasonable confidencethat it will take us25 minutes (one "pomodoro)to:

  • Craft theJSDOC commentdocumenting therender_footer functionso that all future developers willeasily understand what the function does.
  • Write a (unit)test covering the acceptance criteria (test first!)
  • Write the (bare minimum) code topass the test assertions.

Note On Time Estimates: if it takeslonger than25 mins "budget",don't panic or feel like you have "failed",it's not a "problem" ...it's just "more data" (knowledge/experience)that you can incorporate into improvingfuture estimates!over time you will getreally good at estimating,this is just astarting point

render_footerJSDOC Comment Documentation

Here is a sample comment which documents therender_footer function:

/** * `render_footer` renders the `<footer>` of the Todo List App * which contains count of items to (still) to be done and a `<ul>` "menu" * with links to filter which todo items appear in the list view. *@param {Object} model - the App's (current) model (or "state"). *@return {Object} <section> DOM Tree which containing the <footer> element. *@example * // returns <footer> DOM element with other DOM elements nested: * var DOM = render_footer(model); */

Write yourown JSDOC or add these lines to yourtodo-app.js file.

render_footer Test

Here is a sample test you can add to yourtest/todo-app.test.js file:(if you feel confident in your TDD skills,you couldtry to write your own test/assertions...)

test.only('render_footer view using (elmish) HTML DOM functions',function(t){constmodel={todos:[{id:1,title:"Learn Elm Architecture",done:true},{id:2,title:"Build Todo List App",done:false},{id:3,title:"Win the Internet!",done:false}],hash:'#/'// the "route" to display};// render_footer view and append it to the DOM inside the `test-app` node:document.getElementById(id).appendChild(app.render_footer(model));// todo-count should display 2 items left (still to be done):constleft=document.getElementById('count').innerHTML;t.equal(left,"<strong>2</strong> items left","Todos remaining: "+left);// count number of footer <li> items:t.equal(document.querySelectorAll('li').length,3,"3 <li> in <footer>");// check footer link text and href:constlink_text=['All','Active','Completed'];consthrefs=['#/','#/active','#/completed'];document.querySelectorAll('a').forEach(function(a,index){// check link text:t.equal(a.textContent,link_text[index],"<footer> link #"+index+" is: "+a.textContent+" === "+link_text[index]);// check hrefs:t.equal(a.href.replace('about:blank',''),hrefs[index],"<footer> link #"+index+" href is: "+hrefs[index]);});// check for "Clear completed" button in footer:constclear=document.querySelectorAll('.clear-completed')[0].textContent;t.equal(clear,'Clear completed','<button> in <footer> "Clear completed"');elmish.empty(document.getElementById(id));// clear DOM ready for next testt.end();});

Run this test:

node test/todo-app.test.js

you will see something like this:render_footer-test-failing

render_footer Implementation

Given the docs and test above, attempt to write therender_footer function.

Note: for now we arenot "concerned"with what happens when the "Clear completed"<buton> is clicked/tapped.We will "cover" that below. For now, focus on rendering the DOM.

If you get "stuck" trying to make the tests pass, first keep trying!
Then "ask a friend" and finally, consult thereference implementation in:todo-app.js

For good measure, we add asecond test to check our "pluarisation":

test('render_footer 1 item left (pluarisation test)',function(t){constmodel={todos:[{id:1,title:"Be excellent to each other!",done:false}],hash:'#/'// the "route" to display};// render_footer view and append it to the DOM inside the `test-app` node:document.getElementById(id).appendChild(app.render_footer(model));// todo-count should display "1 item left" (still to be done):constleft=document.getElementById('count').innerHTML;t.equal(left,"<strong>1</strong> item left","Todos remaining: "+left);elmish.empty(document.getElementById(id));// clear DOM ready for next testt.end();});

This testshould pass without any further code needing to be written.

Once you have written the code to pass the tests,you should see something like this:

render_footer-tests-passing-coverage-100percent

view Function

Now that we have the individual ("lower order") functionsrender_main#51,render_item#52,andrender_footer#53for rendering thesections of the todo app,we can write theview function to render theentire app!

With themain andfooter "partial" views built,the overallview is quite simple:

todoapp-view

To save on repetition, and illustrate just howsimpletheview is,this is the "HTML" with the<section class"main"> and<footer>partials replaced by invocationsto the respective functionsrender_main andrender_footer:

<sectionclass="todoapp"><headerclass="header"><h1>todos</h1><inputclass="new-todo"placeholder="What needs to be done?"autofocus=""></header>  render_main(model)  render_footer(model)</section>

view Acceptance Criteria

Theview displays:

  • <section> inside which the app is rendered.
  • <h1> containing the title text "todos".
  • <input>has placeholder text"What needs to be done?"
  • <ul> list of todo itemshaszero items by default (based on theinitial_model)
  • <footer> count is Zero when the app is firstrendered with no todos in themodel.

view JSDOC Comment Documentation

Here is a sample JSDOC comment you can add to yourtodo-app.js file:

/** * `view` renders the entire Todo List App * which contains count of items to (still) to be done and a `<ul>` "menu" * with links to filter which todo items appear in the list view. *@param {Object} model - the App's (current) model (or "state"). *@return {Object} <section> DOM Tree which containing all other DOM elements. *@example * // returns <section> DOM element with other DOM els nested: * var DOM = view(model); */

These should be pretty familiar to you by now.If you feel comfortable extending it with more detail, go for it!

viewTests

A sample test for theview functionyou can add to yourtest/todo-app.test.js file:(if you feel confident in your TDD skills,you couldtry to write your own test/assertions...)

test.only('view renders the whole todo app using "partials"',function(t){// render the view and append it to the DOM inside the `test-app` node:document.getElementById(id).appendChild(app.view(app.model));// initial_modelt.equal(document.querySelectorAll('h1')[0].textContent,"todos","<h1>todos");// placeholder:constplaceholder=document.getElementById('new-todo').getAttribute("placeholder");t.equal(placeholder,"What needs to be done?","paceholder set on <input>");// todo-count should display "0 items left" (based on initial_model):constleft=document.getElementById('count').innerHTML;t.equal(left,"<strong>0</strong> items left","Todos remaining: "+left);elmish.empty(document.getElementById(id));// clear DOM ready for next testt.end();});

Run this test:

node test/todo-app.test.js

you will see something like this ("Red"):app.view-not-a-function

view FunctionImplementation

You should have the knowledge & skillto write theview function and make the test pass.

If you get "stuck" trying to make the tests pass, first keep trying!
Then "ask a friend" and finally, consult thereference implementation in:todo-app.js

When you runnpm test you should see something like this:image

Checkpoint!

So far we have made alot of progress with our Todo List Appquest,however if we were tostop working on thisnow we would havenothing to show a "user".Users can'tinteract with functions,even those withgreat test coverage!

What weneed is to start putting all the pieces togetherinto a functioning app!

Mount the App inindex.html

Open yourindex.html fileand ensure that the following lines are in the<body>:

<body><divid="app"></div><!-- CSS Styles are 100% optional. but they make it look *much* nicer --><linkrel="stylesheet"href="todomvc-common-base.css"><linkrel="stylesheet"href="todomvc-app.css"><scriptsrc="elmish.js"></script><scriptsrc="todo-app.js"></script><script>varmodel={todos:[{id:1,title:"Learn Elm Architecture",done:true},{id:2,title:"Build Todo List App",done:false},{id:3,title:"Win the Internet!",done:false}],hash:'#/'// the "route" to display};mount(model,update,view,'app');</script><!-- Below this point is all related to the Tests for the App --><divid="test-app"></div><!-- Create a test-app div to mount the app --></body>

For a complete "snapshot" of theindex.html file here,see:index.html

If you run the project with commandnpm startand navigate to:http://127.0.0.1:8000/

You should see:view-working

So theviewlooks like a TodoMVC Todo List(mostly thanks to the imported CSS),however we still cannotinteract with the app.

Next we're going to move to "wiring-up" thefunctionalityto construct the UX.

Functionality - TheFun Part!

With all the "foundation" well defined and tested,we canconfidently move on to building out thefeaturespeopleusing the app will interact with!

Requirements?

Take a look at this list of test output:https://github.com/tastejs/todomvc/tree/main/tests#example-output

TodoMVC  1. No Todos    ✓ should hide #main and #footer (201ms)  2. New Todo    ✓ should allow me to add todo items (548ms)    ✓ should clear text input field when an item is added (306ms)    ✓ should trim text input (569ms)    ✓ should show #main and #footer when items added (405ms)  3. Mark all as completed    ✓ should allow me to mark all items as completed (1040ms)    ✓ should allow me to clear the completion state of all items (1014ms)    ✓ complete all checkbox should update state when items are completed (1413ms)  4. Item    ✓ should allow me to mark items as complete (843ms)    ✓ should allow me to un-mark items as complete (978ms)    ✓ should allow me to edit an item (1155ms)    ✓ should show the remove button on hover  5. Editing    ✓ should hide other controls when editing (718ms)    ✓ should save edits on enter (1093ms)    ✓ should save edits on blur (1256ms)    ✓ should trim entered text (1163ms)    ✓ should remove the item if an empty text string was entered (1033ms)    ✓ should cancel edits on escape (1115ms)  6. Counter    ✓ should display the current number of todo items (462ms)  7. Clear completed button    ✓ should display the number of completed items (873ms)    ✓ should remove completed items when clicked (898ms)    ✓ should be hidden when there are no items that are completed (893ms)  8. Persistence    ✓ should persist its data (3832ms)  9. Routing    ✓ should allow me to display active items (871ms)    ✓ should allow me to display completed items (960ms)    ✓ should allow me to display all items (1192ms)    ✓ should highlight the currently applied filter (1095ms)27 passing (1m)

We are going to write each one of these tests and then

1. No Todos, should hide #footer and #main

Add the following test to yourtest/todo-app.test.js file:

test.only('1. No Todos, should hide #footer and #main',function(t){// render the view and append it to the DOM inside the `test-app` node:document.getElementById(id).appendChild(app.view({todos:[]}));// No Todosconstmain_display=window.getComputedStyle(document.getElementById('main'));t.equal('none',main_display._values.display,"No Todos, hide #main");constmain_footer=window.getComputedStyle(document.getElementById('footer'));t.equal('none',main_footer._values.display,"No Todos, hide #footer");elmish.empty(document.getElementById(id));// clear DOM ready for next testt.end();});

Run the test with:

node test/todo-app.js

You should see the following output:image

Make it Pass!

Simply replace the instances of"style=display: block;" in the view codewith a reference to a "computed style" e.g:

// Requirement #1 - No Todos, should hide #footer and #mainvardisplay="style=display:"+(model.todos.length>0 ?+"block" :"none");

You should see:no-todos-test-passing

Testing it in your web browser you should see the desired result:

no-todos-hide-main-and-footer

If you get stuck trying to make the test pass, see:todo-app.js

Recommended reading on CSSvisibility:hidden vs.display:nonethe difference isimportant for UI:https://stackoverflow.com/questions/133051/what-is-the-difference-between-visibilityhidden-and-displaynone


2. New Todo, should allow me to add todo items

The second batch of tests involves adding a new todo item to the list:

2. New Todo  ✓ should allow me to add todo items (548ms)  ✓ should clear text input field when an item is added (306ms)  ✓ should trim text input (569ms)  ✓ should show #main and #footer when items added (405ms)

Let's create a test with these 4 assertions.

Add the following code/test to yourtest/todo-app.test.js file:

// Testing localStorage requires "polyfil" because:// https://github.com/jsdom/jsdom/issues/1137 ¯\_(ツ)_/¯// globals are usually bad! but a "necessary evil" here.global.localStorage=global.localStorage ?global.localStorage :{getItem:function(key){constvalue=this[key];returntypeofvalue==='undefined' ?null :value;},setItem:function(key,value){this[key]=value;},removeItem:function(key){deletethis[key]}}localStorage.removeItem('elmish_store');test('2. New Todo, should allow me to add todo items',function(t){elmish.empty(document.getElementById(id));// render the view and append it to the DOM inside the `test-app` node:elmish.mount({todos:[]},app.update,app.view,id,app.subscriptions);constnew_todo=document.getElementById('new-todo');// "type" content in the <input>:consttodo_text='Make Everything Awesome!     ';// deliberate whitespace!new_todo.value=todo_text;// trigger the [Enter] keyboard key to ADD the new todo:new_todo.dispatchEvent(newKeyboardEvent('keyup',{'keyCode':13}));constitems=document.querySelectorAll('.view');t.equal(items.length,1,"should allow me to add todo items");// check if the new todo was added to the DOM:constactual=document.getElementById('1').textContent;t.equal(todo_text.trim(),actual,"should trim text input")// subscription keyCode trigger "branch" test (should NOT fire the signal):constclone=document.getElementById(id).cloneNode(true);new_todo.dispatchEvent(newKeyboardEvent('keyup',{'keyCode':42}));t.deepEqual(document.getElementById(id),clone,"#"+id+" no change");// check that the <input> was reset after the new item was addedt.equal(new_todo.value,'',"should clear text input field when an item is added")constmain_display=window.getComputedStyle(document.getElementById('main'));t.equal('block',main_display._values.display,"should show #main and #footer when items added");constmain_footer=window.getComputedStyle(document.getElementById('footer'));t.equal('block',main_footer._values.display,"item added, show #footer");elmish.empty(document.getElementById(id));// clear DOM ready for next testlocalStorage.removeItem('elmish_store');// clear "localStorage" for next testt.end();});

Run the test with:

node test/todo-app.js

You should see the following output:

test-failing

Todo Listsubscriptions

So far in the Todo List Appwe have not implemented anysubscriptions,however, in order to "listen" for the[Enter] key "event"(to add a Todo List item), we need to dive into event listeners.

Thankfully, we touched upon this while buildingElm(ish),if you need a recap, see:elmish.md#subscriptions-for-event-listeners

Try to make the "2. New Todo" batch of testspassby creating (and exporting) asubscriptions functionin yourlib/todo-app.js file.

If you get "stuck", checkout the sample code:todo-app.js > subscriptions

Once you see the tests passing:

add-todo-tests-passing

Let's add someinteraction!

3. Mark all as completed

The third batch of tests involves "Toggling" all todos as "done=true":

3. Mark all as completed  ✓ should allow me to mark all items as completed  ✓ should allow me to clear the completion state of all items  ✓ complete all checkbox should update state when items are completed

Luckily, given that we know how to use aboolean value,thesethree assertions can be "solved" withminimal code.Let's create a test with these 3 assertions.

Add the following code/test to yourtest/todo-app.test.js file:

test.only('3. Mark all as completed ("TOGGLE_ALL")',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('elmish_'+id);constmodel={todos:[{id:0,title:"Learn Elm Architecture",done:true},{id:1,title:"Build Todo List App",done:false},{id:2,title:"Win the Internet!",done:false}],hash:'#/'// the "route" to display};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);// confirm that the ONLY the first todo item is done=true:constitems=document.querySelectorAll('.view');document.querySelectorAll('.toggle').forEach(function(item,index){t.equal(item.checked,model.todos[index].done,"Todo #"+index+" is done="+item.checked+" text: "+items[index].textContent)})// click the toggle-all checkbox to trigger TOGGLE_ALL: >> truedocument.getElementById('toggle-all').click();// click toggle-all checkboxdocument.querySelectorAll('.toggle').forEach(function(item,index){t.equal(item.checked,true,"TOGGLE each Todo #"+index+" is done="+item.checked+" text: "+items[index].textContent)});t.equal(document.getElementById('toggle-all').checked,true,"should allow me to mark all items as completed")// click the toggle-all checkbox to TOGGLE_ALL (again!) true >> falsedocument.getElementById('toggle-all').click();// click toggle-all checkboxdocument.querySelectorAll('.toggle').forEach(function(item,index){t.equal(item.checked,false,"TOGGLE_ALL Todo #"+index+" is done="+item.checked+" text: "+items[index].textContent)})t.equal(document.getElementById('toggle-all').checked,false,"should allow me to clear the completion state of all items")// *manually* "click" each todo item:document.querySelectorAll('.toggle').forEach(function(item,index){item.click();// this should "toggle" the todo checkbox to done=truet.equal(item.checked,true,".toggle.click() (each) Todo #"+index+" which is done="+item.checked+" text: "+items[index].textContent)});// the toggle-all checkbox should be "checked" as all todos are done=true!t.equal(document.getElementById('toggle-all').checked,true,"complete all checkbox should update state when items are completed")elmish.empty(document.getElementById(id));// clear DOM ready for next testlocalStorage.removeItem('elmish_store');t.end();});

Yes, it's a "big" test with several assertions.We prefer to keep them "clustered" together because they testthe functionality as a "block".Some people prefer to split the assertions out into individual unit tests,our advice to the "practical developer" is: be pragmatic!If you are testing the functionality and the test is legible,there's no "harm" in having several assertions.

If you attempt to run the test file:

node test/todo-app.test.js

You will see something like this:

toggle-all-test-failing

While there mayappear to be "many" assertions in this test,in reality there are only two bits of functionality.

Firstly, we need a newcasein theupdateswitch statement:TOGGLE_ALL.
andsecond we need to add a couple of lines to ourTOGGLEblock tocheck ifall todos aredone=true ordone=false.In the case whereall todos aredone=true we should reflectthis in the "state" of thetoggle-all checkbox.Theeasiest way of representing this in themodel iswith a new property, e.g:model.all_done=truewhenall todos aredone=true.

The only other thing we need to update is therender_mainfunction to includesignal('TOGGLE_ALL') in the attributes array.

Try and make this test pass by yourself before consulting thesample code:lib/todo-app.js

4. Item (Toggle, Edit & Delete)

4. Item  ✓ should allow me to mark items as complete (843ms)  ✓ should allow me to un-mark items as complete (978ms)  ✓ should allow me to edit an item (1155ms)  ✓ should show the remove button on hover

Of these requirements, we already have the first two "covered"because we implemented theTOGGLE feature (above).

We can add another "proxy" test just for "completeness":

test.only('4. Item: should allow me to mark items as complete',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false}],hash:'#/'// the "route" to display};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);constitem=document.getElementById('0')t.equal(item.textContent,model.todos[0].title,'Item contained in model.');// confirm that the todo item is NOT done (done=false):t.equal(document.querySelectorAll('.toggle')[0].checked,false,'Item starts out "active" (done=false)');// click the checkbox to toggle it to done=truedocument.querySelectorAll('.toggle')[0].click()t.equal(document.querySelectorAll('.toggle')[0].checked,true,'Item should allow me to mark items as complete');// click the checkbox to toggle it to done=false "undo"document.querySelectorAll('.toggle')[0].click()t.equal(document.querySelectorAll('.toggle')[0].checked,false,'Item should allow me to un-mark items as complete');t.end();});

You should not need to write any additional codein order to make this test pass; just run it and move on.

toggle-todo-tests-passing

4.1DELETE an Item

should show the remove button on hover
Acceptance Criteria
  • should show the<button>on hover (over the item) ... thankfully the TodoMVC CSShandles this for us, we just need ourviewto render the<button>
  • Clicking/tapping the<button>sends thesignal('DELETE', todo.id, model)
  • TheDELETE update case receives thetodo.idand removes it from themodel.todos Array.
DELETE ItemTest

Append the following test code to yourtest/todo-app.test.js file:

test.only('4.1 DELETE item by clicking <button>',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false}],hash:'#/'// the "route" to display};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);// const todo_count = ;t.equal(document.querySelectorAll('.destroy').length,1,"one destroy button")constitem=document.getElementById('0')t.equal(item.textContent,model.todos[0].title,'Item contained in DOM.');// DELETE the item by clicking on the <button>:constbutton=item.querySelectorAll('button.destroy')[0];button.click()// confirm that there is no loger a <button>t.equal(document.querySelectorAll('button.destroy').length,0,'there is no loger a <button> as the only item was DELETEd')t.equal(document.getElementById('0'),null,'todo item successfully DELETEd');t.end();});

If you run the testsnode test/todo-app.test.jsyou should now see:delete-test-one-assertion-failing

The first two assertions areoptional andshould (always)pass given that they rely on functionality defined previously.The second two will only pass once youmake them pass!

DELETE ItemImplementation

Thefirst step is to add an invocation ofsignal('DELETE' ...)to therender_item view rendering function.Specifically thebutton line:

button(["class=destroy"])

Add thesignal function invocation:

button(["class=destroy",signal('DELETE',item.id)])

simply adding this function invocation as an Array element will set itas anonclick attribute for the<button>therefore when theuser clicks the button it will"trigger" thesignal function with the appropriate arguments.There is no "magic" just code we tested/wrote earlier.

Second we need to add acase statementto theupdate function.You should attempt to "solve" this yourself.There is no "right" answer, there are at least5 ways of solving this, as always, you should write the codethat you feel is mostreadable.

If you get "stuck" or want to confirm your understandingof the implementation of theDELETE functionality,check the code intodo-app.js >updatefunction.

Rather bizarrely the edit functionality is mentionedboth in the Item and Editing sections.

should allow me to edit an item

This is kindameaningless as an assertion.What does "edit an item" actuallymean?
(we have expanded the acceptance criteria below...)


5.EDIT an Item

Editing a Todo List item is (by far)the most "advanced" functionality in the TodoMVC appbecause it involves multiple steps and "dynamic UI".

Don't panic! Just because something has "more steps" than we have seen before,doesn't mean we should be "overwhelmed" by its' complexity.We just need to "break it down" into "bitesize chunks"!

Note: the most "difficult" part of implementing the "edit an item"functionality is having a "mental picture" of the UXso that we can write thetests firstand isolate the required functions (update actions) from the keyboard/mouseinteractions. i.e. breaking down the steps into distinct "units".

First let's review the TodoMVC "Editing" test assertions:

EDIT Item Test Titles & Acceptance Criteria

5. Editing  ✓ should hide other controls when editing (718ms)  ✓ should save edits on enter (1093ms)  ✓ should save edits on blur (1256ms)  ✓ should trim entered text (1163ms)  ✓ should remove the item if an empty text string was entered (1033ms)  ✓ should cancel edits on escape (1115ms)

Further reading of the TodoMVC Spec:https://github.com/tastejs/todomvc/blob/main/app-spec.md#itemreveals the following acceptance criteria:

  • Double-click on Item<label>title</label>to begin editing (that item)
  • Render an<input>if in "editingmode"(see screenshot and markup below)
    • Addclass="editing" to<li> when editing
    • Remove (don't add)class="editing" from<li>when no longer editing.
  • Set theitem.id as theid of the<input>so that we know which item is being edited.
  • Addcase inkeyup Event Listenerfor[Enter] keyup (seesubscriptions above)if we are in "editingmode",get the text value from the<input>instead of<input>so that weupdate theexisting Todo Item title (text).
  • When[Enter] is pressed while in "editingmode","dispatch" theSAVE action:signal('SAVE')
    • If the<input> isblank,delete the todo item.

Byinspecting the DOM for the VanillaJS TodoMVC example:https://todomvc.com/examples/vanillajs
we can see thattwo things change in the DOM when in "editingmode":

  • <li> the CSSclass="editing" is addedto the todo list item beingedited.
  • <input> is inserted into the DOMinside the<li>so the item title can be edited.

todo-edit-html

Here is thesample HTML in "editingmode"(copy-pasted) from the VanillaJS TodoMVC implementationthe<li> is being edited (as per screenshot above):

<ulclass="todo-list"><lidata-id="1533987109280"class="completed"><divclass="view"><inputclass="toggle"type="checkbox"checked=""><label>hello world</label><buttonclass="destroy"></button></div></li><lidata-id="1534013859716"class="editing"><divclass="view"><inputclass="toggle"type="checkbox"><label>totes editing this todo item</label><buttonclass="destroy"></button></div><inputclass="edit"></li></ul>

From the HTML/DOM we can see that "editing" a Todo item isdeceptively simplefrom a markup perspective, wejust need to knowwhich item we are editingand render the appropriate tags/classes.

Three Steps toEDIT an Item

There arethree steps to Editing a Todo List item:

  1. Trigger the "double-click" event listener/handler1.1. Receiving thesingal('EDIT', item.id)activates "editingmode".
  2. Edit the todo list item'stitle property
  3. Save the updated itemtitle:singal('SAVE', item.id)

For thesethree steps there are twoupdate actions:EDIT andSAVEwhich will require two newcase statements in theupdate function.

Note: there is a "fourth" step which is "Cancelling" an edit,which we will cover insection 5.5 below, but for now we areonly considering the "happy path" which results in a successful edit.

5.1render_item view function with "Edit Mode"<input>

In order to edit an item therender_item functionwill require3 modifications:

  1. Add thesignal('EDIT', item.id) as anonclick attribute to<label>so that when a<label> is (double-)clickedthemodel.editing property is set by theupdate function (see below).
  2. Apply the"class=editing" to the list item which is being edited.
  3. Display the<input>with the Todo list item title as it'svalue property.

5.2render_item "Edit Mode"Test

For the above modifications (requirements) we can write asingle testwith four assertions. Append the following code totest/todo-app.test.js:

test.only('5. Editing: > Render an item in "editing mode"',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Bootstrap for as long as you can",done:false},{id:2,title:"Let's solve our own problem",done:false}],hash:'#/',// the "route" to displayediting:2// edit the 3rd todo list item (which has id == 2)};// render the ONE todo list item in "editing mode" based on model.editing:document.getElementById(id).appendChild(app.render_item(model.todos[2],model,mock_signal),);// test that signal (in case of the test mock_signal) is onclick attribute:t.equal(document.querySelectorAll('.view > label')[0].onclick.toString(),mock_signal().toString(),"mock_signal is onclick attribute of label");// test that the <li> and <input> was rendered:t.equal(document.querySelectorAll('.editing').length,1,"<li class='editing'> element is visible");t.equal(document.querySelectorAll('.edit').length,1,"<input class='edit'> element is visible");t.equal(document.querySelectorAll('.edit')[0].value,model.todos[2].title,"<input class='edit'> has value: "+model.todos[2].title);t.end();});

There is quite a lot to "unpack" here, but the main gist is thatbased on themodel.editing key being set to2, ourrender_item function,will add theediting CSS class to the<li> element and render an<input> with CSS classedit.The TodoMVC style sheet (todomvc-app.css) will take care of displayingthe input correctly.

Setting theonclick attribute of the<label> elementto whatever is passed in as the third argument ofredner_itemi.e. thesignal will mean that a specific action will be dispatched/triggeredwhen the<label> element is clicked.

SPOILER ALERT: If you want totry to make the "Edit Mode"Testassertionspass without reading the "solution",do it now before proceeding to the reading theimplementation section.


5.2render_item "Edit Mode"Implementation

Given that there are 4 assertions that need to passand we know there are 3 changes that need to be madeto therender_item function,rather than leaving you (the reader) wondering "where do I start?!",here is the code that makes the tests pass:

Before:

functionrender_item(item,model,signal){return(li(["data-id="+item.id,"id="+item.id,item.done ?"class=completed" :""],[div(["class=view"],[input([item.done ?"checked=true" :"","class=toggle","type=checkbox",typeofsignal==='function' ?signal('TOGGLE',item.id) :''],[]),// <input> does not have any nested elementslabel([],[text(item.title)]),button(["class=destroy",typeofsignal==='function' ?signal('DELETE',item.id) :''])])// </div>])// </li>)}

After:

functionrender_item(item,model,signal){return(li(["data-id="+item.id,"id="+item.id,item.done ?"class=completed" :"",model&&model.editing&&model.editing===item.id ?"class=editing" :""],[div(["class=view"],[input([item.done ?"checked=true" :"","class=toggle","type=checkbox",typeofsignal==='function' ?signal('TOGGLE',item.id) :''],[]),// <input> does not have any nested elementslabel([typeofsignal==='function' ?signal('EDIT',item.id) :''],[text(item.title)]),button(["class=destroy",typeofsignal==='function' ?signal('DELETE',item.id) :''])]),// </div>].concat(model&&model.editing&&model.editing===item.id ?[// editing?input(["class=edit","id="+item.id,"value="+item.title,"autofocus"])] :[]))// </li>)}

Let's walk through the three code changes made:

  1. Adding"class=editing" to the<li> based onmodel.editingis the simplest code modification, similar to the conditional attributeclass=completed on the previous line.
model&&model.editing&&model.editing===item.id ?"class=editing" :""

We include the check formodel && model.editing because if either of thesetwo areundefined there's no need to keep checking.Only if themodel.editing matches theitem.id(the todo list item being rendered) do we render the"class=editing".Only one todo list itemtitle will be edited at once,so this will only match (at most)one item in themodel.todos array.

  1. Setting thesignal('EDIT', item.id)

Why do we need thetypeof signal (type-checking)...?

label([typeofsignal==='function' ?signal('EDIT',item.id) :''],[text(item.title)]),

Whycan't we just write this:

label([signal('EDIT',item.id)],[text(item.title)]),

Given thatsignal is the final argument to therender_item function,it is considered anoptional argument.If for any reason therender_item function is invokedwithout thesingalparameter, then attempting toinvokesignal('EDIT', item.id)will result in aReferenceError: signal is not defined which will"crash" the appfatally.

If you are theonly person who is going to write code that will invokerender_item, you don't need to "worry" about thetypeof signalbecause there is "no need" for type-checking thesignal;surely you won'tforget to invoke it with a validsignal ...however wealways approach our JavaScript code a"defensive programming"perspectivebecause weknow fromexperiencethat banking on the"happy path"in JS codeis like driving without a seatbelt;you might be "fine" most of the time, but when something "bad" happens,you will go flying through the windscreen and have areally bad day!

dilbert-bugs

If you want toavoid having to domanual "type-checking",useElm, it does all this for youtransparently.

  1. Append the<input>to the<li> if in "editingmode":
].concat(model&&model.editing&&model.editing===item.id ?[// editing?input(["class=edit","id="+item.id,"value="+item.title,"autofocus"])] :[]))// </li>

Thereason we use.concat is to allow us tooptionally render the element ornothing thenappend it to theArray of child elements nested in the<li>.

Analternative to using.concat() could be an emptydiv node:

model&&model.editing&&model.editing===item.id ?// editing?input(["class=edit","id="+item.id,"value="+item.title,"autofocus"])  :div()// empty element.

This is because attempting to return anything other than a DOM elementwill result in the following error:

TypeError:Argument1ofNode.appendChilddoesnotimplementinterfaceNode

We are not "fans" of having "empty" elements in the DOM, it's "sloppy".
Hence theconcat() approach which results in "clean" DOM.

At this point our test assertions all pass:

node test/todo-app.test.js

render_item-tests-pass

But we are building avisual application and are notseeing anything ...

Visualise Editing Mode?

Let's take abrief detour tovisualise the progress we have made.

Open theindex.html fileand alter the contents of the<script> tag:

<script>varmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Bootstrap for as long as you can",done:false},{id:2,title:"Let's solve our own problem",done:false}],hash:'#/',// the "route" to displayediting:2// edit the 3rd todo list item (which has id == 2)};mount(model,update,view,'app',subscriptions);</script>

Then in your terminal, start the live-server:

npm start

In your browser, vist:http://127.0.0.1:8000/
You should see that thethird todo list item is in "editingmode":

elm-todomvc-editing-item

Nothing will happen (yet) if you attempt to "save" any changes.Let's work on thecase (handler) forsignal('EDIT', item.id)which will handle the "double-click" event and setmodel.editing.

5.2 Double-Click item<label> to Edit

The TodoMVCspec for itemhttps://github.com/tastejs/todomvc/blob/main/app-spec.md#itemincludes the line:

Double-clicking the<label> activates editing mode, by toggling the .editing class on its<li>

Note: the sample TodoMVC Browser Tests:https://github.com/tastejs/todomvc/tree/main/tests#example-outputdoesnot include a test-case fordouble-clicking.We are going to add one below for "extra credit".

Since Double-clicking/tapping is theonly way to edit a todo item,we feel that it deserves a test.

How do we Track Double-Clicking?

When we don't know how to do something, a good place to start is to searchfor the keywords we want, e.g: "JavaScript detect double-click event"for which the top result is the following StackOverflow Q/A:https://stackoverflow.com/questions/5497073/how-to-differentiate-single-click-event-and-double-click-event

Reading though all the answers, we determine that the most relevant (to us)is:https://stackoverflow.com/a/16033129/1148249 (which uses "vanilla" JS):

stackoverflow-double-click-example

Note: when you find a StackOverflow question/answerhelpful,upvote to show your appreciation!

<divonclick="doubleclick(this, function(){alert('single')}, function(){alert('double')})">click me</div><script>functiondoubleclick(el,onsingle,ondouble){if(el.getAttribute("data-dblclick")==null){el.setAttribute("data-dblclick",1);setTimeout(function(){if(el.getAttribute("data-dblclick")==1){onsingle();}el.removeAttribute("data-dblclick");},300);}else{el.removeAttribute("data-dblclick");ondouble();}}</script>

Given that we are using the Elm Architecture to manage the DOM,we don't want a function thatalters the DOM.So we are going toborrow thelogic from this example butsimplify it.Since we are not mutating the DOM by settingdata-dblclick attributes,we won't need to remove the attribute using asetTimeout,

5.2'EDIT' update caseTest

In keeping with our TDD approach,ourfirst step when adding thecase expressionfor'EDIT' in theupdate function is to write atest.

Append following test code to yourtest/todo-app.test.js file:

test.only('5.2 Double-click an item <label> to edit it',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('todos-elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Let's solve our own problem",done:false}],hash:'#/'// the "route" to display};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);constlabel=document.querySelectorAll('.view > label')[1]// "double-click" i.e. click the <label> twice in quick succession:label.click();label.click();// confirm that we are now in editing mode:t.equal(document.querySelectorAll('.editing').length,1,"<li class='editing'> element is visible");t.equal(document.querySelectorAll('.edit')[0].value,model.todos[1].title,"<input class='edit'> has value: "+model.todos[1].title);t.end();});

If you attempt to run this test:node test/todo-app.test.jsyou will see output similar to the following:

edit-double-click-test-failing

Let's write the code necessary to make the test assertionspass!If you want to try this yourself based on the StackOverflow answer (above),go for it! (don't scroll down to the "answer" till you have tried...)

5.2'EDIT' update caseImplementation

Given our "research" (above) of how to implement a "double-click" handler,we can write the'EDIT' case as the following:

case'EDIT':// this code is inspired by: https://stackoverflow.com/a/16033129/1148249// simplified as we are not altering the DOM!if(new_model.clicked&&new_model.clicked===data&&Date.now()-300<new_model.click_time){// DOUBLE-CLICK < 300msnew_model.editing=data;console.log('DOUBLE-CLICK',"item.id=",data,"| model.editing=",model.editing,"| diff Date.now() - new_model.click_time: ",Date.now(),"-",new_model.click_time,"=",Date.now()-new_model.click_time);}else{// first clicknew_model.clicked=data;// so we can check if same item clicked twice!new_model.click_time=Date.now();// timer to detect double-click 300msnew_model.editing=false;// resetconsole.log('FIRST CLICK! data:',data);}break;

If you ignore/remove theconsole.log lines (which we are using for now!),the code is only a few lines long:

case'EDIT':// this code is inspired by: https://stackoverflow.com/a/16033129/1148249// simplified as we are not altering the DOM!if(new_model.clicked&&new_model.clicked===data&&Date.now()-300<new_model.click_time){// DOUBLE-CLICK < 300msnew_model.editing=data;}else{// first clicknew_model.clicked=data;// so we can check if same item clicked twice!new_model.click_time=Date.now();// timer to detect double-click 300msnew_model.editing=false;// reset}break;

The main "purpose" of this code is todetect if a<label> was clickedtwice in the space of 300 milliseconds and apply theitem.id tothemodel.editing property so that we know which<li> to render in"editing mode".

Run the test and watch itpass:node test/todo-app.test.jsedit-double-click-test-pass

In this case the time between the two clicks was 31 milliseconds,so they will count as a "double-click"!

If a<label> is clicked slowly, themodel.editing willnot be set,and we willnot enter "editing mode".Let's add a quick test for the scenariowhere two clicks are more than 300ms apart.

Append following test code to yourtest/todo-app.test.js file:

test.only('5.2.2 Slow clicks do not count as double-click > no edit!',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('todos-elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Let's solve our own problem",done:false}],hash:'#/'// the "route" to display};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);constlabel=document.querySelectorAll('.view > label')[1]// "double-click" i.e. click the <label> twice in quick succession:label.click();setTimeout(function(){label.click();// confirm that we are now in editing mode:t.equal(document.querySelectorAll('.editing').length,0,"<li class='editing'> element is NOT visible");t.end();},301)});

There is no need to write any code to make this test pass,this is merely an additional test toconfirm that our check for thetime between clicks works; clicks spaced more than 300ms will not countas "double-click".

edit-item-not-double-click

5.3'SAVE' a Revised Todo Item Title after Editing it

Once you are done editing a todo list item title,you want tosave your changes!

5.3'SAVE' update caseTest

Append following test code to yourtest/todo-app.test.js file:

test.only('5.3 [ENTER] Key in edit mode triggers SAVE action',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('todos-elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Let's solve our own problem",done:false}],hash:'#/',// the "route" to displayediting:1// edit the 3rd todo list item (which has id == 2)};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);// change theconstupdated_title="Do things that don\'t scale!  "// apply the updated_title to the <input>:document.querySelectorAll('.edit')[0].value=updated_title;// trigger the [Enter] keyboard key to ADD the new todo:document.dispatchEvent(newKeyboardEvent('keyup',{'keyCode':13}));// confirm that the todo item title was updated to the updated_title:constlabel=document.querySelectorAll('.view > label')[1].textContent;t.equal(label,updated_title.trim(),"item title updated to:"+updated_title+' (trimmed)');t.end();});

If you attempt to run this test:node test/todo-app.test.jsyou will see output similar to the following:

save-edit-test-fails

5.3'SAVE' update caseImplementation

Thefirst step in the implementation is to create the'SAVE' caseinupdate function:

case'SAVE':varedit=document.getElementsByClassName('edit')[0];varvalue=edit.value;varid=parseInt(edit.id,10);// End Editingnew_model.clicked=false;new_model.editing=false;if(!value||value.length===0){// delete item if title is blank:returnupdate('DELETE',new_model,id);}// update the value of the item.title that has been edited:new_model.todos=new_model.todos.map(function(item){if(item.id===id&&value&&value.length>0){item.title=value.trim();}returnitem;// return all todo items.});break;

Thesecond step istriggering thiscase in thesubscriptionsevent listener forkeyup:

Before:

document.addEventListener('keyup',functionhandler(e){switch(e.keyCode){caseENTER_KEY:varnew_todo=document.getElementById('new-todo');if(new_todo.value.length>0){signal('ADD')();// invoke singal inner callbacknew_todo.value='';// reset <input> so we can add another tododocument.getElementById('new-todo').focus();}break;}});

After:

document.addEventListener('keyup',functionhandler(e){switch(e.keyCode){caseENTER_KEY:varediting=document.getElementsByClassName('editing');if(editing&&editing.length>0){signal('SAVE')();// invoke singal inner callback}varnew_todo=document.getElementById('new-todo');if(new_todo.value.length>0){signal('ADD')();// invoke singal inner callbacknew_todo.value='';// reset <input> so we can add another tododocument.getElementById('new-todo').focus();}break;}});

When you run the tests:node test/todo-app.test.jsthey should nowpass:save-update-test-pass

5.4'SAVE' aBlank item.titledeletes the itemTest

Our mini-mission is to make the following TodoMVC test assertionpass:

✓ should remove the item if an empty text string was entered (1033ms)

Append following test code to yourtest/todo-app.test.js file:

test.only('5.4 SAVE should remove the item if an empty text string was entered',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('todos-elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Let's solve our own problem",done:false}],hash:'#/',// the "route" to displayediting:1// edit the 3rd todo list item (which has id == 2)};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);t.equal(document.querySelectorAll('.view').length,2,'todo count: 2');// apply empty string to the <input>:document.querySelectorAll('.edit')[0].value='';// trigger the [Enter] keyboard key to ADD the new todo:document.dispatchEvent(newKeyboardEvent('keyup',{'keyCode':13}));// confirm that the todo item was removed!t.equal(document.querySelectorAll('.view').length,1,'todo count: 1');t.end();});

If you attempt to run this test:node test/todo-app.test.jsyou will see output similar to the following:

save-blank-title-test-failing

5.4'SAVE' aBlank item.titledeletes the itemImplementation

To make this test pass we just need to add a couple of lines to the'SAVE' case in theupdate function:

if(!value||value.length===0){// delete item if title is blank:returnupdate('DELETE',new_model,id);}

when youre-run the tests, they willpass:

save-blank-title-test-pass

5.5'CANCEL' edit on [esc] Key Press

When a user presses the [esc] ("escape") key, editing should be "cancelled"without saving the changes:

✓ should cancel edits on escape

5.5'CANCEL' edit on [esc]Test

Append following test code to yourtest/todo-app.test.js file:

test.only('5.5 CANCEL should cancel edits on escape',function(t){elmish.empty(document.getElementById(id));localStorage.removeItem('todos-elmish_'+id);constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Let's solve our own problem",done:false}],hash:'#/',// the "route" to displayediting:1// edit the 3rd todo list item (which has id == 2)};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);t.equal(document.querySelectorAll('.view > label')[1].value,model.todos[1].title,'todo id 1 has title: '+model.todos[1].title);// apply empty string to the <input>:document.querySelectorAll('.edit')[0].value='Hello World';// trigger the [esc] keyboard key to CANCEL editingdocument.dispatchEvent(newKeyboardEvent('keyup',{'keyCode':27}));// confirm the item.title is still the original title:t.equal(document.querySelectorAll('.view > label')[1].value,model.todos[1].title,'todo id 1 has title: '+model.todos[1].title);t.end();});

If you attempt to run this test:node test/todo-app.test.jsit should fail.

5.5'CANCEL' edit on [esc]Implementation

To make this test pass wefirst need to add a'CANCEL'case to theupdate function:

case'CANCEL':new_model.clicked=false;new_model.editing=false;break;

Second we need totrigger the'CANCEL' actionwhen the[esc] key is pressed, so we need to add acaseto theswitch(e.keyCode) { in the subscriptions event listener:

Before:

document.addEventListener('keyup',functionhandler(e){switch(e.keyCode){caseENTER_KEY:varediting=document.getElementsByClassName('editing');if(editing&&editing.length>0){signal('SAVE')();// invoke singal inner callback}varnew_todo=document.getElementById('new-todo');if(new_todo.value.length>0){signal('ADD')();// invoke singal inner callbacknew_todo.value='';// reset <input> so we can add another tododocument.getElementById('new-todo').focus();}break;}});

After:

document.addEventListener('keyup',functionhandler(e){console.log('e.keyCode:',e.keyCode,'| key:',e.key);switch(e.keyCode){caseENTER_KEY:varediting=document.getElementsByClassName('editing');if(editing&&editing.length>0){signal('SAVE')();// invoke singal inner callback}varnew_todo=document.getElementById('new-todo');if(new_todo.value.length>0){signal('ADD')();// invoke singal inner callbacknew_todo.value='';// reset <input> so we can add another tododocument.getElementById('new-todo').focus();}break;caseESCAPE_KEY:signal('CANCEL')();break;}});

when you re-run the tests, they will pass:cancel-editing-on-esc-keypress-test-passing

6. Counter

✓ should display the current number of todo items

6. CounterTest

Append following test code to yourtest/todo-app.test.js file:

test.only('6. Counter > should display the current number of todo items',function(t){elmish.empty(document.getElementById(id));constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Bootstrap for as long as you can",done:false},{id:2,title:"Let's solve our own problem",done:false}],hash:'#/'};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);// count:constcount=parseInt(document.getElementById('count').textContent,10);t.equal(count,model.todos.length,"displays todo item count: "+count);elmish.empty(document.getElementById(id));// clear DOM ready for next testlocalStorage.removeItem('todos-elmish_'+id);t.end();});

Thankfully, the counter was already implemented aboveso this testalready passes:

counter-test-passing

Just keep onmovin'

7. Clear Completed Button

When items are complete we should be able todelete them in bulk.

✓ should display the number of completed items✓ should remove completed items when clicked✓ should be hidden when there are no items that are completed

7. Clear Completed ButtonTest

Append following test code to yourtest/todo-app.test.js file:

test.only('7. Clear Completed > should display the number of completed items',function(t){elmish.empty(document.getElementById(id));constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Bootstrap for as long as you can",done:true},{id:2,title:"Let's solve our own problem",done:true}],hash:'#/'};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);// count todo items in DOM:t.equal(document.querySelectorAll('.view').length,3,"at the start, there are 3 todo items in the DOM.");// count completed itemsconstcompleted_count=parseInt(document.getElementById('completed-count').textContent,10);constdone_count=model.todos.filter(function(i){returni.done}).length;t.equal(completed_count,done_count,"displays completed items count: "+completed_count);// clear completed items:constbutton=document.querySelectorAll('.clear-completed')[0];button.click();// confirm that there is now only ONE todo list item in the DOM:t.equal(document.querySelectorAll('.view').length,1,"after clearing completed items, there is only 1 todo item in the DOM.");// no clear completed button in the DOM when there are no "done" todo items:t.equal(document.querySelectorAll('clear-completed').length,0,'no clear-completed button when there are no done items.')elmish.empty(document.getElementById(id));// clear DOM ready for next testlocalStorage.removeItem('todos-elmish_'+id);t.end();});

7. Clear Completed ButtonImplementation

First we need to update thebutton section in therender_footer functionto include thedone count:

Before:

button(["class=clear-completed","style=display:"+display_clear],[text("Clear completed")])

After:

button(["class=clear-completed","style=display:"+display_clear,signal('CLEAR_COMPLETED')],[text("Clear completed ["),span(["id=completed-count"],[text(done)]),text("]")])

Seconde we need to add a'CLEAR_COMPLETED'case to theupdate function:

case'CLEAR_COMPLETED':new_model.todos=new_model.todos.filter(function(item){return!item.done;// only return items which are item.done = false});break;

The tests should pass:

clear-completed-button-tests-passing


8. Persistence > Save Todo List items tolocalStorage

✓ should persist its data

8. PersistenceTest

We have already covered saving themodeltolocalStorage indetail (above),we are adding a "proxy" test for completeness:

test.only('8. Persistence > should persist its data',function(t){elmish.empty(document.getElementById(id));constmodel={todos:[{id:0,title:"Make something people want.",done:false}],hash:'#/'};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);// confirm that the model is saved to localStorageconsole.log('localStorage',localStorage.getItem('todos-elmish_'+id));t.equal(localStorage.getItem('todos-elmish_'+id),JSON.stringify(model),"data is persisted to localStorage");elmish.empty(document.getElementById(id));// clear DOM ready for next testlocalStorage.removeItem('todos-elmish_'+id);t.end();});

Again, this test shouldalready pass:

persistence-test-passing

9. Routing

The following assertions:

✓ should allow me to display active items✓ should allow me to display completed items✓ should allow me to display all items✓ should highlight the currently applied filter
  • 'SHOW_ALL' the default view.
  • 'SHOW_ACTIVE' item.done === false
  • 'SHOW_COMPLETED' item.done === true

9. RoutingTest

Append following test code to yourtest/todo-app.test.js file:

test.only('9. Routing > should allow me to display active/completed/all items',function(t){elmish.empty(document.getElementById(id));constmodel={todos:[{id:0,title:"Make something people want.",done:false},{id:1,title:"Bootstrap for as long as you can",done:true},{id:2,title:"Let's solve our own problem",done:true}],hash:'#/active'// ONLY ACTIVE items};// render the view and append it to the DOM inside the `test-app` node:elmish.mount(model,app.update,app.view,id,app.subscriptions);t.equal(document.querySelectorAll('.view').length,1,"one active item");letselected=document.querySelectorAll('.selected')[0]t.equal(selected.id,'active',"active footer filter is selected");// empty:elmish.empty(document.getElementById(id));localStorage.removeItem('todos-elmish_'+id);// show COMPLTED items:model.hash='#/completed';elmish.mount(model,app.update,app.view,id,app.subscriptions);t.equal(document.querySelectorAll('.view').length,2,"two completed items");selected=document.querySelectorAll('.selected')[0]t.equal(selected.id,'completed',"completed footer filter is selected");// empty:elmish.empty(document.getElementById(id));localStorage.removeItem('todos-elmish_'+id);// show ALL items:model.hash='#/';elmish.mount(model,app.update,app.view,id,app.subscriptions);t.equal(document.querySelectorAll('.view').length,3,"three items total");selected=document.querySelectorAll('.selected')[0]t.equal(selected.id,'all',"all footer filter is selected");elmish.empty(document.getElementById(id));// clear DOM ready for next testlocalStorage.removeItem('todos-elmish_'+id);t.end();});

9. RoutingImplementation

Given that we are using "hash" based routing,where the content of the app changes in response to the hash portion of the URLimplementing routing is a matter offiltering the Todo List itemsin response to the hash.

There 3 steps to implementing this:

  1. Create an Event Listener for thewindow.onhashchange eventwhich invokessignal('ROUTE').

  2. Create a'ROUTE' case in theupdate functionwhich sets themodel.hash value.

  3. Based on themodel.hash value defined above,filter themodel.todos.

Since this is thefinal quest in the TodoMVC/Todo List App,the we encourage you to attempt to write thisbefore/without looking at the "solution".

Remember that you only want to write theminimum codenecessary to make the test assertions pass.

If you get "stuck" consult the code intodo-app.js.

9.1 RoutingEvent Listener

Add the following event listener to yoursubscriptionsto "listen" for when the URL hash changes:

window.onhashchange=functionroute(){signal('ROUTE')();}

9.2 ROUTEcase

Add the'ROUTE'caseto yourupdate function:

case'ROUTE':new_model.hash=(window&&window.location&&window.location.hash) ?window.location.hash :'#/';break;

OR, if you are confident that your appwillalways run in a Web Browser with awindow.location.hash property:

case'ROUTE':new_model.hash=window.location.hash;break;

ButWhy...?

Question: Why do we "copy" thewindow.location.hashtomodel.hash instead of just "getting" it fromwindow.location.hasheach time we need to know what the hash is?

Answer: technically, we couldavoid havingthe'ROUTE' case inupdate completelyand just use thewindow.location.hashinstead ofmodel.hash,thereason we add this "step"is that we want to have a "single source of truth" in themodel.This is agood habit to haveas it makesdebugging your applicationmuch easier because youknowexactlywhat the "full state" of the application is/was at any point in time.

You will often read/hear the expression "easier toreason about",all this means is that you can "work through" something in your headwithout getting "confused" by having "too many things to keep track of".

9.3Filter themodel.todos based onmodel.hash

We need to do the filtering "non-destructively",so it needs to happen in theview functionrender_main(just before rendering).

render_main functionBefore (without filter):

functionrender_main(model,signal){// Requirement #1 - No Todos, should hide #footer and #mainvardisplay="style=display:"+(model.todos&&model.todos.length>0 ?"block" :"none");// console.log('display:', display);return(section(["class=main","id=main",display],[// hide if no todo items.input(["id=toggle-all","type=checkbox",typeofsignal==='function' ?signal('TOGGLE_ALL') :'',(model.all_done ?"checked=checked" :""),"class=toggle-all"],[]),label(["for=toggle-all"],[text("Mark all as complete")]),ul(["class=todo-list"],(model.todos&&model.todos.length>0) ?model.todos.map(function(item){returnrender_item(item,model,signal)}) :null)// </ul>])// </section>)}

render_main functionAfter (withmodel.hash filter):

functionrender_main(model,signal){// Requirement #1 - No Todos, should hide #footer and #mainvardisplay="style=display:"+(model.todos&&model.todos.length>0 ?"block" :"none");// console.log('display:', display);return(section(["class=main","id=main",display],[// hide if no todo items.input(["id=toggle-all","type=checkbox",typeofsignal==='function' ?signal('TOGGLE_ALL') :'',(model.all_done ?"checked=checked" :""),"class=toggle-all"],[]),label(["for=toggle-all"],[text("Mark all as complete")]),ul(["class=todo-list"],(model.todos&&model.todos.length>0) ?model.todos.filter(function(item){switch(model.hash){case'#/active':return!item.done;case'#/completed':returnitem.done;default:// if hash doesn't match Active/Completed render ALL todos:returnitem;}}).map(function(item){returnrender_item(item,model,signal)}) :null// if there are no todos, don't show anything.)// </ul>])// </section>)}

The important lines are:

.filter(function(item){switch(model.hash){case'#/active':return!item.done;case'#/completed':returnitem.done;default:// if hash doesn't match Active/Completed render ALL todos:returnitem;}})

Array.filter returns anew Array(it does not "mutate" the Array it is filtering)so we will only see the todo items that match thehash in the URL.'#/active' means any todos which are not yet done i.e.!doneand'#/completed' are the items which aredone=true.If the URLhash does not match either of these two filters,then simply "show everything".

Question: is this "logic in the view"...?
Answer:Yes, it ispresentation logic.Theview function, **render_main in this caseis merelyfiltering the datanon-destructively before rendering it.UsingArray.filter is a "fancy" (concise) way of writing anif statement.if statements are "OK" in views because they are"conditional presentation logic"i.e. only show this sectionif a certain variable is set.
By usingArray.filter followed byArray.map we render asubsetof themodel.todoswithout "mutating" themodel.todos Array.In other words if the URL hash is'#/completed'the user only wants to see the "completed" items,we don't want to "lose" the todos that are not yet complete,we just want to "hide" them temporarily,if we were to apply this filter in theupdate function it would"lose" the other todos (i.e. destroy the data!)the best way to filter data non-destructively is in theview

Done!

In your terminal, run:

npm start

You should have a fully-featured Todo list App!

elm-todo

Try out your Todo List App!

If you found this tutorialuseful,please "star" the project on GitHub ⭐️ to show your appreciationand share it with others in the community who might find it useful! Thanks! ✨

Consider sharing your creation with your friendsby deploying it to GitHub Pages!https://github.com/dwyl/learn-github-pages

Thanks for Learning with Us!

About

✅ A step-by-step complete beginner example/tutorial for building a Todo List App (TodoMVC) from scratch in JavaScript following Test Driven Development (TDD) best practice. 🌱

Topics

Resources

License

Stars

Watchers

Forks


[8]ページ先頭

©2009-2025 Movatter.jp