diff --git a/lib/todo-app.js b/lib/todo-app.js
index 2b9c956..6782ae6 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
@@ -187,6 +205,12 @@ function render_main (model, signal) {
return item;
}
})
+ .filter(function (item) {
+ if (!model.search || model.search.length === 0) {
+ return true;
+ }
+ return item.title.toLowerCase().indexOf(model.search.toLowerCase()) !== -1;
+ })
.map(function (item) {
return render_item(item, model, signal)
}) : null
@@ -208,27 +232,43 @@ function render_main (model, signal) {
*/
function render_footer (model, signal) {
- // count how many "active" (not yet done) items by filtering done === false:
- var done = (model.todos && model.todos.length > 0) ?
+ var filtered_todos = (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:
+ return item;
+ }
+ })
+ .filter(function (item) {
+ if (!model.search || model.search.length === 0) {
+ return true;
+ }
+ return item.title.toLowerCase().indexOf(model.search.toLowerCase()) !== -1;
+ }) : [];
+
+ var total_done = (model.todos && model.todos.length > 0) ?
model.todos.filter( function (i) { return i.done; }).length : 0;
- var count = (model.todos && model.todos.length > 0) ?
+ var total_count = (model.todos && model.todos.length > 0) ?
model.todos.filter( function (i) { return !i.done; }).length : 0;
- // Requirement #1 - No Todos, should hide #footer and #main
- var display = (count > 0 || done > 0) ? "block" : "none";
+ var display = (total_count > 0 || total_done > 0) ? "block" : "none";
+
+ var filtered_done = filtered_todos.filter(function (i) { return i.done; }).length;
+ var filtered_count = filtered_todos.filter(function (i) { return !i.done; }).length;
- // number of completed items:
- var done = (model.todos && model.todos.length > 0) ?
- (model.todos.length - count) : 0;
- var display_clear = (done > 0) ? "block;" : "none;";
+ var display_clear = (filtered_done > 0) ? "block;" : "none;";
- // pluarisation of number of items:
- var left = (" item" + ( count > 1 || count === 0 ? 's' : '') + " left");
+ var left = (" item" + ( filtered_count > 1 || filtered_count === 0 ? 's' : '') + " left");
return (
footer(["class=footer", "id=footer", "style=display:" + display], [
span(["class=todo-count", "id=count"], [
- strong(count),
+ strong(filtered_count),
text(left)
]),
ul(["class=filters"], [
@@ -260,7 +300,7 @@ function render_footer (model, signal) {
[
text("Clear completed ["),
span(["id=completed-count"], [
- text(done)
+ text(filtered_done)
]),
text("]")
]
@@ -293,7 +333,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...",
+ model.search ? "value=" + model.search : ""
+ ], []) // search input
]), //
render_main(model, signal),
render_footer(model, signal)
@@ -311,6 +357,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);
@@ -334,6 +382,12 @@ function subscriptions (signal) {
}
});
+ document.addEventListener('input', function handler (e) {
+ if (e.target && e.target.id === 'search-todo') {
+ signal('SEARCH', e.target.value)();
+ }
+ });
+
window.onhashchange = function route () {
signal('ROUTE')();
}
diff --git a/test/todo-app.test.js b/test/todo-app.test.js
index 5ec459c..7ea4f46 100644
--- a/test/todo-app.test.js
+++ b/test/todo-app.test.js
@@ -11,6 +11,7 @@ test('`model` (Object) has desired keys', function (t) {
const keys = 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.equal(app.model.search, '', "model.search defaults to empty string");
t.end();
});
@@ -678,3 +679,203 @@ test('9. Routing > should allow me to display active/completed/all items',
t.end();
});
+test('10. Search > model should have search field', function (t) {
+ t.equal(typeof app.model.search, 'string', "`search` field is available on model.");
+ t.equal(app.model.search, '', "initial search value is empty string.");
+ t.end();
+});
+
+test('10.1 Search > update SEARCH action should set search field', function (t) {
+ const model = { todos: [], hash: '#/' };
+ t.equal(model.search, undefined, "search is not persisted on plain stored models.");
+
+ const updated_model = app.update('SEARCH', model, "test");
+ t.equal(updated_model.search, "test", "search field is updated to 'test'.");
+
+ const cleared_model = app.update('SEARCH', updated_model, "");
+ t.equal(cleared_model.search, "", "search field is cleared.");
+
+ t.end();
+});
+
+test('10.2 Search > render_main should filter todos by search term', function (t) {
+ const model = {
+ todos: [
+ { id: 0, title: "Buy milk", done: false },
+ { id: 1, title: "Buy eggs", done: false },
+ { id: 2, title: "Go to gym", done: false }
+ ],
+ hash: '#/',
+ search: ""
+ };
+
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 3, "no search: 3 items");
+ elmish.empty(document.getElementById(id));
+
+ model.search = "buy";
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 2, "search 'buy': 2 items");
+ elmish.empty(document.getElementById(id));
+
+ model.search = "gym";
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 1, "search 'gym': 1 item");
+ elmish.empty(document.getElementById(id));
+
+ model.search = "nonexistent";
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 0, "search 'nonexistent': 0 items");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});
+
+test('10.3 Search > search should be case-insensitive', function (t) {
+ const model = {
+ todos: [
+ { id: 0, title: "Buy Milk", done: false },
+ { id: 1, title: "buy eggs", done: false }
+ ],
+ hash: '#/',
+ search: ""
+ };
+
+ model.search = "BUY";
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 2, "search 'BUY' matches both items");
+ elmish.empty(document.getElementById(id));
+
+ model.search = "MILK";
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 1, "search 'MILK' matches 'Buy Milk'");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});
+
+test('10.4 Search > search should work with route filtering (All + search)', function (t) {
+ const model = {
+ todos: [
+ { id: 0, title: "Buy milk", done: false },
+ { id: 1, title: "Buy eggs", done: true },
+ { id: 2, title: "Go to gym", done: false }
+ ],
+ hash: '#/',
+ search: "buy"
+ };
+
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 2, "All + search 'buy': 2 items");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});
+
+test('10.5 Search > search should work with route filtering (Active + search)', function (t) {
+ const model = {
+ todos: [
+ { id: 0, title: "Buy milk", done: false },
+ { id: 1, title: "Buy eggs", done: true },
+ { id: 2, title: "Go to gym", done: false }
+ ],
+ hash: '#/active',
+ search: "buy"
+ };
+
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 1, "Active + search 'buy': 1 item (only 'Buy milk' is active)");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});
+
+test('10.6 Search > search should work with route filtering (Completed + search)', function (t) {
+ const model = {
+ todos: [
+ { id: 0, title: "Buy milk", done: false },
+ { id: 1, title: "Buy eggs", done: true },
+ { id: 2, title: "Go to gym", done: false }
+ ],
+ hash: '#/completed',
+ search: "buy"
+ };
+
+ document.getElementById(id).appendChild(app.render_main(model, mock_signal));
+ t.equal(document.querySelectorAll('.view').length, 1, "Completed + search 'buy': 1 item (only 'Buy eggs' is completed)");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});
+
+test('10.7 Search > render_footer count should reflect filtered results', function (t) {
+ const model = {
+ todos: [
+ { id: 0, title: "Buy milk", done: false },
+ { id: 1, title: "Buy eggs", done: true },
+ { id: 2, title: "Go to gym", done: false }
+ ],
+ hash: '#/',
+ search: ""
+ };
+
+ document.getElementById(id).appendChild(app.render_footer(model));
+ let count = parseInt(document.getElementById('count').textContent, 10);
+ t.equal(count, 2, "no search: 2 items left");
+ elmish.empty(document.getElementById(id));
+
+ model.search = "buy";
+ document.getElementById(id).appendChild(app.render_footer(model));
+ count = parseInt(document.getElementById('count').textContent, 10);
+ t.equal(count, 1, "search 'buy': 1 item left ('Buy milk' is active)");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});
+
+test('10.8 Search > render_footer clear completed should reflect filtered results', function (t) {
+ const model = {
+ todos: [
+ { id: 0, title: "Buy milk", done: false },
+ { id: 1, title: "Buy eggs", done: true },
+ { id: 2, title: "Go to gym", done: true }
+ ],
+ hash: '#/',
+ search: ""
+ };
+
+ document.getElementById(id).appendChild(app.render_footer(model));
+ let completed_count = parseInt(document.getElementById('completed-count').textContent, 10);
+ t.equal(completed_count, 2, "no search: 2 completed items");
+ elmish.empty(document.getElementById(id));
+
+ model.search = "gym";
+ document.getElementById(id).appendChild(app.render_footer(model));
+ completed_count = parseInt(document.getElementById('completed-count').textContent, 10);
+ t.equal(completed_count, 1, "search 'gym': 1 completed item in results");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});
+
+test('10.9 Search > view should render search input', function (t) {
+ const model = {
+ todos: [],
+ hash: '#/',
+ search: ""
+ };
+
+ document.getElementById(id).appendChild(app.view(model));
+ const search_input = document.getElementById('search-todo');
+ t.notEqual(search_input, null, "search input exists");
+ t.equal(search_input.getAttribute('placeholder'), 'Search todos...', "search input has correct placeholder");
+ elmish.empty(document.getElementById(id));
+
+ model.search = "test";
+ document.getElementById(id).appendChild(app.view(model));
+ const search_input_with_value = document.getElementById('search-todo');
+ t.equal(search_input_with_value.value, 'test', "search input displays search value");
+ elmish.empty(document.getElementById(id));
+
+ t.end();
+});