From e34b271f9aa84c6138bcb0ed8b7a82b98f2d2d9a Mon Sep 17 00:00:00 2001 From: Caroline Date: Sat, 18 Apr 2026 22:09:24 +0800 Subject: [PATCH] solo-coder-phase4: 16887dade-8543-4b6c-a294-33d8ece545ae / alpha / round1 (.115908579437896:b436bd7da03543b9e246fd6f739b8de0_69e381d887d9eec1bd0494a9.69e381da87d9eec1bd0494ad.f5fddafe86f645e693676fa9:Trae CN.T(2026/4/18 21:06:34)) --- lib/todo-app.js | 88 ++++++++++++++---- test/todo-app.test.js | 201 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 17 deletions(-) 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(); +});