Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 71 additions & 17 deletions lib/todo-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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':
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"], [
Expand Down Expand Up @@ -260,7 +300,7 @@ function render_footer (model, signal) {
[
text("Clear completed ["),
span(["id=completed-count"], [
text(done)
text(filtered_done)
]),
text("]")
]
Expand Down Expand Up @@ -293,7 +333,13 @@ function view (model, signal) {
"class=new-todo",
"placeholder=What needs to be done?",
"autofocus"
], []) // <input> is "self-closing"
], []), // <input> is "self-closing"
input([
"id=search-todo",
"class=search-todo",
"placeholder=Search todos...",
model.search ? "value=" + model.search : ""
], []) // search input
]), // </header>
render_main(model, signal),
render_footer(model, signal)
Expand All @@ -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);

Expand All @@ -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')();
}
Expand Down
201 changes: 201 additions & 0 deletions test/todo-app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});