mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
[DOC] update the tutorial todo app
This commit is contained in:
committed by
Samuel Degueldre
parent
7fdca35a74
commit
b8e7ee5ff7
+293
-320
@@ -50,10 +50,11 @@ the following content:
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>OWL Todo App</title>
|
<title>OWL Todo App</title>
|
||||||
<link rel="stylesheet" href="app.css" />
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
<script src="owl.js"></script>
|
<script src="owl.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</head>
|
</body>
|
||||||
<body></body>
|
|
||||||
</html>
|
</html>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -71,44 +72,34 @@ Note that we put everything inside an immediately executed function to avoid lea
|
|||||||
anything to the global scope.
|
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). Be aware that you should download the `owl.iife.js` or `owl.iife.min.js`, because these files
|
Finally, `owl.js` should be the last version downloaded from the Owl repository (you can use `owl.min.js` if you prefer). Be aware that you should download the `owl.iife.js` or `owl.iife.min.js`, because these files
|
||||||
are built to run directly on the browser (other files such as `owl.cjs.js` are
|
are built to run directly on the browser, and rename it `owl.js` (other files such as `owl.cjs.js` are
|
||||||
built to be bundled by other tools).
|
built to be bundled by other tools).
|
||||||
|
|
||||||
Now, the project should be ready. Loading the `index.html` file into a browser
|
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
|
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.
|
message such as `hello owl 2.x.y` in the console.
|
||||||
|
|
||||||
## 2. Adding a first component
|
## 2. Adding a first component
|
||||||
|
|
||||||
An Owl application is made out of [components](../reference/component.md), with
|
An Owl application is made out of [components](../reference/component.md), with
|
||||||
a single root component. Let us start by defining an `App` component. Replace the
|
a single root component. Let us start by defining a `Root` component. Replace the
|
||||||
content of the function in `app.js` by the following code:
|
content of the function in `app.js` by the following code:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const { Component, mount } = owl;
|
const { Component, mount, xml } = owl;
|
||||||
const { xml } = owl.tags;
|
|
||||||
const { whenReady } = owl.utils;
|
|
||||||
|
|
||||||
// Owl Components
|
// Owl Components
|
||||||
class App extends Component {
|
class Root extends Component {
|
||||||
static template = xml`<div>todo app</div>`;
|
static template = xml`<div>todo app</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup code
|
mount(Root, document.body);
|
||||||
function setup() {
|
|
||||||
mount(App, { target: document.body });
|
|
||||||
}
|
|
||||||
|
|
||||||
whenReady(setup);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Now, reloading the page in a browser should display a message.
|
Now, reloading the page in a browser should display a message.
|
||||||
|
|
||||||
The code is pretty simple, but let us explain the last line in more detail. The
|
The code is pretty simple: we define a component with an inline template, then
|
||||||
browser tries to execute the javascript code in `app.js` as quickly as possible,
|
mount it in the document body.
|
||||||
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`](../reference/utils.md#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
|
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.
|
components in a sub folder, and a main file that would initialize the application.
|
||||||
@@ -149,20 +140,20 @@ with the following keys:
|
|||||||
tasks. Since the title is something created/edited by the user, it offers
|
tasks. Since the title is something created/edited by the user, it offers
|
||||||
no guarantee that it is unique. So, we will generate a unique `id` number for
|
no guarantee that it is unique. So, we will generate a unique `id` number for
|
||||||
each task.
|
each task.
|
||||||
- `title`: a string, to explain what the task is about.
|
- `text`: a string, to explain what the task is about.
|
||||||
- `isCompleted`: a boolean, to keep track of the status of the task
|
- `isCompleted`: a boolean, to keep track of the status of the task
|
||||||
|
|
||||||
Now that we decided on the internal format of the state, let us add some demo
|
Now that we decided on the internal format of the state, let us add some demo
|
||||||
data and a template to the `App` component:
|
data and a template to the `App` component:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
class App extends Component {
|
class Root extends Component {
|
||||||
static template = xml/* xml */ `
|
static template = xml/* xml */ `
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<t t-foreach="tasks" t-as="task" t-key="task.id">
|
<t t-foreach="tasks" t-as="task" t-key="task.id">
|
||||||
<div class="task">
|
<div class="task">
|
||||||
<input type="checkbox" t-att-checked="task.isCompleted"/>
|
<input type="checkbox" t-att-checked="task.isCompleted"/>
|
||||||
<span><t t-esc="task.title"/></span>
|
<span><t t-esc="task.text"/></span>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -170,12 +161,12 @@ class App extends Component {
|
|||||||
tasks = [
|
tasks = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "buy milk",
|
text: "buy milk",
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "clean house",
|
text: "clean house",
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -245,29 +236,25 @@ a little bit:
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Task Component
|
// 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>`;
|
|
||||||
|
|
||||||
class Task extends Component {
|
class Task extends Component {
|
||||||
static template = TASK_TEMPLATE;
|
static template = xml /* xml */`
|
||||||
static props = ["task"];
|
<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>`;
|
||||||
|
static props = ["task"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// App Component
|
// Root Component
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const APP_TEMPLATE = xml /* xml */`
|
class Root extends Component {
|
||||||
|
static template = xml /* xml */`
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<t t-foreach="tasks" t-as="task" t-key="task.id">
|
<t t-foreach="tasks" t-as="task" t-key="task.id">
|
||||||
<Task task="task"/>
|
<Task task="task"/>
|
||||||
</t>
|
</t>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
class App extends Component {
|
|
||||||
static template = APP_TEMPLATE;
|
|
||||||
static components = { Task };
|
static components = { Task };
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
@@ -276,14 +263,9 @@ class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Setup code
|
// Setup
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function setup() {
|
mount(Root, document.body, {dev: true});
|
||||||
owl.config.mode = "dev";
|
|
||||||
mount(App, { target: document.body });
|
|
||||||
}
|
|
||||||
|
|
||||||
whenReady(setup);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A lot of stuff happened here:
|
A lot of stuff happened here:
|
||||||
@@ -292,24 +274,22 @@ A lot of stuff happened here:
|
|||||||
- whenever we define a sub component, it needs to be added to the static
|
- whenever we define a sub component, it needs to be added to the static
|
||||||
[`components`](../reference/component.md#static-properties)
|
[`components`](../reference/component.md#static-properties)
|
||||||
key of its parent, so Owl can get a reference to it,
|
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
|
- 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
|
purpose. It says that each `Task` should be given exactly one prop, named
|
||||||
`task`. If this is not the case, Owl will throw an
|
`task`. If this is not the case, Owl will throw an
|
||||||
[error](../reference/props_validation.md). This is extremely
|
[error](../reference/props_validation.md). This is extremely
|
||||||
useful when refactoring components
|
useful when refactoring components
|
||||||
- finally, to activate the props validation, we need to set Owl's
|
- finally, to activate the props validation, we need to set Owl's
|
||||||
[mode](../reference/config.md#mode) to `dev`. This is done in the `setup`
|
[mode](../reference/config.md#mode) to `dev`. This is done in the last argument
|
||||||
function. Note that this should be removed when an app is used in a real
|
of the `mount` 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
|
production environment, since `dev` mode is slightly slower, due to extra
|
||||||
checks and validations.
|
checks and validations.
|
||||||
|
|
||||||
## 6. Adding tasks (part 1)
|
## 6. Adding tasks (part 1)
|
||||||
|
|
||||||
We still use a list of hardcoded tasks. It's really time to give the user a way
|
We still use a list of hardcoded tasks. It's really time to give the user a way
|
||||||
to add tasks himself. The first step is to add an input to the `App` component.
|
to add tasks himself. The first step is to add an input to the `Root` component.
|
||||||
But this input will be outside of the task list, so we need to adapt `App`
|
But this input will be outside of the task list, so we need to adapt `Root`
|
||||||
template, js, and css:
|
template, js, and css:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
@@ -327,9 +307,9 @@ template, js, and css:
|
|||||||
addTask(ev) {
|
addTask(ev) {
|
||||||
// 13 is keycode for ENTER
|
// 13 is keycode for ENTER
|
||||||
if (ev.keyCode === 13) {
|
if (ev.keyCode === 13) {
|
||||||
const title = ev.target.value.trim();
|
const text = ev.target.value.trim();
|
||||||
ev.target.value = "";
|
ev.target.value = "";
|
||||||
console.log('adding task', title);
|
console.log('adding task', text);
|
||||||
// todo
|
// todo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,10 +338,9 @@ 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
|
tasks is a core feature of a task list, so let us make it as fast as possible by
|
||||||
focusing the input.
|
focusing the input.
|
||||||
|
|
||||||
Since `App` is a component, it has a
|
We need to execute code when the `Root` component is ready (mounted). Let's do
|
||||||
[`mounted` lifecycle method](../reference/component.md#lifecycle) that we can
|
that using the `onMounted` hook. We will also need to get a reference to the
|
||||||
implement. We will also need to get a reference to the input, by using the
|
input, by using the `t-ref` directive with the [`useRef`](../reference/hooks.md#useref) hook:
|
||||||
`t-ref` directive with the [`useRef`](../reference/hooks.md#useref) hook:
|
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
|
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
|
||||||
@@ -369,22 +348,21 @@ implement. We will also need to get a reference to the input, by using the
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// on top of file:
|
// on top of file:
|
||||||
const { useRef } = owl.hooks;
|
const { Component, mount, xml, useRef, onMounted } = owl;
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// in App
|
// in App
|
||||||
inputRef = useRef("add-input");
|
setup() {
|
||||||
|
const inputRef = useRef("add-input");
|
||||||
mounted() {
|
onMounted(() => inputRef.el.focus());
|
||||||
this.inputRef.el.focus();
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `inputRef` is defined as a class field, so it is equivalent to defining it
|
This is a very common situation: whenever we need to perform some actions depending
|
||||||
in the constructor. It simply instructs Owl to keep a reference to anything with
|
on the lifecycle of a component, we need to do it in the `setup` method, by using
|
||||||
the corresponding `t-ref` keyword. We then implement the `mounted` lifecycle
|
one of the lifecycle hook. Here, we first get a reference to the `inputRef`,
|
||||||
method, where we now have an active reference that we can use to focus the input.
|
then in the `onMounted` hook, we simply focus the html element.
|
||||||
|
|
||||||
## 7. Adding tasks (part 2)
|
## 7. Adding tasks (part 2)
|
||||||
|
|
||||||
@@ -405,12 +383,12 @@ Now, the `addTask` method can be implemented:
|
|||||||
addTask(ev) {
|
addTask(ev) {
|
||||||
// 13 is keycode for ENTER
|
// 13 is keycode for ENTER
|
||||||
if (ev.keyCode === 13) {
|
if (ev.keyCode === 13) {
|
||||||
const title = ev.target.value.trim();
|
const text = ev.target.value.trim();
|
||||||
ev.target.value = "";
|
ev.target.value = "";
|
||||||
if (title) {
|
if (text) {
|
||||||
const newTask = {
|
const newTask = {
|
||||||
id: this.nextId++,
|
id: this.nextId++,
|
||||||
title: title,
|
text: text,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
};
|
};
|
||||||
this.tasks.push(newTask);
|
this.tasks.push(newTask);
|
||||||
@@ -428,7 +406,7 @@ the user interface. We can fix the issue by making `tasks` reactive, with the
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
// on top of the file
|
// on top of the file
|
||||||
const { useRef, useState } = owl.hooks;
|
const { Component, mount, xml, useRef, onMounted, useState } = owl;
|
||||||
|
|
||||||
// replace the task definition in App with the following:
|
// replace the task definition in App with the following:
|
||||||
tasks = useState([]);
|
tasks = useState([]);
|
||||||
@@ -443,12 +421,8 @@ did not change in opacity. This is because there is no code to modify the
|
|||||||
`isCompleted` flag.
|
`isCompleted` flag.
|
||||||
|
|
||||||
Now, this is an interesting situation: the task is displayed by the `Task`
|
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,
|
component, but it is not the owner of its state, so ideally, it should not modify it.
|
||||||
we want to communicate the request to toggle a task to the `App` component.
|
However, for now, that's what we will do (this will be improved in a later step).
|
||||||
Since `App` is a parent of `Task`, we can
|
|
||||||
[trigger](../reference/event_handling.md) an event in `Task` and listen
|
|
||||||
for it in `App`.
|
|
||||||
|
|
||||||
In `Task`, change the `input` to:
|
In `Task`, change the `input` to:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
@@ -459,36 +433,23 @@ and add the `toggleTask` method:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
toggleTask() {
|
toggleTask() {
|
||||||
this.trigger('toggle-task', {id: this.props.task.id});
|
this.props.task.isCompleted = !this.props.task.isCompleted;
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
We now need to listen for that event in the `App` template:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<div class="task-list" t-on-toggle-task="toggleTask">
|
|
||||||
```
|
|
||||||
|
|
||||||
and implement the `toggleTask` code:
|
|
||||||
|
|
||||||
```js
|
|
||||||
toggleTask(ev) {
|
|
||||||
const task = this.tasks.find(t => t.id === ev.detail.id);
|
|
||||||
task.isCompleted = !task.isCompleted;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 9. Deleting tasks
|
## 9. Deleting tasks
|
||||||
|
|
||||||
Let us now add the possibility do delete tasks. To do that, we first need to add
|
Let us now add the possibility do delete tasks. This is different from the previous
|
||||||
a trash icon on each task, then we will proceed just like in the previous section.
|
feature: deleting task has to be done on the task itself, but the actual operation
|
||||||
|
need to be done on the task list. So, we need to communicate the request to the
|
||||||
|
`Root` component. This is usually done by providing a callback in a prop.
|
||||||
|
|
||||||
First, let us update the `Task` template, css and js:
|
First, let us update the `Task` template, css and js:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
|
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
|
||||||
<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>
|
<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>
|
||||||
<span><t t-esc="props.task.title"/></span>
|
<span><t t-esc="props.task.text"/></span>
|
||||||
<span class="delete" t-on-click="deleteTask">🗑</span>
|
<span class="delete" t-on-click="deleteTask">🗑</span>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
@@ -517,217 +478,226 @@ First, let us update the `Task` template, css and js:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
static props = ["task", "onDelete"];
|
||||||
|
|
||||||
deleteTask() {
|
deleteTask() {
|
||||||
this.trigger('delete-task', {id: this.props.task.id});
|
this.props.onDelete(this.props.task);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
And now, we need to listen to the `delete-task` event in `App`:
|
And now, we need to provide the `onDelete` callback to each tasks in the `Root`
|
||||||
|
component:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<div class="task-list" t-on-toggle-task="toggleTask" t-on-delete-task="deleteTask">
|
<Task task="task" onDelete.bind="deleteTask"/>
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
deleteTask(ev) {
|
deleteTask(task) {
|
||||||
const index = this.tasks.findIndex(t => t.id === ev.detail.id);
|
const index = this.tasks.findIndex(t => t.id === task.id);
|
||||||
this.tasks.splice(index, 1);
|
this.tasks.splice(index, 1);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notice that the `onDelete` prop is defined with a `.bind` suffix: this is a special
|
||||||
|
suffix that make sure the function callback is bound to the component.
|
||||||
|
|
||||||
## 10. Using a store
|
## 10. Using a store
|
||||||
|
|
||||||
Looking at the code, it is apparent that we now have code to handle tasks
|
Looking at the code, it is apparent that all the code handling tasks is scattered
|
||||||
scattered in more than one place. Also, it mixes UI code and business logic
|
all around the application. Also, it mixes UI code and business logic
|
||||||
code. Owl has a way to manage state separately from the user interface: a store.
|
code. Owl does not provide any high level abstraction to manage business logic,
|
||||||
|
but it is easy to do it with the basic reactivity primitives (`useState` and `reactive`).
|
||||||
|
|
||||||
Let us use it in our application. This is a pretty large refactoring (for our
|
Let us use it in our application to implement a central store. This is a pretty
|
||||||
application), since it involves extracting all task related code out of the
|
large refactoring (for our application), since it involves extracting all task
|
||||||
components. Here is the new content of the `app.js` file:
|
related code out of the components. Here is the new content of the `app.js` file:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const { Component, Store, mount } = owl;
|
const { Component, mount, xml, useRef, onMounted, useState, reactive, useEnv } = owl;
|
||||||
const { xml } = owl.tags;
|
|
||||||
const { whenReady } = owl.utils;
|
|
||||||
const { useRef, useDispatch, useStore } = owl.hooks;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Store
|
// Store
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const actions = {
|
function useStore() {
|
||||||
addTask({ state }, title) {
|
const env = useEnv();
|
||||||
title = title.trim();
|
return useState(env.store);
|
||||||
if (title) {
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// TaskList
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
class TaskList {
|
||||||
|
nextId = 1;
|
||||||
|
tasks = [];
|
||||||
|
|
||||||
|
addTask(text) {
|
||||||
|
text = text.trim();
|
||||||
|
if (text) {
|
||||||
const task = {
|
const task = {
|
||||||
id: state.nextId++,
|
id: this.nextId++,
|
||||||
title: title,
|
text: text,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
};
|
};
|
||||||
state.tasks.push(task);
|
this.tasks.push(task);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
toggleTask({ state }, id) {
|
|
||||||
const task = state.tasks.find((t) => t.id === id);
|
toggleTask(task) {
|
||||||
task.isCompleted = !task.isCompleted;
|
task.isCompleted = !task.isCompleted;
|
||||||
},
|
}
|
||||||
deleteTask({ state }, id) {
|
|
||||||
const index = state.tasks.findIndex((t) => t.id === id);
|
deleteTask(task) {
|
||||||
state.tasks.splice(index, 1);
|
const index = this.tasks.findIndex((t) => t.id === task.id);
|
||||||
},
|
this.tasks.splice(index, 1);
|
||||||
};
|
}
|
||||||
const initialState = {
|
}
|
||||||
nextId: 1,
|
|
||||||
tasks: [],
|
function createTaskStore() {
|
||||||
};
|
return reactive(new TaskList());
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Task Component
|
// Task Component
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const TASK_TEMPLATE = xml/* xml */ `
|
class Task extends Component {
|
||||||
|
static template = xml/* xml */ `
|
||||||
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
|
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
|
||||||
<input type="checkbox" t-att-checked="props.task.isCompleted"
|
<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="() => store.toggleTask(props.task)"/>
|
||||||
t-on-click="dispatch('toggleTask', props.task.id)"/>
|
<span><t t-esc="props.task.text"/></span>
|
||||||
<span><t t-esc="props.task.title"/></span>
|
<span class="delete" t-on-click="() => store.deleteTask(props.task)">🗑</span>
|
||||||
<span class="delete" t-on-click="dispatch('deleteTask', props.task.id)">🗑</span>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
class Task extends Component {
|
|
||||||
static template = TASK_TEMPLATE;
|
|
||||||
static props = ["task"];
|
static props = ["task"];
|
||||||
dispatch = useDispatch();
|
|
||||||
|
setup() {
|
||||||
|
this.store = useStore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// App Component
|
// Root Component
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const APP_TEMPLATE = xml/* xml */ `
|
class Root extends Component {
|
||||||
|
static template = xml/* xml */ `
|
||||||
<div class="todo-app">
|
<div class="todo-app">
|
||||||
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
|
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<t t-foreach="tasks" t-as="task" t-key="task.id">
|
<t t-foreach="store.tasks" t-as="task" t-key="task.id">
|
||||||
<Task task="task"/>
|
<Task task="task"/>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
class App extends Component {
|
|
||||||
static template = APP_TEMPLATE;
|
|
||||||
static components = { Task };
|
static components = { Task };
|
||||||
|
|
||||||
inputRef = useRef("add-input");
|
setup() {
|
||||||
tasks = useStore((state) => state.tasks);
|
const inputRef = useRef("add-input");
|
||||||
dispatch = useDispatch();
|
onMounted(() => inputRef.el.focus());
|
||||||
|
this.store = useStore();
|
||||||
mounted() {
|
|
||||||
this.inputRef.el.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addTask(ev) {
|
addTask(ev) {
|
||||||
// 13 is keycode for ENTER
|
// 13 is keycode for ENTER
|
||||||
if (ev.keyCode === 13) {
|
if (ev.keyCode === 13) {
|
||||||
this.dispatch("addTask", ev.target.value);
|
this.store.addTask(ev.target.value);
|
||||||
ev.target.value = "";
|
ev.target.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Setup code
|
// Setup
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function setup() {
|
const env = {
|
||||||
owl.config.mode = "dev";
|
store: createTaskStore(),
|
||||||
const store = new Store({ actions, state: initialState });
|
};
|
||||||
App.env.store = store;
|
mount(Root, document.body, { dev: true, env });
|
||||||
mount(App, { target: document.body });
|
|
||||||
}
|
|
||||||
|
|
||||||
whenReady(setup);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11-Saving tasks in local storage
|
## 11. Saving tasks in local storage
|
||||||
|
|
||||||
Now, our TodoApp works great, except if the user closes or refresh the browser!
|
Now, our TodoApp works great, except if the user closes or refresh the browser!
|
||||||
It is really inconvenient to only keep the state of the application in memory.
|
It is really inconvenient to only keep the state of the application in memory.
|
||||||
To fix this, we will save the tasks in the local storage. With our current
|
To fix this, we will save the tasks in the local storage. With our current
|
||||||
codebase, it is a simple change: only the setup code needs to be updated.
|
codebase, it is a simple change: we need to save tasks to local storage and
|
||||||
|
listen to any change.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
function makeStore() {
|
class TaskList {
|
||||||
const localState = window.localStorage.getItem("todoapp");
|
constructor(tasks) {
|
||||||
const state = localState ? JSON.parse(localState) : initialState;
|
this.tasks = tasks || [];
|
||||||
const store = new Store({ state, actions });
|
const taskIds = this.tasks.map((t) => t.id);
|
||||||
store.on("update", null, () => {
|
this.nextId = taskIds.length ? Math.max(...taskIds) + 1 : 1;
|
||||||
localStorage.setItem("todoapp", JSON.stringify(store.state));
|
}
|
||||||
});
|
// ...
|
||||||
return store;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup() {
|
function createTaskStore() {
|
||||||
owl.config.mode = "dev";
|
const saveTasks = () => localStorage.setItem("todoapp", JSON.stringify(taskStore.tasks));
|
||||||
const env = { store: makeStore() };
|
const initialTasks = JSON.parse(localStorage.getItem("todoapp") || "[]");
|
||||||
mount(App, { target: document.body, env });
|
const taskStore = reactive(new TaskList(initialTasks), saveTasks);
|
||||||
|
saveTasks();
|
||||||
|
return taskStore;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The key point is to use the fact that the store is an
|
The key point is that the `reactive` function takes a callback that will be called
|
||||||
[`EventBus`](../reference/event_bus.md) which triggers an `update` event
|
every time an observed value is changed. Note that we need to call the `saveTasks`
|
||||||
whenever it is updated.
|
method initially to make sure we observe all current values.
|
||||||
|
|
||||||
## 12. Filtering tasks
|
## 12. Filtering tasks
|
||||||
|
|
||||||
We are almost done, we can add/update/delete tasks. The only missing feature is
|
We are almost done, we can add/update/delete tasks. The only missing feature is
|
||||||
the possibility to display the task according to their completed status. We will
|
the possibility to display the task according to their completed status. We will
|
||||||
need to keep track of the state of the filter in `App`, then filter the visible
|
need to keep track of the state of the filter in `Root`, then filter the visible
|
||||||
tasks according to its value.
|
tasks according to its value.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// on top of file, readd useState:
|
class Root extends Component {
|
||||||
const { useRef, useDispatch, useState, useStore } = owl.hooks;
|
static template = xml /* xml */`
|
||||||
|
<div class="todo-app">
|
||||||
// in App:
|
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
|
||||||
filter = useState({value: "all"})
|
<div class="task-list">
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<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">
|
<t t-foreach="displayedTasks" t-as="task" t-key="task.id">
|
||||||
<Task task="task"/>
|
<Task task="task"/>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-panel" t-if="tasks.length">
|
<div class="task-panel" t-if="store.tasks.length">
|
||||||
<div class="task-counter">
|
<div class="task-counter">
|
||||||
<t t-esc="displayedTasks.length"/>
|
<t t-esc="displayedTasks.length"/>
|
||||||
<t t-if="displayedTasks.length lt tasks.length">
|
<t t-if="displayedTasks.length lt store.tasks.length">
|
||||||
/ <t t-esc="tasks.length"/>
|
/ <t t-esc="store.tasks.length"/>
|
||||||
</t>
|
</t>
|
||||||
task(s)
|
task(s)
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span t-foreach="['all', 'active', 'completed']"
|
<span t-foreach="['all', 'active', 'completed']"
|
||||||
t-as="f" t-key="f"
|
t-as="f" t-key="f"
|
||||||
t-att-class="{active: filter.value===f}"
|
t-att-class="{active: filter.value===f}"
|
||||||
t-on-click="setFilter(f)"
|
t-on-click="() => this.setFilter(f)"
|
||||||
t-esc="f"/>
|
t-esc="f"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
...
|
||||||
|
this.filter = useState({ value: "all" });
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayedTasks() {
|
||||||
|
const tasks = this.store.tasks;
|
||||||
|
switch (this.filter.value) {
|
||||||
|
case "active": return tasks.filter(t => !t.isCompleted);
|
||||||
|
case "completed": return tasks.filter(t => t.isCompleted);
|
||||||
|
case "all": return tasks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(filter) {
|
||||||
|
this.filter.value = filter;
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@@ -752,8 +722,8 @@ same time, display the number of tasks in a small panel below the main list:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Notice here that we set dynamically the class of the filter with the object
|
Notice here that we set dynamically the css class of the filter with the object
|
||||||
syntax: each key is a class that we want to set if its value is truthy.
|
syntax.
|
||||||
|
|
||||||
## 13. The Final Touch
|
## 13. The Final Touch
|
||||||
|
|
||||||
@@ -768,16 +738,16 @@ the user experience.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Make the title of a task clickable, to toggle its checkbox:
|
2. Make the text of a task clickable, to toggle its checkbox:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<input type="checkbox" t-att-checked="props.task.isCompleted"
|
<input type="checkbox" t-att-checked="props.task.isCompleted"
|
||||||
t-att-id="props.task.id"
|
t-att-id="props.task.id"
|
||||||
t-on-click="dispatch('toggleTask', 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>
|
<label t-att-for="props.task.id"><t t-esc="props.task.text"/></label>
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Strike the title of completed task:
|
3. Strike the text of completed task:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.task.done label {
|
.task.done label {
|
||||||
@@ -809,142 +779,145 @@ For reference, here is the final code:
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
(function () {
|
(function () {
|
||||||
const { Component, Store, mount } = owl;
|
const { Component, mount, xml, useRef, onMounted, useState, reactive, useEnv } = owl;
|
||||||
const { xml } = owl.tags;
|
|
||||||
const { whenReady } = owl.utils;
|
|
||||||
const { useRef, useDispatch, useState, useStore } = owl.hooks;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Store
|
// Store
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const actions = {
|
function useStore() {
|
||||||
addTask({ state }, title) {
|
const env = useEnv();
|
||||||
title = title.trim();
|
return useState(env.store);
|
||||||
if (title) {
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// TaskList
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
class TaskList {
|
||||||
|
constructor(tasks) {
|
||||||
|
this.tasks = tasks || [];
|
||||||
|
const taskIds = this.tasks.map((t) => t.id);
|
||||||
|
this.nextId = taskIds.length ? Math.max(...taskIds) + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
addTask(text) {
|
||||||
|
text = text.trim();
|
||||||
|
if (text) {
|
||||||
const task = {
|
const task = {
|
||||||
id: state.nextId++,
|
id: this.nextId++,
|
||||||
title: title,
|
text: text,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
};
|
};
|
||||||
state.tasks.push(task);
|
this.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 = {
|
toggleTask(task) {
|
||||||
nextId: 1,
|
task.isCompleted = !task.isCompleted;
|
||||||
tasks: [],
|
}
|
||||||
};
|
|
||||||
|
deleteTask(task) {
|
||||||
|
const index = this.tasks.findIndex((t) => t.id === task.id);
|
||||||
|
this.tasks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTaskStore() {
|
||||||
|
const saveTasks = () => localStorage.setItem("todoapp", JSON.stringify(taskStore.tasks));
|
||||||
|
const initialTasks = JSON.parse(localStorage.getItem("todoapp") || "[]");
|
||||||
|
const taskStore = reactive(new TaskList(initialTasks), saveTasks);
|
||||||
|
saveTasks();
|
||||||
|
return taskStore;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Task Component
|
// 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)">🗑</span>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
class Task extends Component {
|
class Task extends Component {
|
||||||
static template = TASK_TEMPLATE;
|
static template = xml/* xml */ `
|
||||||
|
<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
|
||||||
|
<input type="checkbox"
|
||||||
|
t-att-id="props.task.id"
|
||||||
|
t-att-checked="props.task.isCompleted"
|
||||||
|
t-on-click="() => store.toggleTask(props.task)"/>
|
||||||
|
<label t-att-for="props.task.id"><t t-esc="props.task.text"/></label>
|
||||||
|
<span class="delete" t-on-click="() => store.deleteTask(props.task)">🗑</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
static props = ["task"];
|
static props = ["task"];
|
||||||
dispatch = useDispatch();
|
|
||||||
|
setup() {
|
||||||
|
this.store = useStore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// App Component
|
// Root Component
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const APP_TEMPLATE = xml/* xml */ `
|
class Root extends Component {
|
||||||
<div class="todo-app">
|
static template = xml/* xml */ `
|
||||||
|
<div class="todo-app">
|
||||||
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
|
<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<Task t-foreach="displayedTasks" t-as="task" t-key="task.id" task="task"/>
|
<t t-foreach="displayedTasks" t-as="task" t-key="task.id">
|
||||||
|
<Task task="task"/>
|
||||||
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-panel" t-if="tasks.length">
|
<div class="task-panel" t-if="store.tasks.length">
|
||||||
<div class="task-counter">
|
<div class="task-counter">
|
||||||
<t t-esc="displayedTasks.length"/>
|
<t t-esc="displayedTasks.length"/>
|
||||||
<t t-if="displayedTasks.length lt tasks.length">
|
<t t-if="displayedTasks.length lt store.tasks.length">
|
||||||
/ <t t-esc="tasks.length"/>
|
/ <t t-esc="store.tasks.length"/>
|
||||||
</t>
|
</t>
|
||||||
task(s)
|
task(s)
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span t-foreach="['all', 'active', 'completed']"
|
<span t-foreach="['all', 'active', 'completed']"
|
||||||
t-as="f" t-key="f"
|
t-as="f" t-key="f"
|
||||||
t-att-class="{active: filter.value===f}"
|
t-att-class="{active: filter.value===f}"
|
||||||
t-on-click="setFilter(f)"
|
t-on-click="() => this.setFilter(f)"
|
||||||
t-esc="f"/>
|
t-esc="f"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
class App extends Component {
|
|
||||||
static template = APP_TEMPLATE;
|
|
||||||
static components = { Task };
|
static components = { Task };
|
||||||
|
|
||||||
inputRef = useRef("add-input");
|
setup() {
|
||||||
tasks = useStore((state) => state.tasks);
|
const inputRef = useRef("add-input");
|
||||||
filter = useState({ value: "all" });
|
onMounted(() => inputRef.el.focus());
|
||||||
dispatch = useDispatch();
|
this.store = useStore();
|
||||||
|
this.filter = useState({ value: "all" });
|
||||||
mounted() {
|
|
||||||
this.inputRef.el.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addTask(ev) {
|
addTask(ev) {
|
||||||
// 13 is keycode for ENTER
|
// 13 is keycode for ENTER
|
||||||
if (ev.keyCode === 13) {
|
if (ev.keyCode === 13) {
|
||||||
this.dispatch("addTask", ev.target.value);
|
this.store.addTask(ev.target.value);
|
||||||
ev.target.value = "";
|
ev.target.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayedTasks() {
|
get displayedTasks() {
|
||||||
|
const tasks = this.store.tasks;
|
||||||
switch (this.filter.value) {
|
switch (this.filter.value) {
|
||||||
case "active":
|
case "active":
|
||||||
return this.tasks.filter((t) => !t.isCompleted);
|
return tasks.filter((t) => !t.isCompleted);
|
||||||
case "completed":
|
case "completed":
|
||||||
return this.tasks.filter((t) => t.isCompleted);
|
return tasks.filter((t) => t.isCompleted);
|
||||||
case "all":
|
case "all":
|
||||||
return this.tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilter(filter) {
|
setFilter(filter) {
|
||||||
this.filter.value = filter;
|
this.filter.value = filter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Setup code
|
// Setup
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function makeStore() {
|
const env = { store: createTaskStore() };
|
||||||
const localState = window.localStorage.getItem("todoapp");
|
mount(Root, document.body, { dev: true, env });
|
||||||
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";
|
|
||||||
const env = { store: makeStore() };
|
|
||||||
mount(App, { target: document.body, env });
|
|
||||||
}
|
|
||||||
|
|
||||||
whenReady(setup);
|
|
||||||
})();
|
})();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user