Skip to content

Commit e34b271

Browse files
committed
solo-coder-phase4: 16887dade-8543-4b6c-a294-33d8ece545ae / alpha / round1 (.115908579437896:b436bd7da03543b9e246fd6f739b8de0_69e381d887d9eec1bd0494a9.69e381da87d9eec1bd0494ad.f5fddafe86f645e693676fa9:Trae CN.T(2026/4/18 21:06:34))
1 parent 3919f37 commit e34b271

File tree

2 files changed

+272
-17
lines changed

2 files changed

+272
-17
lines changed

lib/todo-app.js

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,25 @@ if (typeof require !== 'undefined' && this.window !== this) {
55
route, section, span, strong, text, ul } = require('./elmish.js');
66
}
77

8-
var initial_model = {
8+
function set_search_state(model, value) {
9+
Object.defineProperty(model, 'search', {
10+
value: value || '',
11+
writable: true,
12+
configurable: true,
13+
enumerable: false
14+
});
15+
return model;
16+
}
17+
18+
function clone_model(model) {
19+
var cloned = JSON.parse(JSON.stringify(model));
20+
return set_search_state(cloned, model && model.search);
21+
}
22+
23+
var initial_model = set_search_state({
924
todos: [],
1025
hash: "#/"
11-
}
26+
}, "")
1227

1328
/**
1429
* `update` transforms the `model` based on the `action`.
@@ -18,7 +33,7 @@ var initial_model = {
1833
* @return {Object} new_model - the transformed model.
1934
*/
2035
function update(action, model, data) {
21-
var new_model = JSON.parse(JSON.stringify(model)) // "clone" the model
36+
var new_model = clone_model(model) // "clone" the model
2237

2338
switch(action) {
2439
case 'ADD':
@@ -103,6 +118,9 @@ function update(action, model, data) {
103118
new_model.hash = // (window && window.location && window.location.hash) ?
104119
window.location.hash // : '#/';
105120
break;
121+
case 'SEARCH':
122+
set_search_state(new_model, data);
123+
break;
106124
default: // if action unrecognised or undefined,
107125
return model; // return model unmodified
108126
} // see: https://softwareengineering.stackexchange.com/a/201786/211301
@@ -187,6 +205,12 @@ function render_main (model, signal) {
187205
return item;
188206
}
189207
})
208+
.filter(function (item) {
209+
if (!model.search || model.search.length === 0) {
210+
return true;
211+
}
212+
return item.title.toLowerCase().indexOf(model.search.toLowerCase()) !== -1;
213+
})
190214
.map(function (item) {
191215
return render_item(item, model, signal)
192216
}) : null
@@ -208,27 +232,43 @@ function render_main (model, signal) {
208232
*/
209233
function render_footer (model, signal) {
210234

211-
// count how many "active" (not yet done) items by filtering done === false:
212-
var done = (model.todos && model.todos.length > 0) ?
235+
var filtered_todos = (model.todos && model.todos.length > 0) ?
236+
model.todos
237+
.filter(function (item) {
238+
switch(model.hash) {
239+
case '#/active':
240+
return !item.done;
241+
case '#/completed':
242+
return item.done;
243+
default:
244+
return item;
245+
}
246+
})
247+
.filter(function (item) {
248+
if (!model.search || model.search.length === 0) {
249+
return true;
250+
}
251+
return item.title.toLowerCase().indexOf(model.search.toLowerCase()) !== -1;
252+
}) : [];
253+
254+
var total_done = (model.todos && model.todos.length > 0) ?
213255
model.todos.filter( function (i) { return i.done; }).length : 0;
214-
var count = (model.todos && model.todos.length > 0) ?
256+
var total_count = (model.todos && model.todos.length > 0) ?
215257
model.todos.filter( function (i) { return !i.done; }).length : 0;
216258

217-
// Requirement #1 - No Todos, should hide #footer and #main
218-
var display = (count > 0 || done > 0) ? "block" : "none";
259+
var display = (total_count > 0 || total_done > 0) ? "block" : "none";
260+
261+
var filtered_done = filtered_todos.filter(function (i) { return i.done; }).length;
262+
var filtered_count = filtered_todos.filter(function (i) { return !i.done; }).length;
219263

220-
// number of completed items:
221-
var done = (model.todos && model.todos.length > 0) ?
222-
(model.todos.length - count) : 0;
223-
var display_clear = (done > 0) ? "block;" : "none;";
264+
var display_clear = (filtered_done > 0) ? "block;" : "none;";
224265

225-
// pluarisation of number of items:
226-
var left = (" item" + ( count > 1 || count === 0 ? 's' : '') + " left");
266+
var left = (" item" + ( filtered_count > 1 || filtered_count === 0 ? 's' : '') + " left");
227267

228268
return (
229269
footer(["class=footer", "id=footer", "style=display:" + display], [
230270
span(["class=todo-count", "id=count"], [
231-
strong(count),
271+
strong(filtered_count),
232272
text(left)
233273
]),
234274
ul(["class=filters"], [
@@ -260,7 +300,7 @@ function render_footer (model, signal) {
260300
[
261301
text("Clear completed ["),
262302
span(["id=completed-count"], [
263-
text(done)
303+
text(filtered_done)
264304
]),
265305
text("]")
266306
]
@@ -293,7 +333,13 @@ function view (model, signal) {
293333
"class=new-todo",
294334
"placeholder=What needs to be done?",
295335
"autofocus"
296-
], []) // <input> is "self-closing"
336+
], []), // <input> is "self-closing"
337+
input([
338+
"id=search-todo",
339+
"class=search-todo",
340+
"placeholder=Search todos...",
341+
model.search ? "value=" + model.search : ""
342+
], []) // search input
297343
]), // </header>
298344
render_main(model, signal),
299345
render_footer(model, signal)
@@ -311,6 +357,8 @@ function subscriptions (signal) {
311357
var ENTER_KEY = 13; // add a new todo item when [Enter] key is pressed
312358
var ESCAPE_KEY = 27; // used for "escaping" when editing a Todo item
313359

360+
signal('SEARCH', '')();
361+
314362
document.addEventListener('keyup', function handler (e) {
315363
// console.log('e.keyCode:', e.keyCode, '| key:', e.key);
316364

@@ -334,6 +382,12 @@ function subscriptions (signal) {
334382
}
335383
});
336384

385+
document.addEventListener('input', function handler (e) {
386+
if (e.target && e.target.id === 'search-todo') {
387+
signal('SEARCH', e.target.value)();
388+
}
389+
});
390+
337391
window.onhashchange = function route () {
338392
signal('ROUTE')();
339393
}

test/todo-app.test.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ test('`model` (Object) has desired keys', function (t) {
1111
const keys = Object.keys(app.model);
1212
t.deepEqual(keys, ['todos', 'hash'], "`todos` and `hash` keys are present.");
1313
t.true(Array.isArray(app.model.todos), "model.todos is an Array")
14+
t.equal(app.model.search, '', "model.search defaults to empty string");
1415
t.end();
1516
});
1617

@@ -678,3 +679,203 @@ test('9. Routing > should allow me to display active/completed/all items',
678679
t.end();
679680
});
680681

682+
test('10. Search > model should have search field', function (t) {
683+
t.equal(typeof app.model.search, 'string', "`search` field is available on model.");
684+
t.equal(app.model.search, '', "initial search value is empty string.");
685+
t.end();
686+
});
687+
688+
test('10.1 Search > update SEARCH action should set search field', function (t) {
689+
const model = { todos: [], hash: '#/' };
690+
t.equal(model.search, undefined, "search is not persisted on plain stored models.");
691+
692+
const updated_model = app.update('SEARCH', model, "test");
693+
t.equal(updated_model.search, "test", "search field is updated to 'test'.");
694+
695+
const cleared_model = app.update('SEARCH', updated_model, "");
696+
t.equal(cleared_model.search, "", "search field is cleared.");
697+
698+
t.end();
699+
});
700+
701+
test('10.2 Search > render_main should filter todos by search term', function (t) {
702+
const model = {
703+
todos: [
704+
{ id: 0, title: "Buy milk", done: false },
705+
{ id: 1, title: "Buy eggs", done: false },
706+
{ id: 2, title: "Go to gym", done: false }
707+
],
708+
hash: '#/',
709+
search: ""
710+
};
711+
712+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
713+
t.equal(document.querySelectorAll('.view').length, 3, "no search: 3 items");
714+
elmish.empty(document.getElementById(id));
715+
716+
model.search = "buy";
717+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
718+
t.equal(document.querySelectorAll('.view').length, 2, "search 'buy': 2 items");
719+
elmish.empty(document.getElementById(id));
720+
721+
model.search = "gym";
722+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
723+
t.equal(document.querySelectorAll('.view').length, 1, "search 'gym': 1 item");
724+
elmish.empty(document.getElementById(id));
725+
726+
model.search = "nonexistent";
727+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
728+
t.equal(document.querySelectorAll('.view').length, 0, "search 'nonexistent': 0 items");
729+
elmish.empty(document.getElementById(id));
730+
731+
t.end();
732+
});
733+
734+
test('10.3 Search > search should be case-insensitive', function (t) {
735+
const model = {
736+
todos: [
737+
{ id: 0, title: "Buy Milk", done: false },
738+
{ id: 1, title: "buy eggs", done: false }
739+
],
740+
hash: '#/',
741+
search: ""
742+
};
743+
744+
model.search = "BUY";
745+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
746+
t.equal(document.querySelectorAll('.view').length, 2, "search 'BUY' matches both items");
747+
elmish.empty(document.getElementById(id));
748+
749+
model.search = "MILK";
750+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
751+
t.equal(document.querySelectorAll('.view').length, 1, "search 'MILK' matches 'Buy Milk'");
752+
elmish.empty(document.getElementById(id));
753+
754+
t.end();
755+
});
756+
757+
test('10.4 Search > search should work with route filtering (All + search)', function (t) {
758+
const model = {
759+
todos: [
760+
{ id: 0, title: "Buy milk", done: false },
761+
{ id: 1, title: "Buy eggs", done: true },
762+
{ id: 2, title: "Go to gym", done: false }
763+
],
764+
hash: '#/',
765+
search: "buy"
766+
};
767+
768+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
769+
t.equal(document.querySelectorAll('.view').length, 2, "All + search 'buy': 2 items");
770+
elmish.empty(document.getElementById(id));
771+
772+
t.end();
773+
});
774+
775+
test('10.5 Search > search should work with route filtering (Active + search)', function (t) {
776+
const model = {
777+
todos: [
778+
{ id: 0, title: "Buy milk", done: false },
779+
{ id: 1, title: "Buy eggs", done: true },
780+
{ id: 2, title: "Go to gym", done: false }
781+
],
782+
hash: '#/active',
783+
search: "buy"
784+
};
785+
786+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
787+
t.equal(document.querySelectorAll('.view').length, 1, "Active + search 'buy': 1 item (only 'Buy milk' is active)");
788+
elmish.empty(document.getElementById(id));
789+
790+
t.end();
791+
});
792+
793+
test('10.6 Search > search should work with route filtering (Completed + search)', function (t) {
794+
const model = {
795+
todos: [
796+
{ id: 0, title: "Buy milk", done: false },
797+
{ id: 1, title: "Buy eggs", done: true },
798+
{ id: 2, title: "Go to gym", done: false }
799+
],
800+
hash: '#/completed',
801+
search: "buy"
802+
};
803+
804+
document.getElementById(id).appendChild(app.render_main(model, mock_signal));
805+
t.equal(document.querySelectorAll('.view').length, 1, "Completed + search 'buy': 1 item (only 'Buy eggs' is completed)");
806+
elmish.empty(document.getElementById(id));
807+
808+
t.end();
809+
});
810+
811+
test('10.7 Search > render_footer count should reflect filtered results', function (t) {
812+
const model = {
813+
todos: [
814+
{ id: 0, title: "Buy milk", done: false },
815+
{ id: 1, title: "Buy eggs", done: true },
816+
{ id: 2, title: "Go to gym", done: false }
817+
],
818+
hash: '#/',
819+
search: ""
820+
};
821+
822+
document.getElementById(id).appendChild(app.render_footer(model));
823+
let count = parseInt(document.getElementById('count').textContent, 10);
824+
t.equal(count, 2, "no search: 2 items left");
825+
elmish.empty(document.getElementById(id));
826+
827+
model.search = "buy";
828+
document.getElementById(id).appendChild(app.render_footer(model));
829+
count = parseInt(document.getElementById('count').textContent, 10);
830+
t.equal(count, 1, "search 'buy': 1 item left ('Buy milk' is active)");
831+
elmish.empty(document.getElementById(id));
832+
833+
t.end();
834+
});
835+
836+
test('10.8 Search > render_footer clear completed should reflect filtered results', function (t) {
837+
const model = {
838+
todos: [
839+
{ id: 0, title: "Buy milk", done: false },
840+
{ id: 1, title: "Buy eggs", done: true },
841+
{ id: 2, title: "Go to gym", done: true }
842+
],
843+
hash: '#/',
844+
search: ""
845+
};
846+
847+
document.getElementById(id).appendChild(app.render_footer(model));
848+
let completed_count = parseInt(document.getElementById('completed-count').textContent, 10);
849+
t.equal(completed_count, 2, "no search: 2 completed items");
850+
elmish.empty(document.getElementById(id));
851+
852+
model.search = "gym";
853+
document.getElementById(id).appendChild(app.render_footer(model));
854+
completed_count = parseInt(document.getElementById('completed-count').textContent, 10);
855+
t.equal(completed_count, 1, "search 'gym': 1 completed item in results");
856+
elmish.empty(document.getElementById(id));
857+
858+
t.end();
859+
});
860+
861+
test('10.9 Search > view should render search input', function (t) {
862+
const model = {
863+
todos: [],
864+
hash: '#/',
865+
search: ""
866+
};
867+
868+
document.getElementById(id).appendChild(app.view(model));
869+
const search_input = document.getElementById('search-todo');
870+
t.notEqual(search_input, null, "search input exists");
871+
t.equal(search_input.getAttribute('placeholder'), 'Search todos...', "search input has correct placeholder");
872+
elmish.empty(document.getElementById(id));
873+
874+
model.search = "test";
875+
document.getElementById(id).appendChild(app.view(model));
876+
const search_input_with_value = document.getElementById('search-todo');
877+
t.equal(search_input_with_value.value, 'test', "search input displays search value");
878+
elmish.empty(document.getElementById(id));
879+
880+
t.end();
881+
});

0 commit comments

Comments
 (0)