Owl - Tutorial - Todoapp - MD at Master Odoo - Owl
Owl - Tutorial - Todoapp - MD at Master Odoo - Owl
master
1 contributor
Raw Blame
This project will be an opportunity to discover and learn some important Owl concepts,
such as components, store, and how to organize an application.
Content
1. Setting up the project
2. Adding a first component
3. Displaying a list of tasks
4. Layout: some basic css
5. Extracting Task as a subcomponent
6. Adding tasks (part 1)
7. Adding tasks (part 2)
8. Toggling tasks
9. Deleting tasks
10. Using a store
11. Saving tasks in local storage
12. Filtering tasks
13. The Final Touch
14. Final Code
todoapp/
index.html
app.css
app.js
owl.js
The entry point for this application is the file index.html , which should have the
following content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>OWL Todo App</title>
<link rel="stylesheet" href="app.css" />
<script src="owl.js"></script>
<script src="app.js"></script>
</head>
<body></body>
</html>
Then, app.css can be left empty for now. It will be useful later on to style our
application. app.js is where we will write all our code. For now, let's just put the
following code:
(function () {
console.log("hello owl", owl.__info__.version);
})();
Note that we put everything inside an immediately executed function to avoid leaking
anything to the global scope.
Finally, owl.js should be the last version downloaded from the Owl repository (you
can use owl.min.js if you prefer).
Now, the project should be ready. Loading the index.html file into a browser should
show an empty page, with the title Owl Todo App , and it should log a message such as
hello owl 1.0.0 in the console.
// Owl Components
class App extends Component {
static template = xml`<div>todo app</div>`;
}
// Setup code
function setup() {
const app = new App();
app.mount(document.body);
}
whenReady(setup);
The code is pretty simple, but let us explain the last line in more detail. The browser
tries to execute the javascript code in app.js as quickly as possible, and it could
happen that the DOM is not ready yet when we try to mount the App component. To
avoid this situation, we use the whenReady helper to delay the execution of the setup
function until the DOM is ready.
Note 1: in a larger project, we would split the code in multiple files, with components in
a sub folder, and a main file that would initialize the application. However, this is a very
small project, and we want to keep it as simple as possible.
Note 2: this tutorial uses the static class field syntax. This is not yet supported by all
browsers. Most real projects will transpile their code, so this is not a problem, but for
this tutorial, if you need the code to work on every browser, you will need to translate
each static keyword to an assignation to the class:
class App extends Component {}
App.template = xml`<div>todo app</div>`;
Note 3: writing inline templates with the xml helper is nice, but there is no syntax
highlighting, and this makes it very easy to have malformed xml. Some editors support
syntax highlighting for this situation. For example, VS Code has an addon Comment
tagged template , which, if installed, will properly display tagged templates:
Note 4: Large applications will probably want to be able to translate templates. Using
inline templates makes it slightly harder, since we need additional tooling to extract the
xml from the code, and to replace it with the translated values.
Now that we decided on the internal format of the state, let us add some demo data
and a template to the App component:
tasks = [
{
id: 1,
title: "buy milk",
isCompleted: true,
},
{
id: 2,
title: "clean house",
isCompleted: false,
},
];
}
The template contains a t-foreach loop to iterate through the tasks. It can find the
tasks list from the component, since the component is the rendering context. Note
that we use the id of each task as a t-key , which is very common. There are two css
classes: task-list and task , that we will use in the next section.
Finally, notice the use of the t-att-checked attribute: prefixing an attribute by t-att
makes it dynamic. Owl will evaluate the expression and set it as the value of the
attribute.
.task-list {
width: 300px;
margin: 50px auto;
background: aliceblue;
padding: 10px;
}
.task {
font-size: 18px;
color: #111111;
}
This is better. Now, let us add an extra feature: completed tasks should be styled a little
differently, to make it clearer that they are not as important. To do that, we will add a
dynamic css class on each task:
.task.done {
opacity: 0.7;
}
This Task component will display a task, but it cannot own the state of the task: a
piece of data should only have one owner. Doing otherwise is asking for trouble. So,
the Task component will get its data as a prop . This means that the data is still owned
by the App component, but can be used by the Task component (without modifying
it).
Since we are moving code around, it is a good opportunity to refactor the code a little
bit:
// -------------------------------------------------------------------------
// Task Component
// -------------------------------------------------------------------------
const TASK_TEMPLATE = xml /* xml */`
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
<input type="checkbox" t-att-checked="props.task.isCompleted"/>
<span><t t-esc="props.task.title"/></span>
</div>`;
// -------------------------------------------------------------------------
// App Component
// -------------------------------------------------------------------------
const APP_TEMPLATE = xml /* xml */`
<div class="task-list">
<t t-foreach="tasks" t-as="task" t-key="task.id">
<Task task="task"/>
</t>
</div>`;
tasks = [
...
];
}
// -------------------------------------------------------------------------
// Setup code
// -------------------------------------------------------------------------
function setup() {
owl.config.mode = "dev";
const app = new App();
app.mount(document.body);
}
whenReady(setup);
first, we have now a sub component Task , defined on top of the file,
whenever we define a sub component, it needs to be added to the static
components key of its parent, so Owl can get a reference to it,
the templates have been extracted out of the components, to make it easier to
differentiate the "view/template" code from the "script/behavior" code,
the Task component has a props key: this is only useful for validation purpose. It
says that each Task should be given exactly one prop, named task . If this is not
the case, Owl will throw an error. This is extremely useful when refactoring
components
finally, to activate the props validation, we need to set Owl's mode to dev . This is
done in the setup function. Note that this should be removed when an app is
used in a real production environment, since dev mode is slightly slower, due to
extra checks and validations.
<div class="todo-app">
<input placeholder="Enter a new task" t-on-keyup="addTask"/>
<div class="task-list">
<t t-foreach="tasks" t-as="task" t-key="task.id">
<Task task="task"/>
</t>
</div>
</div>
addTask(ev) {
// 13 is keycode for ENTER
if (ev.keyCode === 13) {
const title = ev.target.value.trim();
ev.target.value = "";
console.log('adding task', title);
// todo
}
}
.todo-app {
width: 300px;
margin: 50px auto;
background: aliceblue;
padding: 10px;
}
.task-list {
margin-top: 8px;
}
We now have a working input, which log to the console whenever the user adds a task.
Notice that when you load the page, the input is not focused. But adding tasks is a core
feature of a task list, so let us make it as fast as possible by focusing the input.
Since App is a component, it has a mounted lifecycle method that we can implement.
We will also need to get a reference to the input, by using the t-ref directive with the
useRef hook:
// on top of file:
const { useRef } = owl.hooks;
// in App
inputRef = useRef("add-input");
mounted() {
this.inputRef.el.focus();
}
nextId = 1;
tasks = [];
addTask(ev) {
// 13 is keycode for ENTER
if (ev.keyCode === 13) {
const title = ev.target.value.trim();
ev.target.value = "";
if (title) {
const newTask = {
id: this.nextId++,
title: title,
isCompleted: false,
};
this.tasks.push(newTask);
}
}
}
This almost works, but if you test it, you will notice that no new task is ever displayed
when the user press Enter . But if you add a debugger or a console.log statement,
you will see that the code is actually running as expected. The problem is that Owl has
no way of knowing that it needs to rerender the user interface. We can fix the issue by
making tasks reactive, with the useState hook:
8. Toggling tasks
If you tried to mark a task as completed, you may have noticed that the text did not
change in opacity. This is because there is no code to modify the isCompleted flag.
Now, this is an interesting situation: the task is displayed by the Task component, but
it is not the owner of its state, so it cannot modify it. Instead, we want to communicate
the request to toggle a task to the App component. Since App is a parent of Task , we
can trigger an event in Task and listen for it in App .
toggleTask() {
this.trigger('toggle-task', {id: this.props.task.id});
}
toggleTask(ev) {
const task = this.tasks.find(t => t.id === ev.detail.id);
task.isCompleted = !task.isCompleted;
}
9. Deleting tasks
Let us now add the possibility do delete tasks. To do that, we first need to add a trash
icon on each task, then we will proceed just like in the previous section.
.task {
font-size: 18px;
color: #111111;
display: grid;
grid-template-columns: 30px auto 30px;
}
.delete {
opacity: 0;
cursor: pointer;
text-align: center;
}
.task:hover .delete {
opacity: 1;
}
deleteTask() {
this.trigger('delete-task', {id: this.props.task.id});
}
deleteTask(ev) {
const index = this.tasks.findIndex(t => t.id === ev.detail.id);
this.tasks.splice(index, 1);
}
Let us use it in our application. This is a pretty large refactoring (for our application),
since it involves extracting all task related code out of the components. Here is the new
content of the app.js file:
// -------------------------------------------------------------------------
// Store
// -------------------------------------------------------------------------
const actions = {
addTask({ state }, title) {
title = title.trim();
if (title) {
const task = {
id: state.nextId++,
title: title,
isCompleted: false,
};
state.tasks.push(task);
}
},
toggleTask({ state }, id) {
const task = state.tasks.find((t) => t.id === id);
task.isCompleted = !task.isCompleted;
},
deleteTask({ state }, id) {
const index = state.tasks.findIndex((t) => t.id === id);
state.tasks.splice(index, 1);
},
};
const initialState = {
nextId: 1,
tasks: [],
};
// -------------------------------------------------------------------------
// Task Component
// -------------------------------------------------------------------------
const TASK_TEMPLATE = xml/* xml */ `
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
<input type="checkbox" t-att-checked="props.task.isCompleted"
t-on-click="dispatch('toggleTask', props.task.id)"/>
<span><t t-esc="props.task.title"/></span>
<span class="delete" t-on-click="dispatch('deleteTask', props.task.id)">🗑
</div>`;
// -------------------------------------------------------------------------
// App Component
// -------------------------------------------------------------------------
const APP_TEMPLATE = xml/* xml */ `
<div class="todo-app">
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-inp
<div class="task-list">
<t t-foreach="tasks" t-as="task" t-key="task.id">
<Task task="task"/>
</t>
</div>
</div>`;
inputRef = useRef("add-input");
tasks = useStore((state) => state.tasks);
dispatch = useDispatch();
mounted() {
this.inputRef.el.focus();
}
addTask(ev) {
// 13 is keycode for ENTER
if (ev.keyCode === 13) {
this.dispatch("addTask", ev.target.value);
ev.target.value = "";
}
}
}
// -------------------------------------------------------------------------
// Setup code
// -------------------------------------------------------------------------
function setup() {
owl.config.mode = "dev";
const store = new Store({ actions, state: initialState });
App.env.store = store;
const app = new App();
app.mount(document.body);
}
whenReady(setup);
function makeStore() {
const localState = window.localStorage.getItem("todoapp");
const state = localState ? JSON.parse(localState) : initialState;
const store = new Store({ state, actions });
store.on("update", null, () => {
localStorage.setItem("todoapp", JSON.stringify(store.state));
});
return store;
}
function setup() {
owl.config.mode = "dev";
App.env.store = makeStore();
const app = new App();
app.mount(document.body);
}
The key point is to use the fact that the store is an EventBus which triggers an update
event whenever it is updated.
// in App:
filter = useState({value: "all"})
get displayedTasks() {
switch (this.filter.value) {
case "active": return this.tasks.filter(t => !t.isCompleted);
case "completed": return this.tasks.filter(t => t.isCompleted);
case "all": return this.tasks;
}
}
setFilter(filter) {
this.filter.value = filter;
}
Finally, we need to display the visible filters. We can do that, and at the same time,
display the number of tasks in a small panel below the main list:
<div class="todo-app">
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/
<div class="task-list">
<t t-foreach="displayedTasks" t-as="task" t-key="task.id">
<Task task="task"/>
</t>
</div>
<div class="task-panel" t-if="tasks.length">
<div class="task-counter">
<t t-esc="displayedTasks.length"/>
<t t-if="displayedTasks.length lt tasks.length">
/ <t t-esc="tasks.length"/>
</t>
task(s)
</div>
<div>
<span t-foreach="['all', 'active', 'completed']"
t-as="f" t-key="f"
t-att-class="{active: filter.value===f}"
t-on-click="setFilter(f)"
t-esc="f"/>
</div>
</div>
</div>
.task-panel {
color: #0088ff;
margin-top: 8px;
font-size: 14px;
display: flex;
}
.task-panel .task-counter {
flex-grow: 1;
}
.task-panel span {
padding: 5px;
cursor: pointer;
}
.task-panel span.active {
font-weight: bold;
}
Notice here that we set dynamically the class of the filter with the object syntax: each
key is a class that we want to set if its value is truthy.
.task.done label {
text-decoration: line-through;
}
Final code
Our application is now complete. It works, the UI code is well separated from the
business logic code, it is testable, all under 150 lines of code (template included!).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>OWL Todo App</title>
<link rel="stylesheet" href="app.css" />
<script src="owl.js"></script>
<script src="app.js"></script>
</head>
<body></body>
</html>
(function () {
const { Component, Store } = owl;
const { xml } = owl.tags;
const { whenReady } = owl.utils;
const { useRef, useDispatch, useState, useStore } = owl.hooks;
// -------------------------------------------------------------------------
// Store
// -------------------------------------------------------------------------
const actions = {
addTask({ state }, title) {
title = title.trim();
if (title) {
const task = {
id: state.nextId++,
title: title,
isCompleted: false,
};
state.tasks.push(task);
}
},
toggleTask({ state }, id) {
const task = state.tasks.find((t) => t.id === id);
task.isCompleted = !task.isCompleted;
},
deleteTask({ state }, id) {
const index = state.tasks.findIndex((t) => t.id === id);
state.tasks.splice(index, 1);
},
};
const initialState = {
nextId: 1,
tasks: [],
};
// -------------------------------------------------------------------------
// Task Component
// -------------------------------------------------------------------------
const TASK_TEMPLATE = xml/* xml */ `
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
<input type="checkbox" t-att-checked="props.task.isCompleted"
t-att-id="props.task.id"
t-on-click="dispatch('toggleTask', props.task.id)"/>
<label t-att-for="props.task.id"><t t-esc="props.task.title"/></label>
<span class="delete" t-on-click="dispatch('deleteTask', props.task.id)">🗑
</div>`;
// -------------------------------------------------------------------------
// App Component
// -------------------------------------------------------------------------
const APP_TEMPLATE = xml/* xml */ `
<div class="todo-app">
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-inp
<div class="task-list">
<Task t-foreach="displayedTasks" t-as="task" t-key="task.id" task="ta
</div>
<div class="task-panel" t-if="tasks.length">
<div class="task-counter">
<t t-esc="displayedTasks.length"/>
<t t-if="displayedTasks.length lt tasks.length">
/ <t t-esc="tasks.length"/>
</t>
task(s)
</div>
<div>
<span t-foreach="['all', 'active', 'completed']"
t-as="f" t-key="f"
t-att-class="{active: filter.value===f}"
t-on-click="setFilter(f)"
t-esc="f"/>
</div>
</div>
</div>`;
inputRef = useRef("add-input");
tasks = useStore((state) => state.tasks);
filter = useState({ value: "all" });
dispatch = useDispatch();
mounted() {
this.inputRef.el.focus();
}
addTask(ev) {
// 13 is keycode for ENTER
if (ev.keyCode === 13) {
this.dispatch("addTask", ev.target.value);
ev.target.value = "";
}
}
get displayedTasks() {
switch (this.filter.value) {
case "active":
return this.tasks.filter((t) => !t.isCompleted);
case "completed":
return this.tasks.filter((t) => t.isCompleted);
case "all":
return this.tasks;
}
}
setFilter(filter) {
this.filter.value = filter;
}
}
// -------------------------------------------------------------------------
// Setup code
// -------------------------------------------------------------------------
function makeStore() {
const localState = window.localStorage.getItem("todoapp");
const state = localState ? JSON.parse(localState) : initialState;
const store = new Store({ state, actions });
store.on("update", null, () => {
localStorage.setItem("todoapp", JSON.stringify(store.state));
});
return store;
}
function setup() {
owl.config.mode = "dev";
App.env.store = makeStore();
const app = new App();
app.mount(document.body);
}
whenReady(setup);
})();
.todo-app {
width: 300px;
margin: 50px auto;
background: aliceblue;
padding: 10px;
}
.task-list {
margin-top: 8px;
}
.task {
font-size: 18px;
color: #111111;
display: grid;
grid-template-columns: 30px auto 30px;
}
.task:hover {
background-color: #def0ff;
}
.delete {
opacity: 0;
cursor: pointer;
text-align: center;
}
.task:hover .delete {
opacity: 1;
}
.task.done {
opacity: 0.7;
}
.task.done label {
text-decoration: line-through;
}
.task-panel {
color: #0088ff;
margin-top: 8px;
font-size: 14px;
display: flex;
}
.task-panel .task-counter {
flex-grow: 1;
}
.task-panel span {
padding: 5px;
cursor: pointer;
}
.task-panel span.active {
font-weight: bold;
}