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 , etc.
*/
function render_main (model, signal) {
- // Requirement #1 - No Todos, should hide #footer and #main
- var display = "style=display:"
- + (model.todos && model.todos.length > 0 ? "block" : "none");
+ var has_todos = model.todos && model.todos.length > 0;
+ var display = "style=display:" + (has_todos ? "block" : "none");
+
+ var filtered_by_route = has_todos ? model.todos.filter(function (item) {
+ switch(model.hash) {
+ case '#/active':
+ return !item.done;
+ case '#/completed':
+ return item.done;
+ default:
+ return item;
+ }
+ }) : [];
+
+ var search_term = (model.search || '').trim();
+ var filtered_items = filtered_by_route;
+
+ if (search_term.length > 0) {
+ filtered_items = filtered_by_route.filter(function (item) {
+ return item.title.toLowerCase().indexOf(search_term.toLowerCase()) !== -1;
+ });
+ }
+
+ var has_filtered_items = filtered_items.length > 0;
+ var show_empty_message = has_todos && !has_filtered_items;
return (
- section(["class=main", "id=main", display], [ // hide if no todo items.
+ section(["class=main", "id=main", display], [
input(["id=toggle-all", "type=checkbox",
typeof signal === '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':
- return item.done;
- default: // if hash doesn't match Active/Completed render ALL todos:
- return item;
- }
- })
- .map(function (item) {
- return render_item(item, model, signal)
- }) : null
- ) //
- ]) //
+ 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();
+});
+