diff --git a/lib/todo-app.js b/lib/todo-app.js index 2b9c956..2a4511c 100644 --- a/lib/todo-app.js +++ b/lib/todo-app.js @@ -5,10 +5,25 @@ if (typeof require !== 'undefined' && this.window !== this) { route, section, span, strong, text, ul } = require('./elmish.js'); } -var initial_model = { +function set_search_state(model, value) { + Object.defineProperty(model, 'search', { + value: value || '', + writable: true, + configurable: true, + enumerable: false + }); + return model; +} + +function clone_model(model) { + var cloned = JSON.parse(JSON.stringify(model)); + return set_search_state(cloned, model && model.search); +} + +var initial_model = set_search_state({ todos: [], hash: "#/" -} +}, "") /** * `update` transforms the `model` based on the `action`. @@ -18,7 +33,7 @@ var initial_model = { * @return {Object} new_model - the transformed model. */ function update(action, model, data) { - var new_model = JSON.parse(JSON.stringify(model)) // "clone" the model + var new_model = clone_model(model) // "clone" the model switch(action) { case 'ADD': @@ -103,6 +118,9 @@ function update(action, model, data) { new_model.hash = // (window && window.location && window.location.hash) ? window.location.hash // : '#/'; break; + case 'SEARCH': + set_search_state(new_model, data); + break; default: // if action unrecognised or undefined, return model; // return model unmodified } // see: https://softwareengineering.stackexchange.com/a/201786/211301 @@ -162,36 +180,49 @@ function render_item (item, model, signal) { * @return {Object}
DOM Tree which containing the todo list - ]) //
+ show_empty_message ? + div(["class=empty-message"], [text("No todos match your search.")]) : + ul(["class=todo-list"], + has_filtered_items ? + filtered_items.map(function (item) { + return render_item(item, model, signal) + }) : null + ) + ]) ) } @@ -281,6 +312,7 @@ function render_footer (model, signal) { * var DOM = view(model); */ function view (model, signal) { + var search_value = model.search || ''; return ( section(["class=todoapp"], [ // array of "child" elements @@ -293,7 +325,13 @@ function view (model, signal) { "class=new-todo", "placeholder=What needs to be done?", "autofocus" - ], []) // is "self-closing" + ], []), // is "self-closing" + input([ + "id=search-todo", + "class=search-todo", + "placeholder=Search todos...", + "value=" + search_value + ], []) // search input ]), // render_main(model, signal), render_footer(model, signal) @@ -311,6 +349,8 @@ function subscriptions (signal) { var ENTER_KEY = 13; // add a new todo item when [Enter] key is pressed var ESCAPE_KEY = 27; // used for "escaping" when editing a Todo item + signal('SEARCH', '')(); + document.addEventListener('keyup', function handler (e) { // console.log('e.keyCode:', e.keyCode, '| key:', e.key); @@ -337,6 +377,12 @@ function subscriptions (signal) { window.onhashchange = function route () { signal('ROUTE')(); } + + document.addEventListener('input', function (e) { + if (e.target && e.target.id === 'search-todo') { + signal('SEARCH', e.target.value)(); + } + }); } /* module.exports is needed to run the functions using Node.js for testing! */ diff --git a/test/todo-app.test.js b/test/todo-app.test.js index 5ec459c..57d7d42 100644 --- a/test/todo-app.test.js +++ b/test/todo-app.test.js @@ -678,3 +678,149 @@ test('9. Routing > should allow me to display active/completed/all items', t.end(); }); +test('10. Search > should filter todos by search term', function (t) { + localStorage.removeItem('todos-elmish_' + id); + elmish.empty(document.getElementById(id)); + const model = { + todos: [ + { id: 0, title: "Buy milk", done: false }, + { id: 1, title: "Buy eggs", done: false }, + { id: 2, title: "Walk the dog", done: true } + ], + hash: '#/' + }; + elmish.mount(model, app.update, app.view, id, app.subscriptions); + + t.equal(document.querySelectorAll('.view').length, 3, "three items total"); + + const model_with_search = app.update('SEARCH', model, 'milk'); + elmish.empty(document.getElementById(id)); + document.getElementById(id).appendChild(app.render_main(model_with_search, mock_signal)); + + t.equal(document.querySelectorAll('.view').length, 1, "one item matches 'milk'"); + t.equal(document.querySelectorAll('.view')[0].textContent, 'Buy milk', "matching item is 'Buy milk'"); + + elmish.empty(document.getElementById(id)); + localStorage.removeItem('todos-elmish_' + id); + t.end(); +}); + +test('10.1 Search > should ignore leading and trailing whitespace in search term', function (t) { + localStorage.removeItem('todos-elmish_' + id); + elmish.empty(document.getElementById(id)); + const model = { + todos: [ + { id: 0, title: "Buy milk", done: false }, + { id: 1, title: "Buy eggs", done: false } + ], + hash: '#/' + }; + + const model_with_whitespace = app.update('SEARCH', model, ' milk '); + document.getElementById(id).appendChild(app.render_main(model_with_whitespace, mock_signal)); + + t.equal(document.querySelectorAll('.view').length, 1, "one item matches ' milk ' (trimmed)"); + t.equal(document.querySelectorAll('.view')[0].textContent, 'Buy milk', "matching item is 'Buy milk'"); + + elmish.empty(document.getElementById(id)); + + const model_with_only_spaces = app.update('SEARCH', model, ' '); + document.getElementById(id).appendChild(app.render_main(model_with_only_spaces, mock_signal)); + + t.equal(document.querySelectorAll('.view').length, 2, "all items shown when search term is only spaces"); + + elmish.empty(document.getElementById(id)); + localStorage.removeItem('todos-elmish_' + id); + t.end(); +}); + +test('10.2 Search > should show empty message when no items match search', function (t) { + localStorage.removeItem('todos-elmish_' + id); + elmish.empty(document.getElementById(id)); + const model = { + todos: [ + { id: 0, title: "Buy milk", done: false }, + { id: 1, title: "Buy eggs", done: false } + ], + hash: '#/' + }; + + const model_with_search = app.update('SEARCH', model, 'nonexistent'); + document.getElementById(id).appendChild(app.render_main(model_with_search, mock_signal)); + + t.equal(document.querySelectorAll('.view').length, 0, "no items match 'nonexistent'"); + t.equal(document.querySelectorAll('.empty-message').length, 1, "empty message is shown"); + t.equal(document.querySelectorAll('.empty-message')[0].textContent, 'No todos match your search.', "empty message text is correct"); + + elmish.empty(document.getElementById(id)); + localStorage.removeItem('todos-elmish_' + id); + t.end(); +}); + +test('10.3 Search > should not show empty message when there are no todos at all', function (t) { + localStorage.removeItem('todos-elmish_' + id); + elmish.empty(document.getElementById(id)); + const model = { + todos: [], + hash: '#/' + }; + + const model_with_search = app.update('SEARCH', model, 'milk'); + document.getElementById(id).appendChild(app.render_main(model_with_search, mock_signal)); + + t.equal(document.querySelectorAll('.empty-message').length, 0, "no empty message when there are no todos"); + + elmish.empty(document.getElementById(id)); + localStorage.removeItem('todos-elmish_' + id); + t.end(); +}); + +test('10.4 Search > should combine with route filter', function (t) { + localStorage.removeItem('todos-elmish_' + id); + elmish.empty(document.getElementById(id)); + const model = { + todos: [ + { id: 0, title: "Buy milk", done: false }, + { id: 1, title: "Buy eggs", done: false }, + { id: 2, title: "Walk the dog", done: true }, + { id: 3, title: "Buy bread", done: true } + ], + hash: '#/active' + }; + + const model_with_search = app.update('SEARCH', model, 'Buy'); + document.getElementById(id).appendChild(app.render_main(model_with_search, mock_signal)); + + t.equal(document.querySelectorAll('.view').length, 2, "two active items match 'Buy'"); + + elmish.empty(document.getElementById(id)); + localStorage.removeItem('todos-elmish_' + id); + t.end(); +}); + +test('10.5 Search > footer should still show original counts when searching', function (t) { + localStorage.removeItem('todos-elmish_' + id); + elmish.empty(document.getElementById(id)); + const model = { + todos: [ + { id: 0, title: "Buy milk", done: false }, + { id: 1, title: "Buy eggs", done: false }, + { id: 2, title: "Walk the dog", done: true } + ], + hash: '#/', + search: 'milk' + }; + + document.getElementById(id).appendChild(app.render_footer(model)); + + const count = parseInt(document.getElementById('count').textContent, 10); + t.equal(count, 2, "footer shows 2 items left (original count)"); + + const completed_count = parseInt(document.getElementById('completed-count').textContent, 10); + t.equal(completed_count, 1, "footer shows 1 completed item (original count)"); + + elmish.empty(document.getElementById(id)); + localStorage.removeItem('todos-elmish_' + id); + t.end(); +}); +