🦉 OWL учебник: TodoApp 🦉

Для данного учебника, мы создадим очень простое приложение "Список задач". Приложение должно удовлетворять следующим условиям:

  • пользователь может создавать и удалять задачи
  • задачи могут быть помечены как завершенные
  • задачи могут быть отфильтрованы для отображения активных/завершенных

Этот проект будет возможностью для исследования и изучения некоторых важных концепций Owl, такие как компоненты, хранилище, и то как должно быть организовано приложение

Содержание

  1. Настройка проекта
  2. Добавляем первый компонент
  3. Отображеие списка задач
  4. Макет: немного css
  5. Выделение задачи в отдельный дочерний компонент
  6. Добавление задач (часть 1)
  7. Добавление задач (часть 2))
  8. Переключение задач
  9. Удаление задач
  10. Использование хранилища
  11. Сохранение задач в local storage
  12. Фильтрация задач
  13. Последний штрих
  14. Финальный код

1. Настройка проекта

Для этого учебника, мы создадим очень простой проект, со статическими файлами, без дополнительных инструментов. На первом шаге мы создадим следующую файловую структуру:

todoapp/
    index.html
    app.css
    app.js
    owl.js

Точкой входа для этого приложения будет файл index.html, вот его содержимое:

<!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>

Файл app.css, на данный момент, можно оставить пустым. Он станет полезным позже, когда мы будем оформлять наше приложение. В файле app.js мы будем писать весь наш код. А пока давайте запишем в него пока следующее:

(function () {
  console.log("hello owl", owl.__info__.version);
})();

Обратите внимание, что все что мы кладем внутрь - это немедленно выполняемая функция

Обратите внимание, что мы помещаем все в немедленно выполняемую функцию, чтобы избежать утечки чего-либо в глобальную область видимости.

owl.js должна быть последнй версии из Owl репозитория (в данном случае речь идет о версии 1.4.10, которая применяется в релизе 14 версии Odoo. Прим. пер.)( вы можете использовать owl.min.js если хотите). Имейте в виду, что вам следует загрузить owl.iife.js или owl.iife.min.js, потому что эти файлы созданы для работы в браузере (другие файлы, такие как owl.cjs.js, созданы для работы в связке с другими инструментами).

Теперь проект должен быть готов. Открыв файл в index.html в браузере, должна открыться пустая страница с заголовком Owl Todo App, а в консоли должно появиться следующие сообщение hello owl 1.4.10

2. Добавляем первый компонент

Приложение Owl состоит из компонентов, с одним корневым компонентом. Давайте начнем с определения компонента App. Замените содержимое app.js следующим кодом:

const { Component, mount } = owl;
const { xml } = owl.tags;
const { whenReady } = owl.utils;

// Owl Components
class App extends Component {
  static template = xml`<div>todo app</div>`;
}

// Setup code
function setup() {
  mount(App, { target: document.body });
}

whenReady(setup);

Теперь, после перезагрузки страницы браузер покажет сообщение todo app

Код очень прост, но давайте объясним последнюю строку более детально. Браузер старается выполнить код javascript в app.js настолько быстро, насколько это возможно и может так случиться что DOM не готов, когда мы попытаемся примонтировать к нему компонент App. Для того чтобы избежать этой ситуации мы используем вспомогательную функцию whenReady для того, чтобы задержать выполнение функции setup до тех пор, пока DOM не будет готов.

Примечание 1: в больших проектах мы должны разделять код на множество файлов, с компонетами в подкаталогах, и на основной файл который будет инициализировать приложение. Тем не менее это очень маленький проект и мы хотим сохранить его как можно более простым.

Примечание 2: этот учебник использует синтаксис полей статических классов. На данный момент этот функционал еще не поддерживается всеми браузерами. Большинство реальных проектов транспилируют свой код, и в таком подходе нет особых проблем, но для данного учебника, если вы хотите заставить работать код во всех браузерах вам нужно будет траслировать каждое ключевое слово static в присвоение классу:

class App extends Component {}
App.template = xml`<div>todo app</div>`;

Примечание 3: написание внутреннего шаблона с помощью xml это прекрасно, но тогда не будет подстветки синтаксиса, что легко может привести к поврежденному xml. Некоторые редакторы поддерживают подсветку синтаксиса для таких ситуаций. Например VS Code имеет аддон Comment tagged templates, который, если его установить, будет правильно отображать помеченные шаблоны:

    static template = xml /* xml */`<div>todo app</div>`;

Примечание 4: Большие приложения, скорее всего, захотят иметь возможность переводить шаблоны. Использование встроенных шаблонов немного усложняет задачу, поскольку нам нужны дополнительные инструменты для извлечения xml из кода и замены его переведенными значениями.

3. Отображеие списка задач

Теперь когда база готова, пришло время начинать думать о задачах. Чтобы выполнить то, что нам нужно, мы будем отслеживать задачи в виде массива объектов со следующими ключами:

  • id: номер. Это невероятно полезно иметь способ уникально идентифицировать задачу. Поскольку название это то, что пользователь может задавать/изменять, то оно не гарантирует нам уникальное значение. Поэтому мы будем генерировать уникальный id для каждой задачи.
  • title: строка, описывает задачу.
  • isCompleted: булево, позволяет отслеживать статус задачи.

Теперь когда мы определили внутрениий формат состояния, давайте добавим немного демо данных и шаблон в компонент App:

class App extends Component {
  static template = xml/* xml */ `
    <div class="task-list">
        <t t-foreach="tasks" t-as="task" t-key="task.id">
            <div class="task">
                <input type="checkbox" t-att-checked="task.isCompleted"/>
                <span><t t-esc="task.title"/></span>
            </div>
        </t>
    </div>`;

  tasks = [
    {
      id: 1,
      title: "buy milk",
      isCompleted: true,
    },
    {
      id: 2,
      title: "clean house",
      isCompleted: false,
    },
  ];
}

Шаблон содержит цикл t-foreach для итерирования задач. Он может найти список tasks в компоненте так как компонент представляет собой контекст рендеринга. Обратите внимание, что мы используем id каждой задачи в качестве t-key, что является распространенной практикой. Есть два класса css: task-list и task, которые мы будем использовать в следующем разделе.

Наконец обратите внимание на использование атрибута t-att-checked: префикс атрибута t-att делает его динамическим. Owl исполнит выражение и установит его в качестве значения атрибута.

4. Макет: немного css

Пока что наш список задач выглядить простенько и бедненько. Давайте добавим следующее содержимое в файл app.css:

.task-list {
  width: 300px;
  margin: 50px auto;
  background: aliceblue;
  padding: 10px;
}

.task {
  font-size: 18px;
  color: #111111;
}

Уже лучше. Теперь давайте добавим еще немного доплнительных фичей: завершенные задачи должны быть оформлены иначе, чтобы можно было легко их отделять от остальных. Для того, чтобы это сделать, мы добавим динамическйи css класс на каждую задачу:

    <div class="task" t-att-class="task.isCompleted ? 'done' : ''">
.task.done {
  opacity: 0.7;
}

Обратите внимание, что здесь мы имеем другое использование динамического атрибута.

5. Выделение задачи в отдельный дочерний компонент

Теперь уже понятно что у нас должен быть компонент Task чтобы инкапсулировать внешний вид и поведение задачи.

Компоненет Task будет отображать задачу, но не может владеть состоянием задачи: часть с данными должна иметь только одного владельца. Делать иначе - напрашиваться на неприятности. Поэтому компонент Task получит свои данные из prop. Это означает что данным владеет компонент App, но они могут быть использованы компонентом Task (без возможности изменять их)

Поскольку мы перемещаем код, это хорошая возможность немного отрефакторить его:

// -------------------------------------------------------------------------
// 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 {
    static template = TASK_TEMPLATE;
    static props = ["task"];
}

// -------------------------------------------------------------------------
// 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>`;

class App extends Component {
    static template = APP_TEMPLATE;
    static components = { Task };

    tasks = [
        ...
    ];
}

// -------------------------------------------------------------------------
// Setup code
// -------------------------------------------------------------------------
function setup() {
    owl.config.mode = "dev";
    mount(App, { target: document.body });
}

whenReady(setup);

Тут много чего произошло:

  • во первых у нас появился компонент Task, определенный вверху файла
  • всякий раз, когда мы определяем подкомпонент, его нужно добавить к статическому ключу components его родителя, чтобы Owl мог получить ссылку на него,
  • t-шаблоны были извлечены из компонентов, чтобы было легче отличить код «представления/шаблона» от кода «скрипта/поведения»,
  • компонент Task имеет ключ props: это полезно только для целей проверки. В нем говорится, что каждой Task должно быть присвоено ровно одно свойство с именем task. Если это не так, Owl выдаст ошибку. Это очень полезно при рефакторинге компонентов
  • наконец, чтобы активировать проверку props, нам нужно установить Owl в режим dev. Это сделано в функции setup. Обратите внимание, что включение данного режима следует удалить, когда приложение используется на продакшене поскольку режим dev работает немного медленнее из-за дополнительных валидаций и проверок.

6. Добавление задач (часть 1)

Мы все еще используем список задач который задали в коде. Теперь пришло время дать пользователю возможно добавлять задачи самостоятельно. Первый шаг это добавить input к комопненту App. Но этот input должен быть за пределами списка задач, поэтому мы должны доработать шаблон, js и css для App:

<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;
}

.todo-app > input {
  display: block;
  margin: auto;
}

.task-list {
  margin-top: 8px;
}

Теперь у нас есть рабочий input, который пишет в лог консоли каждый раз когда пользователь добавляет задачу. Обратите внимание, что когда вы загружаете страницу, то input не в фокусе. Но добавление задачи это основная функция списка задач, поэтому давайте ускорим этот процесс путем наведения фокуса на этот input.

Т.к. App это компонент, то он имеет метод жизненного цикла mounted который мы можем реализовать. Нам так же нужно получить ссылку на input, для этого мы будем использовать директиву t-ref с хуком useRef:

<input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
// вверху файла:
const { useRef } = owl.hooks;
// в App
inputRef = useRef("add-input");

mounted() {
    this.inputRef.el.focus();
}

inputRef определен как поле класса, это тоже самое что и определение в конструкторе. Он просто указывает Owl сохранять ссылку на что-либо с соответствующим ключевым словом t-ref. Когда мы реализуем метод жизненного цикла mounted, где мы теперь имеем активную ссылку, которая может быть использована для наведения фокуса на input

7. Добавление задач (часть 2)

В предыдущем разделе мы сделали все кроме реализации кода, который создает задачу! Поэтому давайте сделаем это сейчас.

Мы должны найти способ создания уникальных номеров для id. Для того, чтобы сделать это мы просто добавим nextId к App. В то же время нам надо удалить демо задачи из App:

nextId = 1;
tasks = [];

Теперь можно реализовать метод addTask следующим образом:

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);
        }
    }
}

Уже почти все работае, но если вы протестируете получившийся результат, вы заметите что новая задача не отображается когда пользователь нажимает Enter. Но если вы добавите debugger или console.log выражение, вы увидите что это код выполняется как и ожидалось. Проблема заключается в том что Owl не имеет понятия что надо перерисовать пользовательски интерфейс. Мы можем исправить сделав tasks реактивным с помощью хука useState:

// вверху файла
const { useRef, useState } = owl.hooks;

// замените определение task в App следующим кодом:
tasks = useState([]);

Теперь все работает так как и ожидалось!

8. Переключение задач

Если вы попытаетесь пометить задачу как завершенную, вы можете заметить, что текст не изменил прозрачность. Это потому что у нас еще нет кода, который изменяет флаг isCompleted.

Теперь у нас интересная ситуация: задача отображается компонентом Task, но он не является владельцем состояни и по этому не может изменять его. Вместо этого мы хотим передать запрос для переключения задачи в компоненте App. Поскольку App является родителем Task мы можем запустить событие в компоненте Task и слушать его в App.

В компоненте Task, измение input следующим образом:

<input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>

и метод toggleTask:

toggleTask() {
    this.trigger('toggle-task', {id: this.props.task.id});
}

Теперь нам надо слушать это событие в шаблоне App:

<div class="task-list" t-on-toggle-task="toggleTask">

и реализовать код метода toggleTask:

toggleTask(ev) {
    const task = this.tasks.find(t => t.id === ev.detail.id);
    task.isCompleted = !task.isCompleted;
}

9. Удаление задач

Теперь давайте добавим возможность удалять задачи. Для того, чтобы сделать это мы сперва должны добавить иконку корзины для каждой задачи, затем мы пойдем тем же путем что и в предыдущем разделе

Для начала давайте обновим шаблон, css и js компонента Task:

<div class="task" t-att-class="props.task.isCompleted ? 'done' : ''">
    <input type="checkbox" t-att-checked="props.task.isCompleted" t-on-click="toggleTask"/>
    <span><t t-esc="props.task.title"/></span>
    <span class="delete" t-on-click="deleteTask">🗑</span>
</div>
.task {
  font-size: 18px;
  color: #111111;
  display: grid;
  grid-template-columns: 30px auto 30px;
}

.task > input {
  margin: auto;
}

.delete {
  opacity: 0;
  cursor: pointer;
  text-align: center;
}

.task:hover .delete {
  opacity: 1;
}
deleteTask() {
    this.trigger('delete-task', {id: this.props.task.id});
}

И теперь нам надо слушать событие delete-task в компоненте App:

<div class="task-list" t-on-toggle-task="toggleTask" t-on-delete-task="deleteTask">
deleteTask(ev) {
    const index = this.tasks.findIndex(t => t.id === ev.detail.id);
    this.tasks.splice(index, 1);
}

10. Использование хранилища

Глядя на код, становится очевидным, что теперь у нас есть код для обработки задач, расположенных более чем в одном месте. А еще у нас смешан UI код и код бизнес логики. У Owl есть способ управлять состоянием отдельно от пользовательского интерфейса: Хранилище.

Давайте задействуем его в нашем приложении. Это досточно большой процесс рефакторинга (для нашего приложения разумеется), поскольку нам потребуется извлечь из компонентов весь код, связанный с задачами. Вот новое содержимое файла app.js:

const { Component, Store, mount } = owl;
const { xml } = owl.tags;
const { whenReady } = owl.utils;
const { useRef, useDispatch, 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-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)">🗑</span>
    </div>`;

class Task extends Component {
  static template = TASK_TEMPLATE;
  static props = ["task"];
  dispatch = useDispatch();
}

// -------------------------------------------------------------------------
// App Component
// -------------------------------------------------------------------------
const APP_TEMPLATE = xml/* 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="tasks" t-as="task" t-key="task.id">
                <Task task="task"/>
            </t>
        </div>
    </div>`;

class App extends Component {
  static template = APP_TEMPLATE;
  static components = { Task };

  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;
  mount(App, { target: document.body });
}

whenReady(setup);

11 Сохранение задач в local storage

Теперь наше приложение TodoApp работае прекрасно, за исключением того, что если пользователь обновит страницу в браузере, то ваши задачи будут утеряны. Это действительно неудобно хранить состояние исключительно в памяти. Для того, чтобы это исправить мы будем сохранять наши задачи в local storage браузера. С нашей текущей кодовой базой это будет не сложно: необходимо обновить код настройки.

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";
  const env = { store: makeStore() };
  mount(App, { target: document.body, env });
}

Ключевой момент это исползьвание

Ключевым моментом является использование того факта, что хранилище является EventBus которое запускает событие update когда оно обновляется

12. Фильтрация задач

Мы уже почти закончили, мы можем добавлять/модифицировать/удалять задчи. Единственной пропущеной фичей является возможность отображать задачи в соотвествии с их завершенным статусом. Мы будем отслеживать состояние фильтра в App и затем фильтровать видимые задачи на основании его значения

// вверху файла добавьте useState:
const { useRef, useDispatch, useState, useStore } = owl.hooks;

// в 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;
}

И наконец, нам нужно отобразить видимые фильтры. Мы можем сделать это в одно время, отображать номер задачи в маленькой панели ниже списка задач:

<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;
}

Обратите внимание что мы установили динамический класс

Обратите внимание, что мы динамически устанавливаем класс фильтра с синтаксисом объекта: каждый ключ — это класс, который мы хотим установить, если его значение истинно.

13. Последний штрих

Функционал нашего списка закончен. Мы можем добавить еще несколько дополнительных деталей, чтобы улучшить наш пользовательский опыт.

  1. Добавим обратную связь когда пользователь навел курсор на задачу:
.task:hover {
  background-color: #def0ff;
}
  1. Сделаем название задачи клкабельным, для активации чекбокса:
<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>
  1. Перечеркнем название выполненной задачи:
.task.done label {
  text-decoration: line-through;
}

14. Финальный код

Теперь наше приложение полностью завершено. Оно работает, UI код отделен от кода бизнес логики, оно может быть протестировано, все 150 строк кода (включая шаблоны!)

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, mount } = 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)">🗑</span>
    </div>`;

  class Task extends Component {
    static template = TASK_TEMPLATE;
    static props = ["task"];
    dispatch = useDispatch();
  }

  // -------------------------------------------------------------------------
  // App Component
  // -------------------------------------------------------------------------
  const APP_TEMPLATE = xml/* xml */ `
        <div class="todo-app">
        <input placeholder="Enter a new task" t-on-keyup="addTask" t-ref="add-input"/>
        <div class="task-list">
            <Task t-foreach="displayedTasks" t-as="task" t-key="task.id" task="task"/>
        </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>`;

  class App extends Component {
    static template = APP_TEMPLATE;
    static components = { Task };

    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";
    const env = { store: makeStore() };
    mount(App, { target: document.body, env });
  }

  whenReady(setup);
})();
.todo-app {
  width: 300px;
  margin: 50px auto;
  background: aliceblue;
  padding: 10px;
}

.todo-app > input {
  display: block;
  margin: auto;
}

.task-list {
  margin-top: 8px;
}

.task {
  font-size: 18px;
  color: #111111;
  display: grid;
  grid-template-columns: 30px auto 30px;
}

.task:hover {
  background-color: #def0ff;
}

.task > input {
  margin: auto;
}

.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;
}