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
completeproperty 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
Todotype with DefineMap. - Define a
Todo.Listtype along with anactiveandcompleteproperty 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
completedto the<li>’sclassNameif the<li>’s todo is complete. - Use
checked:bindto two-way bind the checkbox’scheckedproperty to its todo’scompleteproperty. - Use
{{todo.name}}to insert the value todo’snameas the content of the<label>andvalueof 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
idas the identity property of theTodotype.
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
TodoandTodo.Listtypes 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
getListis a Promise that resolves to aTodo.Listwith 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
destroyingto the<li>’sclassNameif 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
enterEventtodomEvents.
Update the JavaScript tab to define a <todo-create> component with the following:
- A view that:
- Updates the
todo’snamewith the<input>’svalueusingvalue:bind. - Calls
createTodowhen theenterkey is pressed usingon:enter.
- Updates the
- A ViewModel with:
- A
todoproperty that holds a newTodoinstance. - A
createTodomethod that saves theTodoinstance 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 itstodosproperty to the resolved value oftodosPromiseusingtodos: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
nameand save that change to the server.
the solution
Update the <todo-list>'s view to:
- Use the
isEditingmethod to addeditingto theclassNameof the<li>being edited. - When the checkbox changes, update the todo on the server with save,
- Call
editwith the currenttodo. - Set up the edit input to:
- Two-way bind its value to the current todo’s
nameusingvalue:bind. - Call
updateNamewhen the enter key is pressed usingon:enter. - Focus the input when
isEditingis true using the special [can-util/dom/attr/attr.special.focused] attribute. - Call
cancelEditif 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
editingproperty of typeTodothat stores which todo is being edited. - A
backupNameproperty that stores the todo’s name before being edited. - An
editmethod that sets up the editing state. - A
cancelEditmethod that undos the editing state if in the editing state. - An
updateNamemethod 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
hrefs 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
ViewModelaccess to the observable route.data by:- Defining a
routeDataproperty whose value is route.data. - Create a pretty routing rule so if the URL looks like
"#!active", thefilterproperty ofroute.datawill be set to"active"with route.register. - Initialize route.data's values with route.start().
- Defining a
- Change
todosPromiseto check ifthis.routeData.filteris: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-allbutton change all todos to complete or incomplete. - Make the
clear-completedbutton delete all complete todos.
the solution
Add the following to the Todo.List model:
- An
allCompleteproperty that returnstrueif every todo is complete. - A
savingproperty that returns todos that are being saved using isSaving. - An
updateCompleteTomethod that updates every todo’scompleteproperty to the specified value and updates the compute on the server with save. - A
destroyCompletemethod that deletes every complete todo with destroy.
Update <todo-mvc>'s view to:
- Cross bind the
toggle-all’scheckedproperty to theViewModel’sallCheckedproperty. - Disable the
toggle-allbutton while any todo is saving. - Call the
Todo.List’sdestroyCompletemethod when theclear-completedbutton is clicked on.
Update <todo-mvc>'s ViewModel to include:
- A
todosListproperty that gets its value from thetodosPromiseusing an asynchronous getter. - An
allCheckedproperty that returnstrueif every todo is complete. The property can also be set totrueorfalseand 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.