Всем привет, меня зовут Александр и вы находить в моем блоге, посвященному платформе Odoo.

Здесь я пишу статьи, которые дополняют основную документацию и свой опыт работы с системой.

Учебники по работе с Odoo

В этой главе вы узнаете как подготовить рабочее место для разработки на Odoo

Если вы совсем новичек и не имеете вообще никакого опыта разработки, начните вот с этой статьи:

Ниже информация для тех, кто считает себя более подготовленным:

А здесь разъясняется мой профессиональный взгляд, на то, как подготовить проект для разработки на Odoo руками без использования менеджера разработки

Как ускорить разработку на Odoo с помощью Docker на Windows

При стандартной работе менеджера разработки на Windows все будет работать очень медленно. Это связано с тем, что все файлы с которыми мы работаем и пытаемся пробросить внутрь контейнера доступны для WSL Linux как сетевые. Чтобы избежать этого мы будем сразу работать внутри WSL Linux. Это позволит значительно ускорить работу системы и позволит не переходить на Linux сразу.

Такой подход имеет свои ограничения - скорость копирования данных из файловой системы Windows в файловую систему WSL Linux очень низкая. Файловая система Linux находится внутри виртуального жесткого диска, который имеет динамический размер. Поэтому такой способ разработки подходит для небольших(по объему базы данных) и учебных проектов. В случае промышленной разработки я рекомендую переходить целиком на Linux, это позволит обойти все эти ограничения.

Будем считать что у вас уже установлены следующие программы на вашу Windows

  • Docker Desktop
  • VSCode

При установке Docker Desktop его инсталятор сам попытается установить WSL2, а так же в него будет установлено два минималистичных WSL Linux дистрибутива с именами docker-desktop и docker-desktop-data

Для наших целей необходимо установить WSL2 и Debian

Запускаем PowerShell (от имени администратора)

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

Чтобы проверить, какой версии установлен wsl и в случае необходимости сменить версию, следуйте советам на здесь Чтобы удалить подсистему linux, следуйте советам вот тут

Установка Debian на WSL

Устанавливаем Debian

wsl --install -d Debian

При запуске вводим имя пользователя и пароль. Я ставлю следущие:

  • имя пользователя - odoo
  • пароль - odoo

После этого вы должны остаться в Debian консоли примерно такого вида:

odoo@DESTOP-B6VG0DP:_

Если вы вышли в консоль Windows то зайти заново можно с помощью команды:

wsl --distribution Debian --user odoo

Настройка Docker Desktop

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

Настройка Docker-Desktop

  1. Нажимаем на шестриренку и открвается меню настроек
  2. Находим пункт WSL Integration
  3. Снимаем галочку, чтобы не использовался встроенный дитрибутив
  4. Включаем Debian
  5. Применяем наши настройки

Подключение к Debian с помощью VSCode

Для всех дальнейших действий мы будем использовать VSCode, поэтому запускаем его и устанавливаем себе дополнительное расширение с именем "Remote Development"

Установка расширения

  1. Кликаем на эту иконку
  2. В строке поиска вводим текст
  3. Устанавливаем это расширение

После этого мы сможем подключиться к WSL дистрибутиву

Подключение к WSL

  1. Нажимаем на эту кнопку
  2. выбираем этот пункт меню и выбираем Debian

Затем ждем когда VSCode установит свои компоненты внуть Debian, при первом запуске это может занять некоторое время

После того как VSCode все установил и подключился, нам нужно выбрать рабочий каталог

Открываем рабочий каталог

  1. Нажимаем на иконку для перехода в режим управления файлами
  2. Нажимаем кнопку "Открыть папку"
  3. Выбираем домашний каталог нашего пользователя, в нашем случае это /home/odoo
  4. Нажимаем ОК

На вопрос о доверии этой папке:

Доверяете ли вы автору

  1. Ставим галочку
  2. Нажимаем - "Да, я доверяю авторам"

После этого надо открыть терминал:

Открываем терминал

  1. Нажимаем на меню "Терминал"
  2. Открваем

Теперь мы можем работать с коммандной строкой нашего Debian, а справа у нас файлы домашнего каталога пользовалетя odoo

Начинаем подготовку системы:

Ставим все нужные пакеты

sudo apt install -y git mc docker.io docker-compose

система попросит пароль - это пароль от нашего пользователя Debian в нашем случае это odoo

Сейчас мы находимся в домашнем каталоге пользователя odoo. В Linux файловая систем имеет древовидную структуру и всегда начинается с /. Это так называемый корень файловой системы. Все домашние каталоги пользователей находятся в каталоге /home и называются именем пользователя. В нашем случае наша домашний каталог имеет полный путь /home/odoo. В этом же каталоге хранятся все индивидуальные настройки самого пользователя. Так же для навигации по каталогам вы можете запустить команду mc, ее мы установили на предыдущем шаге. Это двух-панельный файловый менеджер. Оно поможет лучше ориентироваться в файловой системе Linux. Если вы запустили mc то кликните мышкой на F10(Выход) в правом нижнем углу, или наберите exit.

Теперь давайте создадим каталог для хранения наших проектов:

mkdir /home/odoo/projects

Теперь заходим в папку projects

cd /home/odoo/projects

И клонируем туда наш менеджер проектов:

git clone https://github.com/aayartsev/odoo_dev_project.git

И создадим каталог для нашего учебного проекта:

mkdir /home/odoo/projects/odoo_demo_project-16

Теперь заходим туда

cd /home/odoo/projects/odoo_demo_project-16

И запускаем нашу менеджер проектов:

python3 /home/odoo/projects/odoo_dev_project/odpm.py --init https://github.com/aayartsev/odoo_demo_project.git

Когда система спросит какую версию вы odoo вы хотите запустить то пишите 16.0, т.к. модули в этом проекте рассчитаны на нее.

После этого вы можете открыть новое окно VSCode и отрыть новое подключение к WSL, при выборе папке уже выбирайте /home/odoo/projects/odoo_demo_project-16

Если вы хотите сделать проект для 17 версии, то создайте в каталог /home/odoo/projects/odoo_demo_project-17 и попробуйте сделать это самостоятельно, глядя на документацию самого проекта. Все модули и ветки работаю и в 17 версии, но требуют небольшой правки. При запуске odoo сама вам об этом напишет и откажется запускаться. Это будет первая тренировка на отладку системы. Обратите внимание на плашку ru, нажав на которую, вы попадете на русскую версию документации.

Для работы с git репозиториями нужно будет сформировать ключи и отправить публичную часть на git сервер. Как это сделать, вы можете прочитать тут, вас интересует именно Linux, хотя на компьютере у вас стоить Windows.

Перезапустить систему

Остановить систему можно нажатием сочетания клавиш Ctrl+C, затем нажать кнопку вверх и нажать Enter, таким образом система будет перезапущена

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Установка docker и docker-compose в данной статье не рассматривается. Для Windows и MacOS надо поставить Docker Desktop

Сам проект со инструкцией по его применению находится тут - система разработки

Проект с разрабатываемыми модулями odoo находится тут - проект с odoo модулями

Данный проект автоматизирует все те действия, которые описаны здесь и здесь

Работает на всех основных трех ОС:

  • Windows - работает корректно, но медленно, если хотите ускорить процесс читайте эту статью
  • Linux - все протестировано на системах основанных на Debian, думаю на остальных будет работать тоже
  • MacOs - протестировано на процессоре M1 - все отлично работает

Для комфортной работы с git репозиториями необходимо сформировать ssh ключ и загрузить его публичную часть на сервер с git(например github.com). Как это сделать, вы можете прочитать тут

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Общая информация о том как сформировать проект для разработки на Odoo

  1. Базовая настройка odoo происходит для системы debian (безусловно можно запустить odoo нативно на любой платформе, но сами разработчики выбрали для себя именно debian)
  2. Для запуска odoo на других операционных системах мы будем использовать Docker
  3. Для запуска в рамках системы контейнеризации Docker мы будем использовать для Windows или MacOS DockerDesktop. Поскольку технология контейнеризации принадлежит исключительно к Linux системе, то для того, чтобы Docker бесшовно работал на других ОС в его состав входит механизм запуска виртуальной машины с Linux в внутри которой и запускается контейнер.
  4. Конфигурирование проекта для разработки на odoo является достаточно не тривиальной задачей как для новичка, так и представляет множество возможностей совершить ошибку по невнимательности уже опытному разработчику

Из чего состоит проект для разработки на odoo:

Под проектом разработки на odoo я подразумеваю каталог, открыв который в VSCode мы сможем вести нормальную разработку:

  • получим доступ ко всем необходимым файлам
  • будем иметь возможность запускать и останавливать процесс с разрабатываемым приложением
  • производить отладку системы как с помощью отладчика так и с помощью нестареющей классики print().

Чтобы соблюсти все эти условия нам необходимо проделать следующие действия:

  • в первую очередь мы должны подключить к проекту каталог с исходными текстами самой odoo. У себя в хостовой системе я делаю клон всего репозитория и символьную ссылку от ее реального расположения в каталог своего текущего проекта, плюс мы должны пробросить этот же каталог в контейнер.
  • если у нас есть проекты, от которых зависит наш разрабатываемый проект, то нам нужно склонировать на свой компьютер нужные репозитории и так же сделать символьные ссылки из нашего проекта на эти каталоги (может возникнуть ситуация что мы вынуждены будет лезть туда отладчиком), плюс мы должны пробросить все эти каталоги во внутрь контейнера.
  • в третью очередь нам нужно подключить к проекту каталог с текущим разрабатываемым проектом, который представляет собой отдельный git репозиторий с модулями для odoo, или даже может содержать несколько каталогов с модулями. Мы точно так же делаем мягкую ссылку от каталога с репозиторием в наш проект. Точно так же делаем проброс каталога с этим репозиторием во внутрь контейнера

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

git checkout 16.0

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

/home/user/odoo_projects - каталог с проектами модулей odoo
/home/user/odoo_projects/github.com/aayartsev/odoo_demo_project - в этом же каталоге для каждого сервиса, 
автора и репозитория создается свой подкаталог, чтобы различные проекты не могли пересекаться по имени
/home/user/odoo_projects/github/OCA/manufacture - например если в зависимостях есть модуль manufacture

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

/home/user/projects/odoo_demo_project-16

В конце всех этих мероприятий у нас должен получится каталог с проектом внутрь которого проброшены в виде мягких ссылок следующие каталоги git репозиториев:

  • каталог с самой платформой odoo
  • каталог с проектом, который мы сейчас разрабатываем
  • каталоги проектов от которых зависит наш текущий разрабатываемый проект от сторонних разработчиков Созданы следующие файлы и каталоги внутри проекта
  • файл конфигурации системы
  • каталог с файлами окружения, куда будут устанавливаться python пакеты для нашего проекта
  • каталог для хранения статических ресурсов odoo и системы pre-commit, это нужно для того, чтобы между перезапусками системы данные не исчезали, т.к. контейнер всегда запускается с состоянием файловой системы образа

И эти же каталоги и файл конфигурации у нас должны быть прописаны в docker-compose.yml для их проброса внутрь контейнера. Таким образом мы добились того, что для нас в редакторе кода и внутри контейнера доступны одни и те же ресурсы. Теперь, для того чтобы нам запустить процесс odoo внутри контейнера нужно соблюсти следующие условия:

  • При запуске процесса надо указать путь к конфигурационному файлу, причем путь к нему должен указываться относительно файловой системы контейнера. Т.е. мы должны создать его в своем каталоге с проектом, прописать проброс его внутрь контейнера в файле docker-compose.yml

  • Внутри файла конфигурации должны быть прописаны пути ко всем каталогам с модулями, пути тоже должны быть написаны относительно файловой системы контейнера. Обратите внимание, что внутри каталога с проектом odoo есть аж два каталога с названием addons и они оба должны быть указаны в файле конфигурации

  • Все проекты должны быть переключены на ветку с одинаковой версией, т.е. перед запуском проекта мы должны убедиться что, если мы разрабатываем проект для версии 14 то odoo и проекты от которых зависит наш разрабатываемый проект должны быть переключены на ветку 16.0. Это связано с тем, что по сложившейся традиции проект может содержать в себе модули для разных версий, где каждая версия находится в отдельной ветке и имеет название НОМЕР_ВЕРСИИ.0, например 8.0, 12.0, 14.0 и т.д.

  • Ваш текущий разрабатываемый проект должен находиться в нужной вам в текущий момент ветке Если все эти условия выполнены то процесс odoo запущенный внутри контейнера правильно найдет все пути к модулям и запустится без ошибок.

  • Все каталоги с кодом, так же должны быть прописаны в файле ./.vscode/launch.json, примерно следующим образом:

{
    "configurations": [
        {
        "name": "Odoo: Remote Attach",
        "type": "python",
        "request": "attach",
        "port": 5678,
        "host": "localhost",
        "pathMappings": [
            {
                "localRoot": "${workspaceFolder}/odoo_demo_project",
                "remoteRoot": "/home/odoo/extra-addons/odoo_demo_project"
            },
            {
                "localRoot": "${workspaceFolder}/odoo",
                "remoteRoot": "/home/odoo/odoo"
            }
        ]
        }
    ]
}

Подводя итог - мы собрали проект таким образом чтобы одни и те же файлы, которые мы видим в своем редакторе кода, видны внутри контейнера и мы запускаем целевой процесс в изолированном стандартном окржужении. Работа с git проиходит в хостовой системе, и VSCode спокойно видит все репозитории внутри проекта и можно использовать его инструментарий.

Дополнительная фишка такого подхода - это использование одних и тех же git репозиториев в различных проектах для разных версий odoo.

Финальный вид должен быть примерно таким:

Финальный результат

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Создание окружения для разработки на odoo с помощью docker. С запуском pre-commit и возможностью использвоания отладчика

Установка docker и docker-compose в данной статье не рассматривается. Это легко гуглится)

Создание и настройка docker образа для работы с проектом

Создаем нативный образ для работы с odoo под arm или x86

FROM python:3.7.12-bullseye
# Переменные проекта
ARG USER_UID=9999
ARG USER_GID=9999
  

ARG USER_NAME=odoo
ARG PASSWORD=odoo

# Подготавливаем внутреннего пользователя
RUN groupadd --gid $USER_GID $USER_NAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USER_NAME -p $(openssl passwd -crypt $PASSWORD) \
&& apt-get update \
&& apt-get install -y sudo \
&& echo $USER_NAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USER_NAME \
&& chmod 0440 /etc/sudoers.d/$USER_NAME\
&& mkdir /home/$USER_NAME/.ssh\
&& chown $USER_NAME:$USER_NAME /home/$USER_NAME/.ssh\
&& mkdir -p /home/$USER_NAME/.vscode-server\
&& chown -R $USER_NAME:$USER_NAME /home/$USER_NAME/.vscode-server\
&& apt-get -y install git postgresql-client node-less npm ssh \
&& python3 -m pip install pre-commit

# устанавливаем все необходимые зависимости

RUN apt-get -y install libxml2-dev libxslt1-dev libldap2-dev\
libsasl2-dev libtiff5-dev libjpeg62-turbo-dev libopenjp2-7-dev\
zlib1g-dev libfreetype6-dev liblcms2-dev libwebp-dev\
libharfbuzz-dev libfribidi-dev libxcb1-dev libpq-dev\
openssl build-essential libssl-dev libxrender-dev \
git-core libx11-dev libxext-dev libfontconfig1-dev libfreetype6-dev fontconfig\
xvfb libfontconfig python3-virtualenv virtualenv

# Скачиваем и устанавливаем wkhtmltopdf который будет рендерить PDF файлы как нам нужно,
# пакет из официальных репозиториев не рендерит заголовки и подвалы

RUN wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.bullseye_arm64.deb \
&& apt-get install -y ./wkhtmltox_0.12.6.1-2.bullseye_arm64.deb

USER $USER_NAME

Для того, чтобы собрать образ (image) необходимо запустить следующую команду

docker build -f $HOME/projects/project-odoo/Dockerfile -t odoo-arm .

Получившийся образ будет базовым, на его основе мы создадим демо проект для 16.0 версии. Если вы хотите что-то добавить в образ после его создания, то это можно сделать следующим образом - запускаете нужную вам команду, как показано ниже:

docker run odoo-arm  bash -c "pip3 install passlib"

Теперь изменения, которые мы внесли в базовый образ необходимо сохранить Вводим команду, которая покажет текущие образы

docker ps -l

Находим образ с нужным именем

CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS                     PORTS     NAMES
f26b7ca2f1eb   odoo-arm   "bash -c 'cd /home/o…"   9 minutes ago   Exited (0) 5 minutes ago             upbeat_dijkstra

И сохраняем все получившиеся изменения в образ с нужным именем

docker commit f26b7ca2f1eb odoo-arm:v2.0_with_passlib

Настройка проекта для работы с docker

Видиние того, как правильно подготавливать проект для разработки у каждого специалиста свое. Поэтому мое описание не надо воспринимать как нечто в последней инстанции и самое правильено решение. Это всего лишь один из бесконечного количества вариантов.

Свой взгляд на формирование проекта я изложил здесь

Теперь давайте поговорим про запуск docker контейнера с помощью которого у нас будет вестиcь разработка. Для того чтобы запустить odoo нам нужен работаютщий postgresql сервер, к которому будет подключаться odoo. Чтобы его (сервер базы данных postgresql) запустить мы воспользуемся инструментом оркестровки docker контейнеров - docker-compose. Docker-compose это система управления и запуска docker контейнров. Мы будем ее использовать для того, чтобы запустить сразу 2 образа с нужными нам настройками и параметрами. Для настройки запуска образов у нас в каталоге проекта лежит файл docker-compose.yml

Вот его содержимое:

version: "3.8"


services:
  db:
    image: postgres:13
    user: root
    tty: true
    ports:
      - 5432:5432
    environment:
      - POSTGRES_PASSWORD=odoo
      - POSTGRES_USER=odoo
      - POSTGRES_DB=postgres

  odoo:
    image: odoo-amd64
    user: odoo
    tty: true
    depends_on:
      - db
    environment:
      - PYTHONUNBUFFERED=1
    ports:
      - 8069:8069
      - 5678:5678
    volumes: 
      - /home/odoo/odoo:/home/odoo/odoo
      - /home/odoo/projects/odoo_demo_project-16/venv:/home/odoo/venv
      - /home/odoo/projects/odoo_demo_project-16/dev_odoo_docker_config_file.conf:/home/odoo/odoo.conf
      - /home/odoo/projects/odoo_demo_project-16/dev_project:/home/odoo/dev_project
      - /home/odoo/odoo_backups:/home/odoo/backups
      - /home/odoo/projects/odoo_demo_project-16/docker_home/.local:/home/odoo/.local
      - /home/odoo/projects/odoo_demo_project-16/docker_home/.cache:/home/odoo/.cache
      - /home/odoo/odoo_projects/github.com/aayartsev/odoo_demo_project:/home/odoo/extra-addons/odoo_demo_project

    command: bash -c ' cd /home/odoo && source /home/odoo/venv/bin/activate && python3 -u -m debugpy --listen 0.0.0.0:5678 /home/odoo/odoo/odoo-bin -c /home/odoo/odoo.conf --limit-time-real 99999 -d test_db -i first_module -u first_module'

Теперь давайте рассмотрим подробнее, что именно сздесь указано: Если в кратце то мы видим инструкцию для запуска двух сервисов с именами db и web. Если с сервисом db примерно все понятно, что мы берем образ для postgresql 13 запускаем все от внутреннего пользователя docker контейнера с именем root и указываем через параметры окружения первичные логин и пароль для подключения к базе. То на сервисе с именем web надо остановиться поподробнее: image - имя образа, который мы собрали в первой части этой статьи user - все действия внутри контейнера будут запускаться от имени этого пользователя в нашем случае это odoo depends_on - данный параметр указвает на то, что наш сервис web зависит от работающего сервиса db и система, автоматически сначала запустит его, а потом уже наш web. enviroment - в нашем случае мы указываем только один параметр PYTHONUNBUFFERED=1, который нужен для того, чтобы при отрабоке функции print() в нашем коде результат выводился в консоль. Очень полезно при отладке. ports - здесь мы укзаываем порты которые буду пробрасываться из этого контейнера на наш localhost, что позволит подключаться к сервису запущенному внутри контейнра просто обратившись на на этот порт по адресу localhost или 127.0.0.1. В нашем случае указаны 2 порта: - 8069 - порт по умолчанию который используется odoo, он нам нужен для подключение к системе через браузер, или программны образом - 5678 - порт который прослушивает дебаггер python, нам он нужен для подключения отладчика. volumes - в этом параметре мы указываем все каталоги и файлы, которые нам нужно пробросить внутрь контейнера, при этом, чтобы они были доступны и в нашей файловой системе.

# Пробрасываем каталог с исходниками платформы внутрь контейнера
- /home/odoo/odoo:/home/odoo/odoo
# Подключаем файл конфигурации из нашего проекта внутрь контейнра
- /home/odoo/projects/odoo_demo_project-16/venv:/home/odoo/venv
      - /home/odoo/projects/odoo_demo_project-16/dev_odoo_docker_config_file.conf:/home/odoo/odoo.conf
# Подключаем файлы проекта с которым будем работать внутрь контейнера
- /home/odoo/odoo_projects/github/OCA/manufacture:/home/odoo/extra-addons/manufacture
# Так же подключаем каталог для хранения файлов, где хранятся сессии и статические ресурсы системы, это нам нужно для того, чтобы при перезапуске контейнера эти данные не исчезали. 
- /home/odoo/projects/odoo_demo_project-16/docker_home/.local:/home/odoo/.local
# Плюс каталог в котором будуте хранится кэш для pre-commit
- /home/odoo/projects/odoo_demo_project-16/docker_home/.cache:/home/odoo/.cache

Все остальные файлы, которые указаны в файле конфигурации я пробрасываю для индивидуальной настроки pre-commit, т.к. macos имеет свои особенности и мне при его запуске необходиме внести исключение. command - по сути команда которая будет запущена при запуске контейнера.

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

docker run odoo-arm bash -c 'cd /home/odoo && python3 -m venv /home/odoo/venv && . /home/odoo/venv/bin/activate && wget -O odoo_requirements.txt https://raw.githubusercontent.com/odoo/odoo/16.0/requirements.txt && python3 -m pip install -r odoo_requirements.txt && python3 -m pip install debugpy==1.6.3'

Данная команда скачает файл со всеми python зависимостями для нашей 16 версией и установт в указанный каталог, после этого еще доустановит в наше окружение указанную версию debugpy. Этот пакет нам нужен для запуска системы с отладчиком.

Далее не забываем правильно настроить пути к каталогам с модулями в файле dev_odoo_docker_config_file.conf. Так же необходимо учитывать что там должны быть пути для файловой системы внутри контейнера. Смотрите файл docker-compose.yml параметр volumes - там указаны все проброшенные пути к модулям

Настройка VSCode с возможностью дебага

Установку и настройку VSCode я тут описывать не буду. Для правильной работы нам нужно установить дополнение Dockerиз маркет дополнений Затем мы открываем каталог с проектом odoo_demo_project-16. Нажимая на иконку с отладкой VSCode предложит настроить следующий файл

odoo_demo_project-16/.vscode/launch.json

Если вы правильно выполнили предыдущие настройки, то у вас внутри контейнера уже должен быть установлен пакет debugpy. А параметры запуска уже учиыватю использование данного модуля для отладки. Поэтому теперь нам нужно только подготовить саму VSCode для подключения к серверу отладки. Для правильной настройки нам в файле launch.json нужно указать адрес сервера к котрому нужно подключиться, а так же какой каталог вашего репозитория каком каталоге соотвествует внутри docker контейнера. Вот пример файл запуска отладчика:

{
	"configurations": [
		{
			"name": "Odoo: отладка в Docker",
			"type": "python",
			"request": "attach",
			"port": 5678,
			"host": "localhost",
			"pathMappings": [
				{
					"localRoot": "${workspaceFolder}/manufacture",
					"remoteRoot": "/home/odoo/extra-addons/manufacture"
				}
			]
		}
	]
}

Все, теперь после запуска контейнера с odoo вы нажав на F5 автоматически подключитесь к сервер отладки и можете использовать точки останова и прочее. Подробнее про настройку отладки можно почитать в официальной документации VSCode Для запуска pre-commit нам необходимо запустить следующую команду:

docker run odoo-arm bash -c 'cd /home/odoo/extra-addons/odoo_demo_project && ls && git config --global --add safe.directory /home/odoo/extra-addons/odoo_demo_project && pre-commit run --all-files'

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

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Взаимодействие с базой данных

Содержание

Вводная информация

Odoo работает совместно с СУБД PostgreSQL. На данном этапе мы не будем углубляться в технические тонкости. Я остановлюсь только на тех вещах, которые необходимы начинающему разработчику. В дальнейшем запущенный экземпляр сервера PostgreSQL я буду называть разными словами, например сервер БД, просто БД, Postgres или как-то еще.

Для того, чтобы наш экземпляр сервера odoo подключился к серверу БД нам нужно этому экземпляру как-то объяснить к какому серверу и порту подключаться, а так же с каким именем и паролем это делать. В случае с odoo вся эта информация должна быть отображена в файле конфигурации (шаблон которого находится в каталоге templates в каталоге менеджера управления проектами):

Параметры для работы с БД

Поскольку для разработки мы используем менеджер управления проектами, то он сделает все за нас. Запустит контейнер с БД, задаст все необходимые настройки для него и сгенерирует конфигурационный файл на его базе. Для того, чтобы узнать как, подключиться к БД внутри контейнера, можно посмотреть в docker-compose.yml файл, который создается в каталоге с нашим проектом. В нем будет описан сервис с именем db:

  db:
    image: postgres:13
    user: root
    tty: true
    ports:
      - 5432:5432
    environment:
      - POSTGRES_PASSWORD=odoo
      - POSTGRES_USER=odoo
      - POSTGRES_DB=postgres

Здесь мы можем видеть, что для подключения к БД напрямую, например удобным клиентом к различных базам данных DBeaver или плагином к вашей IDE. Мы будем использоват порт 5432, сервером у нас будет localhost или 127.0.0.1. А имя и пароль мы берем из переменных POSTGRES_PASSWORD и POSTGRES_USER. Подключаться к БД напрямую во время разработки частенько приходится и эта информация здесь именно для того, чтобы облегчить вам жизнь.

Создание новой БД

Поскольку, odoo сама может подключаться к БД как администратор, то она же может управлять не только данными в конкретной базе, но создавать новые базы, делать их копии, создавать архивы и удалять. Для этого в системе odoo есть менеджер баз данных. Если вы запустили сервер odoo в первый раз без указания ключа -d, то система предложит вам самостоятельно создавать БД заполнив соответствующую форму:

Создание новой БД

  1. Мастер-пароль - пароль от менеджера базы данных, его хеш хранится в файле конфигурации по ключу admin_passwd. Поскольку он не назначен, то на желтом фоне можно увидеть предложенный от системы пароль. Он потребуется если вы захотите сделать копию базы, удалить базу или загрузить новую. В общем все манипуляции с базами данных потребуют ввода пароля. Если он отсутствует в файле конфигурации, то при манипуляции с базами система его не будет спрашивать. При использовании менеджера разработки пароль не устанавливается и вы можете делать с базами все что захотите. Обратите внимание, что при создании новой базы система заставляет вас его использовать.
  2. Имя базы данных. Вы просто вводите имя
  3. Email - он же логин для учетной записи администратора. Вам не обязательно вводить реальный email, более того логин не обязан быть похож на электронный адрес. Даже сами создатели по умолчанию используют admin.
  4. Пароль для учетной записи администратора
  5. Номер телефона - не обязательное поле. Ни на что не влияет.
  6. Язык, можете выбрать тот, который вам удобнее, но для разработки я бы рекомендовал использовать английский
  7. Страна - ее выбор влияет на настройки бухгалтерского учета и другие параметры, например валюта. Поэтому лучше использовать ту страну, для которой вы создаете проект
  8. Нужно ли устанавливать демо данные. Если стоит галочка, то система при создании новой базы добавит демо данные. Клиентов, поставщиков, создаст документы продаж и закупок. Может быть удобно при изучении в качестве примера.

Использование менеджера БД

Если же у вас уже есть несколько баз данных, то система предложит вам выбрать ту, с которой вы будете работать:

Менеджер БД

  1. Обратите внимание на ссылку. Вы всегда можете перейти по ней чтобы попасть в менеджер баз данных odoo
  2. Предупреждение о том, что не установлен мастер-пароль (для разработки я его не использую т.к. больше мешает, чем помогает). Его можно назначить нажав на кнопку Set Master Password (6)
  3. Выбор базы данных, здесь вы можете ее удалить Delete, сделать копию Dublicate или же загрузить себе архив Backup. В этом случае браузер скачает файл, который содержит в себе полную копию вашй базы данных. И вы можете ее восстановить с помощью кнопку Restore Database
  4. Создание базы данных. Выше уже описано, как это делать.
  5. Восстановление базы данных их архива. См. п. 3
  6. Установка мастер-пароля. Смотри п. 1 из описания картинки про создание базы данных

Восстановление БД

При восстановлении базы данных вы увидите следующее:

Восстановление БД

  1. Введите мастер пароль
  2. Выберите файл с архивом
  3. Введите имя базы данных
  4. Выберите тип копирования, если вам не понятно о чем идет речь, выбирайте верхний вариант This database is a copy

Создание архива БД

При создании архива базы данных:

Создание архива БД

  1. Введите мастер пароль
  2. Выберите имя БД, для которой хотите сделать резервную копию
  3. Выберите тип архива. zip- включает в себя файловое хранилище, и может быть реально большим, pg_dump - выгрузит только архив самой базы данных без файлового хранилища, может быть полезно в различных случаях, например когда файловое хранилище очень большое и для разработки оно вам не нужно

При дублировании и удалении я думаю все достаточно очевидно)

Вход в БД

После того как выбрали базу данных, у нас появится следующее окошко для входа:

Вход в БД

  1. Можно выбрать другую базу данных
  2. Логин, который мы задавали при создании базы данных
  3. Пароль, который мы задавали при создании базы данных
  4. Перейти в менеджер управления базами данных

Выход из БД

Чтобы выйти из базы данных нужно нажать следующую кнопку:

Выход из БД

  1. Нажмите в правом верхнем углу на имя пользователя
  2. В появившемся меню нажмите Выйти или Logout

Автоматизация действий с БД

При использовании менеджера разработки есть возможно автоматизировать рутинные действия:

  • Если вы испольуете ключ -d и указываете имя базы данных, то система проверит существует ли база с таким именем, и если есть, запустит систему в режиме работы с одной базой. Если базы данных не существует, то она будет создана.
  • При автоматическом создании базы данных вы может заранее задать параметры в файле конфигурации для создания базы данных. Смотрите ключ db_creation_data в документации и файле конфигурации
  • Вы так же можете автоматически удалить базу данных с указанным именем с помощью параметров командной строки. Читайте документацию
  • Можно так же делать архив базы данных и восстанавливать базу данных из предоставленного вам архива, для этого достаточно положить его в каталог, который указан в .env файле по ключу BACKUP_DIR. При создании архива вашей базы данных, новый архив появится в этом же каталоге. Имя архива обычно выглядит как имя_базы_дата_время.

Примеры:

Запустить в режиме монобазы или создать новую с именем db-name-16 и установить модули в файле конфигуркции по ключу init_modules и обновить модули указанные в файле конфигурации по ключу update_modules

python3 /path/to/your/project/docker_start.py -d db-name-16 -i -u

Удалить базу данных, создать сразу с таким же именем, установить модули в файле конфигурации по ключу init_modules и обновить модули указанные в файле конфигурации по ключу update_modules

python3 /path/to/your/project/docker_start.py -d db-name-16 -i -u --db-drop db-name-16

Восстановить архив с именем db_name_16_2023_12_12_12_12_12, присвоить имя базе данных db-name-16 и установить модули в файле конфигуркции по ключу init_modules и обновить модули укзанные в файле конфигурации по ключу update_modules. Архив с таким именему будет искаться в каталогe, который указан в .env файле по ключу BACKUP_DIR

python3 /path/to/your/project/docker_start.py -d db-name-16 -i -u --db-restore db_name_16_2023_12_12_12_12_12

Задания для самостоятельного выполнения:

  1. Запустите систему без ключа -d. Создайте базу данных из менеджера БД. Укажите в качестве имени базы my_new_database-16, в качестве логина администратора new_admin, и паролем new_password
  2. Войдите в эту базу
  3. Выйдите из нее
  4. Создайте резервную копию - zip архив
  5. Удалите базу my_new_database-16
  6. Восстановите из резервной копии базу с именем my_new_database_2-16
  7. Войдите в нее
  8. Выйдите из нее
  9. Удалите все базы данных
  10. Создайте базу данных с использованием менеджера разработки
  11. Войдите в нее
  12. Удалите ее с использованием менеджера разработки

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Режим разработчика

Содержание

Включение режима разработчика

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

Переходим в глобальные настройки

  1. Нажмите на кнопку основного меню
  2. Нажмите на "Настройки"

Вы откроете глобальные настройки системы:

Переходим в глобальные настройки

Обратите внимание на количество пунктов меню.

А теперь давайте добавим GET параметр debug=1 в наш адрес:

Включили режим разработки

  1. Вот таким образом мы добавляем GET параметр debug=1 в наш адрес и нажимаем Ввод.
  2. После того как обновилась страница, мы увидим что добавилось еще 2 пункта меню
  3. Появилась иконка жука - это дополнительное меню для разработчиков.

Как мы видим включение режима разработки добавило дополнительные пункты меню. Такие пункты могут стать видны не только в настройках, но в других приложения системы. Это связано с тем, что режим включение режима разработки, как бы добавляет вашего текущего пользователя в дополнительную группу, участники которой могут видеть эти пункты меню. (На самом деле не только пункты меню, но и другие сущности, но пока нам хватит и этого).

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

Технические параметры

Меню Переводы мы точно так же не будем сейчас детально рассматривать, оно предоставляет доступ к инструментам для работы с переводами.

Теперь давайте перейдем в нашу демо программу Главное Меню -> First Model Root -> record 001

Открываем демо программу

  1. Нажмите на кнопку главного меню
  2. Выбирете First Model Root
  3. В появившемся списке нажмите на record 001

У нас откроется представление Form или просто форма записи

Режим разработки в форме

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

Режим разработчика с ассетами

Помимо стандартного режима разработчика, существует еще так называемый режим разработчика с ассетами. Что это значит? У платформы Odoo есть свой механизм сборки всех статических ресурсов (файлы javascript, css, scss, xml файлы шаблонов, эти файлы и назваются ассетами). В обычном режиме система сначала собирает все эти файлы в пакеты - так называемые бандлы. Это нужно для того, чтобы уменьшить объем загрузки от сервера к клиентам. Но при разработке фронт-энд части (приложение, которое работает непосредственно в браузере) нам, как разработчиком нужно видеть не гигантские файлы по многу мегабайт, а все файлы по отдельности, что позволит увидеть какая строка какого файла вызывает ошибку или использовать встроенный в браузер отладчик для поиска ошибок в собственном коде на js.

Этот режим активируется так же как и обычный, только вместо 1 мы ставим слово assets и получается debug=assets.

Как выйти из режима разработчика

Нажмите на "жука" и в выпадающем списке найдите пункт Leave The Developers Tools

Покинуть режим разработчика

Задания для самостоятельного выполнения:

  1. Войдите в режим разработчика
  2. Откройте первую запись демо приложения
  3. Наведите на все поля где есть вопросики и изучите информацию
  4. Выйдите из режима разработчика

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Как отлаживать приложение

Содержание

Как отлаживать приложение

Одна из самых частых задач при разработке - это поиск и устранение ошибок. Это может быть весьма сложным мероприятием, и для того, чтобы облегчить себе жизнь, мы будем использовать специальные инструменты для этого:

  1. Отладчик
  2. Вывод в консоль с помощью команды print()

Давайте воспользуемся этими инструментами. Для начала найдите и откройте файл first_model.py, как показано на рисунке ниже:

Откройте файл first_model.py

Затем мы добавим в него содержимое 33 строки, и настроим отладчик:

Настраиваем и запускаем отладку

  1. Добавьте в файл команду print("self", self). Сохраните и перезапустите систему. После этого откройте приложение в браузере и найдите первую запись Главное Меню -> First Model Root -> record 001 и нажмите на кнопку START FUNCTION.
  2. После нажатия на кнопку вы увидите, что в консоли появилась надпись self first.model(1). Где первая часть - это строка, которую мы написали первым аргументом функции print("self", self), а first.model(1) - это строковое представление объекта self. Т.е. мы можем посмотреть состояние объекта в момент выполнения нашей программы
  3. Теперь перейдем в режим отладки
  4. Когда наша программа работает, мы нажимаем на зеленую стрелочку и запускаем отладчик
  5. Если отладчик уже запустили, остановите его нажав на красную пиктограмму в пункте 7(это пункт 5, через один будет 7 ), и теперь вы можете поставить, так называемую "точку останова". Запустите отладчик, снова нажав на зеленую стрелку п.4. Затем опять откройте нашу программу и нажмите кнопку START FUNCTION.
  6. Когда выполнение нашей программы подойдет к этой точке, она автоматически встанет на паузу. И вы сможете увидеть все объекты и их состояние в этом месте. Как вы можете видеть, этот инструмент гораздо более совершеннее чем print(), тем не менее они оба могут быть использованы для отладки
  7. Панель управления отладчиком, тут вы можете делать пошаговый проход по коду программы, погружаться глубже и даже делать шаг назад.

Задание для самостоятельного выполнения:

  1. Вам нужно повторить все действия которые вы видите на картинках и получить аналогичный результат.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Как организована работа с git

Содержание

Основные сведения

Для того, чтобы вести эффективную разработку, нам необходимо использовать git. Сами основы работы с git в этой статье отражены не будут. Вы можете самостоятельно найти множество видео или статей в интернете. Но я рекомендую читать официальную документацию. Она полностью переведена на русский язык. Здесь я опишу то, как можно использовать git с помощью VSCode и менеджер разработки организует с ним работу.

В первую очередь менеджер использует возможности git при переключении с между проектами. Поскольку каждая ветка репозитория являет собой некоторое состояние файловой системы, а в odoo комьюнити принято за стандарт то что в одном репозитории модули для каждой версии системы хранятся в своей ветке и имя ветки совпадает с номером версии, то при старте системы, менеджер проектов проверяет совпадает ли версия из файла конфигурации с версией самой odoo, а так же версиями репозиториев модулей-зависимостей. Т.е. при открытии проекта версии 14.0 менеджер зайдет в каталог с исходниками odoo и переключит состояние git репозитория на ветку с именем 14.0. А если версия проекта указана как 16.0, то соответственно переключит на версию 16.0.

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

В прошлый раз, при изучении приемов отладки, мы вставляли команду print(), и это изменение увидела VSCode, нажмите на иконку Использование git и у вас появится следующий экран:

Использование git

  1. Здесь вы видите изменения, которые вы внесли, вы так же можете их корректировать, прямо в правой части.
  2. Если вас устраивают эти изменения вы можете их добавить к коммиту, нажав на плюсик
  3. Оставьте комметарий к своему коммиту
  4. Нажмите Фиксация и вы совершите коммит, ваши изменения будут добавлены в локальный git репозиторий.
  5. Дальше вы можете добавить ваш коммит на гит сервер, но мы пока этот момент не будет рассматривать

Переключение на другую ветку

Для того, чтобы переключиться на другую ветку нам нужно в командной строке перейти в каталог с разрабатываемым проектом на odoo. Когда мы открываем наш проект в VSCode и запустили менеджер разработки, то он создаст мягкую ссылку на разрабатываемый проект. После этого мы можем легко перейти в этот каталог и переключиться на нужную ветку.

cd /path/to/odoo_demo_project-16/odoo_demo_project

Посмотрим какие ветки нам доступны:

git pull
git branch -r

Эта команда обновит состояние локального репозитория и покажет ветки, которые доступны на удаленном сервере

Затем переключаемся на нужную ветку

git checkout имя-ветки

После этого состояние файловой системы будет соответствовать состоянию выбранной ветки.

Задания для самостоятельного выполнения:

  1. Внесите какое либо изменение в файл с проектом
  2. Добавьте его в коммит
  3. Напишите комментарий к коммиту и сделайте этот самый коммит.
  4. После этого вы можете в терминале перейти в каталог с разрабатываемым проектом, в нашем случае это будет:
cd /path/to/odoo_demo_project-16/odoo_demo_project

или, если вы открыли терминал в VSCode, то сразу находитесь в каталоге проекта можно просто:

cd ./odoo_demo_project

и после этого вы можете вбить команду:

git log

И вы увидите список все коммитов, в том числе и те которые сделали вы. Управлять репозиторием вы можете как с помощью инструментов VSCode, так и с помощью коммандной строки. Лично я пользуюсь обоими этими инструментами.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Работа с сессиями odoo при активной разработке

Оглавление

Сессия

Сессия в odoo называются все запросы, которые подписаны одним идентификатором. Этот идентификатор выдается вам, когда вы заходите первый раз на страницу системы. Если вы ввели логин и пароль, то у вас будет авторизованная сессия. В первую очередь это нужно для того, чтобы при работе системы все запросы можно было автоматически идентифицировать и предоставить соответствующий доступ запросу, который отравился в систему с вашим идентификатором.

Сессия сочетает в себе ряд параметров, которые позволяют однозначно автоматически идентифицировать любой ваш запрос. Вот эти параметры:

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

О чем говорят эти параметры? Они говорят следующее:

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

Технические особенности

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

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

Это обусловлено тем, что система odoo при открытии новой вкладки автоматически считывает к себе идентификатор сессии и при последующих обращения к серверу она не обращается к кукам, а использует полученных идентификатор из памяти. В этом случае вкладки являются изолированными друг от друга процессами. И поэтому, когда у нас осталась висеть вкладка, которая запомнила старую куку и вы ее не удалили, то при создании новой базы или переключении пользователя вы будете получать частые вылеты из сеанса. Рецептом от такого поведения является - закрытие всех вкладок с odoo, которые относятся к нужному вам адресу, в случае разработки это - 127.0.0.1:8069.

Советы для начинающих разработчиков

Чтобы избежать проблемного поведения я использую следующий подход - веду разработку системы всегда в режиме инкогнито. Это позволяет использовать уже открытый браузер для поиска в интернете, а все рабочие сеансы odoo находятся в соседнем окне в режиме инкогнито. Если я запутался с сеансами, мне достаточно закрыть все окна в режиме инкогнито и таким образом, профиль, который создается при открытии первого окна в режиме инкогнито будет удален. И открыть новое окно в режиме инкогнито, для него будет создан новый профиль и я могу спокойно авторизоваться не боясь, что у меня где-то застряли идентификаторы сеанса в одной из вкладок. Тут обязательно закрывать именно все окна в режиме инкогнито, потому что при создании нового окна в режиме инкогнито не создается новый профиль а используется тот, который был создан при открытии самого первого окна в режиме инкогнито.

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

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Строение фреймворка.

Читая документацию бывает очень трудно понять из каких компонентов состоит фреймворк odoo и как они взаимодействуют между собой

Глобально система состоит из 3-х больших частей:

Т.е. если упростить все действия пользователь совершает в браузере, который по http протоколу взаимодйествует с сервером, который в свою очередь все данные хранит в БД

База данных

Начнем с БД, т.к. с точки зрения фреймворка, мы с базой почти не взаимодействуем. Все обращения за данными и сохранения в базу происходит под капотом. Есть возможность выполнять SQL запросы в базу, но это частные случаи, которые применяются в ограниченном количестве сценариев. В стандартной ситуации вы можете даже не догадываться что происходит обращение к БД. Все настройки, и обновления значений в базе делает фреймворк. Тем не менее при разработке на odoo придется учитывать наличие БД, т.к. odoo это система управляемая с помощью данных. Почти все действия подразумевают обращения к таблицам и их записям.

Сервер odoo

Сервер предствляет из себя весьма комплексную систему состоящую из следующих Абстракций:

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

Используемые в odoo абстракции делятся на 3 типа:

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

  • Генератор шаблонов Qweb. Ключевой момент - из документации это не явно следует, но существует 2 разных генератора шаблонов с одним именем qweb:

    1. Генератор шаблонов, который выполняется на стороне сервера и написан на python
    2. Генератор шаблонов, который выполняется на стороне браузера и написан на JavaScript

    Оба этих генератора используют одни и те же инструкции и команды, но есть ряд отличий, которые надо учитывать в зависимости от используемого генератора Генератор шаблонов используется, как правило, для формирования html форм, страниц и компонентов. Может применяться для создания печатных форм, почтовых сообщений, индивидуальных веб страниц, виджетов JS клиента и в остальных подобных местах

  • Механизм создания отчетов(Не печатных форм). В платформе odoo есть ряд инструментов которые позволяют создавать так называемые Pivot отчеты а так же строить на их основании Графики

  • Система поиска, она реализована в виде отдельного представления, тем не менее это полноценный вспомогательный компонент со своим инструментарием и широким кругом возможностей

  • Модель безопасности. Встроенная система разграничения прав доступа внутри системы odoo

  • Механизм хранения ссылок на записи

  • Система переводов

  • Мощная система взаимодействия с электронной почтой

  • Система хранения файлов

Модули

Примерная структура модуля

addons/plant_nursery/
|-- __init__.py
|-- __manifest__.py
|-- controllers/
|   |-- __init__.py
|   |-- plant_nursery.py
|   |-- portal.py
|-- data/
|   |-- plant_nursery_data.xml
|   |-- plant_nursery_demo.xml
|   |-- mail_data.xml
|-- models/
|   |-- __init__.py
|   |-- plant_nursery.py
|   |-- plant_order.py
|   |-- res_partner.py
|-- report/
|   |-- __init__.py
|   |-- plant_order_report.py
|   |-- plant_order_report_views.xml
|   |-- plant_order_reports.xml (report actions, paperformat, ...)
|   |-- plant_order_templates.xml (xml report templates)
|-- security/
|   |-- ir.model.access.csv
|   |-- plant_nusery_groups.xml
|   |-- plant_nusery_security.xml
|   |-- plant_order_security.xml
|-- static/
|   |-- img/
|   |   |-- my_little_kitten.png
|   |   |-- troll.jpg
|   |-- lib/
|   |   |-- external_lib/
|   |-- src/
|   |   |-- js/
|   |   |   |-- widget_a.js
|   |   |   |-- widget_b.js
|   |   |-- scss/
|   |   |   |-- widget_a.scss
|   |   |   |-- widget_b.scss
|   |   |-- xml/
|   |   |   |-- widget_a.xml
|   |   |   |-- widget_a.xml
|-- views/
|   |-- assets.xml
|   |-- plant_nursery_menus.xml
|   |-- plant_nursery_views.xml
|   |-- plant_nursery_templates.xml
|   |-- plant_order_views.xml
|   |-- plant_order_templates.xml
|   |-- res_partner_views.xml
|-- wizard/
|   |--make_plant_order.py
|   |--make_plant_order_views.xmlT

Как было описано выше - модуль это строительный блок платформы odoo, которы содержит в себе все необходимые абстракции, статические ресурсы и с использованием вспомогательных подсистем. Т.е. каждая абстракция, которая описана в нашем модуле будет иметь внутри себя ссылку на имя модуля. Именем модуля является имя каталога в котором находится манифест файл и все остальные файл которые содержат в себе описание абстракций. Любая абстракция которая описывается с помощью xml файл с данными, имеет уникальный id внутри модуля. Для того, чтобы обратиться к этим данным внутри платформы надо знать что полный id абстракции будет имя_модуля.id_записи_абстракции

Модель

Модель - базовая абстракция платформы odoo. Если очень сильно упростить, то почти вся разработка, касающаяся серверной части, это работа с моделями. В odoo модель - это python класс, которому соответствует таблица в базе данных(на самом деле это не обязательное условие, и модель может не иметь таблицы, но в 99% случаев таблица у модели есть). Имя таблицы в БД это имя модели с где точки . заменены на подчеркивания _.

Т.е. если имя модели model.name то имя таблицы в БД будет model_name.

Класс модели имеет атрибуты в виде классов полей. Если перейти по ссылке, то можно увидеть описание всех типов полей, которые используются в odoo

Так, же помимо атрибутов в виде полей класс модели может иметь и методы, в которых и происходят основные вычисления бизнес логики. Помимо этого, любая модель наследует базовые методы от системного класса odoo.models.Model

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

Экземпляр класса модели называется - Набор Записей он же НЗ или рекордсет. Т.е. в момент выполнения функции в ссылке на исполняемый экземпляр класса модели self находятся объекты записей таблицы БД с которыми идет работа. Объект записи это по сути НЗ который содержит в себе значения всех полей таблицы, которая соответствует текущей модели. НЗ бывает 3-х видов:

  1. НЗ без записей и тогда вы не сможете получить доступ к данным, но сможете запустить все методы доступные в модели
  2. НЗ с одной записью, в этом случае нам доступны все методы класса и значения полей этой записи примерно таким образом:
    record_value = self.field_name
    
  3. НЗ с множеством записей. В этом случае для того, чтобы получить доступ к значениям полей нужной записи необходимо проитерировать данный НЗ и уже работать с каждой записью внутри цикла отдельно:
    for record in self:
    	if record.id == 12:
    		record_value = record.field_name
    

Все остальные детали хорошо описаны в документации

JS Клиент

Все что нужно знать об этой части написано тут и тут. Основной принцип работы JS клиента - обращение к серверу odoo с помощью API. В системе есть URI /web/dataset/call_kw который принимает json строку с именем модели, именем функции внутри модели и ее параметрами , и может выполнить эту функцию и вернуть ответ в виде json строки. Таким образом реализован универсальный механизм взаимодействия JS клиента и серверной части написанной на Python.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Модули

Оглавление:

Что такое модуль

Модули в Odoo - это строительные блоки. Если вы хотите что-то добавить или изменить в системе, вам необходимо создать модуль внутри которого будут находится все необходимые файлы.

Модуль - это каталог в котором обязательно должен находиться файл __manifest__.py. Какие параметры нужно указывать в этом файле, вы можете узнать из этой статьи документации

Техническое имя модуля - это имя каталога в котором находится файл __manifest__.py. Его можно увидеть если зайти в каталог приложений и включить режим разработчика:

Имя модуля

Техническое имя модуля является важной частью пространства имен самой odoo. Модули могут зависеть от других модулей, и тогда их технические имена(имена их каталогов) нужно прописывать в ключе depends в файле __manifest__.py. Так же при использование xml_id идентификаторов(о них я напишу позже), полный идентификатор состоит из технического имени модуля и уникального id записи внутри этого модуля.

В дальнейшем, если я пишу "имя модуля", то я имею ввиду его техническо имя (имя каталога)

Модуль так же могут называть приложением. Как правило приложение это не один модуль а несколько модулей, объединенных между собой цепочкой зависимостей.

В модуле должны находится все файлы и ресурсы, которые необходимы для работы. Как правильно оформлять содержимое модуля можно посмотреть вот здесь

Если модули - это просто каталоги, то где платформа odoo находит их? Сама платформа ищет сканирует каталоги, которые указаны в параметре addons_path в файле конфигурации. В этот параметр не надо добавлять каталоги с самими модулями, в него надо добавлять каталоги, в которых находятся каталоги с модулями, например:

Подключение каталогов с модулями

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

Установка и обновление модуля

Чтобы все изменения, которые мы хотим привнести в систему odoo применились, нам необходимо установить модуль. Модуль так же можно обновить.

Сделать это можно как с помощью графического интерфейса:

Установка модуля

  1. Нажмите на главное меню
  2. Нажмите на пункт Apps
  3. Найдите нужный вам модуль и нажмите Activate

Дождитесь обновления страницы.

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

При ведении разработки, или например автоматическом развертывании базы, устанавливать модули вручную не всегда удобно и для этого есть параметр командной строки -i, подробнее можно ознакомится тут. Если в этом параметре указать список имен модулей, то при запуске система попытается сама их установить. Обращаю ваше внимание на то, что вы работаете через менеджер разработки и он поддерживает только те параметры командной строки, которые указаны в документации. При разработке менеджера я решил, что каждый раз указывать при запуске списка модулей которые надо установить (инициализировать) это не очень удобно и я перенес этот список в файл конфигурации config.json где в параметре init_modules вы можете указать весь список, а при запуске системы достаточно указать параметр -i.

Чтобы обновить наш модуль first_module:

Обновление модуля

  1. В поиске введите его техническое имя
  2. Нажмите на три точки в правом верхнем углу плитки и выберите в появившемся меню пункт Upgrade

Таким образом запуститься процесс обновления модуля.

Так же вы можете сделать это с помощью параметра командной строки -u, его использование аналогично использованию параметра -i. В файле конфигурации указываете список модулей, который хотите обновить и при перезапуске системы он обновится.

В чем разница между установкой и обновлением?

  1. При установке имя модуля записывается в реестр системы (об этом напишу позже) и система уже будет знать об этом модуле
  2. Если модуль не установлен, то система будет игнорировать его python файлы при перезапуске. Т.е. когда вы устанавливаете модуль, потом вносите изменения в python файл, то при перезапуске система автоматически их подхватит. Такова особенность платформы.
  3. При установке модуля система будет игнорировать атрибут в xml-файлах как no_update и если в файле конфигурации параметр without_demo будет установлен как Falseсистема принудительно установит демо-данные модуля.
  4. При обновлении модуля система находит указанный модуль и обновляет все данные указанные в xml-файлах. Т.е. в момент обновления система парсит их и обновляет в базе данных все связанные с ними записи. (В связи с этим есть некоторые тонкости, расскажу об этом в статье об xml-файлах)
  5. При обновлении, все записи, которые находятся внутри тега с атрибутом no_update, буду проигнорированы и не обновлены.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Модели

С подробной документацией вы можете ознакомиться здесь - Документация по моделям. В этой статье я расскажу базовые концепции, которые авторам документации кажутся очевидными, а вот у новичков вызывают много вопросов. В любой непонятной ситуации читайте документацию).

Модель в odoo - это основная абстракция с которой предстоит взаимодействовать разработчику. Есть три типа моделей:

  1. Абстрактная модель
  2. Стандартная модель
  3. Временная модель

Модель(здесь и далее мы будем иметь ввиду именно Стандартную модель) - это комплексная абстракция, которая объединяет класс python при этом состояние этого класса(значения атрибутов) хранятся в базе данных.

Имя модели

  1. Имя модели задается параметром _name в классе модели. Обратите внимание на стиль наименования, в нем резделителем слов является точка.
  2. Это же имя модели мы можем увидеть уже в адресной строке, это пример того, что имена моделей используются много где внутри системы
  3. после установки или обновления модуля, система сама создаст таблицу в базе данных с именем модели, но точки будут заменены на символ _(подчеркивание)

Т.е. создавая объявляя класс модели мы автоматически создаем таблицу, имя которой соответствует имени модели, а атрибуты класса - это поля(столбцы) таблицы:

Имя модели

  1. Здесь вы можете видеть как мы объявили поле name и в таблице тоже появилось такое поле, и это же поле мы можем видеть уже в веб-интерфейсе. Обратите внимание что имя поля в базе данных соответствует имени переменной в классе модели. А в интерфейсе отображается значение атрибута string переменной name
  2. Здесь мы можем видеть объявленное поле field_one

Как вы уже заметили, у нас в что в интерфейсе, что базе данных уже есть записи:

Записи

Набор записей(Рекордсет)

Мы уже знаем что класс модели описывает то, какая таблица и с какими полями будет создана в базе данных. А чему в python соответствуют записи в базе? По моим наблюдениям это самый неочевидный момент для новичков. Для того, чтобы это понять, придется разобраться с тем, что такое класс и экземпляр класса (Почитать можно на русском на английском). Когда вы уже примерно представляете что это такое, продолжим: В odoo есть дополнительная абстракция - рекордсет или набор записей(НЗ), данная абстракция является экземпляром класса модели который содержит в себе информацию о некоторых записях, каких именно уже определяется самой написанной программой. Рекордсеты делятся на 3 вида:

  1. Рекордсет без записей - не содержит в себе данных ни об одной записи модели, но вы можете воспользоваться методом модели, чьим экземпляром является данный рекордсет.
  2. Рекордсет с одной записью - содержит в себе данный одной записи модели.
  3. Рекордсет с более чем одной записью - содержит в себе информацию о множестве записей модели.

В классе модели мы так же описываем методы, которые собственно и производят вычисления. Если вы внимательно изучили информацию о классах, то уже знаете что первым аргументом метода(функции входящей в состав класса) является self и он же является экземпляром класса в момент выполнения метода. В нашем случае это и есть рекордсет. В разные моменты времени его значение может быть разным:

Рекордсет из 3-х записей

  1. Добавим в код команду print("self", self) - как и было сказано выше в self у нас будет находится рекородсет
  2. Теперь давайте нажмем пункт меню, который отправит команду о том, что мы хотим вывести несколько записей в виде списка (об этом подробнее я напишу позже)
  3. Мы увидим уже знакомые три записи
  4. А вот тут уже мы видим что в консоль вывелось имя модели с номерами в круглых скобках, в мы видим что у нас три номера.
  5. Номера которые отобразила команда print("self", self) соответствует значению поля IDв базе данных. Это служебное поле и создается системой автоматически при создании записи(Об этом позже расскажу).

А теперь давайте нажмем в интерфейсе на запись с именемrecord 001:

Рекордсет из одной записи

В этом случае мы открываем одну запись и видим что в self у нас находится рекордсет с одной записью.

Еще важный неочевидный момент для новичков - рекордсет и записи в базе данных это не одно и тоже. Каждый раз система создает новый рекордсет она обращается к базе данных и запрашивает из нее нужные данные и создает на базе соответствующих записей рекордсет. А когда мы присваиваем какое либо значение полю то это значение не мгновенно попадает в базу, а ждет момента пока пройдут все вычисления и уже после этого результат всех вычислений попадает в базу.

Обратите внимание на то, что в случае, когда у нас в self может НЗ с несколькими записями мы в цикле перебираем записи по одной. В этом случае каждым элементом буде уже рекордсет состоящий из одной записи.

Рекордсет из одной записи

Обратите внимание что считывание из присвоение значений полям может происходить только для рекордсета из одной записи(На самом деле это не совсем так, но пока не будем заострять на этом внимание)

record.result_field = record.field_one * record.field_two

В этом примере мы видим как присваивается значение полю result_field которое равно значению поля field_one умноженным на значение поля field_two. Как вы понимаете для каждой записи значения будут свои.

Задания для самостоятельного повторения:

  1. Повторите действия из статьи самостоятельно
  2. Оставьте обе команды print, которые были показаны
  3. Добавьте самостоятельно через интерфейс 4-ю запись и заполните своими значениями
  4. Посмотрите что выводится в консоли при выводе списка записей и при входе в каждую запись.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Работа с полями

Содержание

Поля

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

Поле представляет собой Python класс, который при создании таблицы в базе данных определяет свойства указанного поля.

Например если мы посмотрим код из нашего демо модуля:

    name = fields.Char(string='Name', help='Name of record')

    field_one = fields.Integer(string='Field One', help='Value of Field One')

    field_two = fields.Float(
        string='Field Two',
        help='Value of Field Two',
    )

    result_field = fields.Float(
        string='Result',
        help='Result of multiplying Field one and Field two',
        compute='_compute_result_field',
    )

Мы сможем увидеть что у нас здесь объявлены 4 поля. Обратите внимание что все они имеют какие-то атрибуты, какие именно вы можете узнать из документации. Здесь вы можете узнать общие атрибуты для всех полей. А так же у каждого тип поля могут быть свои индивидуальные атрибуты - вот тут можно быстро найти атрибуты которые относятся только для поля типа Char, а так прочитать их описание.

Типы полей

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

  • Базовые поля - классы полей, которые позволяют объявлять поля с базовыми типами данных:
    • Bool (Булево значение)
    • Char (Строка)
    • Integer (Целое число)
    • Float (Десятичная дробь)
  • Расширенные поля - классы полей которые реализуют хранение сложных данных (Текст, картинка, время, файл). Поскольку поле - это класс, то у каждого класса могут быть вспомогательные методы, внимательно изучайте документацию
    • Binary (Хранит двоичные данные, файл по простому)
    • Html (Хранит html разметку)
    • Image (Хранит изображения)
    • Monetary (Хранит значение в денежных единицах)
    • Selection (Хранит текстовую метку выбранного из заранее определенного списка значений)
    • Text (Хранит произвольный текст)
    • Date (Хранит дату в текстовом виде)
    • Datetime (Хранит дату и время в текстовом виде)
  • Реляционные поля - классы полей, которые реализуют хранение ссылки на другие модели(таблицы)
    • Many2one (Хранит ID записи модели, на которую ссылается в атрибуте comodel_name)
    • One2many (Является рекордсетом из записей модели, на которую ссылается в атрибуте comodel_name)
    • Many2many (Является рекордсетом из записей модели, на которую ссылается в атрибуте comodel_name)
  • Псевдо-реляционные поля
    • Reference (Хранит строку которая является ссылкой на запись произвольной модели res_model.res_id)
    • Many2oneReference (Хранит ID записи модели имя которой хранится в поле, которое указывается в атрибуте model_field, )
  • Вычисляемые поля - таким полем может быть поле любого типа, особенности лишь в том что в обычных условиях значения полей нужно предварительно задать при создании или изменении записи, а в случае вычисляемого поля значение при обращении к нему будет вычислено.
  • Связанные поля - реализация жесткой ссылки на поле другой модели. Частный случай Вычисляемого поля

Системные объекты

Работа с реляционными полями

У нас имеется три вида реляционных полей:

  • Many2one
  • One2many
  • Many2many

Если с пониманием что делать с полем типа Many2one у новичков проблем не возникает, то при использовании двух других типов One2many и Many2many могут возникать вопросы. Про присвоение значений полям я уже писал в предыдущей статье, но там все действия проводились над полями с простым типом. А что если нам надо произвести какие-либо манипуляции на полями с типами One2many и Many2many? Для этого есть следующий набор команд:

Этот формат представляет собой список триплетов, выполняемых последовательно, где каждый триплет является командой, выполняемой по набору записей. Не все команды применяются во всех ситуациях. Возможные команды:

  • (0, 0, values) Создает новую запись, созданную из предоставленного values а затем добавляет в наше поле(values - это словарь где ключи это имена полей, а значения - собственно их значения).

  • (1, id, values) Обновляет существующую запись c идентификатором, указанным в переменной id, значениями из values. Нельзя задействовать при использовании ве метода create() (нельзя использовать при создании новой записи).

  • (2, id, 0) Удаляет запись с идентификатором, указанным в переменной id, из НЗ, а затем удаляет его (из базы данных тоже удаляет). Нельзя задействовать при использовании ве метода create().

  • (3, id, 0) Удаляет запись c идентификатором, указанным в переменной id, из НЗ (не удаляет из базы данных). Нельзя задействовать при использовании ве метода create() (нельзя использовать при создании новой записи).

  • (4, id, 0) Добавляет существующую запись с идентификатором, указанным в переменной id, в НЗ.

  • (5, 0, 0) Удаляет все записи из НЗ, что эквивалентно использованию команды 3 для каждой записи явно. Нельзя задействовать при использовании ве метода create() (нельзя использовать при создании новой записи).

  • (6, 0, ids) Заменяет все существующие записи в НЗ списком ids, что эквивалентно использованию команды 5, за которой следует команда 4 для каждого id в ids.

Задания для самостоятельного повторения:

Глядя на данный фрагмент кода:

    name = fields.Char(string='Name', help='Name of record')

    field_one = fields.Integer(string='Field One', help='Value of Field One')

    field_two = fields.Float(
        string='Field Two',
        help='Value of Field Two',
    )

    result_field = fields.Float(
        string='Result',
        help='Result of multiplying Field one and Field two',
        compute='_compute_result_field',
    )

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

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Методы наборов записей

Содержание

Базовые методы наборов записей

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

Метод create

self.create() - метод который создает новый записи. Возвращает созданный рекордсет. Пример:

# Создадим список для новых значений
list_of_vals = []
# Создадим переменную типа словарь, с указанными значениями полей
values_for_new_recordset = {
    "name": "record 004",
    "field_one": 40,
    "field_two": 0.4,
}
# Добавим в наш список указанную переменную со значениями
list_of_vals.append(values_for_new_recordset)
# Создадим новый рекордсет (т.е. новые записи в базе данных)
new_recordset = self.env["first.model"].create(list_of_vals)
# Выведем новый рекордсет в консоль
print("new_recordset", new_recordset)

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

Создаем новую запись

После того, как вы добавили вышеуказанный код в функцию, как показано на картинке, не забудьте перезапустить систему. Затем проделайте следующее:

Результат отработки функции

  1. Нажимаем на кнопку START FUNCTION
  2. У нас запускается функция с именем start_function
  3. Мы можем видеть что у нас сначала вывелся рекордсет который находился в self, а потом мы вывели в консоль значение переменной new_recordset и мы видим что система присвоила ему идентификатор 4
  4. В виде списка, мы теперь можем видеть новую запись со значениями указанными в переменной values_for_new_recordset

Мы можем создавать сразу несколько записей одновременно, достаточно сформировать нужные значения, положить их в список и подать в метод create в качестве аргумента

Метод write

  • self.write() - метод который обновляет значения полей. Возвращает True или False. Пример:
# Создадим переменную, которая будет содержать в себе новые значения полей для
# уже существующей записи
new_values_for_current_recordset = {
    "name": "record 005",
    "field_one": 50,
    "field_two": 0.5,
}
# Обновим значения полей новыми значениями
self.write(new_values_for_current_recordset)
# Выведем обновленные значения полей текущего измененного рекордсета из одной записи
print("self", self)
print("name", self.name)
print("field_one", self.field_one)
print("field_two", self.field_two)

Теперь давайте удалим из нашей функции ранее внесенные изменения для метода create и добавим новые для изучения работы метода write:

Обновляем текущую запись

После того, как вы добавили вышеуказанный код в функцию, как показано на картинке, не забудьте перезапустить систему. Затем проделайте следующее:

Результат обновлений полей

  1. Нажимаем на кнопку START FUNCTION
  2. У нас запускается измененная нами функция с именем start_function
  3. В консоль мы выводим новые значения полей
  4. Эти новые значения полей мы можем видеть в интерфейсе текущей записи
  5. И эти же изменения мы можем увидеть в виде списка, когда перейдем туда или обновим страницу

Более того, если нам надо изменить одно поле, то мы можем вместо вызова метода write использовать присвоение:

self.name = "record_005"

Т.е. это будет эквивалентно

self.write({
    "name": "record_005"
})

Если вам надо изменить значение одного поля, то можно спокойно использовать присвоение, а вот если вам надо надо изменить значение нескольких полей, то тут лучше использовать метод write

  • self.search() - метод который осуществляет поиск записей в указанной модели по заданным критериям. Можно считать условным аналогом команды SELECT в SQL. Возвращает рекордсет найденных записей, если ничего не найдено, то возвращает пустой рекордсет. В качестве первого аргумента использует домен поиска Пример использования:
# Создадим новый рекордсет, который будет результатом поиска в нашей модели по заданному домену
# В текущей ситуации мы хотим найти все записи имена которых являются "record_002" и "record_003"
selected_records = self.env["first.model"].search(
    [
        ("name", "in", ["record 002", "record 003"])
    ]
)
print("selected_records", selected_records)

Теперь давайте удалим из нашей функции ранее внесенные изменения для метода write и добавим новые для изучения работы метода search: Пишем функцию поиска

После того, как вы добавили вышеуказанный код в функцию, как показано на картинке, не забудьте перезапустить систему. Затем проделайте следующее:

Ищем

  1. Нажимаем на кнопку START FUNCTION
  2. У нас запускается измененная нами функция с именем start_function
  3. В консоль мы выводим найденный рекордсет, как мы видим, мы получили рекордсет из двух записей с ID равным 2 и 3

Метод search не влияет на данные, он нужен для поиска записей, с которыми мы хотим проделать какие либо манипуляции.

Это три самых часто используемых штатных метода, остальные вы можете изучить из документации

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Представления (Views)

Представление - это механизм отображения данных в интерфейсе. В odoo представления описываются с помощью xml. Все особенности вы можете почерпнуть из документации. Если вкратце, то представления описывают как данные рекордсета будут выглядеть в интерфейсе пользователя. Т.е. для того, чтобы пользователь увидел поля, которые вы уже объявили в модели, а так же он мог вносить или изменять данные, для всего этого нужно создать представление.

Помимо всего прочего xml файлы с представлениями являются (файлами с данными)[расскажу позже], которые загружаются в базу в соответствующую модель с именем ir.ui.view. Данная модель имеет некоторое количество собственных служебных полей, а само описание представления находится внутри поля arch

Odoo предлагает для разработчиков достаточно много типов представлений. В документации вы можете увидеть детальное их описание, а так же описание атрибутов для правильного их применения.

xml файлы с описанием представлений должны быть прописаны в файле __manifest__.py:

Объявление файла с представлениями в манифесте

В данном примере мы рассмотрим 2 основных представления:

Представление типа Form

Представление типа Form всегда применяется для взаимодействия пользователя с одной конкретной записью. Т.е. когда вы нажимаете на конкретной записи то вы автоматически открываете представление типа Form. Помимо способов отображения система odoo так же предоставляет возможность создавать кнопки и отображать стадии объекта(про стадии я расскажу позже).

Для описание представления типа Form вы можете использовать обычный HTML. Остальные специальные теги вы можете изучить из документации. Или из исходных текстов самой odoo.

Представление типа Form

  1. Тег <header>- создает специальную область в верхней части формы
  2. Тег <button> здесь вы можете видеть пример оформления кнопки, попробуйте найти описание этого тега в документации, а так же обратите внимание какую функцию вызывает эта кнопка. Мы это уже делали в прошлых статьях
  3. Тут вы видите сразу несколько тегов <sheet>, <group> и <field>. Первые два помогают в оформлении, а вот последний <field> как раз и выводит поля. Для каждого тип поля есть свой виджет, но вы можете применять виджеты других типов полей к своему. Попробуйте найти описание тегов в документации.

Представление тип Tree(List)

Представление типа Tree - наверное самое распространенное представление, позволяет отображать набор записей в виде таблицы.

Представление типа Form

Здесь вы можете видеть описание представления в виде списка.

Применение изменений

Обратите внимание что при изменении файла система не подхватит их автоматически, для этого нужно обновить модуль в котором находится xml файл. Сделать это можно как описано ранее.

Добавление поля

Ели помните, то у каждой записи есть поля, которые автоматически создаются системой. Поэтому давайте для практического примера добавим в наши представления поле id:

Добавление поля ID

  1. Добавляем отображение поля ID в представление типа Tree
  2. Добавляем отображение поля ID в представление типа Form

Задания для самостоятельного повторения:

  1. Добавьте в представлении типа Form атрибут, который сделает его только для чтения, см. документацию или примеры в исходных кодах системы
  2. Попробуйте сделать поле невидимым в одном из представлений, см. документацию
  3. Используя знания о полях добавьте любое поле и объявите его в любом из представлений.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Экшены

Содержание

Что такое экшены?

Экшен(Action) - в odoo это механизм вызова заранее определенного действия. Сейчас расшифрую - в odoo есть очень большая часть всей системы - это веб клиент. Этот клиент представляет собой очень большую и серьезную программу, более того, часть кода, который мы пишем как бы для серверной части, выполняется именно клиентом. Для чего я это рассказываю? А для того, чтобы чуть лучше передать смысл того, что из себя представляют Экшены.

Экшен - это специальная структура, которая при обработке веб клиентом запустит событие, которое в этом экшене описано. Экшен может хранится в базе данных в своей таблицы, а может быть возвращен функцией на сервер в виде python словаря.

В системе odoo предопределено 6 видов экшенов, я их здесь кратно опишу, т.к. на мой взгляд из документации некоторые вещи не очевидны для новичков:

Экшен вызова представления

Экшен вызова представления хранится в модели ir.actions.act_window. Используется для того, чтобы сказать веб клиенту какое представление и какой модели нужно отобразить. Чтобы узнать подробности - изучайтие документацию и исходные тексты платформы.

Экшен открытия URL

Экшен открытия URL - хранится в модели ir.actions.act_url. Его задача - открыть внешнюю ссылку, это может быть в принципе любой ресурс.

Экшен запуска кода на сервере

Экшен запуска кода на сервере его основная задача - это вызвать какую либо функцию на стороне сервера. Записи хранятся в модели ir.actions.server. Читайте внимательно документацию и изучайте исходные тексты платформы

Экшен запуска генератора печатных форм

Экшен запуска генератора печатных форм - записи хранятся в модели ir.actions.report.xml. Используется для запуска генерации печатной формы для выбранных записей в указанной модели. Это могу быть всякого рода бланки накладных, счетов и т.д. С детальным примером использования можете ознакомится вот здесь

Экшен запуска функции на клиенте

Экшен запуска функции на клиенте - весьма редко используемый экшен, его задача запустить javascript функцию на стороне web-клиента. Может быть весьма полезным для решения различного рода задач. Но для этого требуется хорошо знать как работает js часть фреймворка. Записи хранятся в модели ir.actions.client. Примеров в коде платформы не много, поэтому позже напишу свой.

Автоматически выполняемые действия

Автоматически выполняемые действия - единственный экшен который не связан с web-клиентом. Он выполняется исключительно на сервере, его задача запускать в заданное время указанную функцию нужной модели. Т.е. он является фоновым исполнителем задач. Записи хранятся в модели ir.cron. Доступ к этим записям так же доступен через техническое меню основных настроек.

Примеры

Подготовка

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

Вот ее имя - 16.0-actions

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

В файл конфигурации в параметры init_modules и update_modules добавьте имя модуля actions

Запустите менеджер с параметрами -d, -i, и -u

Перейдите откройте браузер по адресу http://127.0.0.1:8069

Предварительная информация

На основании модели first.model я сделал пример, который демонстрирует использование всех типов экшенов, кроме ir.cron, про него, будет отдельный раздел. В этом примере используется наследование, подробнее об этой функциональности вы сможете ознакомится в этой главе.

Пример экшена вызова представления

Экшен без которого не обходится ни один модуль - это Экшен вызова представления:

Экшен вызова представления

  1. При нажатии на нужный пункт меню (Обратите внимание что в пункте меню отображается имя экшена, т.к. явно не указано имя пункта меню)
  2. В меню указан xml_id экшена, который будет вызван при нажатии на этот пункт меню
  3. Собственно описание самого экшена где указано какое представление какой модели будет вызвано.

Пример экшена запуска кода на сервере (серверный экшен)

Для начала я бы хотел продемонстрировать определенную тонкость в настройках системы. Любой экшен можно добавить в меню печати или меню действий: Серверный экшен как экшен печати

  1. Если мы выбираем записи, то у на появляются кнопки с именами Print и Action. Данный механизм служит для того, чтобы к выбранному набору записей, можно было применить экшен. Причем разницы нет какого типа будет этот экшен.
  2. Здесь мы можем видеть что серверный экшен находится в меню Print. Мы добились этого указав в поле binding_model_id xml_id нашей модели first.model, а в поле binding_type указали report. Таким образом мы указали системе что данный экшен запуска кода будет добавлен в меню Print модели first.model.

Такой подход не всегда удобен, т.к. в контекст экшена попадают id только выделенных записей. Поэтому в нашем примере мы будем вызывать экшен запуска кода на сервера с помощью пункта меню:

Вызов из меню серверного экшена

  1. Добавили пункт меню, обратите внимание на параметр parent, в нем указан xml_id родительского пункта меню из модуля first_module
  2. Экшен в пункте меню ссылается на наш экшен запуска кода на сервере
  3. Имя экшена отображается как имя пункта меню
  4. Вызов метода в любой модели, который должен вернуть экшен. В нашем случае мы вызываем метода call_action в модели first.model.

Пример экшена печати

Теперь в нашем методе, который вызывает серверный экшен мы можем считать из базы информацию об экшене печати, и добавить в его контекст все записи у которых поле to_print на равно False: Вызов из меню серверного экшена

  1. Считываем данные из записи экшена печати
  2. В экшене печати указываем шаблон, который будет использоваться для отображения
  3. Возвращаем объект экшена печати с расширенным контекстом

Кстати, попробуйте найти этот экшен печати в меню Print и Action при выделении записи или нескольких

Пример клиентского экшена (js экшен)

А теперь давайте представим ситуацию, что у нас нет записей у которых поле to_print на равно False. В этом случае у нас ничего не должно печататься. В этом случае мы воспользуемся js экшеном для того, чтобы уведомить пользователя о том, что нет записей для печати:

Объявление и использование клиентского экшена

  1. Для начала мы объявляем файл js как ассет
  2. Регистрируем наш клиентский экшен, имя которого должно быть указана в параметре tag при его вызове
  3. Привязываем функцию js, которая будет вызываться при вызове данного экшене
  4. В клиентский экшен вы можете передать параметры, в наше случае это текст сообщения
  5. Вызываем нотификацию о том, что нечего печатать
  6. Внутри js функции мы тоже можем вызвать экшен

Пример экшена открытия URL

Если вы установите во всех записях значение поля to_print как False (т.е. снимите все галочки), то при нажатии на пункт меню у вас система попытается открыть эту же статью в соседней вкладке. Скорее всего сразу не получится и система запросит разрешения, но после того, как вы разрешите, данная статья будет открываться автоматически в соседней вкладке. Данное поведение организовано с помощью экшена открытия URL. Данный экшен мы объявили и тут же вызывали в js файле. См. пункт 6 под предыдущей картинкой

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Меню

Содержание

Использование меню

Меню - это одна из самых небольших абстракций в odoo, тем не менее необходимо уделить некоторое внимание данному вопросу. В системе odoo меню представляет собой обычное дерево где каждый пункт либо вызывает экшен либо содержит дополнительные пункты меню. Корнем (т.е. основание откуда формируется меню) является кнопка основного меню:

Описание меню

  1. Кнопка основного меню
  2. Основной пункт меню, который находится на первом уровне после кнопки корневого меню
  3. Пункт меню второго уровня

Как вы можете видеть - пункты меню описываются в xml файле и не требует каких либо серьезных навыков. Сами пункты меню меню находятся в модели ir.ui.menu. И нам не обязательно использовать тег <menuitem/> мы можем спокойно создать запись как и для любой модели. Например:

<menuitem
    id="first_model_menu_root"
    name="First Model Root"
    web_icon="first_module,static/description/icon.svg"
    active="True"
    sequence="100"
>

Мы можем заменить:

<record id="first_model_menu_root" model="ir.ui.menu">
    <field name="name">First Model Root</field>
    <field name="web_icon">first_module,static/description/icon.svg</field>
    <field name="active">True</field>
    <field name="sequence">100</field>
</record>

Об этом подробнее можно будет почитать в статье про файлы данных(напишу позже).

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

<field name="parent_id" ref="parent_of_first_model_menu_root"/>

Задания для самостоятельного повторения:

  1. Создайте альтернативный набор пунктов меню, который в конце будет запускать тот же самый экшен.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Модель безопасности

Оглавление

Права доступа

Права доступа - определяет какие права доступа имееют члены указанных групп пользователей к записям указанной модели. Контроль доступа осуществляется с помощью модели ir.model.access. Как правило все записи данной модели пишутся в csv файл с именем модели ir.model.access.csv и указывается в файле __manifest__.py. В нашем случае это выглядит вот так:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_first_model_base_group_user,first_model base_group_user,first_module.model_first_model,base.group_user,1,1,1,1

При этом, эту запись вы можете указать и в виде xml файла:


<record id="access_first_model_base_group_user" model="ir.model.access">
    <field name="name">first_model base_group_user</field>
    <field name="model_id" ref="first_module.model_first_model"/>
    <field name="group_id" ref="base.group_user"/>
    <field name="perm_read" eval="True"/>
    <field name="perm_write" eval="True"/>
    <field name="perm_create" eval="True"/>
    <field name="perm_unlink" eval="True"/>
</record>

Правила записей

Подготовка

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

Вот ее имя - 16.0-security-example

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

Обзор системы

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

Откройте настройки текущего сотрудника

Установите для вашего пользователя для приложения First Module уровень доступа Employee:

Выберите уровень доступа - Сотрудник

После этого обновите страницу. И в главном меню у вас уже появится знакомы нам пункт меню:

Доступные записи сотруднику

Теперь давайте рассмотрим что и как у нас сделано с точки зрения кода:

Организация групп

  1. Здесь мы объявили категорию для групп пользователей, которые будут регулировать доступ внутри нашего приложения.
  2. Объявили сами группы. У нас их три и они нужны нам, для того, чтобы в дальнейшем рассмотреть детально сценарии с правилами записей
  3. Как вы можете видеть все группы ссылаются на одну и ту же категорию

Более того, в правом верхнем углу, вы можете видеть окно что при выборе группы доступа наш пользователь входит только в одну группу Employee. А теперь давайте выберем для него группу Manager:

Выбор группы Manager

И давайте посмотрим на выбор группы Administrator:

Выбор группы Administrator

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

Иерархия групп

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

Теперь когда уже понимаем как устроена иерархия групп, давайте еще посмотрим на права доступа:

Текущие права доступа

Как вы можете видеть у на все группы могут читать и писать записи, Менеджеры могут еще и создавать, а Администраторы и удалять.

Но если мы добавим нашего пользователя в группу Employees, то мы увидим не 3 а две записи.

И вот мы подошли к тому, что есть механизм более тонкой настройки доступа на уровне записей.

Правила записей

В нашем мы использует следующее правило записи:

Правило записи для Employee

  1. Домен - к записям, которые попадают под его условия, будут применяться ниже указанные доступы. В текущем примере мы хотим отфильтровать все записи у которых значение поля field_one не равно 30, 25 или 40. И наша запись с именем record 003 как раз имеет значение поля field_one равным 30 и мы поэтому ее перестали видеть, а вот почему - читайте ниже.
  2. Список групп, к которым будет применяться данное правило
  3. Доступ на чтение - если True, то при попытке чтения записей из указанной модели будут проверены группы пользователя, к которым принадлежит пользователь, и если он находится в группе указанной в пункте 2, то для него доступными для чтения останутся только те записи, которые соответствуют условиям домена из пункта 1. Т.е. по условиям прав доступа вы можете читать все записи из указанной модели, но при использовании данного правила доступ на чтение останется только для записей из домен в п. 1. Если же параметр равен False, то данное правило не будет применяться к запросам на чтение и вы сможете увидеть все записи. Попробуйте это сделать самостоятельно - измените параметр и перезапустите систему с обновлением
  4. Доступ на изменения (запись) - принцип аналогичен для чтения из п.3
  5. Доступ на создание - принцип аналогичен для чтения из п.3
  6. Доступ на удаление - принцип аналогичен для чтения из п.3

Теперь давайте добавим нашего пользователя пользователя в группу Manager. Обновим страницу и после этого, мы можем увидеть, что нам доступны все три записи. Давайте зайдем в запись c именем record 003 и попытаемся изменить ее имя а после этого сохранить:

Изменяем запись record 003

И вот какой результат мы получим:

Ограничение правил доступа

  1. При попытке сохранить система предотвратила ее. И сообщает нам что для нашего пользователя с именем Mitchel Admin при попытке изменить запись с именем record 003 этого сделать не удалось, т.к. данное действие не позволено правилом с именем Records: field one is not еqual 30, 25, 40
  2. Обратите внимание не домен, это так называемые безусловный домен - он применяется чтобы ничего не фильтровать
  3. Данное правило будет применяться для группы Manager
  4. Доступ на чтение в этом случае True и это означает что для пользователей из п.3 будет применяться безусловный домен из п.2 Т.е. все пользователи входящие в группу Manager смогут видеть все записи из модели first.model
  5. Доступ на изменение в этом правиле не применяется и поэтому для при попытке изменения будут проверяться правила которые доступны для других групп. И поскольку будучи менеджером мы автоматически являемся участником группы Employee а для них действует правило которое ограничивает чтение и изменение всех записей, у которых значение поля field_one равным 30, 25 или 40, то в этом случае нам система и не позволяет изменить эту запись.
  6. Доступ на создание. В данном случае он равен True, но поскольку мы и так по правам доступа можем это делать и домен записи безусловный, данное значение ни на что не влияет

Задания для самостоятельного повторения:

  1. Самостоятельно добавьте пользователя в группу Administrator
  2. Попробуйте изменить запись с именем record 003
  3. Сформулируйте объяснение почему у вас получился такой результат

Работа с данными

Оглавление

Основные сведения

В документации все уже описано, но я бы хотел сфокусироваться на некоторых неочевидных моментах.

Все xml файлы - это файлы с данными. Т.е. представления, пункты меню, экшены, правила безопасности и много другое является данными, которые при установке либо при обновлении модуля записываются в базу.

Информация о каждой записи, созданной с помощью xml файла имеет уникальный идентификатор в системе, который назвается xml_id или external_id. Он состоит из двух частей - техническое_имя_модуля и уникально_имя_записи_внутри_модуля, которые разделяются точкой. И может выглядеть примерно вот так: first_module.record_001_first_model. И хранится информация о записях в модели ir.model.data.

Все это является частью платформы и используется повсеместно в системе. Вы можете ссылаться на уже созданные записи как внутри самих xml файлов так и с помощью кода:

record = self.env.ref("first_module.record_001_first_model")

в переменной record будет рекордсет из одной записи, которая была создана с помощью xml файла:

Данные о записях

Как вы можете видеть, данные о записях, которые имы видели:

Записи

Если мы внимательно изучим данные о записи в xml файле то увидим, что в теге <record> есть атрибут model который указывает на имя модели, в которой будет создана запись. Атрибут id и есть тот самый xml_id, который есть у каждой записи созданной с помощью xml файла.

Внутри тега <record> вы можете видеть теги field со значениями атрибута name. Это имена полей. Значением поля может быть:

  • текст внутри тега field
  • если поле имеет тип many2one, то значение можно указать как ссылку на другую запись с помощью атрибута ref. Разумеется ссылаться можно не любую запись любой модели а только на запись, которая создается для модели, указанной в параметре comodel_name текущего поля. Пример
  • с помощью атрибута eval. В этом случае текст внутри этого атрибута будет исполнен как код python. Пример

Особенность обновления данных

Когда вы разрабатываете модуль, вы будете часть менять xml файлы, а так же обновлять модуль(и) чтобы измененные данные попали в систему. В связи с этим есть один неочевидный нюанс, который может отнять у вас достаточно много нервов и я бы хотел сфокусироваться на нем. К примеру, вы создали вот такую запись:

<record id="record_001_first_model" model="first.model">
    <field name="name">record 001</field>
    <field name="field_one">10</field>
    <field name="field_two">0.1</field>
</record>

В этом случае система, если не найдет такого xml_id создаст новый и создаст запись, в модели first.model с прописанными значениями полей.

Если же мы поменяем какое либо значение одного из полей, то система обновит это поле новым значением.

А теперь вопрос, что произойдет с данными если вы удалите определение одного из полей, например удалите определение поля field_two?

<record id="record_001_first_model" model="first.model">
    <field name="name">record 001</field>
    <field name="field_one">10</field>
</record>

Чисто интуитивно, нам будет казаться, что система удалит данные из поля field_two. Так вот это не так. В поле останется последнее добавленное значение. Т.е. удалив определение поля из xml файла, вы не удаляете значение из него, а просто перестает записывать туда новое значение. Удалить данные из этого поля можно либо вручную, либо создав новую базу, где ваши файлы не будут создавать значение для этого поля, либо создавать запись с помощью функции, которая будет вызывать метод create если записи не существует, либо вызывать метод write если запись существует.

Вызов функций

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


<function
    model="first.model"
    name="start_function"
    eval="([ref('first_module.record_001_first_model')])"
/>

Это самый простой вариант - просто вызывает метод для рекордсета, который

 <function model="product.template" name="_create_variant_ids">
    <function
        model="product.template"
        name="search"
        eval="[
            [
                ('id', '=', ref('имя_модуля.искомый_id_записи')),
            ]
        ]"
    />
</function>

В этом примере вы можете видеть, что функция _create_variant_ids будет вызвана для рекордсета, который получится в результате вызова во вложенной функции метода search. В этом и предыдущем случае можно увидеть, что позиционный параметр функции можно передать с помощью списка внутри атрибута eval. При этом вы можете использовать ссылки на другие записи.

<function model="ir.model.data" name="_update_xmlids">
    <value
        model="base"
        eval="[{
        'xml_id': 'имя_вашего_модуля.id_записи_который_вы_хотите_присвоить',
        'record': obj().env['first.model'].search([('id', '=', 100)]),
        'noupdate': True,
    }]"
    />
</function>

Весьма комплексный пример, который показывает как можно создать xml_id для автоматически создаваемой записи.

Использование атрибута noupdate

Иногда у вас может возникать ситуация, чтобы данные из xml файлов загружались только один раз при установке модуля, а при дальнейших обновлениях игнорировались. Для этого вы можете к тегу <odoo> либо к тегу <data> добавить атрибут noupdate="1". Этот атрибут укажет системе, что записи с xml_id которые входят внутрь тега этим атрибутом, не будут обновляться при обновлении модуля. При его переустановке - будут.

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

Тем не менее такой функционал есть, и на него завязана некоторая часть других функций системы

Обсуждение

Обсудить можно здесь

Использование перевода

Оглавление

Общие сведения

Как обычно, основная масса информации о том как именно использовать переводы доступна в документации. Постараюсь дополнить то, что осталось за ее рамками.

Система odoo использует везде кодировку utf-8 и поэтому вы можете писать исходные термины и сообщения на любом языке и тогда ваши термины не будут требовать перевода. Это допустимый сценарий, но он имеет ряд недостатков. Поскольку система уже имеет огромное количество готового кода, который имеет смысл использовать(это как это делать, будет описано позже), а в нем все термины созданы на английском языке и для остальных языков применяется перевод. Так вот, когда вы начнете использовать термины например на русском языке, то очень быстро столкнетесь с ситуацией, когда какая-то часть интерфейса требует поменять свое написание и вам придется искать по все системе где вы используете перевод, а где это исходный термин, и легко можно получить когда термин и перевод написаны на одном языке но немного по разному. Поэтому я рекомендую использовать только англоязычные термины, даже если вы совсем плохо знаете язык, вы будете сразу видеть где нужно подтянуть перевод и это будет бросаться в глаза всем участникам. И вы всегда для исправления написания будете использовать один и тот же сценарий, а не бегать по всему коду в поисках всех возможных комбинаций.

Система odoo очень большая и поэтому не все элементы интерфейса могут быть переведены штатными методами, иногда приходится подменять часть представлений или элементов кода как на js, так и на python с помощью наследования

При использовании наследования, переводы могут переводить термины наследуемых модулей(если явно указать источник термина)

Особенности импорта

При попытке загрузить переводы, которые относятся к записям, которые помечены атрибутом noupdate="1"

Перевод контента

Содержимое полей так же может переводиться. Для этого, достаточно добавить атрибут translate=True к атрибутам требуемого поля.

Перевод содержимого полей

Изменения в технической части переводов

Начиная с 16 версии модель ir.translation удалена и теперь все переводы хранятся в сами записях в jsonb полях

Обсуждение

Обсудить можно здесь

Использование системы печати в Odoo

Оглавление

Подготовка

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

Вот ее имя - 16.0-print-form

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

Основные данные

В этой статье я опишу как добавить свою печатную форму для какой либо модели, как на рисунке ниже

Печать

В документации, к сожалению не описано подробно как сделать печатную форму.

Общий сценарий работы системы печати:

        Report Action(На который ссылается формат бумаги)
            |
            |
    Формируется объект из выбранного рекордсета
            |
            |
    Передача объекта и указанного в экшене шаблона на рендеринг в QWeb
            |
            |
    Финальный объект рендеринга, либо HTML либо PDF

Экшен печати

Пример репорт экшена, в котором мы должны указать модель, к которой он будет прикреплен, тип финального контента после рендеринга, в этом случае я указал qweb-html, и external_id шаблона на базе которого будет происходить рендеринг. Детально описание всех параметров вы можете узнать тут. Код ниже взят отсюда

    <record id="first_model_print_form_001" model="ir.actions.report">
        <field name="name">Print Form Name 001</field>
        <field name="model">first.model</field>
        <field name="report_type">qweb-html</field>
        <field name="report_name">first_module.print_form_001_template</field>
        <field name="report_file">first_module.print_form_001_template</field>
        <field name="print_report_name">'Document №%s - %s' % (object.id, object.create_date)</field>
        <field name="binding_model_id" ref="first_module.model_first_model"></field>
    </record>

Печать

  1. Отображаемое имя экшена
  2. тип генерируемого контента
  3. Имя документа, в нем может быть использованы значения полей записи, к которой будет формироваться документ записи
  4. атрибут, который связывает экшен с моделью

Модель расширения объекта печати

Затем на основании вот этой документации мы можем создать модель которая будет создавать объекты для моего шаблона. Это может быть полезно в случаях когда нам нужно, например, добавить дополнительные объекты, или расширить существующие Вот код модели:

class ParticularReport(models.AbstractModel):
    _name = "report.first_module.print_form_001_template"
    _description = "Extend of print form"

    def _get_report_values(self, docids, data=None):
        """
        Функция для расширения данных подаваемых на печать в шаблон
        param : docids : list : список id записей модели, к которой прикреплен шаблон для печати
        param : data : {} : дополнительные которые содержат в себе метаинформацию
        return : dict : словарь данными, используемыми в шаблоне
        """
        first_model_recordset = self.env["first.model"].browse(docids)
        return {
            "doc_ids": docids,
            "docs": first_model_recordset,
            "data": data,
        }

Данная модель может быть создана для любого шаблона. Для этого достаточно правильно указать ее имя и определить метод _get_report_values. Принцип формирования имени следующий: report.полный.xml_d_шаблона или report.имя_модуля.id_шаблона. Как вы можете видеть в примере выше имя нашей модели полностью соответствует этому правилу report.first_module.print_form_001_template. Все параметры и структура объекта, который надо вернуть методом _get_report_values вы можете увидеть в примере и прочесть в контракте функции.

Использование шаблона

Вот код шаблона, который будет отрендерен. Как создавать шаблоны для QWeb движка и какие директивы вы может использовать можно ознакомиться здесь. Вот код

<template id="print_form_001_template">
    <t t-call="web.html_container">
        <t t-foreach="docs" t-as="document">
            <div class="article" t-att-data-oe-model="document and document._name" t-att-data-oe-id="document and document.id">
                <t t-call="first_module.print_form_001_body_template" />
            </div>
        </t>
    </t>
</template>

И вот код основного шаблона, обратите внимание что нам внутри могут быть доступны значения атрибутов и методы набора записей (экземпляра класса модели) по котрой происходит формирование печатной формы


<template id="print_form_001_body_template">
    <h1 t-esc="document.name"></h1>
    <p>This is example of print form for record <span> </span><span t-esc="document.name"/></p>
    <table  class="table table-main">
        <tr>
            <th class="text-center" t-esc="document._fields['field_one'].string"></th>  
            <th class="text-center" t-esc="document._fields['field_two'].string"></th>
            <th class="text-center" t-esc="document._fields['result_field'].string"></th>
        </tr>
        <tr>
            <td class="text-center" t-esc="document.field_one" ></td>
            <td class="text-center" t-esc="document.field_two" ></td>
            <td class="text-center" t-esc="document.result_field" ></td>
        </tr>
    </table>

    <p t-esc="docs"/>

    <p t-esc="data"/>
</template>

Здесь вы можете видеть, как используются дополнительные данные, созданные в служебной промежуточной модели в шаблоне документа:

Дополнительные данные

Формат бумаги

Пример собственного формата бумаги. В данном случае я создал специальный формат, который соответствует накладной Республики Беларусь.

    <record id="paperformat_a4_belarus_tn_portrait" model="report.paperformat">
        <field name="name">ТН Вертикальная Беларусь</field>
        <field name="default" eval="True"/>
        <field name="format">custom</field>
        <field name="page_height">288</field>
        <field name="page_width">204</field>
        <field name="orientation">Portrait</field>
        <field name="margin_top">4</field>
        <field name="margin_bottom">12</field>
        <field name="margin_left">11</field>
        <field name="margin_right">2</field>
        <field name="header_line" eval="False"/>
        <field name="header_spacing">0</field>
        <field name="dpi">90</field>
        <field name="report_ids" eval="[(4, ref('first_module.first_model_print_form_001'))]"/>
    </record>

В целом это весьма синтетический пример, но на его основе вы можете полностью понять, как создавать произвольную печатную форму

Задания для самостоятельного повторения:

  1. Создайте свой шаблон для печати данных из записи
  2. Создайте альтернативный экшен, который будет запускать созданный вами шаблон.

Обсуждение

Обсудить можно здесь

Использование Qweb

Оглавление

Основные данные

В системе odoo есть встроенный генератор шаблонов(точнее генератор по шаблону). Он называется Qweb. Как обычно вы можете изучить его особенности прочитав документацию. Как обычно в этой статье я постараюсь сфокусироваться на том, что не очевидно из документации.

Qweb существует двух видов

Для меня самым неочевидным, было то, что есть реализация Qweb есть как на javascript, так и на python. Но имеют свои особенности, характерные для каждой реализации python и javascript.

Где используется

Генератор шаблонов применяется в следующих элементов:

  • Шаблоны сайта (python вариант)
  • Печатные формы (python вариант)
  • В канбан представлении (javascript вариант)
  • Шаблоны виджетов js части (javascript вариант)
  • Шаблоны почтовых сообщений (python вариант)

Обсуждение

Обсудить можно здесь

Самостоятельное задание после изучения начального этапа.

Задание

Для закрепления материала вам нужно выполнить самостоятельно следующие задачи:

  1. Создать на github новый проект
  2. Создать проект по разработке в отдельном каталоге и подключить к нему в качестве разрабатываемого проекта, созданный вами на github
  3. Создать модуль с именем second_module
  4. В модуле создайте модель с именем second.model
  5. Добавьте все необходимые права доступа для, того чтобы этой моделью мог пользоваться администратор
  6. Сделайте так, чтобы у этой модели были представления вида List и Form.
  7. Добавьте печатную форму
  8. Сделайте несколько записей в модели с помощью xml файла
  9. Сделайте коммит и пуш вашей работы на github.

Обсуждение

Обсудить можно здесь

Наследование

Оглавление

Основная информация

В объектно-ориентированном программировании существует такой терми как наследование. Как правило данный термин применяется в отношении классов ООП, но в случае odoo он имеет схожий смысл, но есть некоторые отличия.

Если говорят о наследовании в контексте odoo то имеется ввиду следующие: Наследование моделей, а так же подразумевается, что эти изменения будут отражены в представлении через одноименный механизм [наследования представлений](наследование моделей). А так же для наследование javascript - в данном случае это старый js фреймворк, для нового вот

Как вы можете видеть, поведение всех элементов системы может быть изменено с помощью наследования. Такой инструмент позволяет использовать уже существующий код невероятно эффективно, т.к. появляется возможность использовать доступные модули в собственных целях и добавлять только действительно отсутствующие элементы.

Наследование моделей

Наследование моделей бывает 3-х типов:

  1. Расширение - Изменение существующей модели. Наиболее часто используемый механизм для добавления дополнительного функционала в уже существующие модули. Модифицирует поведение исходной модели
  2. Классическое наследование. Используется значительно реже. Создает отдельную модель со своей таблицей при этом обладает всеми полями и методами наследуемой модели.
  3. Делегирование. Позволяет создавать модель на основании наследуемой, но при этом не создается ее копия, а используются ссылки на родительский объект. Работает медленнее.

Модификация существующего модуля

Подготовка

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

Вот ее имя - 16.0-inherit-sale-order

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

Затем откройте файл config.json и в ключах init_modules и update_modules добавьте имя нового модуля inherit_sale_order:

config.json

Запустите менеджер с флагами -d имя_базы -i -u, и он сам создаст новую базу с указанными именем

Добавление поля в модель

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

config.json

  1. Наследуем уже существующую модель, с помощью ключевого слова _inherit и добавляем дополнительное поле
  2. Создаем запись, которая будет наследовать уже готовое представление смотрите на поле с именем inherit_id, затем с помощью директивы xpath находим элемент и с помощью атрибута position указываем куда будет пристыкован новый узел. В нашем случае это созданное нами поле с именем additional_field
  3. После того, как мы перезагрузим систему с обновлением модуля (с флагами -d имя_базы -i -u), мы должны будем увидеть что целевой модели, в нашем случае это sale.order будет добавлено дополнительное поле.

Для того, чтобы найти этот объект надо в главном меню открыть пункт Sale и кликнуть по любому документу в списке.

Задания для самостоятельного повторения:

  1. Создайте свое поле самостоятельно и выведите его на форму для модели sale.order

Обсуждение

Обсудить можно здесь

Перенос(Миграция) данных

Содержание

Миграция данных это процесс сохранения корректных данных в БД после обновления модуля до новой версии. Например, простое переименование поля приведет к потере данных, если вы не имеете соответствующих скриптов переноса(миграции) данных.

Подготовка

Эти миграции происходят только в момент, когда меняется версия модуля в базе данных при его обновлении.

Детально описано здесь:

Этот класс управляет миграцией модулей. Файлы запускающие миграцию должны быть python файлами, содержащими функцию migrate(cr, installed_version). Эти файлы должны следовать определенному порядку расположения в древовидной структуре файлов: Каталог migrations, который содержит каталоги с версиями. Версия может быть module или server.module версией (в этом случае файлы будут обрабатываться только этой версией сервера). Имена python файлов могут начинаться с префиксов pre или post и будут выполняться, соответственно, до и после инициализации модуля. Скрипты с префиксом end запускаются после обновления всех модулей.

Example:

<moduledir>
`-- migrations
	|-- 1.0
	|   |-- pre-update_table_x.py
	|   |-- pre-update_table_y.py
	|   |-- post-create_plop_records.py
	|   |-- end-cleanup.py
	|   `-- README.txt                # не запускается
	|-- 16.0.1.1                      # запустится только на сервере версии 16.0
	|   |-- pre-delete_table_z.py
	|   `-- post-clean-data.py
	|-- 0.0.0
	|   `-- end-invariants.py         # запустится для всех версий при обновлении
	`-- foo.py                        # не запустится

Выполнение

Файлы миграции - это просто файлы с python кодом, которые не требуется где-либо регистрировать. При обновлении модуля Odoo осматривает каталог с именем migrations на наличие каталогов с промежуточной версией, вплоть до версии, для которой выполняется обновление. Это случается до того, как все остальные файлы были осмотрены, поэтому в этот момент в вашей БД ничего не поменялось. Затем, если каталоги с нужными именами версий были найдены Odoo запускает python файлы с префиксом pre в них. Они должны содержать и определять функцию с именем migrate Эта функция имеет два аргумента: курсор БД и текущая устанавливаемая версия

После успешной отработки всех функций с префиксом pre Odoo обновит модуль. Теперь текущее состояние БД отличается от предыдущего. Например, если в новой версии мы изменили тип поля, в БД эта колонка будет изменена без сохранения данных. Или, если было изменено имя поля, в новой версии будет создана только новая колонка.

Затем, после того, как модуль был обновлен, Odoo будет по тому же алгоритму искать скрипты с префиксом post и запускать их.

Скрипы с префиксом end будут запущены после обновления всех модулей.

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

Пример

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

Сначала переключаемся на ветку - 16.0

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

В этот моменты мы должны увидеть уже знакомую нам картину. Где версия модуля будет равна 16.0.1.0.0.

Версия модуля до миграции

  1. Откройте приложения Apps
  2. Введите в поисковой строке техническое имя нашего модуля first_module
  3. Переключите представление в Tree View

Теперь, не останавливая системы, откройте еще один терминал и переключитесь на ветку 16.0-migration-example

Переключение во втором терминале

После того как переключили ветку - выберите предыдущий терминал. Теперь, с помощью Ctrl-C остановите систему, нажмите вверх и перезапустите ее вы должны увидеть следующее:

Использование миграции

  1. В манифесте модуля мы изменили версию. Система запустит нашу миграцию только в случае, если новая версия модуля будет выше чем версия модуля сохраненная в БД
  2. Данной версии соответствует имя каталога в папке migrations внутри нашего модуля
  3. Функция, которая запустилась и изменила содержимое БД до применения обновлений самого модуля.
  4. В терминале мы можем увидеть отражение работы нашей функции и системы миграции

Результат миграции вы можете увидеть в нашем модуле:

Результат миграции

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

Результат миграции

  1. Удалили поле field_one, а так же убрали заполнение полей в демо данных
  2. Добавили поле field_tree и в представлениях заменили поля с field_one на field_tree

Так, вот, если бы у нас не было миграции, где мы перед обновлением модуля удалили бы field_one и просто добавили field_tree, то все данные из поля были бы утеряны после обновления. Собственно для таких ситуаций класс миграций и создан

Задания для самостоятельного выполнения:

  1. Создайте новое поле field_four, добавьте его в представления
  2. Замените field_two на field_four
  3. Создайте миграцию для версии 16.0.1.0.3 на основании предыдущего примера

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Статья в разработке

Домены

Содержание

Подготовка

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

Вот ее имя - 16.0-domains

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

В файл конфигурации в параметры init_modules и update_modules добавьте имя модуля domains

Запустите менеджер с параметрами -d, -i, и -u

Перейдите откройте браузер по адресу http://127.0.0.1:8069

Что такое домен?

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

Домен - это своего рода "фильтр", который состоит из комбинации триплетов и операторов объединенных в единый список. Данный фильтр применяется к некоторому набору записей(или записям в таблице) и если эти записи удовлетворяют условию, содержащемуся в домене, то они проходят фильтр, остальные записи отбрасываются или не учитываются. Каждый триплет является списком или кортежем состоит из трех элементов:

  1. Имя поля - имеет тип строка
  2. Оператор - имеет тип строка
  3. Значение

Простейший пример:

domain = [("field_name", "=", some_value)]
domain = [("name", "=", "Deco Addict")]

В этом примере мы сравниваем значение поля name с текстовым значение "Deco Addict". И если, например, в модели res.partner у нас будут записи, у которых соблюдается это условие, то они пройдут фильтр и попадут в выборку, с которой мы в дальнейшем сможем проводить какие-либо манипуляции

Про все доступные операторы вы можете узнать из документации, ссылки на которую я указал выше.

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

domain = [
1.    ('name','=','ABC'),
2.    ('language.code','!=','en_US'),
3.    '|',
3.1      ('country_id.code','=','be'),
3.2      ('country_id.code','=','de')
]

Этот домен можно прочитать как:

  1. Имя равно "ABC" И
  2. Код языка НЕ равен "en_US" И
  3. Код страны равен "be" ИЛИ "de"

Т.е. оператор | имеет арность 2, соответственно оператор ИЛИ применяется к двум следующим за ним триплетам, а вся конструкция рассматривается как единый элемент. Т.е. если нам нужно выбрать из трех кодов стран, то написание конструкции будет следующим:

domain = [
1.  ('name','=','ABC'),
2.  ('language.code','!=','en_US'),
3.  '|'
3.1 - 4.  '|',
3.1 - 4.1    ('country_id.code','=','be'),
3.1 - 4.2    ('country_id.code','=','de'),
3.2      ('country_id.code','=','by')
]

Этот домен можно прочитать как:

  1. Имя равно "ABC" И
  2. Код языка НЕ равен "en_US" И
  3. Код страны равен "be" ИЛИ
  4. "be" ИЛИ "de"

Применение на практике

Одно из самых частых применений доменов относится к ограничению выборки в полях типа many2many. Оно имеет ряд особенностей, на которых стоит остановится подробнее: Применение доменов в many2one поле

  1. Домен объявляется как атрибут поля при объявлении модели и представляет собой список кортежей на python
  2. Домен объявляется как атрибут поля при объявлении модели и представляет собой строку
  3. Домен объявляется как атрибут поля при объявлении модели и представляет собой вызов лямбда функции, которая будет вызвана и вычислена в момент создания экземпляра класса модели или рекордсета
  4. Домен объявляется как атрибут поля в самом представлении

Выше вы можете видеть 4 способа создать и записать один и тот же домен. Давайте попробуем разобраться в этих тонкостях: В случаях 1 - 3, доме создается с помощью python. Что дает позволяет применять домен ко всем представлениям, которые будут отображать текущую модель. Плюс в случае 3 мы можем собрать домен при попытке прочитать данные записи, что дает большую гибкость нежели просто заранее указанный домен. В случае 4 домен применяется только в конкретном представлении, а уже в других его может не существовать или он может быть другим.

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

Это приводит нас к тому, что использование доменов, которые, применяются на стороне клиента позволяет вносить неплохую интерактивность форм, когда предполагаемые значения одного поля зависят от другого:

Значение домена зависит от значения поля

  1. Выбран контакт, у которого есть дочерние контакты (компания и сотруники)
  2. Домен в представлении берет в качестве значения имя поля из п. 1. И таким образом, при выборе организации в другом поле мы уже можем выбрать только ее сотрудников.

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

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

Использование _name_search

  1. Переопределяем метод _name_search в модели res.partner т.к. именно она у нас объявлена в параметре comodel_name нашего many2one поля
  2. Обратите внимение, что у нас в представлении уже объявлен домен и он передается в качестве параметра args
  3. Выводим его в консоль для более наглядного примера
  4. с помощью контекста даем понять, в каких случаях нам необходимо применять наш домен. Это необходимо для того, чтобы отсечь все остальные обращения к данном методу из других мест, т.к. мы переопределяем поведение метода такого глобального бизнес-объекта как res.partner и количество полей many2one со ссылкой на него во всей системе будет измеряться сотнями если не тысячами
  5. добавляем к уже существующему домену наш, который мы хотим создать в текущих условиях. Обратите внимание на инструмент expression, который предназначен для работы с доменами
  6. Мы можем увидеть итоговый домен в консоли перед его применением
  7. Результат работы нашего домена

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

Так же домены постоянно применяются при использовании метода search и являются его обязательным аргументом. Ниже вы можете увидеть пример использования:

Использование search

  1. Нажимаем кнопку
  2. Вызывается функция связанная с этой кнопкой
  3. Внутри функции мы полю one_partner_id присваиваем значение равное рекордсету, который получим при использовании метода search и указанного домена
  4. Результат присвоения мы можем видеть на экране.

Метод search возвращает рекордсет из всех записей модели к которой применяется, которые прошли фильтр указанного домена. В нашем случае, если бы контактов с именем Deco Addict было больше, то система бы вернула исключение, т.к. мы не можем присвоить полю типа many2one рекордсет из более чем одной записи.

Разумеется это не все кейсы использования доменов. Я привел самые часто используемые или неочевидные. Как правило в остальных случаях домен используется как фильтр для отбора записей для различных манипуляций.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Первые веб страницы на odoo

Содержание

В этой статье мы рассмотрим, какие базовые абстракции использует Odoo для формирования веб страниц. Эта статья(и последующие из раздела посвященного сайту) не является учебником по всем возможностям современного сайтостроения, пример будет специально упрощен и сфокусирован на характерных исключительно для Odoo тонкостях. Тем не менее, поскольку сам учебник рассчитан на совсем новичков я постараюсь разъяснить каждый аспект того, что происходит.

Подготовка

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

Вот ее имя - 16.0-webpage-example

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

В файл конфигурации в параметры init_modules и update_modules добавьте имя модуля first_module_public_page

Запустите менеджер с параметрами -d, -i, и -u

Перейдите откройте браузер по адресу http://127.0.0.1:8069/records/

Контроллеры

О том что такое контроллеры, можно почерпнуть из документации

У нас есть контроллеры, которые могут ответить на http запрос, который поступает к нашему экземпляру odoo. Контроллер это функция, которая обрабатывает http запрос направленный на конкретный URI на нашем сервере. Для того, чтобы указать какой именно URI, а так же метод запроса и другие параметры, необходимо задекорировать нашу функцию декоратором http.route:

Контроллер

  1. Декоратор http.route. Связывает указанные URI в первом параметре и функцию-обработчик, которой и является декорируемая функция. Параметры функции-декоратора можно увидеть здесь
  2. Декорируемая функция - обработчик запроса. Внутри нее происходит вся работа для формирования ответа на запрос
  3. Пример осуществления доступа к записям моделей. Обратите внимание, что доступ к объекту окружения env осуществляется не через self а с использованием импортируемого объекта request
  4. Обычный python словарь, который содержит значения для генерации(рендеринга) html строки, на базе Qweb шаблона. Ключи словаря будут доступны внутри шаблона как переменные
  5. В качестве результат работы функции здесь указан html, который в свою очередь генерируется с помощью указанного по xml_id шаблона и ранее описанного словаря значений

Как сгенерировать HTML

Пошаговое описание действий отрисовки html страницы в odoo

  1. Функция request.render выполняет формирование html на основании шаблона с xml_id first_module_public_page.first_model_public_list и передает туда словарь со значениями values
  2. Внутри шаблона вызывается шаблон xml_id first_module_public_page.base_layout , который по факту будет являться нашим родителем и представляет из себя упрощенную разметку html страницы. Красным цветом выделена директива, которая указывает где будет вставлен вызывающий его шаблон.
  3. Для того, чтобы отобразить весь список доступных записей, мы с помощью директивы t-foreach(внимательно смотрите официальную документацию) начинаем итерировать объект с именем all_records и присваиваем для переменной хранящей в себе объект шага итерации record. Как мы можем видеть all_records стал нам доступен после того, как мы добавили его в словарь values и передали в качестве аргумента функции request.render
  4. Если пользователь на первой странице нажмет на ссылку с записью браузер отправит запрос на указанный контроллер. Обратите внимание на то, как контроллером будет интерпретироваться числа стоящее после /records/. Любое число, находящееся в этом uri будет интерпретировано как значение поля id модели first.model и найденный рекордсет из одной записи будет передан в функцию обработчик как аргумент с указанным именем, в нашем случае это имя будет first_model_record_id. Так же обратите внимание на параметр auth=user, это говорит о том, что функция обработчик контроллера будет вызвана только для аутентифицированных пользователей, для гостей будет предложено ввести свой логин и пароль.
  5. Мы опять вызываем функцию request.render в которой указываем имя шаблона с xml_id first_module_public_page.first_model_public_item и словарем со значениями values
  6. Вызов родительского шаблона xml_id first_module_public_page.base_layout
  7. Использование объекта record из ранее созданного словаря значений values. Здесь мы просто обращаемся к атрибутам записи так же как и в коде python

Давайте теперь рассмотрим пример обработки одного запроса, но уже так, как его видит браузер:

Как это выглядит в браузере

  1. Отравляем запрос на сервер
  2. Видим HTML код, который нам прислал сервер и то, как его интерпретировал браузер в виде веб страницы.

Использование ассетов

Как вы уже могли заметить наша веб страница выглядит весьма аскетично для современного веба, но на данном этапе это было сделано намеренно для упрощения финально конструкции. Тем не менее в нашем примере вы так же найдете способы подключения css, js и других файлов, которые вам могут понадобится для построения вашей веб страницы. Собственно эти файлы и называются ассетами. Начиная с версии 15.0 Odoo требует объявления ассетов в __manifest__.py файле по ключу assets:

Использование ассетов

Как мы можем видеть, что в файле __manifest__.py мы сами придумали xml_id для нашего набора или бандла ассетов и затем применили его в шаблоне базовой страницы, который наследуют все остальные шаблоны. Обратите внимание что путь к файлам ассетов начинается с имени модуля. В шаблоне вы так же можете увидеть использование директив t-js="false" и t-css="false", они говорят что один бандл не содержит в себе css, а второй не содержит js файлов.

Безопасность

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

  1. Для начала я дал группе пользователей с xml_id base.group_public права на чтение модели first.model. Вы можете увидеть это здесь
  2. Все не аутентифицированные запросы попадающие на контроллер с параметром auth="public" автоматически присваиваются анонимному пользователю, который и входит в группу base.group_public. Таким образом при обращении к моделям и их записям автоматически будет применяться полный механизм безопасности Odoo. Да, конечно, вы можете использовать sudo() при работе из контроллера, но его использование необходимо максимально ограничить, т.к. с развитием проекта вы рискуете просто упустить из виду бреши в безопасности из-за роста кодовой базы и количества бесконтрольных обращений к системе.
  3. В случае же, если вам нужно скрыть страницу исключительно для аутентифицированных пользователей, вы в контроллере должны применять параметр auth="user", что автоматически вызовет проверку того, аутентифицирован ли пользователь на сайте или нет. И все обращения к ресурсам системы внутри контроллера уже будут авторизованы от его имени.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Использование модуля website

Содержание

В этой статье мы рассмотрим, как использовать модуль website, который идет в стандартной поставке Community Edition.

Подготовка

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

Вот ее имя - 16.0-website-usage-example

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

В файл конфигурации в параметры init_modules и update_modules добавьте имя модуля website_first_module

Запустите менеджер с параметрами -d, -i, и -u

Перейдите откройте браузер по адресу http://127.0.0.1:8069

Зачем использовать модуль website

В предыдущей статье мы рассмотрели вариант того, как можно создавать собственные веб-страницы на основании базовых odoo абстракций. Современный вебсайт достаточно сложная и комплексная система, и каждый раз руками создавать для каждого проекта все необходимые части весьма утомительно. Именно эту задачу и решает базовый модуль website, который добавляет в систему необходимый набор абстракций для разработки полноценного вебсайта на базе платформы odoo. Данный модуль отвечает именно за бизнес-логику и ее построение, все что касается отображения и создания уникального внешнего вида вынесено в еще одну абстракцию - темы вебсайта, они являют собой специальный модуль, который распознается системой как тема. Но про темы мы поговорим в следующий раз. Теперь давайте отобразим все те же самые записи из нашей модели first.model на отдельной веб-странице, но уже с использованием модуля website

Использование модуля website в новом примере

Для того, чтобы можно мы могли использовать модуль website его, очевидно, надо добавить в зависимости. Что и сделали, на забыв добавить наш first_module, в котором содержится модель first.model чьи записи мы планируем отображать на отдельной странице:

Установка зависимостей Как мы можем увидеть - мы просто добавляем имена модулей в ключ depends файла __manifest__.py. И когда мы будем устанавливать наш модуль, система автоматически установит модули-зависимости, а затем позволит нам обращаться к ресурсам, объявленным в этих модулях.

После создания новой базы и установки всех модулей мы увидим следующую картину:

Не аутентифицированная страница с записями

  1. У нас появился полноценный вебсайт с меню, футером и логотипом.
  2. При выборе пункта меню First Model мы увидим список записей, доступных публично (т.е. опубликованных).
  3. Мы еще не прошли аутентификацию, т.е. все что мы видим доступно для всех посетителей нашего сайт.

Теперь давайте нажмем на кнопку Sign In и введем логин и пароль.

Не аутентифицированная страница с записями

  1. После ввода логина и пароля нас принудительно отправит на страницу модуля Discuss, в главном меню выбираем пункт Website. Затем опять кликаем на пункт меню First Model
  2. В итоге мы видим, что у теперь для нас видны все три записи, а так же дополнительная иконка, которая показывает, видна ли данная запись публично или нет. Нажатие на эту иконку либо публикует неопубликованную записи или наоборот снимает публикацию с уже опубликованной записи.

Как вы уже, наверное, заметили наша система достаточно сильно отличается от варианта из предыдущей статьи. Теперь мы видим полноценный веб-сайт, наши записи отображаются с соответствующим оформлением и у нас появилась возможность их публиковать. Все эти дополнительные возможности у нас появились благодаря модулю website.

Как это работает

Шаблон

  1. Как вы видите в параметрах функции-декоратора появился параметр website=True, это означает, что при генерации HTML строки будет в словарь с параметрами будет автоматически добавлен рекордсет из одной записи, который описывает наш текущий сайт. Самих сайтов может быть больше одного, но это мы позже изучим.
  2. Проверяем входит ли наш пользователь, который отправил запрос в группу с xml_id base.group_user. Это группа, куда входят все внутренние пользователи системы.
  3. Мы это делаем для того, что бы для публичных пользователей показывать только опубликованные записи. После установки модуля website, появляется возможность использовать mixin website.published.mixin, который добавляет дополнительное поле is_published. Оно используется для того, чтобы определять доступна ли запись публично или нет. В нашем случае мы с помощью файла с данными, дополнительно заполнили это поле
  4. Как и в прошлый раз, мы генерируем HTML с помощью шаблона xml_id website_first_module.first_model_public_list перед тем, как отдать его браузеру в качестве ответа на запрос.
  5. В этот раз мы используем вместо id записи значение website_url. Для того, чтобы этот параметр корректно работа нам в нашу модель first.model пришлось добавить небольшое дополнение из модуля website. Данный подход позволяет автоматически формировать человекочитаемые ссылки на наши записи. Это очень любят поисковики и поэтому данный функционал включен в модуль website.
  6. Для отрисовки кнопки в виде иконки с глазом, которая одновременно является и идентификатором "опубликованности" записи и ее же переключателем, мы вызываем отдельный шаблон.
  7. Если наш пользователь, который отправил запрос на сервер, является членом группы с xml_id base.group_user, то эта кнопка будет показана
  8. Для того, чтобы правильно идентифицировать запись, на которой нажата кнопка мы в качестве атрибутов data добавляем имя модели записи, ее id и информацию о ее публикации. В дальнейшем я покажу как мы используем эту информацию в js скрипте, который обрабатывает нажатие.
  9. Если мы нажимаем на запись, то браузер отправляет запрос на указанный в п. 5 контроллер, здесь мы проверяем есть ли id записи среди опубликованных и входит ли пользователь в группу с xml_id base.group_user(в данном случае условие написано плохо, т.к. если будет много записей, это будет работать медленно, но материал уже готов, поэтому пока оставлю так))
  10. Если одно из условий не соблюдается, то мы показываем страницу 404, что означает, что запрашиваемая вами страница не существует. Это сделано для того, чтобы нельзя было не аутентифицированному пользователю перейти по прямой ссылке.
  11. Генерация HTML из шаблона с xml_id website_first_module.first_model_public_item.

Использование ассетов

Вы уже наверное обратили внимание, что в файле __manifest__.py есть только один js файл, который добавляется к ассетам, к тому же, судя по их xml_id эти ассеты так же созданы в модуле website. Да все верно. В модуле website содержится специальный бандл для работы вебсайта и мы можем спокойно использовать css классы из bootstrap 5, а так же большое количество уже написанного js кода.

Использование JavaScript

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

Публикация записи 0. Обратите внимание на первые 4 строки. В строке 1 происходит объявление имени данного js модуля в системе (что-то вроде xml_id, только для модулей javascript). Затем идут две строки с ключевыми слованми import. Здесь мы указываем системе, какие уже готовые модули мы хотим использовать в своем коде. Подробнее об этом можно в документации.

  1. Указываем что ждем событие на элементе с классом toggle-published
  2. Если событие, это клик по элементу с классом toggle-published, то запустится функцию с именем _onClickPublishButton
  3. Из атрибутов записи, по которой произошел клик мы собираем информацию о имени модели, идентификаторе и о состоянии публикации. Как вы можете видеть, эта информация находится в самом HTML и была добавлена туда на шаге генерации, см. п 8 из раздела Как это работает
  4. Задействуем готовый механизм обращения к моделям, указываем все необходимые данные, для того, чтобы выполнить метод write в соответствующей модели и таким образом изменить состояние публикации нашей записи
  5. Если в ответе вернулось true, то мы изменяем состояние публикации на противоположное и в зависимости от состояния публикации меняем иконку

Итоги

Как вы можете видеть в модуль website создатели платформы добавили огромное количество идей и инструментов. Использование которых существенно сократит количество трудозатрат. Правда необходимо уметь узнавать что именно уже есть, а чего еще нет. К сожалению по модулю website документация отсутствует и его эффективное использование возможно только если вы умеет читать исходный код системы). Я конечно буду стараться дополнять данный учебник, но объем очень большой и к тому же постоянно меняется от версии к версии, поэтому

Пункт меню на сайт был добавлен вот так

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Создание темы для вебсайта

Содержание

В этой статье мы рассмотрим, как использовать модуль website, который идет в стандартной поставке Community Edition.

Подготовка

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

Вот ее имя - 16.0-website-usage-example

Затем удалите текущую базу и создайте новую.

Убедитесь, что у вас не осталось вкладок со старыми сессиями

В файл конфигурации в параметры init_modules и update_modules добавьте имя модуля theme_airproof

Запустите менеджер с параметрами -d, -i, и -u

Перейдите откройте браузер по адресу http://127.0.0.1:8069

Небольшое вступление

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

Тема в odoo

Темой в odoo называется специальный модуль, который объявлен специальным образом:

Создание модуля-темы

  1. В ключе category файла __manifest__.py необходимо указать значение Theme
  2. В ключе depends файла __manifest__.py необходимо добавить модуль website
  3. В имени модуля на первом месте должно стоять ключевое слово theme_

Менеджер тем в odoo

Чтобы установить произвольную тему вам нужно выполнить следующие действия:

Веб редактор

  1. Залогиниться
  2. Выбрать пункт меню Website
  3. Нажмите Edit, чтобы открыть редактор темы

Выбор тем для установки

  1. Нажать Theme
  2. Пролистать вниз и нажать Switch Theme

В появившемся окне выбрать нужную тему:

Менеджер тем

Как видите, наша тема с именем Airproof уже установлена, мы можем ее обновить, удалить или установить другую тему.

Первоначальные настройки темы

Данный раздел опирается на вот эту статью из официально документации.

Все первоначальные параметры указываются в файле primary_variables.scss. Естественно его необходимо добавить в ассеты в файле __manifest__.py:

Базовые параметры Когда будете заполнять параметры обращайте внимание на ключи, которые заполняются в primary_variables.scss файле.

Использование шрифтов

Так как шрифты это важная часть оформления сайта и, как правило, является базовой, то с них и начнем. Odoo может использовать самостоятельно использовать как шрифты от google: Использование шрифтов Google Здесь вы можете видеть пример использования шрифтов от google без дополнительных манипуляций. Достаточно просто правильно объявить их параметры и система сама их начнет использовать через публичное API

А вот пример того, как вы можете использовать любые другие шрифты локально: Использование локальных шрифтов

  1. Объявляете в файле __manifest__.py в бандле web.assets_frontend дополнительный файл который я назвал fonts.scss.
  2. Описывает базовые параметры шрифта, в том числе, указываете url относительно имени модуля
  3. Добавляете описание шрифта в primary_variables.scss

Для того, чтобы эти шрифты применились их необходимо правильно объявить:

Объявление шрифтов

  1. Объявляем шрифт по умолчанию
  2. Шрифт заголовков
  3. Шрифт в панели навигации
  4. Шрифт на кнопках

Результат использования шрифтов:

Результат использования шрифтов

  1. Использование шрифта из коллекции Google Nunito
  2. Использование собственного шрифта AlumniSansPinstripe

Задание собственных ключевых цветов для темы

Так же для темы вы можете объявить базовые цвета. Это делается так же внутри файла primary_variables.scss следующим образом:

Объявление базовых цветов

  1. Объявление пяти базовых цветов
  2. Здесь могут быть объявлены вспомогательные цвета
  3. Данный набор цветов объявляется как airproof и подключается для использования в теме как набор цветов по умолчанию

Результат объявления базовых цветов:

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

Продолжение следует...(эта статья будет писаться в несколько подходов, т.к. объем очень большой)

Компоновка элементов страницы

В данном разделе мы будем опираться на вот этот раздел документации. Глобально современный сайт, как правило, состоит из трех основных элементов:

  • Заглавная часть (Header) В этой части как правило находятся контакты, ссылки на социальные сети, а так же основное меню сайта
  • Собственно тело страницы (Webpage). Здесь находится контент конкретной страницы.
  • Подвал (Footer). Здесь располагается информация о компании, контактах и другие ссылки, которые могут быть полезные посетителям, но находятся за рамками основного контента сайта.

Заглавная часть (Header)

Объявление header

  1. Обратите внимание для какой модели создается запись
  2. С помощью директивы xpath заменяем стандартный header своим.
  3. Пример изменения ссылок социальных медиа
  4. Вызов шаблона, который отображает кнопку аутентификации, с передачей параметров

Внимательно изучите шаблон заглавной части целиком. Здесь вы можете увидеть, что все элементы из которых состоит шаблон тоже используют вызов дополнительных шаблонов. Вы можете использовать такую же стратегию, а можете создавать описание html прямо внутри текущего шаблона. Исходите из вашей собственной задачи.

Объявление footer

  1. Обратите внимание для какой модели создается запись
  2. С помощью директивы xpath заменяем стандартный footer своим.
  3. Пример блока с призывом
  4. Пример блока с контактным телефоном
  5. Пример блока с почтой
  6. Пример блока с социальными медиа
  7. Пример блока с собственным логотипом

Обратите внимание, что в отличии от заглавной части здесь не используются дополнительные шаблоны, а все нужные блоки html объявлены прямо тут

Тело страницы (Webpage)

Объявление webpage

  1. Обратите внимание как объявлена запись, если вы хотите объявить ее как record то она должна будет принадлежать модели theme.ir.ui.view
  2. С помощью директивы xpath заменяем стандартный footer своим.
  3. Объявляем переменные, которые будут указывать на необходимость рендерить header и footer.
  4. Собственно тело страницы.

Элементы навигации

В данном разделе мы будем опираться на вот этот раздел документации. В этом разделе мы поговорим про элементы навигации, как их добавлять, убирать и менять

Создание элементов меню

Создание элементов меню

  1. Удаляем элемент меню, который вел на страницу контактов
  2. Пример добавления элемента главного меню. Обратите внимание на поле с именем parent_id в нем вы можете увидеть, что применяется метод search с использованием домена, где происходит поиск по имени родительского url и id вебсайта. Как было сказано ранее сайтов может быть больше чем один(вероятно поиск id сайта надо делать через xml_id, но это пример из официальной документации). В данном случае значение родительского url=/default-main-menu зарезервировано системой. Кстати контроллера для обслуживания данного url не существует, поэтому система вернет 404 страницу
  3. Пункт меню Еще. Создается таким же образом как и предыдущий, но как вы видите он уже имеет дочерние пункты. Поэтому при нажатии на него показывается выпадающий список
  4. Пункт меню, который ссылается на внешний сайт. Обратите внимание на значение поля parent_id, здесь уже применяется поиск нужного id с помощью xml_id, и для данного пункта меню родителем является предыдущий.
  5. Еще один дочерний пункт меню, который возвращает 404 страницу

Мега-меню

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

  1. Атрибут меню, который указывает на то что этот пункт меню является мега-меню
  2. Класс, который добавляется к контейнеру, в который помещается контент мега-меню
  3. Контент мега-меню

Управление страницами

В данном разделе мы будем опираться на вот этот раздел документации. В этом разделе мы поговорим про создание отдельных страниц и их оформление

Уже существующие страницы

В стандартной поставке, внутри системы уже есть несколько предопределенных страниц:

  • Страница ошибки 400
  • Страница ошибки 403
  • Страница ошибки 404
  • Страница ошибки 500
  • Страница Home
  • Страница Contact us

Вы можете отключить уже определенные страницы следующим образом:

Для домашней страницы

<record id="website.homepage" model="ir.ui.view">
    <field name="active" eval="False"/>
</record>

Для страницы контактов

<record id="website.contactus" model="ir.ui.view">
    <field name="active" eval="False"/>
</record>

Так же вы можете изменить уже существующую страниц с помощью наследования и директивы XPath Изменение стандартной страницы для ошибки 404

  1. С помощью атрибута inherit_id переопределяем стандартную страницу 404
  2. С помощью директивы XPath указываем что мы хотим заменить узел id="wrap" содержимым нашей директивы
  3. С помощью предопределенной переменной additional_title указываем название страницы
  4. Пишем контент нашей переопределенной страницы

Предопределенные переменные для использования в страницах

Название страницы:

<t t-set="additional_title" t-value="'...'"/>

Meta описание:

<t t-set="meta_description" t-value="'...'"/>

Добавляем CSS класс на страницу

<t t-set="pageName" t-value="'...'"/>

Прячем Заглавую часть (Header)

<t t-set="no_header" t-value="true"/>

Прячем Подвал (Footer)

<t t-set="no_footer" t-value="true"/>

Создание своей страницы

Для начала давайте добавим картинку, которая будет доступна для использования внутри системы:

Добавляем картинку в медиа каталог

  1. Указываем путь к файлу с изображением относительно имени нашего модуля
  2. Созданная запись с id="img_about_01" в модели ir.attachment становится доступной в медиа каталоге. Атрибут public="True" указывает на то, что этот ресурс будет доступен для всех посетителей сайта, а только внутренних

Теперь используя эту картинку в качестве фона создадим свою страницу: Создаем свою страницу

  1. Наименование страницы, отображается в наименовании вкладки браузера
  2. Поле is_published со значением True указывает на то, что эта страница доступна всем посетителям сайта, а не только внутренним
  3. Поле type со значениемqweb указывает на то, что при формировании страницы будет использовать генератор шаблонов qweb
  4. Поле header_overlay со значением True указывает на то, что хедер не будет прилипать к верху как отдельный блок, а страница, будет как бы заходить за него
  5. Использование ранее объявленной картинки, обратите внимание как указан url до нее /web/image/имя_модуля.id_записи_картинки. Опытные читатель уже заметил что это полный xml_id записи этой же картинки. Т.е. все ресурсы объявленные как в примере выше, будут доступны по url созданному по следующему шаблону: /web/image/xml_d_записи
  6. Поле urlсо значением about-us указывает на то, по какому url будет открываться данная страница
  7. Поле key должно хранить уникальный ключ для данной страницы(я пока не выяснил для чего конкретно, узнаете пишите)

Как добавлять виде описано тут Как добавлять иконки описано тут

Создание и управление отдельными блоками (сниппетами)

В данном разделе мы будем опираться на вот этот раздел документации. В этом разделе мы поговорим про создание отдельных блоков и настройке их поведения

Блоки

В odoo создан механизм для создания собственных блоков контента, которые вы можете создавать, а ваши пользователи применять у себя самостоятельно. Блоки делятся на следующие типы:

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

Увидеть все доступные на текущий момент блоки вы можете вот по этой ссылке:

http://127.0.0.1:8069/website/demo/snippets

Структура файлов

Файлы, которые будут описывать блоки должны находиться в следующих местах: Расположение файлов сниппета

  1. В каталоге со статическими ресурсами создаете каталог snippets, где для каждого блока создаете отдельный каталог и файлы, которые нужны для его работы
  2. В каталоге views тоже создаете каталог snippets, в котором будут основные шаблоны этих самых блоков

Создание блока

Создание блока начинается с создания его шаблона: Объявление шаблона блока

  1. Уникальный id шаблона
  2. Уникальное имя класса шаблона
  3. Отображаемое имя блока в панели конструктора. Если не указано, будет применено название Block.
  4. Используется системой для идентификации блока
  5. Тег <section/> используется для самостоятельных блоков, если же блок предназначен для размещения внутри других блоков, то тэг должен быть <div/>

Внутри блока вы можете использовать стандартную сетку Bootstrap, как это сделать правильно описано вот тут

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

Добавление блока в конфигуратор

  1. Наследуем шаблон с блоками (Здесь применяется стандартный механизм наследования для xml представлений)
  2. Добавляем наш набор блоков (состоящий из одного блока) в перед стандартными блоками
  3. Тэг <t/> в котором мы указываем id="x_theme_snippets" - видимо нужен для служебных целей, чтобы система могла его правильно пристыковать
  4. Уже описываем собственно раздел с нашими блоками атрибут id="x_theme_snippets_category" так же нужно указывать, данный элемент мы уже можем видеть внутри DOM модели браузера
  5. Служебный тег <t/> который собственно и определяет уже наш блок. Обратите внимание что в качестве значения атрибута t-snippet указан полный xml_id, а так же атрибут t-thumbnail где указан путь к svg файлу, относительно имени модуля.
  6. Ключевые слова, которые нужны для поиска блока в общем списке

Про дополнительные опции можете прочитать тут

Теперь давайте перетянем на страницу наш блок и заглянем в его параметры: Добавление добавление свойств к блоку

  1. data-selector - позволяет прицепить к определенному элементу пункт меню редактора и добавлять дополнительные классы через конструкцию data-select-<custom-attribute>. В этом примере мы видим что для элемента с селектором table при выборе какого либо пункта меню добавляются в нашем случае class="table-bordered" для выбранного Bordered. Обратите внимание на служебные теги с префиксом we данные теги указывают на то что это они предназначены для работы внутри WebEditor.

Так же часто возникает ситуация, когда нужно использовать что-то более динамическое и в этом случае необходимо будет использовать javasсript. Вот как это можно сделать:

Добавление js к пункту меню в кофигураторе

  1. Для того, чтобы система знала какую именно функцию вызвать ее имя необходимо указать в качестве имени атрибута с ключевым словом data вначале, где разделителем слов должен быть дефис(kebab-case), а имя функции в js должно быть написано camelCase. В нашем случае атрибут с именем data-start-table-func вызовет функцию с именем startTableFunc
  2. в js файле импортируем объект options из web_editor.snippets.options
  3. Добавляем в реестр options свой собственный класс, который будет наследовать options.Class
  4. Объявленная нами функция будет принимать на вход три параметра:
    1. previewMode - имеет булево значение, определяет, является ли в данный момент блок в режиме предпросмотра
    2. widgetValue - параметр, который является значением атрибута из пункта 1
    3. params - объект, который описывает собственно объект настроек, какие параметры могут быть, какие функции содержатся и какие параметры могут быть выбраны

Создание и формами для произвольной модели

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

Формы

Формы - отличный инструмент для того, чтобы получить обратную связь от ваших пользователей. В инструментарии вебсайта есть возможность создавать формы для заранее предопределенных моделей. Для этого вам достаточно следовать инструкции из официальной документации. Там указаны модели для которых вы можете сделать формы. Но что делать если мы хотим сделать форму для произвольной модели?. Ниже я расскажу как это можно сделать.

В файл конфигурации проекта в параметры init_modules и update_modules добавьте имя модуля website_first_module

Давайте создадим шаблон формы: Шаблон формы

  1. Создаем пункт меню, который будет вызывать указанный адрес, а по этому адресу будет открываться наша страница с формой.
  2. Для того, чтобы наша форма отправляла данные в нужную нам модель необходимо указать в атрибуте data-model_name="first.model" имя модели, в нашем случае это first.model
  3. Параметр data-success-mode указывает тип подтверждения при удачной отправке данных через форму в нашем случае redirect можем быть еще message но об этом позже
  4. Параметр data-success-page нужен при использовании метода подтверждения redirect и указывает на какую страницу произойдет перенаправление пользователя
  5. При оформлении полей необходимо у тега input указать атрибут name в котором необходимо написать техническое имя поля (из python файла модели)

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

Форма в редакторе 0. Выделенная прямоугольником запись показывает какие поля и как нужно заполнить для того, чтобы форма появилась в редакторе

  1. Указывается дополнительное поле для хранения дополнительной и мета информации. Чуть подробнее будет в следующем блоке
  2. Наименование формы можно видеть в редакторе

В целом можно пользоваться, но обратите внимание не следующий момент:

Белый список полей

  1. Для того, система сама заполняла поля из полей формы, необходимо указать запуск функции для нужной вам модели, которая пометит поля в системе как доступные для заполнения из формы.
  2. Обратите внимание что мы заполнили 3 поля а значения записались только те, которые указаны в функции 1
  3. Третье значение тоже сохранилось но в текстовом виде в поле из пункта 1 предыдущего блока
  4. Поле для сохранения дополнительной информации с формы.

Обсуждение

Обсудить, указать на ошибки и опечатки можно здесь

Дополнительные материалы

Установка WSL 2 на Windows 10 x64

Запускаем PowerShell (от имени администратора)

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

Чтобы проверить, какой версии установлен wsl и в случае необходимости сменить версию, следуйте советам на здесь Чтобы удалить подсистему linux, следуйте советам вот тут

Установка Debian на WSL

Устанавливаем Debian из Microsoft Store

При запуске вводим имя пользователя и пароль.

Использование Docker для разработки gui.

Данная статья не относится непосредственно к разработке odoo. Это скорее конспект для запуска проекта, который требует работы с графикой внутри Docker контейнера. В моем случае мне понадобилось запустить Tkinter.

На момент написания статьи я работал на MacOS, поэтому предметно будет написано для нее, но данный рецепт будет работать и для Linux и для Windows

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

environment:
    ...
    - DISPLAY=IP_адрес_вашей_хостовой_системы:0
    ...
volumes:
    ...
    - /tmp/.X11-unix:/tmp/.X11-unix
    ...

Теперь чуть поподробенее об этих параметрах:

DISPLAY=IP_адрес_вашей_хостовой_системы:0 - здесь нам необходимо указать адрес X11 сервера который будет отрисовывать нашу графику. В нашеми случае это будет наш собственный компьютер на котором мы запустим этот самый X11 У меня это XQuartz

brew install xquartz

После того, как мы его установили нам надо его запустить

open -a XQuartz

Затем в доке нажимаем на появившуюся иконку и в меню окна нажимаем XQuartz -> Настройки... -> Безопасность -> Разрешать подключения из клиентских сетей. Теперь наш X11 сервер готов принимать соединения из других сетей. В нашеим случае это будет контейнер с программой. Для Windows можно попробовать вот это, ну а в Linux он есть из коробки).

/tmp/.X11-unix:/tmp/.X11-unix - здесь мы пробрасываем так называемый файл сокета внутрь контенера, через него, собственно и будет происходить обмен данными. Этот параметр работает без изменений и в Linux и в MacOS, но для Windows надо будет погуглить данный момент.

Использование API Odoo для удаленного вызова процедур (RPC)

Odoo API

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

1. Получение информации о сессии

Ниже приведен пример на языке Python, я прокоментирую кажду строку, думаю все будет понятно.

# Переменная, которая хранит в себе путь к файлу с информацией о сессии
SESSION_FILE = "session.json"
# Адрес сервера, к которму мы планируем подключаться
HOST = 'www.адрес.сервера.by'
# Протокол, http или https
PROTOCOL = "http"
# Порт на котором слушает наш сервер, для https по умолчанию 443
PORT = "80"
# Объединяем все в одну строку
PROTOCOL_HOST_PORT = f"{PROTOCOL}://{HOST}:{PORT}"
# Указываем авторизационные данные, обратите внимание что тут три параметра, логи, пароль и имя базы данных
EMAIL = "admin"
PASSWORD = "admin"
DATABASE = "demo"

# Обращаю ваше внимание, это не JSON данные, а тип данных Dictionary(Словарь) в Python,
# но да, они очень похожи. При отправке вам нужно будет еще преобразовать их в
# JSON строку(байтовый массив) и закрепитьв  теле POST запроса
json_request_data = {"jsonrpc": "2.0","params":{
        "db":DATABASE,
        "login":EMAIL,
        "password":PASSWORD
    }
}

Обращаю ваше внимание, что запрос к Odoo должен быть оформлен по стандарту jsonrpc 2.0 Теперь подготовим запрос на получение авторазационного токена


#Объявлеям заколовки, которые сделают наш запрос json запросом
headers = {'Content-Type': 'application/json','Accept': 'text/plain'}
# тут мы создаем полный URL запроса
URL = PROTOCOL_HOST_PORT + "/web/session/authenticate"
# Делаем сам запрос,обратите внимание, что наш словарь с авторизационными данными, 
# мы преобразуем в строку с помощью метода json.dumps, т.е. на его менсто можно поставить обычную JSON строку
send_data = requests.post(URL,headers=headers,data=json.dumps(json_request_data))
# В этот момент нам может придти ответ двух видов
# Во первых там может быть информация об уже существующей сессии 
# от этого пользователя и в заголовке будет содержаться сообщение 
# вида session_id=1234567890000
# Поэтому если он там есть, мы его сразу извлекаем и подкидываем в качестве
# информации о сессии
set_cookies = send_data.headers["Set-Cookie"]
if "session_id" in set_cookies:
    session_id = set_cookies.split(";")[0].split("=")[1]
    return {"result":{"session_id":session_id}}
# Если же в заголовке нет информации о сессии, то из заголовка ответа "Set-Cookie" мы извлекаем authorization_token
authorization_token = set_cookies
# И подготавливаем заголовки для второго запроса, где в куку подсовываем наш authorization_token
headers = {'Cookie':authorization_token,'Content-Type': 'application/json','Accept': 'text/plain'}
# Обратите внимание на оформление запроса, в качестве тела запроса надо указать строку с пустыми {}
get_session_info = requests.post(PROTOCOL_HOST_PORT +'/web/session/get_session_info',headers=headers,data="{}")
# Полученный текст ответа и являет собой JSON строку с нужной нам информацией, и мы можем ее развернуть в
# объект для удобного взаимодействия с помощью метода json.loads. Но ничего не мешает работать 
# с ответом как с обычной строкой
session_info = json.loads(get_session_info.text)

Теперь давайте еще раз на словах

  1. Делаем JSONRPC 2.0 запрос на URI /web/session/authenticate
  2. Из ответа извлекаем содержимое заголовка Set-Cookie и это будет Авторизационный Токен или там будет сразу session_id=12345... и тогда мы сразу сохраняем его себе в кеш и пользуемся
  3. Подготавливаем второй JSONRPC 2.0 запрос на URI /web/session/get_session_info
  4. Добавляем к запросу заколовок Cookie и Авторизационный Токен в качестве его параметра
  5. Полученное тело ответа и будет JSON строка с информацией о сессии

2. Использование и проверка session_id

В ифнормации о сессии нас больше всего интересует ключ session_id. Собстенно это и будет нашим идентификатором, для подписи запросов. Наша сессия не бесконечна и ограничена 90 днями, плюс у нас могут возникнуть различные коллизии и session_id может стать не действительным. Чтобы убедиться в том, что наше идентификатор еще валиден, мы должны сделать следующее:

# Обратите внимание на оформление параметра 'Cookie', теперь его значение будет иметь примерно такоей вид
# 'Cookie': "session_id=250769263b42fb907b741608cc99ccdbeeac5940" 
headers = {'Cookie': "session_id="+session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
# Делаем запрос на URI проверки валидности сессии
check_session = requests.post(PROTOCOL_HOST_PORT +'/web/session/check',headers=headers,data="{}")
# Полученную JSON строку парсим в объект
response = json.loads(check_session.text)
# Если в ответе есть ключ error, то кука не валидна, что мы и выводим, если такого ключа нет, то мы
# наша сессия валида
error = response.get('error',False)
if error:
    print(error.get("message"))
    return False
return True

Теперь помимо проверки session_id мы знаем как подписывать запрос к Odoo. Достаточно добавить соотвествующую куку к запросу

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

3. Выполнение метода с помощью RPC

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

# Генерируем уникальный идентификатор запроса
req_uuid = uuid.uuid4()
json_rpc_data = {
    "jsonrpc": "2.0",
    "id": str(req_uuid),
    "method": "call",
    "params": {
            "model": "ir.default", # Имя модели, к которой хотим обратиться
            "method": "search_read", # Метод модели, который мы хотим вызвать для исполнения
            "args": [ # позиционный аргрументы для метода
                # ['id','parent_id','name']
            ],
            "kwargs":{ # ключевые аргументы для метода
                "domain":[
                    ('id','=',1),
                ],
                "fields": ['id','field_id','json_value'],
            }
        }
}
# Оформляем заголовки
headers = {'Cookie': "session_id="+session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
# Делаем запрос на URI удаленного вызова процедур
method_result = requests.post(PROTOCOL_HOST_PORT +'/web/dataset/call_kw',headers=headers,data=json.dumps(json_rpc_data))
# Полученную JSON строку парсим в объект
response = json.loads(method_result.text)

4. Обработка ошибок

Что делать если возникают ошибки на самом сервера. Или как уведомить пользователя с клиентской стороны что он совершил явно не предусматриваемое действие? Для этого реализован механизм передачи возникающих исключений на сторону клиента. Вот как это реализовано. Ниже вы можете видеть пример ответа с ошибкой

{
    "jsonrpc": "2.0",
    "id": "73d5b198-066f-40fa-a52a-6c40e7e21551",
    "error": {
        "code": 200,
        "message": "Odoo Server Error",
        "data": {
            "name": "builtins.KeyError",
            "debug": "Traceback (most recent call last):\n  File \"/home/www-data/odoo/odoo/http.py\", line 653, in _handle_exception\n    return super(JsonRequest, self)._handle_exception(exception)\n  File \"/home/www-data/odoo/odoo/http.py\", line 312
, in _handle_exception\n    raise pycompat.reraise(type(exception), exception, sys.exc_info()[2])\n  File \"/home/www-data/odoo/odoo/tools/pycompat.py\", line 87, in reraise\n    raise value\n  File \"/home/www-data/odoo/odoo/http.py\", line 695, in disp
atch\n    result = self._call_function(**self.params)\n  File \"/home/www-data/odoo/odoo/http.py\", line 344, in _call_function\n    return checked_call(self.db, *args, **kwargs)\n  File \"/home/www-data/odoo/odoo/service/model.py\", line 97, in wrapper\
n    return f(dbname, *args, **kwargs)\n  File \"/home/www-data/odoo/odoo/http.py\", line 337, in checked_call\n    result = self.endpoint(*a, **kw)\n  File \"/home/www-data/odoo/odoo/http.py\", line 939, in __call__\n    return self.method(*args, **kw)\
n  File \"/home/www-data/odoo/odoo/http.py\", line 517, in response_wrap\n    response = f(*args, **kw)\n  File \"/home/www-data/odoo/addons/web/controllers/main.py\", line 934, in call_kw\n    return self._call_kw(model, method, args, kwargs)\n  File \"
/home/www-data/odoo/addons/web/controllers/main.py\", line 926, in _call_kw\n    return call_kw(request.env[model], method, args, kwargs)\n  File \"/home/www-data/odoo/odoo/api.py\", line 771, in __getitem__\n    return self.registry[model_name]._browse(
(), self)\n  File \"/home/www-data/odoo/odoo/modules/registry.py\", line 179, in __getitem__\n    return self.models[model_name]\nKeyError: 'stock.inventory2'\n",
            "message": "stock.inventory2",
            "arguments": [
                "stock.inventory2"
            ],
            "exception_type": "internal_error"
        }
    }
}

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

  • code- код ответа сервера, соотвествуют кодам ответов HTTP сервера
  • message - краткое описание ошибки со стороны платформы
  • data - собственно данные по самой ошибке
    • name - имя ошибки
    • debug - полный трейлог ошибки
    • message - сообщение с описанием ошибки
    • arguments - список аргументов переданных в вознкшее исключение
    • exception_type - тип возникшего исключения

Чаще всего для пользователя самым информативным является поле message. Я рекомендую его показывать пользователям в случае возникновения ошибок. Данным механизмом разработчик серверной части может пользоваться для уведомления пользователей о неправильных действиях

Здесь вы можете ознакомться с полным скриптом
import requests
import json
import os
import io
import uuid

script_dir = os.path.dirname(os.path.abspath(__file__))
# Переменная, которая хранит в себе путь к файлу с информацией о сессии
SESSION_FILE = os.path.join(script_dir,"session.json")
# Адрес сервера, к которму мы планируем подключаться
HOST = 'www.адрес.сервера.by'
# Протокол, http или https
PROTOCOL = "http"
# Порт на котором слушает наш сервер, для https по умолчанию 443
PORT = "80"
# Объединяем все в одну строку
PROTOCOL_HOST_PORT = f"{PROTOCOL}://{HOST}:{PORT}"
# Указываем авторизационные данные, обратите внимание что тут три параметра, логи, пароль и имя базы данных
EMAIL = "admin"
PASSWORD = "admin"
DATABASE = "demo"

# Обращаю ваше внимание, это не JSON данные, а тип данных Dictionary(Словарь) в Python,
# но да, они очень похожи. При отправке вам нужно будет еще преобразовать их в
# JSON строку(байтовый массив) и закрепитьв  теле POST запроса
json_request_data = {"jsonrpc": "2.0","params":{
        "db":DATABASE,
        "login":EMAIL,
        "password":PASSWORD
    }
}

def get_session_info():
    #Объявлеям заколовки, которые сделают наш запрос json запросом
    headers = {'Content-Type': 'application/json','Accept': 'text/plain'}
    # тут мы создаем полный URL запроса
    URL = PROTOCOL_HOST_PORT + "/web/session/authenticate"
    # Делаем сам запрос,обратите внимание, что наш словарь с авторизационными данными, 
    # мы преобразуем в строку с помощью метода json.dumps, т.е. на его менсто можно поставить обычную JSON строку
    send_data = requests.post(URL,headers=headers,data=json.dumps(json_request_data))
    # В этот момент нам может придти ответ двух видов
    # Во первых там может быть информация об уже существующей сессии 
    # от этого пользователя и в заголовке будет содержаться сообщение 
    # вида session_id=1234567890000
    # Поэтому если он там есть, мы его сразу извлекаем и подкидываем в качестве
    # информации о сессии
    set_cookies = send_data.headers["Set-Cookie"]
    if "session_id" in set_cookies:
        session_id = set_cookies.split(";")[0].split("=")[1]
        return {"result":{"session_id":session_id}}
    # Если же в заголовке нет информации о сессии, то из заголовка ответа "Set-Cookie" мы извлекаем authorization_token
    authorization_token = set_cookies
    headers = {'Cookie':authorization_token,'Content-Type': 'application/json','Accept': 'text/plain'}
    # Обратите внимание на оформление запроса, в качестве тела запроса надо указать строку с пустыми {}
    get_session_info = requests.post(PROTOCOL_HOST_PORT +'/web/session/get_session_info',headers=headers,data="{}")
    # Полученный текст ответа и являет собой JSON строку с нужной нам информацией, и мы можем ее развернуть в
    # объект для удобного взаимодействия с помощью метода json.loads. Но ничего не мешает работать 
    # с ответом как с обычной строкой
    session_info = json.loads(get_session_info.text)
    return session_info


def check_if_session_is_valid(session_id):
    if session_id == 'fail':
        return False
    # Обратите внимание на оформление параметра 'Cookie', теперь его значение будет иметь примерно такоей вид
    # 'Cookie': "session_id=250769263b42fb907b741608cc99ccdbeeac5940" 
    headers = {'Cookie': "session_id="+session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
    # Делаем запрос на URI проверки валидности сессии
    check_session = requests.post(PROTOCOL_HOST_PORT +'/web/session/check',headers=headers,data="{}")
    # Полученную JSON строку парсим в объект
    response = json.loads(check_session.text)
    # Если в ответе есть ключ error, то кука не валидна, что мы и выводим, если такого ключа нет, то мы
    # наша сессия валида
    error = response.get('error',False)
    if error:
        print(json.dumps(error.get('message'), indent=4,ensure_ascii=False))
        return False
    return True

def write_session_info_to_file(session_info,file_name):
    with io.open(file_name, 'w', encoding='utf8') as config_file:
        json.dump(session_info, config_file, indent=4)

validate_session_id = False
if os.path.isfile(SESSION_FILE):
    session_info = json.loads(open(SESSION_FILE).read())
    session_id = session_info.get("result",{}).get("session_id","fail")
    if check_if_session_is_valid(session_id):
        validate_session_id = session_id

if not validate_session_id or not os.path.isfile(SESSION_FILE):
    session_info = get_session_info()
    session_id = session_info.get("result",{}).get("session_id","fail")
    if check_if_session_is_valid(session_id):
        validate_session_id = session_id
        write_session_info_to_file(session_info,SESSION_FILE)

if validate_session_id:
    # Генерируем уникальный идентификатор запроса
    req_uuid = uuid.uuid4()
    json_rpc_data = {
        "jsonrpc": "2.0",
        "id": str(req_uuid),
        "method": "call",
        "params": {
                "model": "ir.default", # Имя модели, к которой хотим обратиться
                "method": "search_read", # Метод модели, который мы хотим вызвать для исполнения
                "args": [ # позиционный аргрументы для метода
                    # ['id','parent_id','name']
                ],
                "kwargs":{ # ключевые аргументы для метода
                    "domain":[
                        ('id','=',1),
                    ],
                    "fields": ['id','field_id','json_value'],
                }
            }
    }
    # Оформляем заголовки
    headers = {'Cookie': "session_id="+validate_session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
    # Делаем запрос на URI удаленного вызова процедур
    method_result = requests.post(PROTOCOL_HOST_PORT +'/web/dataset/call_kw',headers=headers,data=json.dumps(json_rpc_data))
    # Полученную JSON строку парсим в объект
    response = json.loads(method_result.text)
    
    print(json.dumps(response, indent=4,ensure_ascii=False))

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

Интеграция Owl в odoo

Odoo определяет необходимые инструменты для переходного этапа, на котором будут сосуществовать легаси виджеты Odoo и компоненты Owl. Есть два возможных сценария:

  1. Компонент Owl должен создавать экземпляры легаси виджетов
  2. Легаси виджет должен создавать экземпляры компонентов Owl

Сценарий 1. Компонент Owl должен создавать экземпляры легаси виджетов

ComponentAdapter это компонент Owl, предназначенный для использования в качестве универсального адаптера для компонентов Owl, которые встраивают устаревшие виджеты Odoo (или динамически как компоненты Owl, так и легаси виджеты Odoo), например::

    Owl Component
            |
    ComponentAdapter (Owl компонент)
            |
Legacy Widget(ы) (или Owl компоненты)

Адаптер принимает класс component/widget в качестве пропса Component и аргументы (кроме первого аргумента parent) для инициализации его в качестве пропса. Например:

<ComponentAdapter Component="LegacyWidget" params="params"/>

Буде транслирован в следующее:

const LegacyWidget = this.props.Component;
const legacyWidget = new LegacyWidget(this, this.props.params);

Если для инициализации устаревшего виджета задано более одного аргумента (в дополнение к parent), то порядок аргументов (для инициализации суб виджета) должен быть определен. Есть две альтернативы. Можно либо (1) указать пропс widgetArgs, соответствующий массиву аргументов, в противном случае (2) должен быть определен субкласс ComponentAdapter. Этот субкласс должен переопределять средство получения widgetArgs для преобразования аргументов, полученных в качестве пропсов, в массив аргументов для вызова init. Например :

(1)

<ComponentAdapter Component="LegacyWidget" firstArg="a" secondArg="b" widgetsArgs="[a, b]"/>

(2)

class SpecificAdapter extends ComponentAdapter {
    get widgetArgs() {
        return [this.props.firstArg, this.props.secondArg];
    }
}
<SpecificAdapter Component="LegacyWidget" firstArg="a" secondArg="b"/>

Если легаси виджет должен обновляться при изменении пропса, необходимо определить субкласс ComponentAdapter, чтобы переопределить updateWidget и renderWidget. Функция updateWidget принимает nextProps в качестве аргумента и должна обновлять внутреннее состояние виджета (функция может быть асинхронной и возвращать объект Promise). Однако, чтобы гарантировать, то что DOM обновляется сразу, он не должен выполнять ре-рендеринг. Эту роль берет на себя функция renderWidget, которая будет вызвана непосредственно перед исправлением DOM и которая, таким образом, должна быть синхронной. Например:

class SpecificAdapter extends ComponentAdapter {
   updateWidget(nextProps) {
       return this.widget.updateState(nextProps);
   }
   renderWidget() {
       return this.widget.render();
   }
}

Сценарий 2. Легаси виджет должен создавать экземпляры компонентов Owl


WidgetAdapterMixin и ComponentWrapper предназначены для совместного использования, когда легаси виджету Odoo необходимо создать экземпляр компонентов Owl. В этом случае иерархия виджетов/компонентов будет выглядеть следующим образом:

Легаси Виджет + WidgetAdapterMixin
                |
    ComponentWrapper (Owl компонент)
                |
        Owl компонент

В этом случае родительский легаси виджет должен использовать WidgetAdapterMixin, который гарантирует, то что хуки Owl (mounted, willUnmount, destroy...) правильно вызываются для субкомпонент. Более того, он должен создавать экземпляр ComponentWrapper и предоставьте ему класс компонентов Owl для использования вместе с его пропсами. Этот враппер гарантирует, что компонент Owl будет корректно обновлен (с помощью willUpdateProps), как это было бы, если бы он был встроен в иерархию Owl. Более того, этот враппер автоматически перенаправляет все события, инициируемые компонентом Owl (или его потомками), на легаси пользовательские события (trigger_up) на родительском легаси виджете. Пример:

class MyComponent extends Component {}
MyComponent.template = xml`<div>Owl компонент со значением <t t-esc="props.value"/></div>`;
const MyWidget = Widget.extend(WidgetAdapterMixin, {
    start() {
        this.component = new ComponentWrapper(this, MyComponent, {value: 44});
        return this.component.mount(this.el);
    },
    update() {
        return this.component.update({value: 45});
    },
});

🦉 Документация OWL 🦉

Изучаем Owl

Вы новичек в Owl? Это хорошее место чтобы начать!

Описание

Здесь вы найдете полный справочник по каждой функции, классу или объекту, предоставляемому Owl.

Другие темы

В этом разделе представлены различные документы, объясняющие некоторые темы, которые нельзя считать ни учебным пособием, ни справочной документацией.

Изучаем Owl

Вы новичек в Owl? Это хорошее место чтобы начать!

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

🦉 Краткая информация 🦉

Компоненты Owl в приложении используются для формиования (динамически) дерева компонентов.

        Root
        /   \
       A     B
      / \
     C   D

State(Состояние): каждый компонент может управлять своим локальным состоянием. Это простой ES6 класс, без каких либо правил:

class Counter extends Component {
  static template = xml`
    <button t-on-click="increment">
      Click Me! [<t t-esc="state.value"/>]
    </button>`;

  state = { value: 0 };

  increment() {
    this.state.value++;
    this.render();
  }
}

Пример выше показывает компонет с локальным состоянием. Обратите внимане что нет ничего необычного в объекте state, и нам надо самостоятельно вызывать функцию render каждый раз, когда мы вносим изменения в наше состояние. Такой подход очень быстро начне бесить (и снижает эффектвность сли мы начинаем использовать его слишком часто). Для этого есть способ лучше: использовать хук useState, который превращает объект в реактивную версию самого себя:

const { useState } = owl.hooks;

class Counter extends Component {
  static template = xml`
    <button t-on-click="increment">
      Click Me! [<t t-esc="state.value"/>]
    </button>`;

  state = useState({ value: 0 });

  increment() {
    this.state.value++;
  }
}

Обратите внимание, что обработчик t-on-click можно даже заменить встроенным выражением:

<button t-on-click="state.value++">

Props(Свойства, пропсы): дочернему компоненту может часто требоваться получать информацию от его родителя. Это реализуется путем добавления необходимой информации в шаблон. This родителя будет доступен дочернему комненту в объекте props. Обратите внимание, что здесь есть важное правило: информация, содержащаяся в объекте props, не принадлежит дочернему компоненту и никогда не должна им изменяться.

class Child extends Component {
  static template = xml`<div>Hello <t t-esc="props.name"/></div>`;
}

class Parent extends Component {
  static template = xml`
    <div>
        <Child name="'Owl'" />
        <Child name="'Framework'" />
    </div>`;
  static components = { Child };
}

Communication(Коммуникация): существует несколько способов передачи информации между компонентами. Вот два наиболее важных:

  • от родительского компонента - дочернему: используя объект props,
  • от дочернего компонента - родительскому: используя запуск событий(эвентов),

Следующий пример показывает оба механизма:

class OrderLine extends Component {
  static template = xml`
    <div t-on-click="add">
        <div><t t-esc="props.line.name"/></div>
        <div>Quantity: <t t-esc="props.line.quantity"/></div>
    </div>`;

  add() {
    this.trigger("add-to-order", { line: this.props.line });
  }
}

class Parent extends Component {
  static template = xml`
    <div t-on-add-to-order="addToOrder">
        <OrderLine
            t-foreach="orders"
            t-as="line"
            line="line" />
    </div>`;
  static components = { OrderLine };
  orders = useState([
    { id: 1, name: "Coffee", quantity: 0 },
    { id: 2, name: "Tea", quantity: 0 },
  ]);

  addToOrder(event) {
    const line = event.detail.line;
    line.quantity++;
  }
}

В этом примере компонент OrderLine инициирует событиеadd-to-order. Что в свою очередь породит событие DOM, которое начнет "всплывать" по DOM дереву. Оно (событие) будет перехвачено родительским компонентом, который в свою очередь получит строку (по ключу detail) и затем увеличит ее количество. Вот детали того, ка работает обработка событий.

Обратите внимание, и в случае, если бы компонент OrderLine напрямую модифицировал объект line. Однако это не очень хорошая практика: это работает только потому, что объект props, полученный дочерним компонентом, является реактивным, поэтому дочерний компонент затем связан с реализацией родительского компонента.

🦉 Как начать проект на Owl 🦉

Содержание

Введение

Каждый проект по разарботке ПО имеет свои потребности. Множество этих потребностей могут быть решены такими инструментами как webpack, gulp, препроцессор css , упакочщики, траспиляторы, и т.д.

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

Owl предназначен для использования без каких-либо дополниельных инструментов. Из-за этого Owl можно «легко» интегрировать в современную инструметарий сборки. В этом разделе мы обсудим несколько различных настроек для запуска проекта. Каждая из этих установок имеет свои преимущества и недостатки в различных ситуациях.

Обычный html файл

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

hello_owl/
  index.html
  owl.js
  app.js

Файл owl.js может быть скачан вот отсюда https://github.com/odoo/owl/releases. Это один файл javascript, который экспортирует весь Owl в глобальный объект owl.

Итак, файл index.html должен содержать следующее:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Hello Owl</title>
    <script src="owl.js"></script>
    <script src="app.js"></script>
  </head>
  <body></body>
</html>

And app.js should look like this:

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

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

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

whenReady(setup);

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

С сервером статических ресурсов

У предыдущего подхода есть большой недостаток: код приложения находится в одном файле. Мы могли бы разделить его на несколько файлов и добавить несколько тегов <script> на html-страницу, но тогда нам нужно тратить свои ресурсы на то, что бы скрипты были вставлены в правильном порядке. Более того нам придется экспортировать содержимое каждого файла в глобальные переменные и мы теряем автодополнение между пре преходе из одного файла в другой.

Существует низкотехнологичное решение этой проблемы: использование собственных модулей javascript. Однако у такого подхода есть ограничения: по соображениям безопасности браузеры не будут принимать модули для контента, обслуживаемого через протокол file. Это означает, что нам нужно использовать статический сервер.

Давайте начнем новый проект со следующей файловой структурой:

hello_owl/
  src/
    app.js
    index.html
    main.js
    owl.js

Как и в прошлый раз файл owl.js может быть загружен отсюда https://github.com/odoo/owl/releases.

Теперь файл index.html должен иметь следующее содрежимое:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Hello Owl</title>
    <script src="owl.js"></script>
    <script src="main.js" type="module"></script>
  </head>
  <body></body>
</html>

Обратите внимание что тег script для main.js имее атриубт type="module". Это означает, что браузер будет анализировать скрипт как модуль и загружать все его зависимости.

Вот содержимое app.js и main.js:

// app.js ----------------------------------------------------------------------
const { Component, mount } = owl;
const { xml } = owl.tags;

export class App extends Component {
  static template = xml`<div>Hello Owl</div>`;
}

// main.js ---------------------------------------------------------------------
import { App } from "./app.js";

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

owl.utils.whenReady(setup);

Файл main.js импортирует файл app.js. Обратите внимание что иструкция импорта имеет суффикс .js и это очень важно. Большиство редакторов могу понимать данный синтаксис и осуществлять автоподстановку.

Теперь, для того, чтобы выполнить этот код, нам нужно натравить на каталог src сервер, который буде отдавать статические ресурсы из этого каталога. Наименее затратный способ это сделать - это использовать SimpleHTTPServer, который входит в стандартную поставку python:

$ cd src
$ python -m SimpleHTTPServer 8022    # теперь контент доступен по адресу localhost:8022

Другой, более "яваскриптовый" способ это сделать свое npm приложение. Сделать это мы можем добавив следюущий package.json файл в корень проекта:

{
  "name": "hello_owl",
  "version": "0.1.0",
  "description": "Starting Owl app",
  "main": "src/index.html",
  "scripts": {
    "serve": "serve src"
  },
  "author": "John",
  "license": "ISC",
  "devDependencies": {
    "serve": "^11.3.0"
  }
}

Теперь мы можем уставновить serve с помощью команды npm install, и затем запустить сервер статических ресурсов с помощью команды npm run serve

Стандартный проект Javascript

Предыдущий подход неплохо работает, и очень хорошо подходить для некоторых сценариев, включая быстрое прототипирование. Однако в нем отсутствуют некоторые полезные функции, такие как livereload, покрытие тестами или объединение кода в один файл.

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

Наш стандартный проект Owl имеет следующую файловую структуру:

hello_owl/
  public/
    index.html
  src/
    components/
      App.js
    main.js
  tests/
    components/
      App.test.js
    helpers.js
  .gitignore
  package.json
  webpack.config.js

В проекте каталог public предназначен для хранения всех статических ресурсов, таких как изображения и стили. Папка src содержит исходный код javascript, и, наконец, tests содержит набор тестов.

Вот содержимое файла index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Hello Owl</title>
  </head>
  <body></body>
</html>

Обратите внимание что тут нет тега <script>. Они будут вставлены системой webpack. Теперь давайте посмотрим на файлы javascript:

// src/components/App.js -------------------------------------------------------
import { Component, tags, useState } from "@odoo/owl";

const { xml } = tags;

export class App extends Component {
  static template = xml`<div t-on-click="update">Hello <t t-esc="state.text"/></div>`;
  state = useState({ text: "Owl" });
  update() {
    this.state.text = this.state.text === "Owl" ? "World" : "Owl";
  }
}

// src/main.js -----------------------------------------------------------------
import { utils, mount } from "@odoo/owl";
import { App } from "./components/App";

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

utils.whenReady(setup);

// tests/components/App.test.js ------------------------------------------------
import { App } from "../../src/components/App";
import { makeTestFixture, nextTick, click } from "../helpers";
import { mount } from "@odoo/owl";

let fixture;

beforeEach(() => {
  fixture = makeTestFixture();
});

afterEach(() => {
  fixture.remove();
});

describe("App", () => {
  test("Works as expected...", async () => {
    await mount(App, { target: fixture });
    expect(fixture.innerHTML).toBe("<div>Hello Owl</div>");

    click(fixture, "div");
    await nextTick();
    expect(fixture.innerHTML).toBe("<div>Hello World</div>");
  });
});

// tests/helpers.js ------------------------------------------------------------
import { Component } from "@odoo/owl";
import "regenerator-runtime/runtime";

export async function nextTick() {
  return new Promise(function (resolve) {
    setTimeout(() => Component.scheduler.requestAnimationFrame(() => resolve()));
  });
}

export function makeTestFixture() {
  let fixture = document.createElement("div");
  document.body.appendChild(fixture);
  return fixture;
}

export function click(elem, selector) {
  elem.querySelector(selector).dispatchEvent(new Event("click"));
}

Вот содерижимое конфигурационных файлов .gitignore, package.json и webpack.config.js:

node_modules/
package-lock.json
dist/
{
  "name": "hello_owl",
  "version": "0.1.0",
  "description": "Demo app",
  "main": "src/index.html",
  "scripts": {
    "test": "jest",
    "build": "webpack --mode production",
    "dev": "webpack-dev-server --mode development"
  },
  "author": "Someone",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.8.4",
    "@babel/plugin-proposal-class-properties": "^7.8.3",
    "babel-jest": "^25.1.0",
    "babel-loader": "^8.0.6",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
    "html-webpack-plugin": "^3.2.0",
    "jest": "^25.1.0",
    "regenerator-runtime": "^0.13.3",
    "serve": "^11.3.0",
    "webpack": "^4.41.5",
    "webpack-cli": "^3.3.10",
    "webpack-dev-server": "^3.10.2"
  },
  "dependencies": {
    "@odoo/owl": "^1.0.4"
  },
  "babel": {
    "plugins": ["@babel/plugin-proposal-class-properties"],
    "env": {
      "test": {
        "plugins": ["transform-es2015-modules-commonjs"]
      }
    }
  },
  "jest": {
    "verbose": false,
    "testRegex": "(/tests/.*(test|spec))\\.js?$",
    "moduleFileExtensions": ["js"],
    "transform": {
      "^.+\\.[t|j]sx?$": "babel-jest"
    }
  }
}
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const host = process.env.HOST || "localhost";

module.exports = function (env, argv) {
  const mode = argv.mode || "development";
  return {
    mode: mode,
    entry: "./src/main.js",
    output: {
      filename: "main.js",
      path: path.resolve(__dirname, "dist"),
    },
    module: {
      rules: [
        {
          test: /\.jsx?$/,
          loader: "babel-loader",
          exclude: /node_modules/,
        },
      ],
    },
    resolve: {
      extensions: [".js", ".jsx"],
    },
    devServer: {
      contentBase: path.resolve(__dirname, "public/index.html"),
      compress: true,
      hot: true,
      host,
      port: 3000,
      publicPath: "/",
    },
    plugins: [
      new HtmlWebpackPlugin({
        inject: true,
        template: path.resolve(__dirname, "public/index.html"),
      }),
    ],
  };
};

С этой конфигурацией мы теперь мы можем использовать следущие команды:

npm run build # собирает пиложение в режиме prod в дистрибутив/

npm run dev # запускает сервер разработки с механизмом livereload

npm run test # запускает jest для тестирования

🦉 How to test Components 🦉

Содержание

Введение

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

В этом разделе, мы будем обсуждать как писать юнит тесты для компонентов.

Юнинт тесты

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

  • создание файла теста: например SomeComponent.test.js,
  • в этом файле импортируйте код из SomeComponent,
  • добавьте тестовый кейс:
    • создайте реальный DOM элемент чтобы использовать его как тестовую фикстуру
    • создайте тестовое окружение
    • создайте экземпляр SomeComponent, прмонтируйте его к фикстуре
    • взаимодействуйте с компонентом и установике проверки его свойств.

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

export function makeTestFixture() {
  let fixture = document.createElement("div");
  document.body.appendChild(fixture);
  return fixture;
}

export function nextTick() {
  let requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
  return new Promise(function(resolve) {
    setTimeout(() => requestAnimationFrame(() => resolve()));
  });
}

export function makeTestEnv() {
    // application specific. It needs a way to load actual templates
    const templates = ...;

    return {
        qweb: new QWeb(templates),
        ..., // each service can be mocked here
    };
}

С таким файлом типичный набор тестов для Jest будет выглядеть так:

// в SomeComponent.test.js
import { SomeComponent } from "../../src/ui/SomeComponent";
import { nextTick, makeTestFixture, makeTestEnv} from '../helpers';


//------------------------------------------------------------------------------
// Setup
//------------------------------------------------------------------------------
let fixture: HTMLElement;
let env: Env;

beforeEach(() => {
  fixture = makeTestFixture();
  env = makeTestEnv();
  // мы устанавливаем здесь окружение по умолчанию для каждого компонента, созданного в тесте
  Component.env = env;
});

afterEach(() => {
  fixture.remove();
});

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("SomeComponent", () => {
  test("component behaves as expected", async () => {
    const props = {...}; // зависит от компонента
    const comp = await mount(SomeComponent, { target: fixture, props });

    // делаем проверки
    expect(...).toBe(...);

    fixture.querySelector('button').click();
    await nextTick();

    // далаем другие проверки
    expect(...).toBe(...);
  });
});

Обратите внимание что Owl ожидает следующий кадр анимации для того, чтобы обновить DOM. Вот почему необходимо явно включать ожидание с помощью nextTick (или других методов) чтобы убедиться что DOM обновился.

Иногда бывает полезно подождать, пока Owl полностью завершит обновление компонентов (в частности, если у нас высокопараллельный пользовательский интерфейс). Следующий помощник просто опрашивает каждые 20 мс внутреннюю очередь задач Owl и возвращает промис, который разрешается, когда он пуст:

function afterUpdates() {
  return new Promise((resolve, reject) => {
    let timer = setTimeout(poll, 20);
    let counter = 0;
    function poll() {
      counter++;
      if (owl.Component.scheduler.tasks.length) {
        if (counter > 10) {
          reject(new Error("timeout"));
        } else {
          timer = setTimeout(poll);
        }
      } else {
        resolve();
      }
    }
  });
}

🦉 Как писать однофайловые компоненты 🦉

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

Для того, чтобы сделать это Owl имеет две небольших вспомогательных функции которые позволяют легко определять шаблоны или стили внутри javascript (или typescript) файла: это xml и css

Это означает что шаблон, стили и код javascript может быть определен в одном и том же файле. Например:

const { Component } = owl;
const { xml, css } = owl.tags;

// -----------------------------------------------------------------------------
// TEMPLATE
// -----------------------------------------------------------------------------
const TEMPLATE = xml/* xml */ `
	<div class="main">
		<Sidebar/>
		<Content />
	</div>`;

// -----------------------------------------------------------------------------
// STYLE
// -----------------------------------------------------------------------------
const STYLE = css/* css */ `
  .main {
    display: grid;
    grid-template-columns: 200px auto;
  }
`;

// -----------------------------------------------------------------------------
// CODE
// -----------------------------------------------------------------------------
class Main extends Component {
  static template = TEMPLATE;
  static style = STYLE;
  static components = { Sidebar, Content };

  // остальная часть компонента...
}

Обратите внимание, что в приведенном выше примере есть встроенный комментарий xml сразу после вызова xml. Это полезно для при использовании модулей редактора, таких как аддон для VS Code Comment tagged templates, который, если его установить, добавляет подсветку синтаксиса к содержимому строки шаблона.

🦉 Как отладить приложение Owl 🦉

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

Как только он будет выполнен, он будет выводить в лог много информации об основных хуках каждого компонента. Код внизу представляет собой сжатую версию, чтобы упростить процесс копирования/вставки:

function debugOwl(t,e){let n,o="[OWL_DEBUG]";function r(t){let e;try{e=JSON.stringify(t||{})}catch(t){e="<JSON error>"}return e.length>200&&(e=e.slice(0,200)+"..."),e}if(Object.defineProperty(t.Component,"current",{get:()=>n,set(s){n=s;const i=s.constructor.name;if(e.componentBlackList&&e.componentBlackList.test(i))return;if(e.componentWhiteList&&!e.componentWhiteList.test(i))return;let l;Object.defineProperty(n,"__owl__",{get:()=>l,set(n){!function(n,s,i){let l=`${s}<id=${i}>`,c=t=>console.log(`${o} ${l} ${t}`),u=t=>(!e.methodBlackList||!e.methodBlackList.includes(t))&&!(e.methodWhiteList&&!e.methodWhiteList.includes(t));u("constructor")&&c(`constructor, props=${r(n.props)}`);u("willStart")&&t.hooks.onWillStart(()=>{c("willStart")});u("mounted")&&t.hooks.onMounted(()=>{c("mounted")});u("willUpdateProps")&&t.hooks.onWillUpdateProps(t=>{c(`willUpdateProps, nextprops=${r(t)}`)});u("willPatch")&&t.hooks.onWillPatch(()=>{c("willPatch")});u("patched")&&t.hooks.onPatched(()=>{c("patched")});u("willUnmount")&&t.hooks.onWillUnmount(()=>{c("willUnmount")});const d=n.__render.bind(n);n.__render=function(...t){c("rendering template"),d(...t)};const h=n.render.bind(n);n.render=function(...t){const e=n.__owl__;let o="render";return e.isMounted||e.currentFiber||(o+=" (warning: component is not mounted, this render has no effect)"),c(o),h(...t)};const p=n.mount.bind(n);n.mount=function(...t){return c("mount"),p(...t)}}(s,i,(l=n).id)}})}}),e.logScheduler){let e=t.Component.scheduler.start,n=t.Component.scheduler.stop;t.Component.scheduler.start=function(){this.isRunning||console.log(`${o} scheduler: start running tasks queue`),e.call(this)},t.Component.scheduler.stop=function(){this.isRunning&&console.log(`${o} scheduler: stop running tasks queue`),n.call(this)}}if(e.logStore){let e=t.Store.prototype.dispatch;t.Store.prototype.dispatch=function(t,...n){return console.log(`${o} store: action '${t}' dispatched. Payload: '${r(n)}'`),e.call(this,t,...n)}}}
debugOwl(owl, {
  // componentBlackList: /App/,  // regexp
  // componentWhiteList: /SomeComponent/, // regexp
  // methodBlackList: ["mounted"], // list of method names
  // methodWhiteList: ["willStart"], // list of method names
  logScheduler: false,  // display/mute scheduler logs
  logStore: true,     // display/mute store logs
});

Приведенный выше код, вставленный куда-нибудь в основной файл javascript приложения owl, будет логировать информацию, выглядящую следующим образом:

[OWL_DEBUG] TodoApp<id=1> constructor, props={}
[OWL_DEBUG] TodoApp<id=1> mount
[OWL_DEBUG] TodoApp<id=1> willStart
[OWL_DEBUG] TodoApp<id=1> rendering template
[OWL_DEBUG] TodoItem<id=2> constructor, props={"id":2,"completed":false,"title":"hey"}
[OWL_DEBUG] TodoItem<id=2> willStart
[OWL_DEBUG] TodoItem<id=3> constructor, props={"id":4,"completed":false,"title":"aaa"}
[OWL_DEBUG] TodoItem<id=3> willStart
[OWL_DEBUG] TodoItem<id=2> rendering template
[OWL_DEBUG] TodoItem<id=3> rendering template
[OWL_DEBUG] TodoItem<id=3> mounted
[OWL_DEBUG] TodoItem<id=2> mounted
[OWL_DEBUG] TodoApp<id=1> mounted

Каждый компонет имеент свой внутрениий id, которой очень сильно поможет при отладке.

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

Описание

Здесь вы найдете полный справочник по каждой функции, классу или объекту, предоставляемому Owl.

🦉 Анимация 🦉

Анимация это комплексаня тема. В ней много различных сценариев, и много решений и технологий. Owl поддерживает только базовые сценарии.

Простые CSS эффекты

Иногда использвание простого CSS более чем достаточно. В таких случаев отсуствует реальная необходимсоть использовать Owl все что требуется - это отрендерить DOM элемент с необходимым классом. Например:

<a class="btn flash" t-on-click="doSomething">Click</a>

Со следующим CSS:

btn {
  background-color: gray;
}

.flash {
  transition: background 0.5s;
}

.flash:active {
  background-color: #41454a;
  transition: background 0s;
}

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

CSS переходы

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

Директива t-transition призвана помочь нам в этом. Он работает с элементами html и компонентами, добавляя и удаляя некоторые классы css.

Чтобы выполнять полезные эффекты перехода, всякий раз, когда элемент появляется или исчезает, необходимо добавить/удалить какой-либо стиль или класс css в определенный момент жизни узла(ноды). Поскольку это непросто сделать вручную, директива Owl t-transition поможет вам.

Всякий раз, когда узел имеет директиву t-transition со значением name, происходит следующая последовательность событий:

При вставке узла(ноды):

  • css классы name-enter и name-enter-active будут добавлены когда узел будет вставлен в DOM.
  • на следующем кадре анимации: класс css name-enter будет удален и класс name-enter-to будет добавлен (поэтому их можно использовать для запуска эффектов перехода css).
  • в конце перехода, name-enter-to и name-enter-active будут удалены.

При удалении узла(ноды):

  • css классы name-leave и name-leave-active будут добавлены перед удалением узла из DOM.
  • на следующем кадре анимации: класс css name-leave будет удален и класс name-leave-to будет добавлен (поэтому их можно использовать для запуска эффектов перехода css).
  • в конце перехода, name-leave-to и name-leave-active будут удалены.

Например, просто

Например, простой эффект появления/затухания можно сделать следующим образом:

<div>
    <div t-if="state.flag" class="square" t-transition="fade">Hello</div>
</div>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}

Директива t-transition может быть применена к элементу узла(ноды) или к компоненту

Примечание:

Owl не поддерживает более одного перехода для узла(ноды), поэтому выражение должно быть единичным значением t-transition (т.е. no space allowed).

SCSS Миксины

Если вы используете SCSS, то вы можете использовать миксины для создания генерации анимаций. Вот пример с появлением/затуханием:

@mixin animation-fade($time, $name) {
  .#{$name}_fade-enter-active,
  .#{$name}_fade-active {
    transition: all $time;
  }

  .#{$name}_fade-enter {
    opacity: 0;
  }

  .#{$name}_fade-leave-to {
    opacity: 0;
  }
}

Использование:

@include animation-fade(0.5s, "o_notification");

Теперь вы можете исползовать ее в своем шаблоне:

<SomeTag  t-transition="o_notification_fade"/>

🦉 Объект browser 🦉

Содержание

Введение

Объект browser содержит некоторые собственные API-интерфейсы браузера, такие как setTimeout, которые используются Owl и его служебными функциями. Их можно использовать, если это необходимо.

owl.browser.setTimeout === window.setTimeout; // возвращает true

На данный момент этот объект содержит некоторые функции, которые Owl не использует. Со временем они будут удалены в Owl 2.0.

Содержимое объекта browser

В частности, объект browser содержит следующие методы и объекты:

  • setTimeout
  • clearTimeout
  • setInterval
  • clearInterval
  • requestAnimationFrame
  • random
  • Date
  • fetch
  • localStorage

🦉 Компонент OWL 🦉

Содержание

Введение

Компоненты OWL это строительные блоки пользовательского интерфейса. Они спроектированы обладать следующими свойствами:

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

  2. composable: каждый компонент может быть легко создан в родительском компоненте с помощью простого тега или директивы в его шаблоне.

  3. asynchronous rendering: фреймворк будет ждать готовности каждого подкомпонента перед применением рендеринга. Он использует нативные промисы под капотом.

  4. uses QWeb as a template system: шаблоны описаны в XML и соответствуют спецификации QWeb. Это требование для Odoo.

OWL компоненты определяются как дочерние классы Component. Рендеринг осуществляется исключительно QWeb шаблоном (который требуется загрузисть предварительно в QWeb) Рендеринг компонента генерирует виртуальное представление компонента, которое затем прикрепляется к DOM, чтобы применить изменения эффективным способом.

Пример

Давайте посмотрим на простой компонент:

const { useState } = owl.hooks;

class ClickCounter extends owl.Component {
  state = useState({ value: 0 });

  increment() {
    this.state.value++;
  }
}
<button t-name="ClickCounter" t-on-click="increment">
  Нажми меня! [<t t-esc="state.value"/>]
</button>

Обратите внимание что код написан в стиле ESNext, это означает, что он будет работать только на последних версиях браузеров без шага транспиляции

Этот пример показывает как компонент должен определяться: это просто класс наследник класса Component. Если нет ключа template, то в этом случае Owl будет использовать имя компонента как имя шаблона. Здесь определен объект состояния путем использования хука useState. Использование объекта состояния не обязательно, но, безусловно, приветствуется. Результатом вызова useState является observed и лбое его изменение вызовет повторный рендеринг.

Описание элементов

Компонент Owl это маленький класс который представляет компонент или какой либо UI элемент. Он существует в контексте окружения(env), доступ к которому распространяется от родителя к своим детям. Окружение должно иметь экземпляр QWeb, который будет исползоваться для рендеринга шаблона компонента.

Имейте в виду, что имя компонента может быть важным: если компонент не определяет ключ template, тогда Owl будет искать в QWeb шаблон с именем компонента (или одним из его предков).

Реактивная система

Owl компоненты - это нормальные классы javascript. Поэтому изменение внутреннего состояния компонента ничего не делает:

class Counter extends Component {
  static template = xml`<div t-on-click="increment"><t t-esc="state.value"/></div>`;
  state = { value: 0 };

  increment() {
    this.state.value++;
  }
}

Кликая на компонент Counter, определенный выше, вызовет increment метод, но он не отрендерит компонент заново. Для того чтобы это исправить можно добавить явный вызов метода render внутри increment:

    increment() {
        this.state.value++;
        this.render();
    }

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

Правильный способ - это использовать реакционную систему: используя хук useState (см. секцию Хуки для изучения подробностей), он может заставить Owl реагировать на изменение состояния. Хук useState генерирует прокси версию объекта (это создается с помощью обозревателя), который позволяет компоненту реагировать на любые изменения. Поэтому пример Counter может быть улучшен примерно вот так:

const { useState } = owl.hooks;

class Counter extends Component {
  static template = xml`<div t-on-click="increment"><t t-esc="state.value"/></div>`;
  state = useState({ value: 0 });

  increment() {
    this.state.value++;
  }
}

Очевидно, мы можем вызывать хук useState более одного раза:

const { useState } = owl.hooks;

class Counter extends Component {
  static template = xml`
      <div>
        <span t-on-click="increment(counter1)"><t t-esc="counter1.value"/></span>
        <span t-on-click="increment(counter2)"><t t-esc="counter2.value"/></span>
      </div>`;
  counter1 = useState({ value: 0 });
  counter2 = useState({ value: 0 });

  increment(counter) {
    counter.value++;
  }
}

Обратите внимание что хуки подчиняются одному важному правилу: они требуют, чтобы их вызывали в конструкторе.

Свойства (Properties)

  • el (HTMLElement | null): ссылка на корневой узел DOM элемента. Является null, когда компонент не смонтирован.

  • env (Object): компонент окружения, который содержит экземпляр QWeb.

  • props (Object): это объект который содержит свойства предоставленные родителем своим дочерним компонентам. Например следующая ситуация, родительский компонент предоставляет user и color значения для ChildComponent

      <div>
        <ChildComponent user="state.user" color="color">
      </div>
    

    Обратите внимание что props принадлежат родителю, не компоненту. Поэтому они никогда не должны изменяться компонентов (в противном случае вы рискуете получить непредсказуемые последствия поскольку родитель может не знать об этих изменениях)

    props могут динамически изменяться родителем. В этом случае, компонент будет проходить через методы жизненного цикла: willUpdateProps, willPatch и patched.

Статические свойства (Static Properties)

  • template (string, optional): если определен то это будет имя шаблона QWeb, который будет редндерить компонент. Обратите внимание, что есть вспомогательный xml, облегчающий определение встроенного шаблона.
  • components (Object, optional): если определен то это будет объект, который содержит классы любых дочерних компонентов, необходимых шаблону. Это основной способ, используемый Owl для создания дочерних компонентов.

    class ParentComponent extends owl.Component {
      static components = { SubComponent };
    }
    
  • props (Object, optional): если определен то это будет объект который описывает тип и форму пропсов предоставленных компоненту. Если Owl находится в режиме dev, то можно использовать для валидации пропсов каждый раз когда компонент создается/обновляется. Смотрите Props Validation для получения более детальной информации

    class Counter extends owl.Component {
      static props = {
        initialValue: Number,
        optional: true,
      };
    }
    
  • defaultProps (Object, optional): если определен то это будет объект который значения по умолчнию для пропсов. Всякий раз, когда props передаются объекту, они будут изменены, чтобы добавить значение по умолчанию (если оно отсутствует). Обратите внимание, что исходный объект не изменяется, вместо этого создается новый объект.

    class Counter extends owl.Component {
      static defaultProps = {
        initialValue: 0,
      };
    }
    
  • style (string, optional): должно быть возвращаемое значение css тега, который используется для внедрения таблицы стилей всякий раз, когда компонент виден на экране.

В классе Component определено еще одно статическое свойство: current. Это свойство устанавливается для определяемого в данный момент компонента (в конструкторе). Таким образом хуки могут получить ссылку на целевой компонент.

Методы

Здесь мы объясним все публичные методы класса Component.

  • mount(target, options) (async): это основной способ добавления компонента в DOM: корневой компонент монтируется в целевой HTMLElement (или фрагмент документа). Очевидно, что это происходит асинхронно, так как каждый дочерний элемент также должен быть создан. Большинству приложений потребуется вызвать mount ровно один раз для корневого компонента.

    Аргумент options является необзательным объектом с ключем position. Ключ position может иметь три возможных значения: first-child, last-child, self.

    • first-child: с этим параметров компонент будет добавлен внутрь целевого элемента на первое место,
    • last-child (значение по умолчанию): с этим параметром компонент будет добавляться на поседнее место внутрь целевого элемента,
    • self: цель будет использоваться как корневой элемент для компонента. Это означает, что целью должен быть
      HTMLElement (а не фрагмент документа). В этой ситуации возможно, что компонент не может быть отмонтирован. Например, если его целью является document.body.

    Обратите внимание что если компонент монтируется, отмонтируется и перемонтируется, то он будет автоманически перерендерен, чтобы убедиться что изменения в состоянии (что-то в окружении), или в хранилище, или...) буду учтены.

    Если компонент примонтирован внутрь элемента или фрагмена которые не в DOM, тогда он будет перерендерен целиком, но не активен: хуки mounted не вызовутся. Это иногда полезно, если мы хотим загрузить приложение в память. В этом случае нам нужно примонтировать корневой компонент заново в элементе, который находится в DOM:

    const app = new App();
    await app.mount(document.createDocumentFragment());
    // app is rendered in memory, but not active
    await app.mount(document.body);
    // app is now visible
    

    Обратите внимание что нормальный способ монтирования приложения это исползование метода mount в классе компонента, а не через создание экземпляра вручную. Смотрите документа по монтированию приложений.

  • unmount(): этот метод может быть использован в случаях когда компонент, необходимо отсоеденить/удалить из DOM. Большинство приожений не должны вызивать unmount, это подходит больше для нижележащих системных компонентов.

  • render() (async): вызов этого метода сразу вызовет повторный ререндеринг.

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

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

  • shouldUpdate(nextProps): этот метод вызывается каждый раз когда обновляются пропсы компонента. Он возвращает булево значение, которое говорит о том может ли компонент игнорировать эти изменения или нет. Если он возвращает false, тогда метод willUpdateProps не будет вызван и ререндеринга не будет. Его реализация по умолчанию всегда возвращает true. Обратиче внимание что это оптимизация аналогичная shouldComponentUpdate в React. Большую часть времени он не должен использоваться,но если он может быть полезным в случаях кода мы управляем большим количество компонентов. Поскольку это оптимизация, то Owl имеет возможность игнорировать резулльтат shouldUpdate в некоторых случаях (например если компонент перемонтируется, или если мы хотим принудительно перерендерит весь UI). Как бы там ни было, если shouldUpdate возвращает true, тогда Owl гарантирует нам, что компонент будет перерендерен в какой-то момент в будущем (за исключением случаев, когда компонент будет уничтожен или произойдет сбой какой-либо части пользовательского интерфейса).

  • destroy(). Как следует из названия, этот метод удалит компонент и выполнит всю необходимую работу, например, размонтирует компонент, его дочерние элементы, удалив отношения родитель/потомок. Этот метод почти никогда не следует вызывать напрямую (за исключением, возможно, корневого компонента) вместо этого он должен выполняться фреймворком.

Очевидно, что эти методы зарезервированы для Owl и не должны использоваться пользователями Owl, если только они не захотят переопределить их. Кроме того, Owl резервирует все имена методов, начинающиеся с __, чтобы предотвратить возможные будущие конфликты с пользовательским кодом всякий раз, когда Owl необходимо изменить.

Жизненный цинкл

Крепкая и надежная система компонентов нуждается в полезных хуках/методах, помогающих разработчикам писать компоненты. Вот полное описание жизненного цикла компонента owl:

только после того, как компонент был перерендерен и добавлен в DOM

МетодОписание
setupнастройка
willStartasync, перед первым рендерингом
mountedтолько после того, как компонент был перерендерен и добавлен в DOM
willUpdatePropsasync, перед обновлением пропсов
willPatchнепосредственно перед исправлением DOM
patchedнепосредственно после исправлением DOM
willUnmountнепосредственно перед удалением из DOM
catchErrorперехватиывае ошибки (см перехват ошибок)

Примечания:

  • порядок вызова хуков точно определен: [willX] хуки вызваются сначала у родителя, затем у дочернего объекта, и [Xed] вызываются в обратном порядке, сначала у дочрних объектов, а потом у родителей.
  • никакие методы хуков не должны вызывать самостоятельно. Они должны вызываться фреймворком Owl тогда, когда это требуется.

constructor(parent, props)

constructor — это не совсем хук, это обычный, нормальный конструктор компонента. Поскольку это не хук, вам нужно убедиться, что вызывается super.

Это то место где обычно настраиватеся начальное состояние и шаблон компонента.

  constructor(parent, props) {
    super(parent, props);
    this.state = useState({someValue: true});
    this.template = 'mytemplate';
  }

Обратите внимение ESNext поля классов, метод constructor не требует реализации в большинстве случаев:

class ClickCounter extends owl.Component {
  state = useState({ value: 0 });

  ...
}

Функции хуков могут быть вызваны в коснтруторе.

setup()

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

Это правильный метод для вызова функций хуков. Обритите внимание что одна из основных причин иметь хук setup в жизненном цикле компонента это сделать возможным сделать монкей патч. Это обычная потребность в экосистеме Odoo.

setup() {
  useSetupAutofocus();
}

willStart()

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

Он будет вызван ровно один раз перед начальным рендеринго. Это полезно в некоторых случаях, например, загрузить внешние ассеты (такие как JS библиотеки) перед тем как как компонент их отрендерит. В другом случае можно загрузить данные с сервера

  async willStart() {
    await owl.utils.loadJS("my-awesome-lib.js");
  }

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

mounted()

mounted метод вызывается каждый раз когда компонент прикрепляется к DOM, после первоначального рендеринга и возможно позже, если компонент был отмпонтирован и перемонтирован. В этой точке, компонент считается active. Это хорошее место для добавления нескольких слушателей или для взаимодействия с DOM, если, например, компоненту необходимо выполнить какое-то действие.

Это противоположность willUnmount. Если компонент был смонтирован, он всегда будет размонтирован в какой-то момент в будущем.

Метод mounted будет вызываться рекурсивно для каждого дочернего элемента. Сначала для родителя, затем для всех детей.

Так же позволяется (но не поощраяется) модифицировать состояние в хуке mounted.

It is allowed (but not encouraged) to modify the state in the mounted hook. Это вызовет повторный рендеринг, который не будет заметен для пользователя, но немного замедлит работу компонента.

willUpdateProps(nextProps)

willUpdateProps это асинхронный хук, вызвается перед установкой новых пропсов. Это полезно если компонент нуждается в выполнении асинхронной задачи, зависящей от пропсов (анпример, предполагая, что пропсы являются некоторым id записи, при извлечении данных записи)

  willUpdateProps(nextProps) {
    return this.loadData({id: nextProps.id});
  }

Этот хук не вызывается в момент первого рендеринга (но willStart вызывается и выполняет простую работу).

willPatch()

willPatch хук вызывается непосредственно перед началом процесса исправления DOM. Он не вызывается при начальном рендеринге. Это полезно для чтения информации из DOM. Например, текущее положение полосы прокрутки.

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

patched(snapshot)

Этот хук вызывается всякий раз, когда компонент действительно обновляет свой DOM (скорее всего, через изменение своего состояния/пропсов или окружения).

Этот метод не вызывается при начальном рендеринге. Он может быть полезен для взаимодействия с DOM (например, через внешнюю библиотеку) всякий раз, когда компонент был исправлен. Обратите внимание, что этот хук не будет вызываться, если компонент не находится в DOM.

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

willUnmount()

willUnmount() это хук который вызывается каждый раз перед тем, как компонент будет отмонтировать из DOM. Это хорошее место для удаления слушателей, например.

  mounted() {
    this.env.bus.on('someevent', this, this.doSomething);
  }
  willUnmount() {
    this.env.bus.off('someevent', this, this.doSomething);
  }

Это метод, противоположный mounted.

catchError(error)

catchError метод полезен, если нам надо перехватить и отреагировать (отрендерить) на ошибки которые возникают в дочерних компонентах. Смотрите на странице обработки ошибок.

Корневой компонент (Root Component)

В большинстве случаев компонент Owl создается автоматически с помощью тега (или директивы t-component) в шаблоне. Однако есть очевидное исключение: корневой компонент приложения Owl должен быть создан вручную:

class App extends owl.Component { ... }

const app = new App();
app.mount(document.body);

Корневой компонент не имеет родителя, и не имеет props (см. примечение ниже). Он будет настроен с окружением (либо env, определенный для его класса, либо пустая среда по умолчанию).

Примечание: корневой компонент воощето может получить объект props в своем коснтрукторе, например так: new App(null, {some: 'object'});. Но это будут не настоящие props, управляемые Owl (поэтому, например они никогда не будут обновляться)

Композиция (Composition)

Пример выше показывает Qweb шаблон с дочерним компонентом. В шаблоне компоненты объявлены с именами тегов соответствующими именам классов. Они должны писаться с большой буквы

<div t-name="ParentComponent">
  <span>some text</span>
  <MyComponent info="13" />
</div>
class ParentComponent extends owl.Component {
    static components = { MyComponent: MyComponent};
    ...
}

В этом примере шаблон ParentComponent создает компонент сразу после тега span. Ключ info будет добавлен к объекут props дочернего компонента. Кадый элемент props это строка, которая представляет собой выражение javascript (QWeb), поэтому она динамическая. Если необходимо передать строковое значение, то вы можете это сделать просто обернув его в одинарные ковычки someString="'somevalue'".

Обратите внимание что контекст для рендеринга для шаблона это и есть сам компонент. Это означате что шаблон может иметь доступ к state (если оно существует), props, env или любому методу определенному в компоненте.

<div t-name="ParentComponent">
    <ChildComponent count="state.val" />
</div>
class ParentComponent {
  static components = { ChildComponent };
  state = useState({ val: 4 });
}

Всякйи когда шаблон рендерится, он будет автоматически создавать дочерний компонент ChildComponent в правильном месте. Это нужно для нахождения ссылко на актуальный класс компонента в специально static ключе components, или класс регистрируется в глобальном реестре QWeb (см. функцию register у QWeb). Сначала он просматривает статический ключ components, а затем обращается к глобальному реестру.

Props: в этом примере дочерний компонент получит объект {count: 4} в своем конструкторе. Это будет присвоено переменной props, к которой можно получить доступ в компоненте (а также в шаблоне). Всякий раз, когда состояние обновляется, дочерний компонент также будет обновляться автоматически. Смотрите раздел props чтобы узнать больше.

CSS и стили: Owl позволяет родителю объявить дополнительные классы CSS или стиль для дочернего компонента: CSS, объявленный в class, style, t-att-class или t-att-style, будет добавлен к элементу корневого компонента.

<div t-name="ParentComponent">
  <MyComponent class="someClass" style="font-weight:bold;" info="13" />
</div>

Предупреждение: есть небольшая оговорка с динамическими атрибутами класса: поскольку Owl должен иметь возможность добавлять/удалять правильные классы, когда это необходимо, он должна знать о налиичи возможных классов. В противном случае он не сможет отличить допустимый класс css, добавленный компонентом или другим пользовательским кодом, от класса, который необходимо удалить. Вот почему мы поддерживаем только явный синтаксис с объектом класса:

<MyComponent t-att-class="{a: state.flagA, b: state.flagB}" />

Привязка к полям ввода форм.

Очень часто требуется иметь возможность прочитать значение из html элемента input (или textarea, или select), чтобы использовать его (примечание: элемент не обязательно должен быть в форме!). Возможный способ сделать это - сделать это вручную:

class Form extends owl.Component {
  state = useState({ text: "" });

  _updateInputValue(event) {
    this.state.text = event.target.value;
  }
}
<div>
  <input t-on-input="_updateInputValue" />
  <span t-esc="state.text" />
</div>

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

Чтобы помочь в этой ситуации, Owl имеет встроенную директиву t-model: ее значение должно быть наблюдаемым значением в компоненте (обычно state.someValue). С помощью директивы t-model мы можем написать более короткий код, эквивалентный предыдущему примеру:

class Form extends owl.Component {
  state = { text: "" };
}
<div>
  <input t-model="state.text" />
  <span t-esc="state.text" />
</div>

The t-model directive works with <input>, <input type="checkbox">, <input type="radio">, <textarea> and <select>:

<div>
    <div>Text in an input: <input t-model="state.someVal"/></div>
    <div>Textarea: <textarea t-model="state.otherVal"/></div>
    <div>Boolean value: <input type="checkbox" t-model="state.someFlag"/></div>
    <div>Selection:
        <select t-model="state.color">
            <option value="">Select a color</option>
            <option value="red">Red</option>
            <option value="blue">Blue</option>
        </select>
    </div>
    <div>
        Selection with radio buttons:
        <span>
            <input type="radio" name="color" id="red" value="red" t-model="state.color"/>
            <label for="red">Red</label>
        </span>
        <span>
            <input type="radio" name="color" id="blue" value="blue" t-model="state.color" />
            <label for="blue">Blue</label>
        </span>
    </div>
</div>

Наподобие обработки событий, директива t-model принимает следующие модификаторы:

Like event handling, the t-model directive accepts the following modifiers:

МодификаторОписание
.lazyобновляет значение по событию change( по умолчанию по событию input)
.numberпытается пробразовать значение в число (использует parseFloat)
.trimсрезает пробелы по края значения

Например:

<input t-model.lazy="state.someVal" />

Эти модификаторы могут комбинироваться. Н

These modifiers can be combined. В этом случае, t-model.lazy.number будет только обновлять число при каждом изменении.

Примечание: онлайн площадка имеет пример того, как это работает.

Ссылки (References)

Хук useRef полезен когда нам нужен способ взаимодействия с какой либо внутреннней частью компонента, отрендеренного Owl. Он может работать как элемент с элементом DOM так и с компонентом, при помощи директивы t-ref. Больше подробностей в разделе Хуки.

Короткий пример того как мы может установить фокус на заданный input:

<div>
    <input t-ref="input"/>
    <button t-on-click="focusInput">Click</button>
</div>
import { useRef } from "owl/hooks";

class SomeComponent extends Component {
  inputRef = useRef("input");

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

Хук useRef может также использоваться для получения ссылок на экземпляр дочернего компонента отрендреренного Owl. В этом случае нам нужно получить к нему доступ с помощью свойства comp вместо el:

The useRef hook can also be used to get a reference to an instance of a sub component rendered by Owl. In that case, we need to access it with the comp property instead of el:

<div>
    <SubComponent t-ref="sub"/>
    <button t-on-click="doSomething">Click</button>
</div>
import { useRef } from "owl/hooks";

class SomeComponent extends Component {
  static components = { SubComponent };
  subRef = useRef("sub");

  doSomething() {
    this.subRef.comp.doSomeThingElse();
  }
}

Обратите внимание, что в этих двух примерах используется суффикс ref для названия ссылки. Это не обязательно, но это полезное соглашение, поэтому мы не забываем обращаться к нему с суффиксом el или comp.

Динамические дочерние компоненты

Это не обычно, но иногда нам нужно имя динамического компонента. В этом случае директива t-component также может использоваться для принятия динамических значений с интерполяцией строк (например, директива [t-attf-] qweb_templating_language.md#dynamic-attributes):

<div t-name="ParentComponent">
    <t t-component="ChildComponent{{id}}" />
</div>
class ParentComponent {
  static components = { ChildComponent1, ChildComponent2 };
  state = { id: 1 };
}

Существует еще более динамичный способ использования t-component: его значение может быть выражением, исполняющим сам класс компонента. В этом случае это класс, который будет использоваться для создания компонента:

class A extends Component<any, any, any> {
  static template = xml`<span>child a</span>`;
}
class B extends Component<any, any, any> {
  static template = xml`<span>child b</span>`;
}
class App extends Component<any, any, any> {
  static template = xml`<t t-component="myComponent" t-key="state.child"/>`;

  state = { child: "a" };

  get myComponent() {
    return this.state.child === "a" ? A : B;
  }
}

В этом примере компонент App выбирает динамически кокретный класс дочернего компонента

Обратите внимание что директива t-component может быть использована только в <t> элементах

Функциональные компоненты

Owl имеет не совсем функциональные компоненты. Однка, это очень близкая альтернатива: вызов дочерних шаблонов.

Функциональный компонент без сохранения состояния в react обычно представляет собой какую-то функцию, которая сопоставляет пропсы с виртуальным DOM (часто с помощью jsx). Почти как шаблон, отредндеренный с помощью props. В Owl это можно сделать, просто определив шаблон, который будет обращаться к объекту props:

const Welcome = xml`<h1>Hello, <t t-esc="props.name"/></h1>`;

class MyComponent extends Component {
  static template = xml`
        <div>
            <t t-call=${Welcome}/>
            <div>something</div>
        </div>
    `;
}

Это работает так, что дочерние шаблоны встроены и имеют доступ к внешнему контексту. Поэтому они могут получить доступ к props и любой другой части вызывающего компонента.

SVG компоненты

Компоненты Owl могут быть использован для создания динамической SVG графики:

class Node extends Component {
  static template = xml`
        <g>
            <circle t-att-cx="props.x" t-att-cy="props.y" r="4" fill="black"/>
            <text t-att-x="props.x - 5" t-att-y="props.y + 18"><t t-esc="props.node.label"/></text>
            <t t-set="childx" t-value="props.x + 100"/>
            <t t-set="height" t-value="props.height/(props.node.children || []).length"/>
            <t t-foreach="props.node.children || []" t-as="child">
                <t t-set="childy" t-value="props.y + child_index*height"/>
                <line t-att-x1="props.x" t-att-y1="props.y" t-att-x2="childx" t-att-y2="childy" stroke="black" />
                <Node x="childx" y="childy" node="child" height="height"/>
            </t>
        </g>
    `;
  static components = { Node };
}

class RootNode extends Component {
  static template = xml`
        <svg height="180">
            <Node node="graph" x="10" y="20" height="180"/>
        </svg>
    `;
  static components = { Node };
  graph = {
    label: "a",
    children: [
      { label: "b" },
      { label: "c", children: [{ label: "d" }, { label: "e" }] },
      { label: "f", children: [{ label: "g" }] },
    ],
  };
}

Компонент RootNode будет отображать живое SVG-представление графика, описанного свойством graph. Обратите внимание, что здесь присутствует рекурсивная структура: компонент Node использует себя как подкомпонент.

Обратите внимание, что поскольку SVG необходимо обрабатывать особым образом (его пространство имен должно быть правильно установлено), существует небольшое ограничение для компонентов Owl: если предполагается, что компонент owl является частью графа svg, то его корневой узел должен быть тегом g, чтобы Owl мог п равильно установить пространство имен.

🦉 Из чего состоит Owl 🦉

Здесть представлено полное визуальное представление всего что может быть экспортировано из глобального объекта owl

Напримре, Component доступер как owl.Component и EventBus экспортируется как owl.core.EventBus.

browser
Component                               misc
Context                                     AsyncRoot
QWeb                                        Portal
mount                                   router
Store                                       Link
useState                                    RouteComponent
config                                      Router
    mode
core                                    tags
    EventBus                                css
    Observer                                xml
hooks                                   utils
    onWillStart                             debounce
    onMounted                               escape
    onWillUpdateProps                       loadJS
    onWillPatch                             loadFile
    onPatched                               shallowEqual
    onWillUnmount                           whenReady
    useContext
    useState
    useRef
    useComponent
    useEnv
    useSubEnv
    useStore
    useDispatch
    useGetters

Обратите внимание, что для удобства хук useState также экспортируется из корня объекта owl.

🦉 Параллельная модель 🦉

Content

Введение

Owl с самого начала разрабатывался с использованием асинхронных компонентов. Это произошло хуков жизненного цикла willStart и willUpdateProps. С помощью этих методов можно создавать сложные высокопараллельные приложения.

Параллельный режим Owl имеет несколько преимуществ: он позволяет отложить рендеринг до завершения какой-либо асинхронной операции, он позволяет загружать библиотеки в lazy режиме, сохраняя при этом полностью функциональный предыдущий экран. И Это хорошо с точки зрения производительности: Owl использует его только для применения результатов различных рендерингов в одном кадре анимации. Owl может отменить рендеринг, который больше не актуален, перезапустить его, в некоторых случаях использовать его повторно.

Но даже несмотря на то, что использование параллелизма довольно просто (и это поведение по умолчанию), асинхронность сложна, потому что она вводит дополнительное измерение, которое значительно увеличивает сложность приложения. В этом разделе объясняется, как Owl справляется с этой сложностью, как в целом работает конкурентный рендеринг.

Рендеринг компонентов

Слово rendering немного расплывчато, поэтому давайте подробнее объясним процесс, посредством которого компоненты Owl отображаются на экране.

Когда компонент монтируется или обновляется, запускается новая визуализация. Он состоит из двух фаз: virtual rendering и patching.

Виртуальный рендеринг (Virtual rendering)

Эта фаза представляет собой процесс рендеринга шаблона в памяти, который создает виртуальное представление желаемого компонента (html). Результатом этого этапа является виртуальный DOM.

Он асинхронный: каждый дочерний компонент нужно либо создать (поэтому нужно будет вызвать willStart), либо обновить (что делается с помощью метода willUpdateProps). Это полностью рекурсивный процесс: компонент является корнем дерева компонентов, и каждый дочерний компонент должен быть (виртуально) отрендерен.

Исправление (Patching)

После завершения рендеринга он будет применен к следующему кадру анимации. Это делается синхронно: все дерево компонентов интегрируется в реальный DOM.

Семантика

Мы даем здесь неформальное описание того, как компоненты создаются/обновляются в приложении. Здесь упорядоченные списки описывают действия, которые выполняются последовательно, маркированные списки описывают действия, которые выполняются параллельно.

Scenario 1: initial rendering Представьте что мы хотим отрендерить следующее дерево компонентов:

        A
       / \
      B   C
         / \
        D   E

Вот что происходит всякий раз, когда мы монтируем корневой компонент (с кодом вроде app.mount(document.body)).

  1. willStart вызывается в A

  2. когда он завершен, шаблон A рендерится.

    • компонент B создается
      1. willStart вызывается в B
      2. шаблон B рендерится
    • компонент C создается
      1. willStart вызывается в C
      2. шаблон C рендерится
        • компонент D создается
          1. willStart вызывается в D
          2. шаблон D рендерится
        • компонент E создается
          1. willStart вызывается в E
          2. шаблон E рендерится

каждый компонень 3. каждый компонент подключается к отдельному элементу DOM в следующем порядке: E, D, C, B, A. (поэтому фактическое полное дерево DOM создается за один проход)

  1. компонент A корневой элемент присоединяется к document.body

  2. Метод mounted вызывается рекурсивно у всех компонентов в следующем порядке: E, D, C, B, A.

Scenario 2: ререндеринг компонента. Теперь давайте предположим, что пользователь нажал на какую-то кнопку в C, и это приводит к обновлению состояния, которое должно:

  • обновить D,
  • удалить E,
  • добавить новый компонент F.

И дерево компонентов теперь будет выглядеть вот так:

        A
       / \
      B   C
         / \
        D   F

Вот что сделает Owl:

  1. потому как состояние изменилос, метод render вызывается в C

  2. шаблоно C рендерится снова

    • компонент D обновляется:
      1. хук willUpdateProps вызывается в D (async)
      2. шаблон D ререндерится
    • компонент F is created:
      1. хук willStart вызывается F (async)
      2. шаблон F ререндерится
  3. willPatch хуки вызываются рекурсивно у компонентовC, D (но не F, потому что он все еще не примонтирован)

  4. комоненты F, D присоединяются к DOM в этом порядке

  5. компонент C присоединен к дом, что вызовет рекурсивно:

    1. willUnmount хук у E
    2. уничтожение E,
  6. mounted хук вызовится у F, patched вызовится у D, C

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

Асинхронный рендеринг

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

Есть две разные распространенные проблемы с моделью асинхронного рендеринга Owl:

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

Вот несколько советов по работе с асинхронными компонентами:

  1. Минимизируйте использование асинхронных компонентов!

  2. Возможно, переместите асинхронную логику в хранилище, которое затем запускает (в основном) синхронный рендеринг.

  3. Lazy загрузка внешних библиотек — хороший пример использования асинхронного рендеринга. Это нормально, потому что мы делаем допущение, что процесс загрузки займет доли секунды и будет выполняться только один раз (см. owl.utils.loadJS)

  4. Во всех остальных случаях вам поможет компонент AsyncRoot. Когда этот компонент встречается, создается новое дочернее дерево рендеринга, так что рендеринг этого компонента (и его дочерних элементов) не привязан к рендерингу остальной части интерфейса. Его можно использовать для асинхронного компонента, чтобы предотвратить задержку рендеринга всего интерфейса, или для синхронного, чтобы его рендеринг не задерживался другими (асинхронными) компонентами. Обратите внимание, что эта директива не влияет на первый рендеринг, а только на последующие (вызванные изменением состояния или пропсов).

    <div t-name="ParentComponent">
      <SyncChild />
      <AsyncRoot>
         <AsyncChild/>
      </AsyncRoot>
    </div>
    

🦉 Объект Config 🦉

Фреймворк Owl спроектирован для работы в различных ситуациях. Однако иногда необходимо настроить его поведение. Это реализовано путем использованиея глобального объекта config. Он предостовляет два параметра:

  • mode (значение по умолчанию: prod),
  • enableTransitions (значение по умолчанию: true).

Режим(Mode)

По умолчанию Owl находится в режиме production, это означает что он будет стараться выполнить все свои действия максимально быстро, и пропускать дорогостоящие операции. Однако, иногда необходимо иметь больше информации о том что происходит, и это уже достигается переключением в dev режим.

Owl имеет флаг режима owl.config.mode. По умолчанию это prod, но вы можете изменить его на dev:

owl.config.mode = "dev";

Обратите внимание что шаблоны скомпилированные с настройкой prod, не будут перекомпилироваться. Поэтому изменение этой настройки лучший способ для начала работы.

В dev режиме происходит такая важная часть работы как валидация пропрсов для каждого создоваемого или обновляемого компонента. Кроме того, дополнительные пропсы вызовут ошибку.

enableTransitions

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

Для того, чтобы решить эту проблему, Owl может быть настроен таким образом, что будет игнорировать директиву t-transition. Для того, чтобы сделать это нам нужно только установить флагк enableTransitions в значение false:

owl.config.enableTransitions = false;

Обратите внимание, что он имеет тот же недостаток, что и режим dev(может prod прим. пер.): все скомпилированные шаблоны, если таковые имеются, сохранят свое текущее поведение.

🦉 Объект Context 🦉

Содержание

Введение

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

С помощь объекта Context, каждый компонент может быть подписан (с помощью хука useContext) на его состояние, будет обновляться каждый раз, когда изменяется состояние контекста.

Пример

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

const deviceContext = new Context({ isMobile: true });
App.env.deviceContext = deviceContext;

Если мы хотим сделать его полностью отзывчивым, нам нужно обновлять его значение всякий раз, когда обновляется размер экрана:

const isMobile = () => window.innerWidth <= 768;
window.addEventListener(
  "resize",
  owl.utils.debounce(() => {
    const state = deviceContext.state;
    if (state.isMobile !== isMobile()) {
      state.isMobile = !state.isMobile;
    }
  }, 15)
);

Затем, каждый компонент, который хотим подписать и рендерить по разному в зависимости от того в мобильном ли мы режиме или настольном.

class SomeComponent extends Component {
  static template = xml`
    <div>
      <t t-if=device.isMobile>
          some simplified user interface
      </t>
      <t t-else="">
          a more advanced user interface
      </t>
    </div>`;
  device = useContext(this.env.deviceContext);
}

Описание элементов

Context

Объект Context должен создаватьс с помощью объекта состояния:

const someContext = new Context({ some: "key" });

Теперь его состояние доступно по ключу state:

someContext.state.some = "other key";

Именно так глобальный код (например, адаптивный код выше) должен читать и обновлять состояние контекста. Однако компоненты никогда не должны считывать состояние контекста непосредственно из контекста, вместо этого они должны использовать хук useContext для правильной регистрации изменений состояния.

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

useContext

Хук useContext это нормальный способ для компонента для самостоятельного отслеживания изменений состояний контексат. Метод useContext возвращает состояние контекста:

device = useContext(this.env.deviceContext);

Это просто наблюдаемое состояние (с помощью Owl Observer), которое содержит общую информацию.

🦉 Окружение (Environment) 🦉

Содержание

Введение

Окружение это объект который содержит экземпляр QWeb. Однако при создании корневого компонета за ним закрепляется окружение (см. ниже для более подробной информации). Это окружение затем автоматически становится доступно каждому дочернему компоненту (и доступно через свойство this.env)

    Root
    /  \
   A    B

В этом случае все компоненты будут иметь доступ к одному экземпляру QWeb. Для внутреннего использования Owl требует, чтобы среда имела ключ qweb, который сопоставляется с экземпляром QWeb. Это экземпляр QWeb, который будет использоваться для рендеринга каждого шаблона в этом конкретном дереве компонентов. Обратите внимание, что если экземпляр QWeb не указан, Owl просто сгенерирует его на лету.

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

Настройка окружения

Для выполнения приложения Owl требуется окружение. В нем есть важный ключ: экземпляр QWeb, который будет рендерить все шаблоны.

Всякий раз, когда монтируется корневой компонент App, Owl настроит допустимую окружение, выполнив следующие шаги:

  • берет объект env, определенный в App.env (если env не был явно настроен, то вернет пустой объект env, определенный в Component)
  • если env.qweb не установлен, Owl создаст экземпляр QWeb.

Правильный способ настроить окружение — просто настроить его в корневом классе компонентов до того, как будет создан первый компонент:

const env = {
    _t: myTranslateFunction,
    user: {...},
    services: {
        ...
    },
};
mount(App, { target: document.body, env });

Этот подход можно применять для обмена окружением между корневыми компонентами, достаточно сделать примерно так:

Component.env = myEnv; // будет значением env по умолчанию для всех компнентов

Обратите внимание что это окружение является глобальным окружением owl для приложения. В следующей секции будет объяснено как расширить окружение для специфического дочернего компонента и его потомков.

Использвоание дочернего окружения

Иногда может быть полезно добавить один (или более) специальных ключа к окружению с точки зрения конкретного компонента и его дочерних элементов. В этом случае решение, представленное выше, не будет работать, так как оно устанавливает глобальную среду.

А вот и хук для текущей ситуации: useSubEnv.

class FormComponent extends Component {
  constructor(parent, props) {
    super(parent, props);
    useSubEnv({ myKey: someValue });
  }
}

Содержимое окружения

Вот несколько хороших вариантов использования дополнительных ключей в окружении:

  • ключи конфигурации,
  • информация о сессии,
  • общие сервисы (такие как выполнение rpcs).

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

Например:

async function myEnv() {
  const templates = await loadTemplates();
  const qweb = new QWeb({ templates });
  const session = getSession();

  return {
    _t: myTranslateFunction,
    session: session,
    qweb: qweb,
    services: {
      localStorage: localStorage,
      rpc: rpc,
    },
    debug: false,
    inMobileMode: true,
  };
}

async function start() {
  const env = await myEnv();
  mount(App, { target: document.body, env });
}

Специальные ключи

Owl добавляет два специальных ключа/значения, если они не предоставлены в среде: экземпляр QWeb и объект browser:

  • qweb будет установлен на пустой экземпляр QWeb. Это абсолютно необходимо для того, чтобы Owl могла отображать что угодно.
  • браузер: это объект, который содержит некоторые общие точки доступа к методам браузера с побочным эффектом. См. объект browser для получения дополнительной информации. Обратите внимание, что объект browser будет удален из окружения в Owl 2.0.

🦉 Шина событий 🦉

Иногда полезно использовать Bus для обмена информацией между различными частями кода. В Owl есть очень простой класс шины, который управляет подписками, запуском событий и обратными вызовами.

const bus = new owl.core.EventBus();

bus.on("some-event", null, function (...args) {
  console.log(...args);
});

bus.trigger("some-event", 1, 2, 3);
// [1,2,3] будет отражено в консоли

Ее API:

МетодОписание
on(eventType, owner, callback)добавляет слушателя
off(eventType, owner)удаляет всех слушателей для owner
trigger(eventType, ...args)инициирует событие
clearудаляет все подписки

Обратите внимание, что Объект Store (хранилище) является примером EventBus

🦉 Обработка событий 🦉

Content

Обработка событий

В шаблоне компонента полезно иметь возможность регистрировать обработчики элементов DOM для определенных событий. Это помогает делать шаблон живым. Существует четыре различных варианта использования.

  1. Зарегистрируйте обработчик событий на узле DOM (событие pure DOM)
  2. Зарегистрируйте обработчик событий на компоненте (событие pure DOM)
  3. Зарегистрируйте обработчик событий на узле DOM (событие business DOM)
  4. Зарегистрируйте обработчик событий в компоненте (событие business DOM)

Событие pure DOM запускается непосредственно взаимодействием с пользователем (например, click).

<button t-on-click="someMethod">Что-то сделать</button>

Это будет примерно переведено в javascript следующим образом:

button.addEventListener("click", component.someMethod.bind(component));

Суффикс (в данном примере click) — это просто имя фактического события DOM.

Business-события DOM (Business DOM Events)

Событие business DOM запускается вызовом trigger для компонента.

<MyComponent t-on-menu-loaded="someMethod" />
 class MyComponent {
     someWhere() {
         const payload = ...;
         this.trigger('menu-loaded', payload);
     }
 }

Вызов trigger генерирует OwlEvent, дочерний класс CustomEvent с дополнительным атрибутом originalComponent (компонент, который инициировал событие). Сгенерированное событие относится к типу menu-loaded и отправляет его элементу DOM компонента (this.el). Событие всплывает и может быть отменено. Родительский компонент, прослушивающий событие menu-loaded, будет получать payload в своем обработчике someMethod (в свойстве detail события) всякий раз, когда событие инициируется.

 class ParentComponent {
     someMethod(ev) {
         const payload = ev.detail;
         ...
     }
 }

По соглашению мы используем кebab-сase для названия business событий.

Директива t-on позволяет предварительно связать свои аргументы. Например,

<button t-on-click="someMethod(expr)">Что-то сделать</button>

Здесь expr — допустимое выражение Owl, оно может быть true или какой либо переменной из контекста рендеринга.

Подсказки типов (Type Hinting)

Обратите внимание, что если вы работаете с Typescript, метод trigger является общи типом с типм payload объекта.

Затем вы можете описать тип события, что позволит увидеть ошибки типов...

this.trigger<MyCustomPayload>("my-custom-event", payload);
myCustomEventHandler(ev: OwlEvent<MyCustomPayload>) { ... }

Встроенные обработчики событий

Можно также напрямую указать встроенные выражения. Например,

<button t-on-click="state.counter++">Счетчик приращений</button>

Здесь state должно быть определено в контексте рендеринга (обычно в компоненте), что может быть переведено примерно так:

button.addEventListener("click", () => {
  context.state.counter++;
});

Предупреждение: встроенные выражения исполняются в контексте шаблона. Это означает, что они могут получить доступ к методам и свойствам компонента. Но если они устанавливают ключ, встроенное выражение на самом деле изменит не компонент, а ключ в дочерней области видимости.

<button t-on-click="value = 1">Set value to 1 (does not work!!!)</button>
<button t-on-click="state.value = 1">Set state.value to 1 (work as expected)</button>

Модификаторы

Чтобы удалить детали событий DOM из обработчиков событий (например, вызовы event.preventDefault) и позволить им сосредоточиться на логике обработки данных, модификаторы можно указать в качестве дополнительных суффиксов директивы t-on.

МодификаторОписание
.stopвызывает event.stopPropagation() перед вызовом метода
.preventвызывает event.preventDefault() перед вызовом метода
.selfвызывает метод, только если event.target является самим элементом
.captureпривязать обработчик событий в режиме capture.
<button t-on-click.stop="someMethod">Что-то сделать</button>

Обратите внимание, что модификаторы можно комбинировать (например, t-on-click.stop.prevent), и порядок может иметь значение. Например, t-on-click.prevent.self предотвратит все клики, а t-on-click.self.prevent предотвратит клики только на самом элементе.

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

<button t-on-click.stop="">Что-то сделать</button>

Это просто остановит распространение события.

🦉 Обработка ошибок 🦉

Content

Введение

По умолчанию всякий раз, когда при рендеринге приложения Owl возникает ошибка, мы уничтожаем все приложение. В противном случае мы не можем гарантировать состояние получившегося дерева компонентов. Оно может быть безнадежно повреждено, но без видимого для пользователя состояния.

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

Пример

Например, вот как мы можем реализовать компонент ErrorBoundary:

<div t-name="ErrorBoundary">
    <t t-if="state.error">
        Error handled
    </t>
    <t t-else="">
        <t t-slot="default" />
    </t>
</div>
class ErrorBoundary extends Component {
  state = useState({ error: false });

  catchError() {
    this.state.error = true;
  }
}

Использовать ErrorBoundary очень просто:

<ErrorBoundary><SomeOtherComponent/></ErrorBoundary>

Обратите внимание, что здесь нужно быть осторожным: UI не должен выдавать никаких ошибок, иначе мы рискуем попасть в бесконечный цикл (также см. страницу слоты для получения дополнительной информации о t-slot директиве).

Описание элементов

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

Есть важные вещи, которые нужно знать:

  • Если ошибка, возникшая во внутреннем цикле рендеринга, не будет обнаружена, то Owl полностью уничтожит приложение. Это сделано специально, т.к. Owl не может гарантировать, что с этого момента состояние не испортится.
  • ошибки, поступающие от обработчиков событий, НЕ обрабатываются с помощью catchError или любого другого механизма Owl. Разработчик приложения должен правильно восстановиться после ошибки.

Кроме того, может быть полезно знать, что всякий раз, когда обнаруживается ошибка, она передается приложению посредством события экземпляра qweb. Это, может быть полезно, напрмире куда-нибудь записать ошибку.

env.qweb.on("error", null, function (error) {
  // что-то делаем
  // реагируем на ошибку
});

🦉 Хуки 🦉

Содержание

Введение

Хуки были популяризированы React как способ решения следующих проблем:

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

Хуки Owl служат той же цели, за исключением того, что они работают с компонентами класса (примечание: хуки React не работают с компонентами класса, и, возможно, из-за этого, кажется, существует неправильное представление о том, что хуки противопоставлены классу. Это явно не так, как показано хуками Owl).

Хуки отлично работают с компонентами Owl: они решают проблемы, упомянутые выше, и, в частности, являются идеальным способом сделать ваш компонент реактивным.

Пример: позиция мыши

Вот классический пример нетривиального хука для отслеживания положения мыши.

const { useState, onMounted, onWillUnmount } = owl.hooks;

// Здесь мы определяем пользовательское поведение: этот хук отслеживает
// состояние позиции мыши.
function useMouse() {
  const position = useState({ x: 0, y: 0 });

  function update(e) {
    position.x = e.clientX;
    position.y = e.clientY;
  }
  onMounted(() => {
    window.addEventListener("mousemove", update);
  });
  onWillUnmount(() => {
    window.removeEventListener("mousemove", update);
  });

  return position;
}

// Основной корневой компонент
class App extends owl.Component {
  static template = xml`
    <div t-name="App">
      <div>Mouse: <t t-esc="mouse.x"/>, <t t-esc="mouse.y"/></div>
    </div>`;

  // этот хук привязан к свойству 'mouse'.
  mouse = useMouse();
}

Обратите внимание, что мы используем префикс use для хуков, как и в React. Это просто условность.

Пример: автофокус

Хуки можно комбинировать для создания нужного эффекта. Например, следующий хук объединяет хук useRef с функциями onPatched и onMounted, чтобы создать простой способ навести фокус на input всякий раз, когда он появляется в DOM:

function useAutofocus(name) {
  let ref = useRef(name);
  let isInDom = false;
  function updateFocus() {
    if (!isInDom && ref.el) {
      isInDom = true;
      ref.el.focus();
    } else if (isInDom && !ref.el) {
      isInDom = false;
    }
  }
  onPatched(updateFocus);
  onMounted(updateFocus);
}

Этот хук берет имя допустимой директивы t-ref, которая должна присутствовать в шаблоне. Затем он проверяет всякий раз, когда компонент монтируется или патчится, недействительна ли ссылка, и в этом случае фокусируется на элементе узла. Этот хук можно использовать так:

class SomeComponent extends Component {
  static template = xml`
    <div>
        <input />
        <input t-ref="myinput"/>
    </div>`;

  constructor(...args) {
    super(...args);
    useAutofocus("myinput");
  }
}

Описание элементов

Одно правило

Есть только одно правило: каждый хук для компонента должен вызываться в конструкторе, в методе setup или в полях класса:

// ok
class SomeComponent extends Component {
  state = useState({ value: 0 });
}

// все еще ok
class SomeComponent extends Component {
  constructor(...args) {
    super(...args);
    this.state = useState({ value: 0 });
  }
}

// все еще ok
class SomeComponent extends Component {
  setup() {
    this.state = useState({ value: 0 });
  }
}

// не ok: это выполняется после вызова конструктора
class SomeComponent extends Component {
  async willStart() {
    this.state = useState({ value: 0 });
  }
}

Как видите, хуку useState не нужно указывать ссылку на компонент. Это потому что есть способ получить ссылку на текущий компонент: статическое свойство Component.current — это ссылка на экземпляр компонента, который создается в данный момент.

Хуки необходимо вызывать в конструкторе, чтобы убедиться, что ссылка установлена правильно. Такой подход хорош как с точки зрения производительности (Owl может использовать его для своей оптимизации) так и чистой архитектуры (это облегчает разработчикам понимание того, что на самом деле происходит в компоненте).

useState

Хук useState, безусловно, самый важный хук для компонентов Owl: это то, что позволяет компоненту быть реактивным т.е. реагировать на изменение состояния.

Хук useState должен принимать объект или массив и будет возвращать его наблюдаемую версию (используя Proxy).

const { useState } = owl.hooks;

class Counter extends owl.Component {
  static template = xml`
    <button t-on-click="increment">
        Click Me! [<t t-esc="state.value"/>]
    </button>`;

  state = useState({ value: 0 });

  increment() {
    this.state.value++;
  }
}

Важно помнить, что useState работает только с объектами или массивами. Это необходимо так как Owl должен реагировать на изменение состояния.

onMounted

onMounted — это не пользовательский хук, а строительный блок, предназначенный для помощи в создании полезных абстракций. onMounted регистрирует обратный вызов, который будет вызываться при монтировании компонента (см. пример вверху этой страницы).

onWillUnmount

onWillUnmount — это не пользовательский хук, а строительный блок, предназначенный для помощи в создании полезных абстракций. onWillUnmount регистрирует обратный вызов, который будет вызываться при размонтировании компонента (см. пример вверху этой страницы).

onWillPatch

onWillPatch — это не пользовательский хук, а строительный блок, предназначенный для помощи в создании полезных абстракций. onWillPatch регистрирует обратный вызов, который будет вызываться непосредственно перед патчем компонента.

onPatched

onPatched — это не пользовательский хук, а строительный блок, предназначенный для помощи в создании полезных абстракций. onPatched регистрирует обратный вызов, который будет вызываться сразу после патчем компонента.

onWillStart

onWillStart — это асинхронный хук. Это означает, что функция, зарегистрированная в хуке, будет запущена непосредственно перед первым рендерингом компонента и может вернуть промис, чтобы выразить тот факт, что это асинхронная операция.

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

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

function useLoader() {
  const component = Component.current;
  const record = useState({});
  onWillStart(async () => {
    const recordId = component.props.id;
    Object.assign(record, await fetchSomeRecord(recordId));
  });
  return record;
}

Обратите внимание, что в этом примере значение записи не обновляется при каждом обновлении пропсов. В этой ситуации нам нужно использовать хук onWillUpdateProps.

onWillUpdateProps

Как и onWillStart, onWillUpdateProps является асинхронным хуком. Он предназначен для запуска при каждом обновлении пропсов компонента. Это может быть полезно для выполнения некоторых асинхронных задач, таких как получение обновленных данных.

function useLoader() {
  const component = Component.current;
  const record = useState({});

  async function updateRecord(id) {
    Object.assign(record, await fetchSomeRecord(id));
  }

  onWillStart(() => updateRecord(component.props.id));
  onWillUpdateProps((nextProps) => updateRecord(nextProps.id));

  return record;
}

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

useContext

См. useContext в документации.

useRef

Хук useRef полезен, когда нам нужен способ взаимодействия с некоторой внутренней частью компонента, отрендеренного Owl. Он может работать как с DOM-узлом, так и с компонентом, помеченным директивой t-ref:

<div>
    <div t-ref="someDiv"/>
    <SubComponent t-ref="someComponent"/>
</div>

В этом примере компонент сможет получить доступ к div и компоненту SubComponent с помощью хука useRef:

class Parent extends Component {
  subRef = useRef("someComponent");
  divRef = useRef("someDiv");

  someMethod() {
    // здесь, если компонент смонтирован, ссылки активны:
     // - this.divRef.el является HTMLElement div
     // - this.subRef.comp является экземпляром подкомпонента
     // - this.subRef.el является корневым HTML-узлом подкомпонента (т.е. this.subRef.comp.el)
  }
}

Как показано в приведенном выше примере, доступ к элементам html осуществляется с помощью ключа el, а доступ к ссылкам на компоненты осуществляется с помощью comp.

Примечания:

  • если используется на компоненте, ссылка будет установлена в переменной refs между willPatch и patched,
  • для компонента доступ к ref.el приведет к получению корневого узла компонента.

Директива t-ref также принимает динамические значения с интерполяцией строк (например, директивы t-attf- и директивы t-component). Например,

<div t-ref="component_{{someCondition ? '1' : '2'}}"/>

Здесь ссылки должны быть установлены следующим образом:

this.ref1 = useRef("component_1");
this.ref2 = useRef("component_2");

Ссылки гарантированно активны только тогда, когда смонтирован родительский компонент. Если это не так, доступ к el или comp вернет null.

useSubEnv

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

Например, если у нас есть компонент представления формы, возможно, мы хотели бы сделать некоторый объект модели доступным для всех дочерних компонентов, но не для всего приложения. Здесь может быть полезен хук useSubEnv: он позволяет компоненту добавлять некоторую информацию в окружение таким образом, что только компонент и его дочерние элементы могут получить к ней доступ:

class FormComponent extends Component {
  constructor(...args) {
    super(...args);
    const model = makeModel();
    useSubEnv({ model });
  }
}

useSubEnv принимает один аргумент: объект, который содержит некоторый ключ/значение, которое будет добавлено в родительское окружение. Обратите внимание, что он будет расширять, а не заменять родительское окружение. И, конечно же, родительское окружение не подвергентся изменениям.

useExternalListener

Хук useExternalListener помогает решить очень распространенную проблему: добавление и удаление слушателя на цели всякий раз, когда компонент монтируется/размонтируется. Например, выпадающему меню (или его родителю) может потребоваться прослушивание события click в window чтобы закрыть его:

useExternalListener(window, "click", this.closeMenu);

useStore

Хук useStore — это точка входа компонента для подключения к хранилищу. Дополнительную информацию см. в документации по объекту Store(Хранилище).

useDispatch

Хук useDispatch — это способ для компонентов получить ссылку на функцию dispatch хранилища. Дополнительную информацию см. в документации по объекту Store(Хранилище).

useGetters

Хук useGetters — это способ для компонентов получить ссылку на геттеры хранилища. Дополнительную информацию см. в документации по объекту Store(Хранилище).

useComponent

Хук useComponent полезен как строительный блок для некоторых настраиваемых хуков, которым может потребоваться ссылка на вызывающий их компонент.

useEnv

Хук useEnv полезен в качестве строительного блока для некоторых настраиваемых хуков, которым может потребоваться ссылка на env вызывающего их компонента.

Создание индивидуальных хуков

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

Но, как и все хорошие вещи в жизни, хуки следует использовать с умеренностью. Они не являются решением всех проблем.

  • они могут быть излишними: если вашему компоненту нужно выполнить какое-то действие, специфичное для него самого (таким образом, конкретный код не должен быть общим), нет ничего плохого в простом методе класса:

    // может быть перебор
    class A extends Component {
      constructor(...args) {
        super(...args);
        useMySpecificHook();
      }
    }
    
    // ok
    class B extends Component {
      constructor(...args) {
        super(...args);
        this.performSpecificTask();
      }
    }
    

    Обратите внимание, что второе решение легче расширять в дочерних компонентах.

  • их может быть сложнее протестировать: если настроенный хук вводит какую-то зависимость, то его сложнее протестировать, не выполняя каких-либо неочевидных манипуляций. Например, предположим, что мы хотим дать ссылку на роутер в хуке useRouter. Мы могли бы сделать это:

    const router = new Router(...);
    
    function useRouter() {
        return router;
    }
    

    Как видите, это не цепляет внутренности компонента. Он просто возвращает глобальный объект, который сложно имитировать.

    Лучшим способом было бы сделать что-то вроде этого: получить ссылку из среды.

    function useRouter() {
      const env = useEnv();
      return env.router;
    }
    

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

🦉 Монтирование приложения 🦉

Содержание

Введение

Монтирование приложения Owl выполняется с помощью метода mount (доступного в owl.mount, если вы используете сборку iife, или его можно напрямую импортировать из owl, если вы используете модульную систему):

const mount = { owl }; // если owl доступна как объект

const env = { ... };
const app = await mount(MyComponent, { target: document.body, env });

Другой пример:

const config = {
  env: ...,
  props: ...,
  target: document.body,
  position: "self",
};
const app = await mount(App, config);

Обычный способ инициализации приложения — сначала настроить окружение, а затем вызвать метод mount.

API

Mount принимает два параметра:

  • C, который должен быть классом компонента (НЕ экземпляром),
  • params, который представляет собой объект со следующими ключами:
    • target (HTMLElement | DocumentFragment): цель операции монтирования
    • env (необязательно, Env) окружение
    • position (optional, "first-child" | "last-child" | "self") – позиция, в которой он должен быть смонтирован (дополнительную информацию см. ниже)
    • props (необязательный, любой): некоторые начальные значения, которые задаются в качестве реквизита. Полезно, когда можно настроить корневой компонент или при тестировании подкомпонентов.

Вот различные позиции, поддерживаемые Owl:

  • first-child: с этой опцией компонент будет добавляться внутрь цели,
  • last-child (значение по умолчанию): с этой опцией компонент будет добавлен в целевой элемент,
  • self: цель будет использоваться как корневой элемент для компонента. Это означает, что целью должен быть HTMLElement (а не фрагмент документа). В этой ситуации возможно, что компонент не может быть размонтирован. Например, если его целью является document.body.

Метод mount возвращает промис, который разрешается в экземпляр созданного компонента.

🦉 Разнообразие(Miscellaneous) 🦉

Содержание

Portal

Введение

Компонент Portal предназначен для использования в качестве прозрачного способа телепортировать часть DOM в узел, представленный его единственными целевым пропсом.

Этот компонент помогает реализовать необходимую инфраструктуру для модальных окон (например, bootstrap-modal).

Использование

Содержимое, которое он будет телепортировать, определяется в узле <Portal> и внутренне использует defaultSlot.

Этот слот должен содержать только один узел, который, в свою очередь, может иметь столько дочерних элементов, сколько необходимо.

Элемент, под которым будет телепортироваться контент, представлен как селектор пропсами target, которые принимают только строку в качестве значения.

Реквизит target поддерживает только статический селектор и не предназначен для передачи в Portal в качестве переменной. А именно, <Portal target="'body'" /> является предполагаемым использованием. Напротив, <Portal target="state.target" /> не поддерживается.

Компонент Portal не имеет определенного состояния, скорее, он должен быть ведомым для своего родителя и, в конечном счете, просто способом для родителя телепортировать часть своей DOM в другое место.

Корневым узлом портала всегда является , и он размещается там, где должен быть_ телепортированный контент. Именно на этот элемент перенаправляются телепортированные события.

Пример

Канонический вариант использования заключается в реализации Dialog, где компонент может прервать естественный рабочий процесс, чтобы помочь пользователю ввести некоторые данные, которые он мог бы использовать позже.

JavaScript:

const { Component, mount } = owl;
const { Portal } = owl.misc;

class TeleportedComponent extends Component {}
class App extends Component {
  static components = { Portal, TeleportedComponent };
}

mount(App, { target: document.body });

XML:

<templates>
    <div t-name="TeleportedComponent">
        <span>Я перееду достаточно скоро</span>
    </div>

    <div t-name="App">
        <span>Я такой же как и все мы</span>
        <Portal target="'body'">
            <TeleportedComponent />
        </Portal>
    </div>
</templates>

В этом примере компонент Portal будет телепортировать div TeleportedComponent как дочерний элемент body. TeleportedComponent действует здесь как Dialog.

Результирующий DOM будет выглядеть так:

<body>
    <div>
        <span>Я такой же как и все мы</span>
        <portal></portal>
    </div>
    <div>
        <span>Я перееду достаточно скоро</span>
    </div>
</body>

Ожидаемое поведение

Телепортированная часть обновляется как DOM любого другого Component и в той же последовательности. А именно, телепортированная часть будет обновлена ​​в зависимости от своих родительских компонентов и исправлена ​​как обычный дочерний элемент.

_business_события, инициированные дочерним компонентом, будут остановлены, чтобы не выходить за пределы target. С другой стороны, они будут перенаправлены на корневой узел Portal и поднимает DOM, как если бы он был вызван обычным дочерним компонентом.

Помните, что эти перенаправленные события являются копиями исходного события. У них есть:

  • Та же полезная нагрузка.
  • Тот же originalComponent, что и их оригинальный аналог, то есть фактический Component, который его запустил.
  • отличное target свойство, чем их исходный аналог. target перенаправленного события обязательно является корневой узел Portal.

Чистые DOM-события не следуют этому шаблону и могут свободно подниматься естественным, неизмененным путем вверх к «телу».

AsyncRoot

Когда этот компонент используется, создается новое дочернее дерево рендеринга, так что рендеринг этого компонента (и его дочерних элементов) не привязан к рендерингу остальной части интерфейса. Его можно использовать для асинхронного компонента, чтобы предотвратить задержку рендеринга всего интерфейса, или для синхронного, чтобы его рендеринг не задерживался другими (асинхронными) компонентами. Обратите внимание, что эта директива не влияет на первый рендеринг, а только на последующие (вызванные изменением состояния или свойств).

<div t-name="ParentComponent">
  <SyncChild />
  <AsyncRoot>
     <AsyncChild/>
  </AsyncRoot>
</div>

AsyncRoot предполагает, что внутри него находится ровно один корневой узел. Это может быть узел dom или компонент.

🦉 Класс Observer(Наблюдатель) 🦉

Owl должен иметь возможность реагировать на изменения состояния. Например, всякий раз, когда состояние компонента изменяется, Owl необходимо перерисовать его. Чтобы помочь с этим, есть класс Observer. Его работа заключается в наблюдении за состоянием объекта (или массива) и реагировании на любые изменения. Observer реализован с помощью объекта Proxy. Обратите внимание, что это означает, что он не будет работать в старых браузерах.

Обратите внимание, что Observer используется хуками useState и useContext. Именно так большинство приложений Owl создают наблюдателей. Для большинства случаев использования нет необходимости напрямую создавать экземпляр наблюдателя.

Пример

Например этот код убде отображать update в консоли

For example, this code will display update in the console:

const observer = new owl.core.Observer();
observer.notifyCB = () => console.log("update");
const obj = observer.observe({ a: { b: 1 } });

obj.a.b = 2;

Этот пример показывает, что наблюдатель может наблюдать за вложенными свойствами.

Ссылка

observe Наблюдатель может наблюдать за несколькими значениями с помощью метода observe. Этот метод принимает объект или массив в качестве аргумента и возвращает прокси (который сопоставляется с исходным объектом/массивом). С помощью этого прокси наблюдатель может обнаружить любое изменение любого внутреннего значения.

Регистрация обратного вызова Всякий раз, когда наблюдатель видит изменение состояния, он вызывает свой метод notifyCB. Никакой дополнительной информации при обратном вызове не передается.

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

const observer = new owl.core.Observer();
const obj = observer.observe({ a: { b: 1 } });

observer.revNumber(obj.a); // 1
obj.a.b = 2;

observer.revNumber(obj.a); // 2

revNumber также может возвращать 0, что указывает на то, что значение не наблюдается.

🦉 Пропсы(Props) 🦉

Содержание

Введение

В Owl props (сокращение от properties) — это объект, который содержит все данные, переданные компоненту его родителем.

class Child extends Component {
  static template = xml`<div><t t-esc="props.a"/><t t-esc="props.b"/></div>`;
}

class Parent extends Component {
  static template = xml`<div><Child a="state.a" b="'string'"/></div>`;
  static components = { Child };
  state = useState({ a: "fromparent" });
}

В этом примере компонент Child получает от своего родителя два реквизита: a и b. Owl собирает их в объект props, при этом каждое значение оценивается в контексте родителя. Таким образом, props.a равно 'fromparent', а props.b равно 'string'.

Обратите внимание, что props — это объект, который имеет смысл только с точки зрения дочернего компонента.

Определение

Объект props состоит из всех атрибутов, определенных в шаблоне, за следующими исключениями:

  • каждый атрибут, начинающийся с t-, не является пропсом (это директивы QWeb),
  • также исключены атрибуты style и class (они применяются Owl к корневому элементу компонента).

В следующем примере:

<div>
    <ComponentA a="state.a" b="'string'"/>
    <ComponentB t-if="state.flag" model="model"/>
    <ComponentC style="color:red;" class="left-pane" />
</div>

объект props содержит следующие ключи:

  • для ComponentA: a и b,
  • для ComponentB: model,
  • для ComponentC: пустой объект

Хорошая практика

Объект props — это набор значений, исходящих от родителя. Таким образом, они принадлежат родителю и никогда не должны изменяться потомком:

class MyComponent extends Component {
  constructor(parent, props) {
    super(parent, props);
    props.a.b = 43; // Never do that!!!
  }
}

Пропсы следует рассматривать только для чтения с точки зрения дочернего компонента. Если есть необходимость их модифицировать, то запрос на их обновление должен быть отправлен родителю (например, с событием).

Любое значение может быть в пропсе. Строки, объекты, классы или даже обратные вызовы могут быть переданы дочернему компоненту (но тогда, в случае обратных вызовов, связь с событиями кажется более подходящей).

Динамические пропсы

Директиву t-props можно использовать для указания полностью динамических реквизитов:

<div t-name="ParentComponent">
    <Child t-props="some.obj"/>
</div>
class ParentComponent {
  static components = { Child };
  some = { obj: { a: 1, b: 2 } };
}

🦉 Проверка пропсов (Props Validation) 🦉

По мере того, как приложение становится сложным, может быть довольно небезопасно определять пропсы неформальным способом. Это приводит к двум проблемам:

  • трудно сказать, как следует использовать компонент, глядя на его код.
  • небезопасно, легко отправить неправильный пропрс в компонент при рефакторинге компонента, либо одного из его родителей.

Система типов пропсов решает обе проблемы, описывая типы и формы пропса. Вот как это работает в Owl:

  • ключ props является статическим ключом (поэтому отличается от this.props в экземпляре компонента)
  • это необязательно: для компонента нормально не определять ключ props.
  • пропсы проверяются всякий раз, когда компонент создается/обновляется
  • пропсы проверяются только в режиме dev (см. страницу конфигурации)
  • если ключ не соответствует описанию, выдается ошибка
  • он проверяет ключи, определенные в (статических) props. Дополнительные ключи, предоставленные родителем, вызовут ошибку.

For example:

class ComponentA extends owl.Component {
    static props = ['id', 'url'];

    ...
}

class ComponentB extends owl.Component {
  static props = {
    count: {type: Number},
    messages: {
      type: Array,
      element: {type: Object, shape: {id: Boolean, text: String }
    },
   date: Date,
   combinedVal: [Number, Boolean]
  };

  ...
}
  • это объект или список строк
  • список строк - это упрощенное определение пропсов, в котором перечислены только имена пропсов. Кроме того, если имя заканчивается на ?, оно считается необязательным.
  • все пропсы по умолчанию обязательны, если они не определены с помощью optional: true (в этом случае проверка выполняется только в том случае, если есть значение)
  • Допустимые типы: Number, String, Boolean, Object, Array, Date, Function и все функции-конструкторы (так что, если у вас есть класс Person, его можно использовать как тип)
  • массивы однородны (все элементы имеют одинаковый тип/форму)

Для каждого ключа определение prop является либо логическим значением, либо конструктором, либо списком конструкторов, либо объектом:

  • булево значение: указывает, что пропс существует и является обязательным.
  • конструктор: должен описывать тип, например: id: Number описываете пропс id как число
  • список конструкторов. В этом случае это означает, что мы допускаем более одного типа. Например, id: [Number, String] означает, что id может быть как строкой, так и числом.
  • объект. Это дает возможность иметь более выразительное определение. Затем разрешены (но не обязательны) следующие подключи:
    • type: основной тип проверяемого пропса
    • element: если тип был Array, то ключ element описывает тип каждого элемента в массиве. Если он не установлен, то мы проверяем только массив, а не его элементы,
    • shape: если тип был Object, то ключ shape описывает интерфейс объекта. Если он не установлен, то мы проверяем только объект, а не его элементы,
    • validate: это функция, которая должна возвращать логическое значение, чтобы определить, является ли значение действительным или нет. Полезно для пользовательской проверки.

Примеры:

  // задокументировано только существование этих 3 ключей
  static props = ['message', 'id', 'date'];
  // size не является обязательным
  static props = ['message', 'size?'];
  static props = {
    messageIds: {type: Array, element: Number},  // список чисел
    otherArr: {type: Array},   // просто массив. проверка подэлементов не производится
    otherArr2: Array,   // тож что и otherArr
    someObj: {type: Object},  // просто объект, без внутренней проверки
    someObj2: {
      type: Object,
      shape: {
        id: Number,
        name: {type: String, optional: true},
        url: String
      ]},    // объект, с ключами id (number), name (string, optional) и url (string)
    someFlag: Boolean,     // логическое, обязательное (даже если `false`)
    someVal: [Boolean, Date],   // либо логическое значение, либо дата
    otherValue: true,     // указывает, что это пропс
    kindofsmallnumber: {
      type: Number,
      validate: n => (0 <= n && n <= 10)
    },
    size: {
      validate:  e => ["small", "medium", "large"].includes(e)
    },
  };

🦉 Язык шаблонов QWeb🦉

Содержание

Введение

[QWeb] (https://doc.open-odoo.ru/developer/13.0/ru/reference/qweb.html) — это основной механизм шаблонов, используемый Odoo. Он основан на формате XML и используется в основном для создания HTML. В OWL шаблоны QWeb компилируются в функции, которые генерируют виртуальное представление HTML.

<div>
    <span t-if="somecondition">Какая-то строка</span>
    <ul t-else="">
        <li t-foreach="messages" t-as="message">
            <t t-esc="message"/>
        </li>
    </ul>
</div>

Директивы шаблона указываются как XML-атрибуты с префиксом t-, например t-if для условий, при этом элементы и другие атрибуты рендерятся напрямую.

Чтобы избежать рендеринга элемента, также доступен элемент-заполнитель <t>, который выполняет свою директиву, но сам по себе не генерирует никаких выходных данных.

В этом разделе мы представляем язык шаблонов, включая его специфичные для Owl расширения.

Директивы

Для справки, вот список всех стандартных директив QWeb:

Система компонентов в Owl требует дополнительных директив для выражения различных потребностей. Вот список всех директив Owl:

Описание элементов

Пробелы

Пробелы в шаблоне обрабатываются особым образом:

  • последовательные пробелы всегда объединяются в один пробел
  • если текстовый узел, состоящий только из пробелов, содержит разрыв строки, он игнорируется
  • предыдущие правила не применяются, если мы находимся в теге <pre>

Корневые узлы

По многим причинам шаблоны Owl QWeb должны иметь один корневой узел. Точнее, результат рендеринга шаблона должен иметь один корневой узел:

<!–– не ok: два корневых узла ––>
<t>
    <div>foo</div>
    <div>bar</div>
</t>

<!–– ok: результат имеет один корневой узел ––>
<t>
    <div t-if="someCondition">foo</div>
    <span t-else="">bar</span>
</t>

Дополнительные корневые узлы фактически будут игнорироваться (даже если они будут рендериться в памяти).

Примечание: это не относится к дочерним шаблонам (см. директиву t-call). В этом случае они будут встроены в основной шаблон и могут иметь много корневых узлов.

Иполнение выражений

Выражения QWeb — это строки, которые будут обрабатываться во время компиляции. Каждая переменная в выражении javascript будет заменена найденой в контексте то есть компонентом). Например, a + b.c(d) будет преобразовано в:

context["a"] + context["b"].c(context["d"]);

Полезно объяснить различные правила, применимые к этим выражениям:

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

    <div><p t-if="1 + 2 === 3">ok</p></div>
    

    допустимо, но следующее неверно:

    <div><p t-if="console.log(1)">NOT valid</p></div>
    
  2. оно может использовать что угодно в контексте рендеринга (как правило, компонент):

    <p t-if="user.birthday === today()">С днем рождения!</p>
    

    действителен, и будет считывать объект user из контекста и вызывать функцию today.

  3. оно может использовать несколько специальных операторов, чтобы избежать использования таких символов, как <, >, & или |. Это необходимо для того, чтобы убедиться, что мы по-прежнему пишем валидный XML.

    Wordreplaced with
    and&&
    or\|\|
    gt>
    gte>=
    lt<
    lte<=

    И мы можем написать так:

    <div><p t-if="10 + 2 gt 5">ok</p></div>
    

Статические HTML-узлы

Обычные, обычные html-узлы рендерятся сами как есть:

  <div>hello</div> <!–– результатом рендерига будет эта же строка ––>

Вывод данных

Директива t-esc необходима всякий раз, когда вы хотите добавить динамическое текстовое выражение в шаблон. Текст экранирован, чтобы избежать проблем с безопасностью.

<p><t t-esc="value"/></p>

рендеринг со значением value, установленным на 42 в контексте рендеринга, дает:

<p>42</p>

Директива t-raw почти такая же, как t-esc, но без экранирования. Это в бывает полезно для вставки строки html куда-нибудь. Очевидно, что в целом это небезопасно, и его следует использовать только для заведомо проверенных строк.

<p><t t-raw="value"/></p>

рендеринг со значением value установленным как <span>foo</span> в контексте рендеринга дает:

<p><span>foo</span></p>

Обратите внимание, что, поскольку содержание выражения заранее неизвестно, директива t-raw должна анализировать html (и преобразовывать его в виртуальную структуру dom) для каждого рендеринга. Таким образом, это будет намного медленнее, чем обычный шаблон. Поэтому рекомендуется по возможности ограничивать использование t-raw.

Назначение переменных

QWeb позволяет создавать переменные внутри шаблона, запоминать вычисление (использовать его несколько раз), давать части данных более четкое имя, ...

Это делается с помощью директивы t-set, которая принимает имя создаваемой переменной. Устанавливаемое значение может быть предоставлено двумя способами:

  1. атрибут t-value, содержащий выражение, и будет установлен результат его выполнения:

    <t t-set="foo" t-value="2 + 1"/>
    <t t-esc="foo"/>
    

    напечатает 3. Обратите внимание, что оценка выполняется во время рендеринга, а не во время компиляции.

  2. если атрибута t-value нет, тело узла сохраняется и его значение устанавливается как значение переменной:

    <t t-set="foo">
        <li>ok</li>
    </t>
    <t t-esc="foo"/>
    

    сгенерирует &lt;li&gt;ok&lt;/li&gt; (содержимое экранировано, так как мы использовали директиву t-esc)

Директива t-set действует как обычная переменная в большинстве языков программирования. Она лексически ограничена (внутренние узлы являются дочерними областяим), может быть затенена, ...

Состояния

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

<div>
    <t t-if="condition">
        <p>ok</p>
    </t>
</div>

Элемент рендерится, если условие (оцениваемое в текущем контексте рендеринга) истинно:

<div>
    <p>ok</p>
</div>

но если условие ложно, оно удаляется из результата:

<div>
</div>

Условный рендеринг применяется к носителю директивы, который не обязательно должен быть <t>:

<div>
    <p t-if="condition">ok</p>
</div>

даст те же результаты, что и в предыдущем примере.

Также доступны дополнительные директивы условного перехода t-elif и t-else:

<div>
    <p t-if="user.birthday == today()">С днем рождения!</p>
    <p t-elif="user.login == 'root'">Добров пожаловать хозяин!</p>
    <p t-else="">Добро пожаловать!</p>
</div>

Динамические атрибуты

Можно использовать директиву t-att- для добавления динамических атрибутов. Его основное использование — выполнение выражения (во время рендеринга) и привязка атрибута к его результату:

Например, если в контексте рендеринга для id установлено значение 32,

<div t-att-data-action-id="id"/> <!-- результат: <div data-action-id="32"></div> -->

Если выражение оценивается как ложное значение, оно вообще не будет установлено:

<div t-att-foo="false"/>  <!-- результат: <div></div> -->

Иногда бывает удобно отформатировать атрибут с интерполяцией строк. В этом случае можно использовать директиву t-attf-. Это полезно, когда нам нужно смешивать литеральные и динамические элементы, такие как классы CSS.

<div t-attf-foo="a {{value1}} is {{value2}} of {{value3}} ]"/>
<!-- результат если значения установлены в 1,2 и 3: <div foo="a 0 is 1 of 2 ]"></div> -->

Если нам нужны полностью динамические имена атрибутов, то есть дополнительная директива: t-att, которая принимает либо объект (с сопоставлением ключей с их значениями), либо пару [key, value]. Например:

<div t-att="{'a': 1, 'b': 2}"/> <!-- результат: <div a="1" b="2"></div> -->

<div t-att="['a', 'b']"/> <!-- <div a="b"></div> -->

Динамический атрибут class

Для удобства Owl поддерживает специальный случай для случая t-att-class: можно использовать объект с ключами, описывающими классы, и значениями, логическими значениями, обозначающими, присутствует класс или нет:

<div t-att-class="{'a': true, 'b': true}"/> <!-- результат: <div class="a b"></div> -->

<div t-att-class="{'a b': true, 'c': true}"/> <!-- результат: <div class="a b c"></div> -->

Обратите внимание, что его можно комбинировать с обычным атрибутом класса:

<div class="a" t-att-class="{'b': true}"/> <!-- результат: <div class="a b"></div> -->

Динамические имена тегов

При написании общих компонентов или шаблонов конкретный тег для элемента HTML еще не известен. В таких ситуациях полезна директива t-tag. Он просто динамически исполняет выражение для использования в качестве имени тега. Шаблон:

<t t-tag="tag">
    <span>content</span>
</t>

будет рендериться как <div><span>content</span></div>, если ключ контекста tag установлен на div.

Циклы

QWeb имеет директиву итерации t-foreach, которая принимает выражение, возвращающее коллекцию для итерации, и второй параметр t-as, предоставляющий имя для использования в текущем элементе итерации:

<t t-foreach="[1, 2, 3]" t-as="i">
    <p><t t-esc="i"/></p>
</t>

будет отрендерено как:

<p>1</p>
<p>2</p>
<p>3</p>

Подобно условиям, t-foreach применяется к элементу, имеющему атрибут директивы, и

<p t-foreach="[1, 2, 3]" t-as="i">
    <t t-esc="i"/>
</p>

эквивалентен предыдущему примеру.

t-foreach может перебирать массив (текущий элемент будет текущим значением) или объект (текущий элемент будет текущим ключом).

В дополнение к имени, переданному через t-as, t-foreach предоставляет несколько других переменных для различных точек данных (примечание: $as будет заменено именем, переданным t-as):

  • $as_value: текущее значение итерации, идентичное $as для списков и целых чисел, но для объектов предоставляет значение (где $as предоставляет ключ)
  • $as_index: текущий индекс итерации (первый элемент итерации имеет индекс 0)
  • $as_first: является ли текущий элемент первым в итерации (эквивалентно $ as_index == 0)
  • $as_last: независимо от того, является ли текущий элемент последним в итерации (эквивалентно $as_index + 1 == $as_size), требуется доступ к размеру итерируемого

Эти дополнительные предоставленные переменные и все новые переменные, созданные в t-foreach, доступны только в области действия t-foreach. Если переменная существует вне контекста t-foreach, значение копируется в конце foreach в глобальный контекст.

<t t-set="existing_variable" t-value="false"/>
<!-- existing_variable теперь False -->

<p t-foreach="Array(3)" t-as="i">
    <t t-set="existing_variable" t-value="true"/>
    <t t-set="new_variable" t-value="true"/>
    <!-- existing_variable и new_variable теперь true -->
</p>

<!-- existing_variable всегда true -->
<!-- new_variable undefined -->

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

Однако в некоторых ситуациях этого недостаточно, и нам нужно помочь Owl решить, является ли элемент на самом деле одним и тем же или другим элементом с теми же свойствами.

Рассмотрим следующую ситуацию: у нас есть список из двух элементов [{text: "a"}, {text: "b"}], и мы визуализируем их в этом шаблоне:

<p t-foreach="items" t-as="item"><t t-esc="item.text"/></p>

Результатом будут два тега <p> с текстом a и b. Теперь, если мы поменяем их местами и перерендерим шаблон, Owl должен знать, каково намерение:

  • должна ли Owl поменять местами узлы DOM,
  • или он должен сохранить узлы DOM, но с обновленным текстовым содержимым?

Это может показаться тривиальным, но на самом деле это важно. Эти две возможности в некоторых случаях приводят к разным результатам. Например, если пользователь выбрал текст первого p, их замена сохранит выделение, а обновление текстового содержимого не произойдет.

Есть много других случаев, когда это важно: теги input с их значением, классы css и анимации, положение прокрутки...

Итак, директива t-key используется для идентификации элемента. Это позволяет Owl понять, действительно ли разные элементы списка отличаются или нет.

Приведенный выше пример можно изменить, добавив ID: [{id: 1, text: "a"}, {id: 2, text: "b"}]. Тогда шаблон может выглядеть так:

<p t-foreach="items" t-as="item" t-key="item.id"><t t-esc="item.text"/></p>

Директива t-key полезна для списков (t-foreach). Ключ должен быть уникальным числом или строкой (объекты работать не будут: они будут преобразованы в строку "[object Object]", которая явно не уникальна).

Кроме того, ключ может быть установлен в теге t или в его дочерних элементах. Все следующие варианты эквивалентны:

<p t-foreach="items" t-as="item" t-key="item.id">
  <t t-esc="item.text"/>
</p>

<t t-foreach="items" t-as="item" t-key="item.id">
  <p t-esc="item.text"/>
</t>

<t t-foreach="items" t-as="item">
  <p t-key="item.id" t-esc="item.text"/>
</t>

Если директивы t-key нет, Owl будет использовать индекс в качестве ключа по умолчанию.

Примечание: директива t-foreach принимает только массивы (списки) или объекты. Это не работает с другими итерируемыми объектами, такими как Set. Однако это всего лишь вопрос использования javascript-оператора .... Например:

<t t-foreach="...items" t-as="item">...</t>

Оператор ... преобразует Set (или любые другие итерации) в список, который будет работать с Owl QWeb.

Рендеринг дочерних шаблонов

Шаблоны QWeb можно использовать для рендеринга верхнего уровня, но их также можно использовать внутри другого шаблона (чтобы избежать дублирования или присвоения имен частям шаблонов), используя директиву t-call:

<div t-name="other-template">
    <p><t t-value="var"/></p>
</div>

<div t-name="main-template">
    <t t-set="var" t-value="owl"/>
    <t t-call="other-template"/>
</div>

будет отображаться как <div><p>owl</p></div>. В этом примере показано, что дочерний шаблон отображается с контекстом выполнения родителя. Дочерний шаблон фактически встроен в основной шаблон, но в дочерней области: переменные, определенные в дочернем шаблоне, не экранируются.

Иногда может потребоваться передать информацию в дочерний шаблон. В этом случае содержимое тела директивы t-call доступно в виде специальной магической переменной 0:

<t t-name="other-template">
    Этот шаблон был вызван с содержимым:
    <t t-raw="0"/>
</t>

<div t-name="main-template">
    <t t-call="other-template">
        <em>содержимое</em>
    </t>
</div>

результат бутет таким:

<div>
    Этот шаблон был вызван с содержимым:
    <em>содержимое</em>
</div>

Это можно использовать для определения переменных, привязанных к дочернему шаблону:

<t t-call="other-template">
    <t t-set="var" t-value="1"/>
</t>
<!-- "var" здесь не существует -->

Динамические дочерние шаблоны

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

<div t-name="main-template">
    <t t-call="{{template}}">
        <em>содержимое</em>
    </t>
</div>

Здесь имя шаблона получается из значения template в контексте рендеринга шаблона.

Переводы

По умолчанию QWeb указывает, что шаблоны должны быть переведены. Если такое поведение нежелательно, существует директива t-translation, которая может отключить переводы (если для нее установлено значение off) со следующими правилами:

  • каждый текстовый узел будет заменен своим переводом,
  • каждое из следующих значений атрибутов также будет переведено: title, placeholder, label и alt,
  • перевод текстовых узлов можно отключить с помощью специального атрибута t-translation, если его значение равно off.

См. здесь для получения более подробной информации о том, как настроить функция перевода в Owl QWeb.

Отладка

Реализация javascript QWeb предоставляет две полезные директивы отладки:

t-debug добавляет оператор отладчика во время рендеринга шаблона:

<t t-if="a_test">
    <t t-debug=""/>
</t>

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

t-log принимает параметр выражения, выполняет выражение во время рендеринга и записывает результат в console.log:

<t t-set="foo" t-value="42"/>
<t t-log="foo"/>

выведет в консоль 42.

🦉 Движок QWeb 🦉

Содрежание

Введение

[QWeb] (https://www.odoo.com/documentation/13.0/reference/qweb.html) — это основной механизм шаблонов, используемый Odoo. Класс QWeb в проекте OWL является реализацией этой спецификации с несколькими интересными моментами:

  • компилирует шаблоны в функции, выводящие виртуальный DOM вместо строки. Это необходимо для компонентной системы.
  • у него есть несколько дополнительных директив: t-component, t-on, ...

В этом разделе мы представляем движок, а не язык шаблонов.

Описание элементов

Этот раздел посвящен коду javascript, который реализует спецификацию QWeb. Owl экспортирует класс QWeb в owl.QWeb. Чтобы использовать его, его нужн просто создать:

const qweb = new owl.QWeb();

Его API достаточно прост:

  • constructor(config): конструктор. Принимает необязательный объект конфигурации с необязательной строкой templates для добавления начальных шаблонов (дополнительную информацию о формате строки см. в addTemplates) и необязательную функцию перевода translateFn (см. раздел о переводах).

    const qweb = new owl.QWeb({ templates: TEMPLATES, translateFn: _t });
    
  • addTemplate(name, xmlStr, allowDuplicate): добавляет специфический шаблон.

    qweb.addTemplate("mytemplate", "<div>hello</div>");
    

    Если для необязательного параметра allowDuplicate установлено значение true, то QWeb просто проигнорирует шаблоны, добавленные во второй раз. В противном случае QWeb сломается.

  • addTemplates(xmlStr): добавить список шаблонов (определяется атрибутом t-name).

    const TEMPLATES = `
      <templates>
        <div t-name="App" class="main">main</div>
        <div t-name="OtherComponent">другой компонент</div>
      </templates>`;
    qweb.addTemplates(TEMPLATES);
    
  • render(name, context, extra): рендерит шаблон. Возвращает vnode, который является виртуальным представлением DOM (см. vdom doc).

    const vnode = qweb.render("App", component);
    
  • renderToString(name, context): рендерит шаблон, но возвращает строку html.

    const str = qweb.renderToString("someTemplate", somecontext);
    
  • registerTemplate(name, template): статическая функция для регистрации глобального шаблона QWeb. Это полезно для часто используемых компонентов в приложении и для того, чтобы сделать шаблон доступным для приложения без ссылки на фактический экземпляр QWeb.

    QWeb.registerTemplate("mytemplate", `<div>some template</div>`);
    
  • registerComponent(name, Component): статическая функция для регистрации компонента OWL в глобальном реестре QWeb. Глобально зарегистрированные компоненты могут использоваться в шаблонах (см. директиву t-component). Это полезно для часто используемых компонентов в приложении.

    class Dialog extends owl.Component { ... }
    QWeb.registerComponent("Dialog", Dialog);
    
    ...
    
    class ParentComponent extends owl.Component { ... }
    qweb.addTemplate("ParentComponent", "<div><Dialog/></div>");
    

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

Переводы

При правильной настройке движок Owl QWeb может переводить все отображаемые шаблоны. Для этого ему нужна функция перевода, которая принимает строку и возвращает строку.

Например:

const translations = {
  hello: "bonjour",
  yes: "oui",
  no: "non",
};
const translateFn = (str) => translations[str] || str;

const qweb = new QWeb({ translateFn });

После настройки все отрендеренные шаблоны будут переведены с помощью translateFn:

  • каждый текстовый узел будет заменен своим переводом,
  • каждое из следующих значений атрибутов также будет переведено: title, placeholder, label и alt,
  • перевод текстовых узлов можно отключить с помощью специального атрибута t-translation, если его значение равно off.

Итак, с помощью приведенного выше translateFn следующие шаблоны:

<div>hello</div>
<div t-translation="off">hello</div>
<div>Are you sure?</div>
<input placeholder="hello" other="yes"/>

будут отрендерены как:

<div>bonjour</div>
<div>hello</div>
<div>Are you sure?</div>
<input placeholder="bonjour" other="yes"/>

Обратите внимание, что перевод выполняется во время компиляции шаблона, а не во время его рендеринга.

🦉 Маршрутизатор(Router) 🦉

Содержание

Введение

Часто бывает полезно организовать приложение вокруг URL-адресов. Если приложение представляет собой одностраничное приложение, нам нужен способ управления этими URL-адресами в браузере. Вот почему существует много разных маршрутизаторов для разных фреймворков. Обычный маршрутизатор может отлично справляться с этой задачей, но специализированный маршрутизатор для Owl может предоставить лучший опыт разработчику.

Маршрутизатор Owl поддерживает следующие функции:

  • режим history или hash
  • декларативные маршруты
  • перенаправление маршрута
  • навигационная охрана
  • параметризованные маршруты
  • компонент <Link/>
  • компонент <RouteComponent/>

Обратите внимание, что он все еще находится на ранней стадии разработки, и, вероятно, все еще есть некоторые проблемы.

Пример

Чтобы использовать маршрутизатор Owl, необходимо выполнить несколько шагов:

  • объявить маршруты
  • создать маршрутизатор
  • добавить его в окружение
async function protectRoute({ env, to }) {
  if (!env.session.authUser) {
    env.session.setNextRoute(to.name);
    return { to: "SIGN_IN" };
  }
  return true;
}

export const ROUTES = [
  { name: "LANDING", path: "/", component: Landing },
  { name: "TASK", path: "/tasks/{{id}}", component: Task },
  { name: "SIGN_UP", path: "/signup", component: SignUp },
  { name: "SIGN_IN", path: "/signin", component: SignIn },
  { name: "ADMIN", path: "/admin", component: Admin, beforeRouteEnter: protectRoute },
  { name: "ACCOUNT", path: "/account", component: Account, beforeRouteEnter: protectRoute },
  { name: "UNKNOWN", path: "*", redirect: { to: "LANDING" } }
];

function makeEnvironment() {
    ...
    const env = { qweb };
    env.session = new Session(env);
    env.router = new owl.router.Router(env, ROUTES);
    await env.router.start();
    return env;
}

App.env = makeEnvironment();
// создвайте корневой компонент тут

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

Описание элементов

Определение маршрута

Маршрут должен быть определен как объект со следующими ключами:

  • name (необязательно): (уникальная) строка, полезная для идентификации текущего маршрута. Если не указано, ему будет присвоено автоматическое имя,
  • path: строка, описывающая URL-адрес. Он может быть статическим: /admin или динамическим: /users/{{id}}. Она также может быть *, чтобы поймать все оставшиеся маршруты.
  • component (необязательно): компонент Owl, который будет использоваться директивой t-routecomponent, если маршрут активен.
  • redirect (необязательно): должен быть объектом назначения (с необязательными ключами path, to и params), если задано, приложение будет перенаправлено в пункт назначения всякий раз, когда мы сопоставляем этот маршрут
  • beforeRouteEnter: определяет защитника навигации.

Router

Конструктор Router принимает три аргумента:

  • env: валидное окружение,
  • списко маршрутов,
  • необязательный объект (с единственным ключом mode, который может быть history (значение по умолчанию) или hash).

history будет использовать History API браузера в качестве механизма управления URL. Пример: https://yourdomain .tld/my_custom_route. Чтобы этот механизм работал, вам нужен способ соответствующей настройки вашего веб-сервера.

hash будет манипулировать хешем URL-адреса. Пример: https://yourdomain.tld/index.html#/my_custom_route.

const ROUTES = [...];
const router = new owl.router.Router(env, ROUTES, {mode: 'history'});

Обратите внимание, что маршрут определяется в списке, и порядок имеет значение: маршрутизатор пытается найти совпадение, проходя по списку.

Маршрутизатор необходимо добавить в среду в дочернем ключе router.

После создания маршрутизатора его необходимо запустить. Это необходимо для инициализации его текущего состояния текущим URL-адресом (а также для потенциального применения любых средств защиты навигации и/или перенаправления).

await router.start();

После запуска маршрутизатор будет отслеживать текущий URL-адрес и отображать его значение в двух ключах:

  • router.currentRoute
  • router.currentParams

Маршрутизатор также имеет метод navigate, полезный для программного изменения приложения в другое состояние (и URL-адрес):

router.navigate({ to: "USER", params: { id: 51 } });

Защитники навигации

Защита навигации очень полезна, поскольку позволяет выполнять некоторую бизнес-логику/выполнять некоторые действия или перенаправлять на другие маршруты всякий раз, когда приложение переходит на новый маршрут. Например, следующая защита проверяет наличие пользователя, прошедшего проверку подлинности, и, если это не так, перенаправляет на маршрут входа.

async function protectRoute({ env, to }) {
  if (!env.session.authUser) {
    env.session.setNextRoute(to.name);
    return { to: "SIGN_IN" };
  }
  return true;
}

Защитник навигации — это функция, которая возвращает промис, который разрешается либо в true (навигация принята), либо в другой целевой объект.

RouteComponent

Компонент RouteComponent направляет Owl для рендеринга компонента, связанного с текущим активным маршрутом (если есть):

<div t-name="App">
    <NavBar />
    <RouteComponent />
</div>

Компонент Link — это компонент Owl, который отображается как тег <a> с любым содержимым. Он вычислит правильный href из своих реквизитов и позволит Owl правильно перейти к заданному URL-адресу, если на него кликнуть.

<Link to="'HOME'">Дома</Link>

🦉 Хранилище(Store) 🦉

Содержание

Введение

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

Решением этой проблемы в Owl является централизованное хранилище. Это класс, который владеет каким либо (или всем) состоянием и позволяет разработчику обновлять его структурированным образом с помощью actions. Затем компоненты Owl могут подключиться к хранилищу, чтобы прочитать свое соответствующее состояние, и они будут повторно отрендерены, если состояние будет обновлено.

Примечание. Хранилище Owl вдохновлено React Redux и VueX.

Пример

Вот пример на что похоже простое хранилище:

const actions = {
  addTodo({ state }, message) {
    state.todos.push({
      id: state.nextId++,
      message,
      isCompleted: false,
    });
  },
};

const state = {
  todos: [],
  nextId: 1,
};

const store = new owl.Store({ state, actions });
store.on("update", null, () => console.log(store.state));

// обновляет state
store.dispatch("addTodo", "fix all bugs");

В этом примере показано, как можно определить и использовать хранилище. Обратите внимание, что в большинстве случаев действия будут отправляться подключенными компонентами.

Описание элементов

Хранилище(Store)

Хранилище представляет собой простой owl.EventBus, который запускает события update всякий раз, когда его состояние изменяется. Обратите внимание, что эти события инициируются только после тика микрозадачи, поэтому только одно событие будет инициировано для любого количества изменений состояния в стеке вызовов.

Кроме того, важно отметить, что состояние наблюдается (с помощью owl.Observer), поэтому оно может знать, было ли оно изменено. См. документацию Observer для более подробной информации. Класс Store довольно мал. Он имеет два общедоступных метода:

  • его конструктор
  • dispatch

Конструктор принимает объект конфигурации с четырьмя (необязательными) ключами:

  • исходное состояние
  • действия
  • геттеры
  • окружение
const config = {
  state,
  actions,
  getters,
  env,
};
const store = new Store(config);

Действия(Actions)

Действия используются для координации изменений состояния. Их можно использовать как для синхронной, так и для асинхронной логики.

const actions = {
  async login({ state }, info) {
    state.loginState = "pending";
    try {
      const loginInfo = await doSomeRPC("/login/", info);
      state.loginState = loginInfo;
    } catch (e) {
      state.loginState = "error";
    }
  },
};

Первый аргумент метода действия — это объект с четырьмя ключами:

  • state: текущее состояние содержимого хранилища,
  • dispatch: функция, которую можно использовать для отправки других действий,
  • getters: объект, содержащий все геттеры, определенные в хранилище,
  • env: текущее окружение. Иногда это бывает полезно, в частности, если действие требует применения некоторых побочных эффектов (таких как выполнение rpc), а метод rpc находится в окружении.

Действия вызываются с помощью метода dispatch в хранилище и могут принимать произвольное количество аргументов.

store.dispatch("login", someInfo);

Обратите внимание, что все, что возвращается действием, также будет возвращено вызовом dispatch.

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

const actions = {
  async fetchSomeData({ state }, recordId) {
    state.recordId = recordId;
    const data = await doSomeRPC("/read/", recordId);
    state.recordData = data;
  },
};

В предыдущем примере есть период времени, в котором состояние имеет recordId, который не соответствует recordData. Более вероятно, что нам нужно атомарное обновление: обновление recordId одновременно со значениями recordData:

const actions = {
  async fetchSomeData({ state }, recordId) {
    const data = await doSomeRPC("/read/", recordId);
    state.recordId = recordId;
    state.recordData = data;
  },
};

Геттеры(Getters)

Обычно данные, содержащиеся в хранилище, хранятся в нормализованном виде. Например,

{
    posts: [{id: 11, authorId: 4, content: 'Greetings'}],
    authors: [{id: 4, name: 'John'}]
}

Однако для пользовательского интерфейса, вероятно, потребуются некоторые денормализованные данные, такие как:

{id: 11, author: {id: 4, name: 'John'}, content: 'Greetings'}

Для этого и нужны getters: они дают централизованный способ обработки и преобразования данных, содержащихся в хранилище.

const getters = {
  getPost({ state }, id) {
    const post = state.posts.find((p) => p.id === id);
    const author = state.authors.find((a) => a.id === post.id);
    return {
      id,
      author,
      content: post.content,
    };
  },
};

// где-то еще
const post = store.getters.getPost(id);

Геттеры принимают как правило один аргумент.

Обратите внимание что геттеры не кешируются

Подключение компонента

В какой-то момент нам нужен способ взаимодействия с хранилищем из компонента. Это означает, что компоненту нужна ссылка на хранилище. По умолчанию он ищет его в ключе env.store. Однако это можно настроить с помощью хука useStore.

Каждое взаимодействие компонента с хранилищем осуществляется с помощью трех хуков хранилища:

  • useStore для подписки компонента на некоторую часть состояния хранилища,
  • useDispatch для получения ссылки на функцию отправки,
  • useGetters для получения ссылки на геттеры, определенные в хранилище.

Предположим, у нас есть такое хранилище:

const actions = {
  increment({ state }, val) {
    state.counter.value += val;
  },
};

const state = {
  counter: { value: 0 },
};
const store = new owl.Store({ state, actions });

Чтобы сделать его доступным для всего приложения, мы поместим его в окружение:

// в этом примере корнево элемент это App
App.env.store = store;

A counter component can then select this value and dispatch an action like this:

class Counter extends Component {
  counter = useStore((state) => state.counter);
  dispatch = useDispatch();
}

const counter = new Counter({ store, qweb });
<button t-name="Counter" t-on-click="dispatch('increment')">
  Click Me! [<t t-esc="counter.value"/>]
</button>

useStore

Хук useStore используется для выбора части состояния хранилища. Он принимает два аргумента:

  • функция-селектор, которая принимает состояние хранилища в качестве первого аргумента (и реквизиты компонента в качестве второго аргумента) и которая должна возвращать часть состояния хранилища, которая будет доступна и будет отслеживаться для изменений,
  • необязательно, объект, который может иметь следующие необязательные ключи:
    • ключ store, содержащий объект хранилища, если мы хотим использовать другое хранилище, отличное от хранилища по умолчанию,
    • ключ isEqual, содержащий функцию равенства, если мы хотим специализировать сравнение (функция должна принимать два аргумента: предыдущий результат и новый результат, и должна возвращать, равны ли они),
    • и ключ onUpdate, содержащий функцию обновления, если мы хотим выполнять произвольный код каждый раз, когда изменяется выбранное состояние (функция получит один аргумент, новый результат, и может выполнять произвольный код).

Если селектор useStore возвращает часть состояния хранилища, компонент будет перерендириваться только при изменении этой части состояния. В противном случае он выполнит строгую проверку на равенство (если не определена опция isEqual, тогда она будет вызываться) и будет обновлять компонент каждый раз, когда эта проверка не пройдена.

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

Кроме того, возвращаемое значение из useStore не должно изменяться. Состояние хранилища должно обновляться только действиями.

useDispatch

Хук useDispatch полезен, когда компонент должен иметь возможность отправлять(dispatch) действия. Он принимает необязательный аргумент, который является хранилищем. Если не указано, будет использоваться хранилище из окружение.

Обратите внимание, что компонент не нужно каким-либо другим образом подключать к хранилищу. Например:

class DoSomethingButton extends Component {
  static template = xml`<button t-on-click="dispatch('something')">Click</button>`;
  dispatch = useDispatch();
}

useGetters

Хук useGetters полезен, когда компонент должен иметь возможность использовать геттеры, определенные в хранилище. Он принимает необязательный аргумент, который является хранилищем. Если не указано, будет использоваться хранилище в среде.

Обратите внимание, что компонент не нужно каким-либо другим образом подключать к хранилищу. Например:

class InfoButton extends Component {
  static template = xml`<span><t t-esc="getters.somevalue()"></span>`;
  getters = useGetters();
}

Семантика

Класс Store и хук useStore стараются быть умными и максимально оптимизировать процесс рендеринга и обновления. Важно знать следующее:

  • компоненты всегда обновляются в порядке их создания (т.е. родитель перед потомком),
  • они обновляются только если находятся в DOM,
  • если родитель является асинхронным, система будет ждать завершения его обновления перед обновлением других компонентов,
  • вообще обновления не согласовываются. Это не проблема для синхронных компонентов, но если есть много асинхронных компонентов, это может привести к ситуации, когда какая-то часть пользовательского интерфейса обновляется, а какая-то другая часть пользовательского интерфейса не обновляется.

Хорошие практики

  • по возможности избегайте асинхронных компонентов. Асинхронные компоненты приводят к ситуациям, когда части пользовательского интерфейса не обновляются сразу.
  • не бойтесь подключать много компонентов, родительских или дочерних, если это необходимо. Например, компонент MessageList может получить список идентификаторов в своем вызове useStore, а компонент Message может получить данные своего собственного сообщения.
  • поскольку функция useStore вызывается для каждого подключенного компонента, для каждого обновления состояния, важно сделать так, чтобы эти функции были максимально быстрыми.

🦉 Слоты(Slots) 🦉

Содержание

Введение

Owl — это система компонентов, основанная на шаблонах. Следовательно, необходимо иметь возможность создавать универсальные компоненты. Например, представьте себе общий компонент Dialog, который может отображать произвольное содержимое.

Очевидно, мы хотим использовать этот компонент везде в нашем приложении, чтобы отображать различный контент. Компонент Dialog технически является владельцем своего содержимого, но является только контейнером. Пользователь Dialog — это компонент, который хочет внедрить что-то внутри Dialog. Именно для этого и нужны слоты.

Пример

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

Слоты вставляются директивой t-slot:

<div t-name="Dialog" class="modal">
  <div class="modal-title"><t t-esc="props.title"/></div>
  <div class="modal-content">
    <t t-slot="content"/>
  </div>
  <div class="modal-footer">
    <t t-slot="footer"/>
  </div>
</div>

Слоты определяются вызывающей стороной с помощью директивы t-set-slot:

<div t-name="SomeComponent">
  <div>some component</div>
  <Dialog title="'Some Dialog'">
    <t t-set-slot="content">
      <div>hey</div>
    </t>
    <t t-set-slot="footer">
      <button t-on-click="doSomething">ok</button>
    </t>
  </Dialog>
</div>

В этом примере компонент Dialog будет рендерить слоты content и footer с его родителем в качестве контекста рендеринга. Это означает, что нажатие на кнопку вызовет выполнение метода doSomething для родителя, а не для диалога.

Примечание. Ранее Owl использовала директиву t-set для определения содержимого слота. Такой подход объявлен deprecated(устаревшим) и больше не должен использоваться в новом коде.

Описание элементов

Слот по умолчанию

Первый элемент внутри компонента, который не является именованным слотом, будет считаться default слотом по умолчанию. Например:

<div t-name="Parent">
  <Child>
    <span>какое то содержимое</span>
  </Child>
</div>

<div t-name="Child">
  <t t-slot="default"/>
</div>

Содержимое по умолчанию

Слоты могут определять содержимое по умолчанию, если родитель их не определил:

<div t-name="Parent">
  <Child/>
</div>

<span t-name="Child">
  <t t-slot="default">содержимое по умолчанию</t>
</span>
<!-- будет отрендерен как: <div><span>содержимое по умолчанию</span></div> -->

Контекст рендеринга: содержимое слота фактически рендерится с контекстом рендеринга, соответствующему тому, где он было определен, а не тому, где он расположе. Это позволяет пользователю определять обработчики событий, которые будут привязаны к правильному компоненту (обычно к прародителю содержимого слота).

Динамические слоты

Директива t-slot фактически может использовать любые выражения, используя интерполяцию строк:

 <t t-slot="{{current}}" />

🦉 Теги(Tags) 🦉

Содержание

Введение

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

Тег XML

Тег xml, безусловно, самый полезный тег. Он используется для определения встроенного шаблона QWeb для компонента. Без тегов создание автономного компонента выглядело бы так:

import { Component } from 'owl'

const name = 'some-unique-name';
const template = `
    <div>
        <span t-if="somecondition">text</span>
        <button t-on-click="someMethod">Click</button>
    </div>
`;
QWeb.registerTemplate(name, template);

class MyComponent extends Component {
    static template = name;

    ...
}

С тегами этот процесс немного упрощается. Имя генерируется уникально, и шаблон регистрируется автоматически:

const { Component } = owl;
const { xml } = owl.tags;

class MyComponent extends Component {
    static template = xml`
        <div>
            <span t-if="somecondition">text</span>
            <button t-on-click="someMethod">Click</button>
        </div>
    `;

    ...
}

CSS tag

Тег CSS полезен для определения таблицы стилей css в файле javascript:

class MyComponent extends Component {
  static template = xml`
        <div class="my-component">some template</div>
    `;
  static style = css`
    .my-component {
      color: red;
    }
  `;
}

Тег css регистрирует внутреннюю информацию css. Затем, всякий раз, когда будет создан первый экземпляр компонента, будет добавлен тег <style> к документу <head>.

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

.my-component {
  display: block;
  .sub-component h {
    color: red;
  }
}

Будет отформатировано как:

.my-component {
  display: block;
}
.my-component .sub-component h {
  color: red;
}

Это расширение предоставляет еще одну полезную функцию: селектор &, который ссылается на родительский селектор. Например, мы хотим, чтобы наш компонент был красным при наведении. Мы хотели бы написать что-то вроде:

.my-component {
  display: block;
  :hover {
    color: red;
  }
}

но он будет отформатирован как:

.my-component {
  display: block;
}
.my-component :hover {
  color: red;
}

Для решения этой проблемы можно использовать селектор &:

.my-component {
  display: block;
  &:hover {
    color: red;
  }
}

будет отформатирован как:

.my-component {
  display: block;
}
.my-component:hover {
  color: red;
}

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

  1. обмен значениями между javascript и css:
import { theme } from "./theme";

class MyComponent extends Component {
  static template = xml`<div class="my-component">...</div>`;
  static style = css`
    .my-component {
      color: ${theme.MAIN_COLOR};
      background-color: ${theme.SECONDARY_color};
    }
  `;
}
  1. правила области видимости для текущего компонента:
import { generateUUID } from "./utils";

const uuid = generateUUID();

class MyComponent extends Component {
  static template = xml`<div data-o-${uuid}="">...</div>`;
  static style = css`
    [data-o-${uuid}] {
      color: red;
    }
  `;
}

🦉 Служебные функции (Utils) 🦉

Owl экспортирует несколько полезных служебных функций, чтобы помочь с распространенными проблемами. Все эти функции доступны в пространстве имен owl.utils.

Содержимое

  • whenReady: выполнение кода, когда DOM готов
  • loadJS: загрузка файлов скриптов
  • loadFile: загрузка файла (полезно для шаблонов)
  • escape: очистка строк
  • debounce: ограничение частоты вызовов функций
  • shallowEqual: сравнение неглубоких объектов

whenReady

Функция whenReady возвращает Promise, разрешенное, когда DOM будет готов (если оно еще не готово, в противном случае разрешается напрямую). Если вызывается с обратным вызовом в качестве аргумента, он выполняет его, как только DOM готов (или напрямую).

Promise.all([loadFile("templates.xml"), owl.utils.whenReady()]).then(function ([templates]) {
  const qweb = new owl.QWeb({ templates });
  const env = { qweb };
  await mount(App, { env, target: document.body });
});

или еще вариант:

owl.utils.whenReady(function () {
  const qweb = new owl.QWeb();
  const env = { qweb };
  await mount(App, { env, target: document.body });
});

loadJS

loadJS принимает URL-адрес (строку) ресурса javascript и загружает его (путем добавления тега script в заголовок документа). Он возвращает промис, поэтому вызывающая сторона может правильно отреагировать, когда он будет готово. Кроме того, он умен: он поддерживает список URL-адресов, загруженных ранее (или загружаемых в настоящее время), и предотвращает двойную работу.

Например, это полезно для ленивой загрузки внешних библиотек:

class MyComponent extends owl.Component {
  willStart() {
    return owl.utils.loadJS("/static/libs/someLib.js");
  }
}

loadFile

loadFile — это вспомогательная функция для загрузки файла. Он просто выполняет запрос GET и возвращает результирующую строку в промисе. Первоначальный вариант использования этой функции — загрузить файл шаблона. Например:

async function makeEnv() {
  const templates = await owl.utils.loadFile("templates.xml");
  const qweb = new owl.QWeb({ templates });
  return { qweb };
}

Обратите внимание, что в отличие от loadJS, эта функция возвращает содержимое файла в виде строки. Он не добавляет тег script и не делает ничего.

escape

Иногда нам нужно отобразить динамические данные (например, данные, созданные пользователем) в пользовательском интерфейсе. Если это делается с помощью шаблона QWeb, это не проблема:

    <div><t t-esc="user.data"/></div>

Механизм QWeb создаст узел div и добавит содержимое строки user.data в качестве текстового узла, поэтому веб-браузер не будет анализировать его как html. Однако это может быть проблемой, если это делается с помощью кода javascript, подобного этому:

class BadComponent extends Component {
  // какой то шаблон с ref в div
  // какой-то код ...

  mounted() {
    this.divRef.el.innerHTML = this.state.value;
  }
}

В этом случае содержимое div будет проанализировано как html, что может привести к нежелательному поведению. Чтобы исправить это, функция escape просто преобразует строку в экранированную версию той же строки, которая будет правильно отображаться браузером, но не будет анализироваться как html (например, "<ok>" экранируется в строку: "&lt;ok&gt;"). Итак, плохой пример выше можно исправить следующим изменением:

this.divRef.el.innerHTML = owl.utils.escape(this.state.value);

debounce

Функция debounce полезна, когда мы хотим ограничить количество раз выполнения какой-либо функции/действия. Например, это может быть полезно для предотвращения проблем с двойным нажатием кнопки.

Она принимает три аргумента:

  • func (функция): это функция, скорость которой будет ограничена.
  • wait (число): это количество миллисекунд, которое мы хотим использовать для ограничения скорости функции func
  • immediate (необязательный, логическое значение, по умолчанию=false): если immediate равно true, функция будет запущена немедленно (по переднему фронту интервала). Если false, функция будет запущена в конце (по заднему фронту).

Она возвращает функцию. Например:

const debounce = owl.utils.debounce;
window.addEventListener("mousemove", debounce(doSomething, 100));

Как показывает этот пример, такое бывает полезно для обработчиков событий, которые запускаются очень быстро, например для таких событий scroll или mousemove.

shallowEqual

Эта функция проверяет, имеют ли два объекта одинаковые значения, назначенные каждому ключу:

shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); // true
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 3 }); // false

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

const completeShallowEqual = (a, b) => shallowEqual(a, b) && shallowEqual(b, a);

Другие темы

В этом разделе представлены различные документы, объясняющие некоторые темы, которые нельзя считать ни учебным пособием, ни справочной документацией.

🦉 VDom 🦉

Owl - это декларативная компонентная система: мы объявляем структуру дерева компонентов, и Owl преобразует ее в список императивных операций. Этот перевод выполняется виртуальным dom. Это низкоуровневый слой Owl, большинству разработчиков не нужно будет напрямую вызывать функции виртуального dom.

Основная идея виртуального dom заключается в том, чтобы сохранить представление DOM в памяти (называемое виртуальным узлом) и всякий раз, когда требуется какое-либо изменение, создавать новое представление, вычислять разницу между старым и новым, а затем применять изменения.

vdom предоставляет две функции:

  • h: создает виртуальный узел(node)
  • patch: сравнивает два узла и применеяет изменения.

Примечание: Виртуальный dom Owl является форком snabbdom.

🦉 Конвейер рендеринга 🦉

Здесь мы объясняем, как устроен конвейер рендеринга в Owl.

Предупреждение: эти заметки носят технический характер и предназначены для людей, работающих над Owl (или заинтересованных в понимании его дизайна).

Общая информация

Рендеринг происходит в два этапа:

  • виртуальный рендеринг: это генерирует виртуальный dom в памяти асинхронно
  • исправление: применяет виртуальное дерево к экрану (синхронно)

Существует несколько классов, участвующих в рендеринге:

  • компоненты
  • планировщик
  • волокна: небольшие объекты, содержащие метаданные, связанные с рендерингом определенного компонента

Компоненты организованы в виде динамического дерева компонентов, видимого в пользовательском интерфейсе. Всякий раз, когда рендеринг инициируется в компоненте C происходит следующее:

  • волокно создается на C и рендерится информацией о пропсах
  • фаза виртуального рендеринга начинается на C (будет асинхронно рендерить все дочерние компоненты)
  • волокно добавляется в планировщик, который будет непрерывно опрашивать каждый кадр анимации, если волокно выполнено
  • как только это будет сделано, планировщик вызовет обратный вызов задачи, который применит исправление (если оно не было отменено за это время).

Сравнение с Vue/React

OWL, React и Vue обладает одной и той же главной особенностью: они позволяют разработчикам создавать декларативные пользовательские интерфейсы. Для этого все эти фреймворки используют виртуальный dom. При это между ними все еще существует много различий.

На этой странице мы попытаемся выделить часть этих различий. Как вы понимаете, было приложено много усилий, чтобы оставаться объективным. Однако, если вы не согласны с некоторыми из обсуждаемых пунктов, не стесняйтесь открывать issue/submit PR, чтобы исправить этот текст.

Содержание

Размер

OWL должен быть небольшим и работать на несколько более низком уровне абстракции, чем React и Vue. Кроме того, jQuery - не совсем аналогичный фреймворк, но его интересно сравнить.

ФреймворкРазмер (minified, gzipped)
OWL18kb
Vue + VueX30kb
Vue + VueX + Vue Router39kb
React + ReactDOM + Redux40kb
jQuery30kb

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

Основан на использовании классов

И React, и Vue отошли от определения компонентов с помощью классов. Они предпочитают более функциональный подход, в частности, с новыми механизмами хуков.

Это имеет как свои преимущества так и недостатки. Но по факту и React, и Vue предлагают множество различных способов определения новых компонентов. В отличие от yb[], Owl имеет только один механизм: компоненты на основе классов. Мы считаем, что компоненты Owl достаточно быстры для всех наших сценариев использования, и сделать их максимально простыми для разработчиков было очень важно (для нас).

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

Скорее всего мир фреймворков пользовательского интерфейса движется в сторону композиции по многим очень веским причинам. Owl по-прежнему хорош в композиции (например, Owl поддерживает слоты, которые являются основным механизмом для создания универсальных компонентов многократного использования). Но он также может использовать наследование (и это очень важно, поскольку шаблоны также могут быть унаследованы с помощью преобразований xpaths).

Сборка и использования различных инструментов

OWL разработан таким образом, чтобы быть простым и спользоваться автономно. По разным причинам Odoo не хочет полагаться на стандартные веб-инструменты (такие как webpack), и OWL можно использовать, просто добавив тег script на страницу.

<script src="owl.min.js" />

Для сравнения, React поощряет использование JSX, что требует этапа сборки, а большинство приложений Vue используют однофайловые компоненты, что также требует этапа сборки.

С другой стороны, дополнительный инструментарий в некоторых случаях может усложнить его использование, хотя он также приносит много преимуществ. К тому же у React/Vue есть большая экосистема.

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

Например, Owl использует стандартный анализатор xml, который поставляется с каждым браузером. Из-за этого Owl не пришлось писать свой собственный анализатор шаблонов. Другим примером является вспомогательная функция тегов xml, которая использует собственные литералы шаблонов, позволяя естественным образом записывать шаблоны xml непосредственно в коде javascript. Такой подход может быть легко интегрирован с плагинами редактора, чтобы получить автозаполнение внутри шаблона.

Использование шаблонов

OWL uses its own QWeb engine, which compiles templates on the frontend, as they are needed. This is extremely convenient for our use case, in particular because templates are described in XML files, and can be modified by XPaths. Since Odoo is at its heart a modular application, this is an important feature for us.

OWL использует свой собственный QWeb-движок, который компилирует шаблоны на фронтэнде по мере необходимости. Это чрезвычайно удобно для нашего сценария использования, в частности, потому, что шаблоны описаны в XML-файлах и могут быть изменены с помощью XPaths. Поскольку Odoo по своей сути является модульным приложением то для нас очень это важная.

<div>
  <button t-on-click="increment">Click Me! [<t t-esc="state.value"/>]</button>
</div>

Vue на самом деле отчасти похож. Его язык шаблонов отчасти близок к QWeb, с заменой v на t. Тем не менее, он также является более функциональным. Например, шаблоны Vue имеют слоты или модификаторы событий. Большая разница заключается в том, что большинство приложений Vue необходимо будет создавать заранее, чтобы скомпилировать шаблоны в функции javascript. Обратите внимание, что Vue имеет отдельную сборку, которая включает в себя компилятор шаблонов.

В отличие от этого, большинство приложений React не используют язык шаблонов, а пишут код JSX, который предварительно компилируется в обычный JavaScript на этапе сборки. Этот пример выполнен с использованием (отчасти устаревшей) системы классов React:

class Clock extends React.Component {
  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.props.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

Преимущества такого подхода заключается в том, что он обладает всей мощью Javascript, но менее структурирован, чем язык шаблонов. Обратите внимание, что инструментарий довольно впечатляющий: здесь, на github, есть подсветка синтаксиса для jsx!

Для сравнения ниже приведен эквивалентный Owl компонент, написанный с помощью тега xml

class Clock extends Component {
  static template = xml`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {props.date.toLocaleTimeString()}.</h2>
      </div>
    `;
}

Асинхронный рендеринг

На самом деле есть разница между OWL и React/Vue: компоненты в OWL полностью асинхронны. В их жизненном цикле есть два асинхронных хука:

  • willStart (перед началом рендеринга компонента)
  • willUpdateProps (до установки новых пропсов)

Оба эти метода могут быть реализованы и возвращают объект promise. Затем рендеринг будет ждать завершения этих promise, прежде чем исправлять DOM. Это полезно в таких случаях использования: например, компонент может захотеть получить внешнюю библиотеку (компоненту календаря может потребоваться специализированная библиотека визуализации календаря)., в его willStart хуке.

class MyCalendarComponent extends owl.Component {
    ...

    willStart() {
        return utils.lazyLoad('static/libs/fullcalendar/fullcalendar.js');
    }
    ...
}

Такой подход содержит в себе ряд опасностей (останавливка рендеринга в ожидании ответа по сети очень плохая идея), но с другой стороны он обладает чрезвычайной мощностью, что и продемонстрировал веб-клиент Odoo.

Ленивая загрузка статических библиотек, вполне, может быть выполнена с помощью React/Vue, но это но будет раелизовано более запутанно. Например, в Vue вам нужно использовать ключевое слово dynamic import, которое необходимо транспилировать во время сборки, чтобы компонент загружался асинхронно (см. документацию).

Реактивность

У React есть простая модель: всякий раз, когда состояние изменяется, оно заменяется новым состоянием (с помощью метода setState). Затем DOM исправляется. Это просто, эффективно и немного неудобно писать.

Vue немного отличается: он волшебным образом заменяет свойства в состоянии на геттеры/сеттеры. При этом он может уведомлять компоненты всякий раз, когда состояние, которое они считывают, было изменено.

Owl ближе к vue: он также волшебным образом отслеживает свойства состояния, но увеличивает внутренний счетчик только при каждом его изменении. Обратите внимание, что это сделано с Proxy, что означает, что он полностью прозрачен для разработчиков. Поддерживается добавление новых ключей. Как только какая-либо часть состояния была изменена, рендеринг ставится на исполнение в следующем тике микрозадачи (очередь объектов promise).

Управление состоянием

Управление состоянием приложения - сложная проблема. За последние несколько лет было предложено много решений. Все зависит от вида приложения, о котором мы говорим. Небольшому приложению может оказаться достаточно обычного объекта чтобы хранить его состояние.

Однако есть несколько общих решений для React и Vue: redux и vuex. Оба они являются централизованным хранилищем, которому принадлежит состояние, и они диктуют, как состояние может быть изменено.

Redux

В Redux состояние видоизменяется редьюсерами. Редьюсеры - это функции, которые изменяют состояние, возвращая другой объект:

  ...
  switch (action.type) {
    case ADD_TODO: {
      const { id, content } = action.payload;
      return {
        ...state,
        allIds: [...state.allIds, id],
        byIds: {
          ...state.byIds,
          [id]: {
            content,
            completed: false
          }
        }
      };
    }

Это немного неудобно писать, но это позволяет системе компонентов проверять, была ли изменена часть состояния. Вот что делает функция connect: она создает компонент connected, который подписан на состояние и запускает рендеринг, если какая-то часть состояния была изменена.

VueX

VueX основан на другом принципе: состояние видоизменяется с помощью некоторых специальных функций (мутаций), которые изменяют состояние на месте:

    function ({state}, payload) {
        const { id, content } = payload;
        const message = {id, content, completed: false};
        state.messages.push(message)
    }

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

Owl

Owl store немного похож на смесь redux и vuex: у него есть действия (но не мутации), и, как и VueX, он отслеживает изменения состояния. Однако он не уведомляет компонент об изменении состояния. Вместо этого компоненты должны подключаться к хранилищу, как в redux, с помощью хука useStore (см. документацию store).

const actions = {
  increment({ state }, val) {
    state.counter.value += val;
  },
};

const state = {
  counter: { value: 0 },
};
const store = new owl.Store({ state, actions });

class Counter extends Component {
  static template = xml`
      <button t-name="Counter" t-on-click="dispatch('increment')">
        Click Me! [<t t-esc="counter.value"/>]
      </button>`;
  counter = useStore((state) => state.counter);
  dispatch = useDispatch();
}

Counter.env.store = store;
const counter = new Counter();

Хуки

Хуки недавно захватили мир React. Они решают множество, казалось бы, несвязанных проблем: присоединяют повторно используемое поведение к компоненту составным способом, извлекают логику с отслеживанием состояния из компонента или повторно используют логику с отслеживанием состояния между компонентами, не изменяя иерархию компонентов.

Вот пример хука React useState:

import React, { useState } from "react";

function Example() {
  // Объявляем новую переменную состояния, которую мы назовем "count".
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

Из-за того, как React разработал API хуков, они работают только для функциональных компонентов. Но в таком случае они действительно очень мощные. Каждая крупная библиотека React находится в процессе редизайна своего API с помощью хуков (например, Redux).

В Vue 2 нет хуков, но проект Vue работает над своей следующей версией, в которой будет представлен новый composition API. Эта работа основана на новых идеях, представленных React hooks.

Судя по тому, как React и Vue представляют свои хуки, может показаться, что хуки не совместимы с компонентами класса. Однако это не так, как показывают Owl хуки. Они вдохновлены как React, так и Vue. Например, хук useState назван в честь React, но его API ближе к reactive Vue-хук.

Вот как выглядит приведенный выше пример Counter в Owl:

import { Component, Owl } from "owl";
import { xml } from "owl/tags";

class Example extends Component {
  static template = xml`
      <div>
        <p>You clicked {count.value} times</p>
        <button t-on-click="increment">Click me</button>
      </div>`;

  count = useState({ value: 0 });

  increment() {
    this.state.value++;
  }
}

Поскольку фреймворк Owl имел хуки с самого начала, его основные API предназначены для взаимодействия с хуками с самого начала. Например, абстракции Context и Store.

🦉 Почему Owl ? 🦉

Общепринятая мудрость заключается в том, что не следует изобретать велосипед заново, так как это приведет к пустой трате усилий и ресурсов. И это верно в большинстве случаев. Фреймворк на javascript - это значительные инвестиции, поэтому вполне логично задать вопрос: почему Odoo решил создать OWL вместо использования стандартного/хорошо известного фреймворка, такого как React или Vue?

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

Это означает, например, что основные части Odoo до самого факта выполнения не знают о том, какие файлы будут загружены/выполнены или каково будет состояние пользовательского интерфейса. Из-за этого Odoo не может полагаться на стандартные инструменты сборки. Кроме того, это подразумевает, что основные части Odoo должны быть чрезвычайно общими. Другими словами, Odoo на самом деле не является приложением с пользовательским интерфейсом. Это приложение, которое генерирует динамический пользовательский интерфейс. И большинство фреймворков не справляются с этой задачей.

Сделать ставку на Owl было непросто, потому существует множество противоречивых потребностей, которые мы хотим тщательно сбалансировать. Выбор чего-либо, кроме хорошо известной структуры, неизбежно вызовет споры. На этой странице будут объяснены причины, по которым мы по-прежнему считаем, что создание Owl - это стоящее дело.

Стратегия

Это правда, что мы хотим сохранить контроль над нашей технологией так как мы не хотим зависеть от Facebook или Google, или любого другого крупного (или малого) вендора. Если они решат сменить свою лицензию или пойти в направлении, которое нам не подходит, это может стать проблемой. Более того Odoo - это не обычное приложение на javascript, и скорее всего наши потребности совершенно не похожи на большинство других приложений.

Компоненты на базе классов

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

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

Совместное использование кода между универсальными компонентами с наследованием - это то на чем построен веб-клиент Odoo И ясно, что наследование - это не корень всех зол. Часто это совершенно простое и подходящее решение. Самое главное - это архитектурные решения.

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

Использование React или Vue значительно усложнило бы обработку патчей компонентов, потому что большая часть состояния скрыта в их внутренностях.

Инструментарий

У React или Vue огромное сообщество, и в их инструментарий было вложено много усилий. Это замечательно, но в то же время - большая проблема для Odoo: поскольку ресурсы полностью динамичны (и могут меняться всякий раз, когда пользователь устанавливает или удаляет дополнение), нам нужно иметь весь этот инструментарий на продукшен серверах. И такой подход весьма далек от идеала.

Кроме того, это очень усложняет настройку инструментов Vue или React: код Odoo - это не простой файл, который импортирует другие файлы. Он постоянно меняется, ассеты объединяются по-разному и в разных контекстах. Все это и является причиной, по которой Odoo имеет свою собственную модульную систему, которая разрешается во время выполнения браузером. Динамическая природа Odoo означает, что нам часто приходится откладывать работу на как можно более поздний этам (другими словами, нам нужен пользовательский интерфейс JIT!)

Наш идеальный фреймворк имеет минимальный (обязательный) инструментарий, что упрощает его развертывание. Использование React без JSX или Vue без файла vue не очень привлекательно.

В то же время Owl предназначен для решения этой проблемы: он компилирует шаблоны браузером, для этого не требуется много кода, поскольку мы используем XML-анализатор, встроенный в каждый браузер. Owl может раотать как с дополнительными инструментами или без них. Он может использовать строки шаблона для написания компонентов из одного файла и легко интегрируется в любую html-страницу с помощью простого тега <script>.

Основан на шаблонах

Odoo хранит шаблоны в виде XML-документов в базе данных. Это очень мощно, поскольку позволяет использовать xpath для кастомизации других шаблонов. Это очень важная особенность odoo и один из ключей к модульности Odoo.

Из-за этого мы по-прежнему хотим, что бы наши шаблоны записывались как XML-документ. Как ни странно, ни один крупный фреймворк не использует XML для хранения шаблонов, хотя это чрезвычайно удобно.

Итак, использование React или Vue означает, что нам нужно создать компилятор шаблонов. Для React это был бы компилятор, который взял бы шаблон QWeb и преобразовал его в фукнцию рендеринга React. Для Vue он преобразовал бы его в шаблон Vue. Затем нам также нужно объединить компилятор шаблонов vue.

Это было бы не только сложно (компиляция языка шаблонов в другой - непростая задача), но и негативно сказалось бы на работе разработчика. Написание компонентов Vue или React в виде шаблона QWeb, было бы неудобным и очень запутанным.

Опыт разработчиков

Это подводит нас к следующему пункту: опыт разработчика. Мы рассматриваем наш выбор как инвестицию в будущее, и мы хотим максимально упростить адаптацию разработчиков.

В то время как многие профессионалы javascript явно думают, что react/vue не сложен (что в некоторой степени верно), также верно и то, что многие специалисты, не являющиеся специалистами по js, перегружены миром фронтенда: функциональными компонентами, хуками и многими другими причудливыми словами. Кроме того, то, что понимание того что происходит в контексте компиляции, может быть затруднено, практически в каждом фреймворке происходит много черной магии. Vue каким-то образом объедините различные пространства имен в одно, под капотом, и добавьте различные внутренние ключи. Svelte преобразовывае код. React же в своюу очередь требует, чтобы преобразования состояний были глубокими, а не поверхностными.

Owl очень старается иметь простой и знакомый API. Он использует классы. Его система реактивности является явной, а не неявной. Правила определения области очевидны. В случае сомнений мы допускаем ошибку, не реализуя ту или иную функцию.

Это, безусловно, отличается от React или Vue, но в то же время отчасти знакомо опытным разработчикам.

JIT компиляция

В мире фронтенда также существует четкая тенденция компилировать код как можно раньше. Большинство фреймворков заранее компилируют шаблоны. И теперь Svelte пытается скомпилировать JS-код, чтобы он мог удалить самого себя из финального пакета.

Это, безусловно, разумно для многих случаев использования. Однако это не то, что нужно Odoo: Odoo извлекет шаблоны из базы данных, и их нужно будет скомпилировать только в самый последний момент, чтобы мы могли применить весь необходимый xpath.

Более того: Odoo должен иметь возможность генерировать (и компилировать) шаблоны во время выполнения. В настоящее время представления форм Odoo интерпретируют xml-описание. Но код представления формы тогда должен выполнять множество сложных операций. С помощью Owl мы сможем преобразовать описание представления в шаблон QWeb, затем скомпилировать его и немедленно использовать.

Реактивность

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

И, безусловно, бывают ситуации, когда нам нужно состояние, которое не доступно только для чтения и не наблюдается. Например, представьте себе компонент электронной таблицы. У него может быть очень большое внутреннее состояние, и он точно знает, когда его нужно отобразить (в основном, всякий раз, когда пользователь выполняет какое-то действие). Тогда наблюдение за его состоянием приводит к чистой потере производительности, как для процессора, так и для памяти.

Конкурентное исполнение

Многие приложения рады просто отображать счетчик всякий раз, когда выполняется новое асинхронное действие, но Odoo хочет другого пользовательского интерфейса: большинство асинхронных изменений состояния не отображаются до готовности. Это иногда называют конкурентным режимом: пользовательский интерфейс отображается в памяти и отображается только тогда, когда он готов (и только если он не был отменен последующими действиями пользователя).

У React теперь есть экспериментальный конкурентный режим, но он не был готов, когда Owl запустился. У Vue на самом деле нет эквивалентного API (suspense - это не то, что нам нужно).

Кроме того, конкурентный режим React сложен в использовании. Параллелизм был одной из редких сильных сторон прежнего фреймворка Odoo js (виджеты), и мы считаем, что Owl теперь имеет очень сильный конкурентный режим, который одновременно прост и мощен.

Вывод

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

Однако мы чувствуем, что в мире фреймворков все еще есть место для чего-то другого. Для фреймворка, который делает его совместимым с Odoo.

И именно поэтому мы создали Owl 🦉.