TodoMVC Guide
This guide will walk you through building a slightly modified version of TodoMVC with CanJS’s Core libraries and can-fixture. It takes about 1 hour to complete.
Setup
The easiest way to get started is to fork the following CodePen by clicking the CodePen button on the top right:
See the Pen CanJS 5.0 - TodoMVC Start by Bitovi (@bitovi) on CodePen.
The CodePen starts with the static HTML and CSS a designer might turn over to a JS developer. We will be adding all the JavaScript functionality.
The CodePen also imports core.mjs, which is a script that includes all of CanJS's core and infrastructure modules as named exports.
Read Setting Up CanJS for instructions on alternate CanJS setups.
Define and use the main component
the problem
In this section, we will define a custom <todo-mvc>
element and use it
in the page's HTML.
the solution
Replace the content of the HTML tab with the <todo-mvc>
element:
<todo-mvc></todo-mvc>
Update the JavaScript
tab to define the <todo-mvc>
element by:
- Extending Component with a tag that matches the custom element we are defining.
- Setting the view to the html that should be displayed within
the
<todo-mvc>
element. In this case it is the HTML that was originally in the page. - Instead of the hard-coded
<h1>Todos</h1>
title, we will read the title from theViewModel
. We'll do this by:
import { Component } from "//unpkg.com/can@5/core.mjs";
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<input id="new-todo"
placeholder="What needs to be done?"/>
</header>
<section id="main">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list">
<li class="todo">
<div class="view">
<input class="toggle" type="checkbox">
<label>Do the dishes</label>
<button class="destroy"></button>
</div>
<input class="edit" type="text" value="Do the dishes">
</li>
<li class="todo completed">
<div class="view">
<input class="toggle" type="checkbox">
<label>Mow the lawn</label>
<button class="destroy"></button>
</div>
<input class="edit" type="text" value="Mow the lawn">
</li>
<li class="todo editing">
<div class="view">
<input class="toggle" type="checkbox">
<label>Pick up dry cleaning</label>
<button class="destroy"></button>
</div>
<input class="edit" type="text" value="Pick up dry cleaning">
</li>
</ul>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>2</strong> items left
</span>
<ul id="filters">
<li>
<a href="#!" class="selected">All</a>
</li>
<li>
<a href="#!active">Active</a>
</li>
<li>
<a href="#!complete">Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed (1)
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"}
}
});
When complete, you should see the same content as before. Only now, it’s rendered with a live-bound stache template. The live binding means that when the template’s data is changed, it will update automatically. You can see this by entering the following in the console:
document.querySelector("todo-mvc").viewModel.appName = "My Todos";
Define the todos type and show the active and complete count
the problem
In this section, we will:
- Create a list of todos and show them.
- Show the number of active (
complete === true
) and complete todos. - Connect a todo’s
complete
property to a checkbox so that when we toggle the checkbox the number of active and complete todos changes.
the solution
In the JavaScript
tab:
- Define a
Todo
type with DefineMap. - Define a
Todo.List
type along with anactive
andcomplete
property with DefineList.
In <todo-mvc>
's ViewModel
:
- Create a list of todos and pass those to the template.
In <todo-mvc>
's view
:
- Use
{{#for(of)}}
to loop through every todo. - Add
completed
to the<li>
’sclassName
if the<li>
’s todo is complete. - Use
checked:bind
to two-way bind the checkbox’schecked
property to its todo’scomplete
property. - Use
{{todo.name}}
to insert the value todo’sname
as the content of the<label>
andvalue
of the text<input/>
. - Insert the active and complete number of todos.
import { Component, DefineMap, DefineList } from "//unpkg.com/can@5/core.mjs";
const Todo = DefineMap.extend("Todo",{
id: "number",
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend("TodoList",{
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
}
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<input id="new-todo"
placeholder="What needs to be done?"/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list">
{{# for(todo of this.todos) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete">
<label>{{ todo.name }}</label>
<button class="destroy"></button>
</div>
<input class="edit" type="text"
value="{{todo.name}}"/>
</li>
{{/ for }}
</ul>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todos.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="#!" class="selected">All</a>
</li>
<li>
<a href="#!active">Active</a>
</li>
<li>
<a href="#!complete">Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed ({{ this.todos.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
todos: {
default(){
return new Todo.List([
{ id: 5, name: "mow lawn", complete: false },
{ id: 6, name: "dishes", complete: true },
{ id: 7, name: "learn canjs", complete: false }
]);
}
}
}
});
When complete, you should be able to toggle the checkboxes and see the number of items left and the completed count change automatically. This is because can-stache is able to listen for changes in observables like can-define/map/map, can-define/list/list.
Get todos from the server
the problem
In this section, we will:
- Load todos from a RESTful service.
- Fake that RESTful service.
the solution
In the Todo
type:
- Specify
id
as the identity property of theTodo
type.
Update the JavaScript
tab to:
- Create a fake data store that is initialized with data for 3 todos with store.
- Trap AJAX requests to
"/api/todos"
and provide responses with the data from the fake data store with can-fixture. - Connect the
Todo
andTodo.List
types to the RESTful"/api/todos"
endpoint using can-realtime-rest-model. This allows you to load, create, update, and destroy todos on the server.
In <todo-mvc>
's ViewModel
:
- Use getList to load a list of all todos on the server. The result
of
getList
is a Promise that resolves to aTodo.List
with the todos returned from the fake data store. That Promise is available to the template asthis.todosPromise
.
In <todo-mvc>
's view
:
- Use
{{#for(todo of todosPromise.value)}}
to loop through the promise’s resolved value, which is the list of todos returned by the server. - Read the active and completed number of todos from the promise’s resolved value.
import { Component, DefineMap, DefineList, fixture, realtimeRestModel } from "//unpkg.com/can@5/core.mjs";
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend("TodoList",{
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
}
});
const todoStore = fixture.store([
{ name: "mow lawn", complete: false, id: 5 },
{ name: "dishes", complete: true, id: 6 },
{ name: "learn canjs", complete: false, id: 7 }
], Todo);
fixture("/api/todos", todoStore);
fixture.delay = 200;
realtimeRestModel({
url: "/api/todos",
Map: Todo,
List: Todo.List
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<input id="new-todo"
placeholder="What needs to be done?"/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list">
{{# for(todo of todosPromise) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete">
<label>{{ todo.name }}</label>
<button class="destroy"></button>
</div>
<input class="edit" type="text"
value="{{todo.name}}"/>
</li>
{{/ for }}
</ul>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="#!" class="selected">All</a>
</li>
<li>
<a href="#!active">Active</a>
</li>
<li>
<a href="#!complete">Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
get todosPromise() {
return Todo.getList({});
}
}
});
When complete, you’ll notice a 1 second delay before seeing the list of todos as they load from the fixtured data store.
Destroy todos
the problem
In this section, we will:
- Delete a todo on the server when its destroy button is clicked.
- Remove the todo from the page after it’s deleted.
the solution
Update <todo-mvc>
's view
to:
- Add
destroying
to the<li>
’sclassName
if the<li>
’s todo is being destroyed using isDestroying. - Call the
todo
’s destroy method when the<button>
is clicked usingon:click
.
import { Component, DefineMap, DefineList, fixture, realtimeRestModel } from "//unpkg.com/can@5/core.mjs";
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend("TodoList",{
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
}
});
const todoStore = fixture.store([
{ name: "mow lawn", complete: false, id: 5 },
{ name: "dishes", complete: true, id: 6 },
{ name: "learn canjs", complete: false, id: 7 }
], Todo);
fixture("/api/todos", todoStore);
fixture.delay = 200;
realtimeRestModel({
url: "/api/todos",
Map: Todo,
List: Todo.List
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<input id="new-todo"
placeholder="What needs to be done?"/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list">
{{# for(todo of todosPromise) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}
{{# if(todo.isDestroying()) }}destroying{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete">
<label>{{ todo.name }}</label>
<button class="destroy" on:click="todo.destroy()"></button>
</div>
<input class="edit" type="text"
value="{{todo.name}}"/>
</li>
{{/ for }}
</ul>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="#!" class="selected">All</a>
</li>
<li>
<a href="#!active">Active</a>
</li>
<li>
<a href="#!complete">Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
get todosPromise() {
return Todo.getList({});
}
}
});
When complete, you should be able to delete a todo by clicking its delete button. After clicking the todo, its name will turn red and italic. Once deleted, the todo will be automatically removed from the page.
The deleted todo is automatically removed from the page because can-realtime-rest-model adds the real-time behavior. The
real-time behavior automatically updates lists (like Todo.List
) when instances
are created, updated or destroyed.
Create todos
the problem
In this section, we will:
- Define a custom
<todo-create>
element that can create todos on the server. - Use that custom element.
the solution
The <todo-create>
component will respond to a user hitting the enter
key. The can-event-dom-enter event provides this functionality, but it
is an Ecosystem module. So to use the enter event we need to:
- Import enterEvent from
everything.mjs
(which includesenterEvent
) instead ofcore.mjs
. - Import domEvents (CanJS's global event registry).
- Add the
enterEvent
todomEvents
.
Update the JavaScript
tab to define a <todo-create>
component with the following:
- A view that:
- Updates the
todo
’sname
with the<input>
’svalue
usingvalue:bind
. - Calls
createTodo
when theenter
key is pressed usingon:enter
.
- Updates the
- A ViewModel with:
- A
todo
property that holds a newTodo
instance. - A
createTodo
method that saves theTodo
instance and replaces it with a new one once saved.
- A
Update <todo-mvc>
's view
to:
- Use the
<todo-create>
component.
This results in the following code:
import { Component, DefineMap, DefineList, fixture, realtimeRestModel,
domEvents, enterEvent } from "//unpkg.com/can@5/everything.mjs";
domEvents.addEvent(enterEvent);
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend({
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
}
});
const todoStore = fixture.store([
{ name: "mow lawn", complete: false, id: 5 },
{ name: "dishes", complete: true, id: 6 },
{ name: "learn canjs", complete: false, id: 7 }
], Todo);
fixture("/api/todos", todoStore);
fixture.delay = 200;
realtimeRestModel({
url: "/api/todos",
Map: Todo,
List: Todo.List
});
Component.extend({
tag: "todo-create",
view: `
<input id="new-todo"
placeholder="What needs to be done?"
value:bind="todo.name"
on:enter="createTodo()"/>
`,
ViewModel: {
todo: { Default: Todo },
createTodo: function() {
this.todo.save().then(function(){
this.todo = new Todo();
}.bind(this));
}
}
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<todo-create/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list">
{{# for(todo of todosPromise) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}
{{# if(todo.isDestroying()) }}destroying{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete">
<label>{{ todo.name }}</label>
<button class="destroy" on:click="todo.destroy()"></button>
</div>
<input class="edit" type="text"
value="{{todo.name}}"/>
</li>
{{/ for }}
</ul>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="#!" class="selected">All</a>
</li>
<li>
<a href="#!active">Active</a>
</li>
<li>
<a href="#!complete">Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
get todosPromise() {
return Todo.getList({});
}
}
});
When complete, you will be able to create a todo by typing the name of the todo and pressing
enter
. Notice that the new todo automatically appears in the list of todos. This
is also because can-realtime-rest-model automatically inserts newly created items into lists that they belong within.
List todos
the problem
In this section, we will:
- Define a custom element for showing a list of todos.
- Use that custom element by passing it the list of fetched todos.
the solution
Update the JavaScript
tab to:
- Define a
<todo-list>
component with:- A ViewModel that expects to be passes a list of
todos as
todos
. - A view that that loops through a list of
todos
(instead oftodosPromise.value
).
- A ViewModel that expects to be passes a list of
todos as
- In
<todo-mvc>
's view, create a<todo-list>
element and set itstodos
property to the resolved value oftodosPromise
usingtodos:from='this.todosPromise.value'
.
import { Component, DefineMap, DefineList, fixture, realtimeRestModel,
domEvents, enterEvent } from "//unpkg.com/can@5/everything.mjs";
domEvents.addEvent(enterEvent);
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend({
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
}
});
const todoStore = fixture.store([
{ name: "mow lawn", complete: false, id: 5 },
{ name: "dishes", complete: true, id: 6 },
{ name: "learn canjs", complete: false, id: 7 }
], Todo);
fixture("/api/todos", todoStore);
fixture.delay = 200;
realtimeRestModel({
url: "/api/todos",
Map: Todo,
List: Todo.List
});
Component.extend({
tag: "todo-create",
view: `
<input id="new-todo"
placeholder="What needs to be done?"
value:bind="todo.name"
on:enter="createTodo()"/>
`,
ViewModel: {
todo: { Default: Todo },
createTodo: function() {
this.todo.save().then(function(){
this.todo = new Todo();
}.bind(this));
}
}
});
Component.extend({
tag: "todo-list",
view: `
<ul id="todo-list">
{{# for(todo of this.todos) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}
{{# if(todo.isDestroying()) }}destroying{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete">
<label>{{ todo.name }}</label>
<button class="destroy" on:click="todo.destroy()"></button>
</div>
<input class="edit" type="text"
value="{{todo.name}}"/>
</li>
{{/ for }}
</ul>
`,
ViewModel: {
todos: Todo.List
}
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<todo-create/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<todo-list todos:from="this.todosPromise.value"/>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="#!" class="selected">All</a>
</li>
<li>
<a href="#!active">Active</a>
</li>
<li>
<a href="#!complete">Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
get todosPromise() {
return Todo.getList({});
}
}
});
When complete, everything should work the same. We didn’t add any new functionality, we just moved code around to make it more isolated, potentially reusable, and more maintainable.
Edit todos
the problem
In this section, we will:
- Make it possible to edit a todo’s
name
and save that change to the server.
the solution
Update the <todo-list>
's view to:
- Use the
isEditing
method to addediting
to theclassName
of the<li>
being edited. - When the checkbox changes, update the todo on the server with save,
- Call
edit
with the currenttodo
. - Set up the edit input to:
- Two-way bind its value to the current todo’s
name
usingvalue:bind
. - Call
updateName
when the enter key is pressed usingon:enter
. - Focus the input when
isEditing
is true using the special [can-util/dom/attr/attr.special.focused] attribute. - Call
cancelEdit
if the input element loses focus.
- Two-way bind its value to the current todo’s
Update the <todo-list>
's ViewModel to include the methods and properties needed to edit a todo’s name, including:
- An
editing
property of typeTodo
that stores which todo is being edited. - A
backupName
property that stores the todo’s name before being edited. - An
edit
method that sets up the editing state. - A
cancelEdit
method that undos the editing state if in the editing state. - An
updateName
method that updates the editing todo and saves it to the server.
import { Component, DefineMap, DefineList, fixture, realtimeRestModel,
domEvents, enterEvent } from "//unpkg.com/can@5/everything.mjs";
domEvents.addEvent(enterEvent);
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend({
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
}
});
const todoStore = fixture.store([
{ name: "mow lawn", complete: false, id: 5 },
{ name: "dishes", complete: true, id: 6 },
{ name: "learn canjs", complete: false, id: 7 }
], Todo);
fixture("/api/todos", todoStore);
fixture.delay = 200;
realtimeRestModel({
url: "/api/todos",
Map: Todo,
List: Todo.List
});
Component.extend({
tag: "todo-create",
view: `
<input id="new-todo"
placeholder="What needs to be done?"
value:bind="todo.name"
on:enter="createTodo()"/>
`,
ViewModel: {
todo: { Default: Todo },
createTodo: function() {
this.todo.save().then(function(){
this.todo = new Todo();
}.bind(this));
}
}
});
Component.extend({
tag: "todo-list",
view: `
<ul id="todo-list">
{{# for(todo of this.todos) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}
{{# if(todo.isDestroying()) }}destroying{{/ if }}
{{# if(this.isEditing(todo)) }}editing{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete" on:change="todo.save()">
<label on:dblclick="this.edit(todo)">{{ todo.name }}</label>
<button class="destroy" on:click="todo.destroy()"></button>
</div>
<input class="edit" type="text"
value:bind="todo.name"
on:enter="this.updateName()"
focused:from="this.isEditing(todo)"
on:blur="this.cancelEdit()"/>
</li>
{{/ for }}
</ul>
`,
ViewModel: {
todos: Todo.List,
editing: Todo,
backupName: "string",
isEditing: function(todo) {
return todo === this.editing;
},
edit: function(todo) {
this.backupName = todo.name;
this.editing = todo;
},
cancelEdit: function() {
if(this.editing) {
this.editing.name = this.backupName;
}
this.editing = null;
},
updateName: function() {
this.editing.save();
this.editing = null;
}
}
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<todo-create/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<todo-list todos:from="this.todosPromise.value"/>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="#!" class="selected">All</a>
</li>
<li>
<a href="#!active">Active</a>
</li>
<li>
<a href="#!complete">Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
get todosPromise() {
return Todo.getList({});
}
}
});
When complete, you should be able to edit a todo’s name.
Routing
the problem
In this section, we will:
- Make it possible to use the browser’s forwards and backwards buttons to change between showing all todos, only active todos, or only completed todos.
- Add links to change between showing all todos, only active todos, or only completed todos.
- Make those links bold when the site is currently showing that link.
the solution
Update the JavaScript
tab to:
- Import route.
Update <todo-mvc>
's view to:
- Set the page links
href
s to a URL that will set the desired properties on route.data when clicked using routeUrl(hashes). - Add
class='selected'
to the link if the current route matches the current properties on route.data using routeCurrent(hash).
Update <todo-mvc>
's ViewModel to:
- Provide the
ViewModel
access to the observable route.data by:- Defining a
routeData
property whose value is route.data. - Create a pretty routing rule so if the URL looks like
"#!active"
, thefilter
property ofroute.data
will be set to"active"
with route.register. - Initialize route.data's values with route.start().
- Defining a
- Change
todosPromise
to check ifthis.routeData.filter
is:falsey
- then return all todos."complete"
- then return all complete todos."incomplete"
- then return all incomplete todos.
import { Component, DefineMap, DefineList, fixture, realtimeRestModel, route,
domEvents, enterEvent } from "//unpkg.com/can@5/everything.mjs";
domEvents.addEvent(enterEvent);
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend({
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
}
});
const todoStore = fixture.store([
{ name: "mow lawn", complete: false, id: 5 },
{ name: "dishes", complete: true, id: 6 },
{ name: "learn canjs", complete: false, id: 7 }
], Todo);
fixture("/api/todos", todoStore);
fixture.delay = 200;
realtimeRestModel({
url: "/api/todos",
Map: Todo,
List: Todo.List
});
Component.extend({
tag: "todo-create",
view: `
<input id="new-todo"
placeholder="What needs to be done?"
value:bind="todo.name"
on:enter="createTodo()"/>
`,
ViewModel: {
todo: { Default: Todo },
createTodo: function() {
this.todo.save().then(function(){
this.todo = new Todo();
}.bind(this));
}
}
});
Component.extend({
tag: "todo-list",
view: `
<ul id="todo-list">
{{# for(todo of this.todos) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}
{{# if(todo.isDestroying()) }}destroying{{/ if }}
{{# if(this.isEditing(todo)) }}editing{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete" on:change="todo.save()">
<label on:dblclick="this.edit(todo)">{{ todo.name }}</label>
<button class="destroy" on:click="todo.destroy()"></button>
</div>
<input class="edit" type="text"
value:bind="todo.name"
on:enter="this.updateName()"
focused:from="this.isEditing(todo)"
on:blur="this.cancelEdit()"/>
</li>
{{/ for }}
</ul>
`,
ViewModel: {
todos: Todo.List,
editing: Todo,
backupName: "string",
isEditing: function(todo) {
return todo === this.editing;
},
edit: function(todo) {
this.backupName = todo.name;
this.editing = todo;
},
cancelEdit: function() {
if(this.editing) {
this.editing.name = this.backupName;
}
this.editing = null;
},
updateName: function() {
this.editing.save();
this.editing = null;
}
}
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<todo-create/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"/>
<label for="toggle-all">Mark all as complete</label>
<todo-list todos:from="this.todosPromise.value"/>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="{{routeUrl(filter=undefined)}}"
{{#routeCurrent(filter=undefined)}}class="selected"{{/routeCurrent}}>All</a>
</li>
<li>
<a href="{{routeUrl(filter='active')}}"
{{#routeCurrent(filter='active')}}class="selected"{{/routeCurrent}}>Active</a>
</li>
<li>
<a href="{{routeUrl(filter='complete')}}"
{{#routeCurrent(filter='complete')}}class="selected"{{/routeCurrent}}>Completed</a>
</li>
</ul>
<button id="clear-completed">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
routeData: {
default(){
route.register("{filter}");
route.start();
return route.data;
}
},
get todosPromise() {
if(!this.routeData.filter) {
return Todo.getList({});
} else {
return Todo.getList({filter: { complete: this.routeData.filter === "complete" }});
}
}
}
});
When complete, you should be able to click the All
, Active
, and Completed
links and
see the right data.
Toggle all and clear completed
the problem
In this section, we will:
- Make the
toggle-all
button change all todos to complete or incomplete. - Make the
clear-completed
button delete all complete todos.
the solution
Add the following to the Todo.List
model:
- An
allComplete
property that returnstrue
if every todo is complete. - A
saving
property that returns todos that are being saved using isSaving. - An
updateCompleteTo
method that updates every todo’scomplete
property to the specified value and updates the compute on the server with save. - A
destroyComplete
method that deletes every complete todo with destroy.
Update <todo-mvc>
's view to:
- Cross bind the
toggle-all
’schecked
property to theViewModel
’sallChecked
property. - Disable the
toggle-all
button while any todo is saving. - Call the
Todo.List
’sdestroyComplete
method when theclear-completed
button is clicked on.
Update <todo-mvc>
's ViewModel to include:
- A
todosList
property that gets its value from thetodosPromise
using an asynchronous getter. - An
allChecked
property that returnstrue
if every todo is complete. The property can also be set totrue
orfalse
and it will set every todo to that value.
import { Component, DefineMap, DefineList, fixture, realtimeRestModel, route,
domEvents, enterEvent } from "//unpkg.com/can@5/everything.mjs";
domEvents.addEvent(enterEvent);
const Todo = DefineMap.extend("Todo",{
id: {type: "number", identity: true},
name: "string",
complete: { type: "boolean", default: false }
});
Todo.List = DefineList.extend({
"#": Todo,
get active() {
return this.filter({ complete: false });
},
get complete() {
return this.filter({ complete: true });
},
get allComplete() {
return this.length === this.complete.length;
},
get saving() {
return this.filter(function(todo) {
return todo.isSaving();
});
},
updateCompleteTo: function(value) {
this.forEach(function(todo) {
todo.complete = value;
todo.save();
});
},
destroyComplete: function() {
this.complete.forEach(function(todo) {
todo.destroy();
});
}
});
const todoStore = fixture.store([
{ name: "mow lawn", complete: false, id: 5 },
{ name: "dishes", complete: true, id: 6 },
{ name: "learn canjs", complete: false, id: 7 }
], Todo);
fixture("/api/todos", todoStore);
fixture.delay = 200;
realtimeRestModel({
url: "/api/todos",
Map: Todo,
List: Todo.List
});
Component.extend({
tag: "todo-create",
view: `
<input id="new-todo"
placeholder="What needs to be done?"
value:bind="todo.name"
on:enter="createTodo()"/>
`,
ViewModel: {
todo: { Default: Todo },
createTodo: function() {
this.todo.save().then(function(){
this.todo = new Todo();
}.bind(this));
}
}
});
Component.extend({
tag: "todo-list",
view: `
<ul id="todo-list">
{{# for(todo of this.todos) }}
<li class="todo {{# if(todo.complete) }}completed{{/ if }}
{{# if(todo.isDestroying()) }}destroying{{/ if }}
{{# if(this.isEditing(todo)) }}editing{{/ if }}">
<div class="view">
<input class="toggle" type="checkbox"
checked:bind="todo.complete" on:change="todo.save()">
<label on:dblclick="this.edit(todo)">{{ todo.name }}</label>
<button class="destroy" on:click="todo.destroy()"></button>
</div>
<input class="edit" type="text"
value:bind="todo.name"
on:enter="this.updateName()"
focused:from="this.isEditing(todo)"
on:blur="this.cancelEdit()"/>
</li>
{{/ for }}
</ul>
`,
ViewModel: {
todos: Todo.List,
editing: Todo,
backupName: "string",
isEditing: function(todo) {
return todo === this.editing;
},
edit: function(todo) {
this.backupName = todo.name;
this.editing = todo;
},
cancelEdit: function() {
if(this.editing) {
this.editing.name = this.backupName;
}
this.editing = null;
},
updateName: function() {
this.editing.save();
this.editing = null;
}
}
});
Component.extend({
tag: "todo-mvc",
view: `
<section id="todoapp">
<header id="header">
<h1>{{ this.appName }}</h1>
<todo-create/>
</header>
<section id="main" class="">
<input id="toggle-all" type="checkbox"
checked:bind="allChecked"
disabled:from="this.todosList.saving.length"/>
<label for="toggle-all">Mark all as complete</label>
<todo-list todos:from="this.todosPromise.value"/>
</section>
<footer id="footer" class="">
<span id="todo-count">
<strong>{{ this.todosPromise.value.active.length }}</strong> items left
</span>
<ul id="filters">
<li>
<a href="{{routeUrl(filter=undefined)}}"
{{#routeCurrent(filter=undefined)}}class="selected"{{/routeCurrent}}>All</a>
</li>
<li>
<a href="{{routeUrl(filter='active')}}"
{{#routeCurrent(filter='active')}}class="selected"{{/routeCurrent}}>Active</a>
</li>
<li>
<a href="{{routeUrl(filter='complete')}}"
{{#routeCurrent(filter='complete')}}class="selected"{{/routeCurrent}}>Completed</a>
</li>
</ul>
<button id="clear-completed"
on:click="this.todosList.destroyComplete()">
Clear completed ({{ this.todosPromise.value.complete.length }})
</button>
</footer>
</section>
`,
ViewModel: {
appName: {default: "TodoMVC"},
routeData: {
default(){
route.register("{filter}");
route.start();
return route.data;
}
},
get todosPromise() {
if(!this.routeData.filter) {
return Todo.getList({});
} else {
return Todo.getList({filter: { complete: this.routeData.filter === "complete" }});
}
},
todosList: {
get: function(lastSet, resolve) {
this.todosPromise.then(resolve);
}
},
get allChecked() {
return this.todosList && this.todosList.allComplete;
},
set allChecked(newVal) {
this.todosList && this.todosList.updateCompleteTo(newVal);
}
}
});
When complete, you should be able to toggle all todos complete
state and
delete the completed todos. You should also have a really good idea how CanJS works!
Result
When finished, you should see something like the following CodePen:
See the Pen CanJS 5.0 - TodoMVC End by Bitovi (@bitovi) on CodePen.