Skip to content
Closed
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
98 changes: 72 additions & 26 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 @@ -162,36 +180,49 @@ function render_item (item, model, signal) {
* @return {Object} <section> DOM Tree which containing the todo list <ul>, 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
) // </ul>
]) // </section>
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
)
])
)
}

Expand Down Expand Up @@ -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
Expand All @@ -293,7 +325,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...",
"value=" + search_value
], []) // search input
]), // </header>
render_main(model, signal),
render_footer(model, signal)
Expand All @@ -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);

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