[REF] web: adapt code to owl 2

Owl 2 changelog: https://github.com/odoo/owl/blob/a9f29c4caad4f32d06be1ec4780572825781cd9b/CHANGELOG.md

Part-of: odoo/odoo#80156
Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Géry Debongnie <ged@odoo.com>
Co-authored-by: Samuel Degueldre <sad@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Simon Genin (ges) <ges@odoo.com>
Co-authored-by: Francois (fge) <fge@odoo.com>
Co-authored-by: Michael Mattiello (mcm) <mcm@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: luvi <luvi@odoo.com>
Co-authored-by: Lucas Perais (lpe) <lpe@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2022-02-10 07:46:12 +00:00
committed by Samuel Degueldre
co-authored by Aaron Bohy Bruno Boi Géry Debongnie Samuel Degueldre Simon Genin Francois Michael Mattiello Julien Mougenot luvi Lucas Perais Jorge Pinna Puissant
parent 966abc39b5
commit 6d13271aac
297 changed files with 6038 additions and 5688 deletions
+6 -2
View File
@@ -359,7 +359,7 @@ This module provides the core of the Odoo Web Client.
'web/static/lib/underscore.string/lib/underscore.string.js',
'web/static/lib/moment/moment.js',
'web/static/lib/owl/owl.js',
'web/static/src/owl_next_adapter.js',
'web/static/src/owl2_compatibility/*.js',
'web/static/src/legacy/js/component_extension.js',
'web/static/lib/jquery/jquery.js',
'web/static/lib/jquery.ui/jquery-ui.js',
@@ -405,7 +405,6 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/js/core/ajax.js',
'web/static/src/legacy/js/core/browser_detection.js',
'web/static/src/legacy/js/core/bus.js',
'web/static/src/legacy/js/core/custom_hooks.js',
'web/static/src/legacy/js/core/class.js',
'web/static/src/legacy/js/core/collections.js',
'web/static/src/legacy/js/core/concurrency.js',
@@ -453,6 +452,11 @@ This module provides the core of the Odoo Web Client.
# first called in web.
'web/static/tests/legacy/helpers/test_utils_file.js'
],
# remove this bundle alongside the owl2 compatibility layer
'web.tests_assets_common': [
('include', 'web.assets_common'),
('after', 'web/static/src/owl2_compatibility/app.js', 'web/static/tests/owl2_compatibility_app.js'),
],
'web.tests_assets': [
'web/static/lib/qunit/qunit-2.9.1.css',
'web/static/lib/qunit/qunit-2.9.1.js',
+1 -1
View File
@@ -55,7 +55,7 @@ class AssetsLoadingError extends Error {}
* @param {string} url the url of the script
* @returns {Promise<true>} resolved when the script has been loaded
*/
const loadJS = memoize(function loadJS(url) {
export const loadJS = memoize(function loadJS(url) {
if (document.querySelector(`script[src="${url}"]`)) {
// Already in the DOM and wasn't loaded through this function
// Unfortunately there is no way to check whether a script has loaded
@@ -10,7 +10,7 @@
*/
let sessionStorage = window.sessionStorage;
let localStorage = owl.browser.localStorage;
let localStorage = window.localStorage;
try {
// Safari crashes in Private Browsing
localStorage.setItem("__localStorage__", "true");
@@ -20,9 +20,13 @@ try {
sessionStorage = makeRAMLocalStorage();
}
export const browser = Object.assign({}, owl.browser, {
export const browser = {
addEventListener: window.addEventListener.bind(window),
removeEventListener: window.removeEventListener.bind(window),
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
console: window.console,
@@ -32,7 +36,8 @@ export const browser = Object.assign({}, owl.browser, {
XMLHttpRequest: window.XMLHttpRequest,
localStorage,
sessionStorage,
});
fetch: window.fetch.bind(window),
};
Object.defineProperty(browser, "location", {
set(val) {
@@ -1,6 +1,6 @@
/** @odoo-module **/
const { Component, QWeb } = owl;
const { Component } = owl;
/**
* Custom checkbox
@@ -8,7 +8,7 @@ const { Component, QWeb } = owl;
* <CheckBox
* value="boolean"
* disabled="boolean"
* t-on-change="_onValueChange"
* onChange="_onValueChange"
* >
* Change the label text
* </CheckBox>
@@ -24,5 +24,6 @@ export class CheckBox extends Component {
CheckBox.template = "web.CheckBox";
CheckBox.nextId = 1;
QWeb.registerComponent("CheckBox", CheckBox);
CheckBox.defaultProps = {
onChange: () => {},
};
@@ -9,6 +9,7 @@
class="custom-control-input"
t-att-disabled="props.disabled"
t-att-checked="props.value"
t-on-change="(ev) => props.onChange(ev.target.checked)"
/>
<label t-att-for="props.id or id" class="custom-control-label">
<t t-slot="default"/>
@@ -1,6 +1,8 @@
/** @odoo-module **/
import { useEffect, useService } from "@web/core/utils/hooks";
import { useService } from "@web/core/utils/hooks";
const { useEffect } = owl;
/**
* @typedef {import("./command_service").CommandOptions} CommandOptions
@@ -11,7 +11,7 @@
<div class="o_command_hotkey">
<t t-slot="name"/>
<span>
<t t-foreach="getKeysToPress(props)" t-as="key">
<t t-foreach="getKeysToPress(props)" t-as="key" t-key="key_index">
<kbd t-esc="key" />
<span t-if="!key_last"> + </span>
</t>
@@ -4,20 +4,19 @@
<t t-name="web.CommandPalette" owl="1">
<div>
<div class="o_command_palette_search">
<input type="text" t-model="state.searchValue" t-on-input="onSearchInput" autofocus="" t-att-placeholder="state.placeholder"/>
<input type="text" t-model="state.searchValue" t-on-input="onSearchInput" t-ref="autofocus" t-att-placeholder="state.placeholder"/>
<i t-att-title="state.placeholder" role="img" t-att-aria-label="state.placeholder" class="fa fa-search"></i>
</div>
<div t-ref="listbox" class="o_command_palette_listbox">
<div t-if="!state.commands.length" class="o_command_palette_listbox_empty" t-esc="state.emptyMessage"/>
<t t-if="!isFuzzySearch">
<t t-foreach="commandsByCategory" t-as="category">
<div class="o_command_category" t-key="category.keyId">
<t t-foreach="category.commands" t-as="command">
<t t-foreach="commandsByCategory" t-as="category" t-key="category.keyId">
<div class="o_command_category">
<t t-foreach="category.commands" t-as="command" t-key="command.keyId">
<t t-set="commandIndex" t-value="state.commands.indexOf(command)" />
<div t-attf-id="o_command_{{commandIndex}}" class="o_command" t-att-class="{ focused: state.selectedCommand === command }" t-on-click="onCommandClicked(commandIndex)" t-on-mouseenter="onCommandMouseEnter(commandIndex)" t-key="command.keyId">
<t t-component="command.Component || DefaultCommandItem" name="command.name" searchValue="clearSearchValue" t-props="command.props" t-on-close="props.closeMe()" t-on-execute-command="executeCommand(command)">
<div t-attf-id="o_command_{{commandIndex}}" class="o_command" t-att-class="{ focused: state.selectedCommand === command }" t-on-click="() => this.onCommandClicked(commandIndex)" t-on-mouseenter="() => this.onCommandMouseEnter(commandIndex)" t-on-close="() => this.props.closeMe()" t-on-execute-command="() => this.executeCommand(command)">
<t t-component="command.Component || DefaultCommandItem" name="command.name" searchValue="clearSearchValue" t-props="command.props">
<t t-set-slot="name">
<span class="o_command_name">
<t t-foreach="command.splitName" t-as="name" t-key="name_index">
@@ -47,7 +47,7 @@ class DefaultFooter extends Component {
DefaultFooter.template = xml`
<span>
<span class='o_promote'>TIP</span> — search for
<t t-foreach="elements" t-as="element">
<t t-foreach="elements" t-as="element" t-key="element.namespace">
<t t-if="!(element_first || element_last)">, </t>
<t t-if="element_last and !element_first"> and </t>
<span t-esc="element.namespace" class='o_promote'/><t t-esc="element.name"/>
@@ -23,17 +23,18 @@ export class ConfirmationDialog extends Dialog {
}
}
ConfirmationDialog.props = {
...Dialog.props,
title: {
validate: (m) => {
return (
typeof m === "string" || (typeof m === "object" && typeof m.toString === "function")
);
},
optional: true,
},
body: String,
confirm: { type: Function, optional: true },
cancel: { type: Function, optional: true },
close: Function,
};
ConfirmationDialog.defaultProps = {
title: _lt("Confirmation"),
@@ -4,7 +4,15 @@ import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
import { useAutofocus } from "@web/core/utils/hooks";
const { Component, useExternalListener, useRef, useState } = owl;
const {
Component,
onMounted,
onWillUpdateProps,
onWillUnmount,
useExternalListener,
useRef,
useState,
} = owl;
const { DateTime } = luxon;
const formatters = registry.category("formatters");
@@ -53,9 +61,13 @@ export class DatePicker extends Component {
useAutofocus();
useExternalListener(window, "scroll", this.onWindowScroll);
onMounted(this.onMounted);
onWillUpdateProps(this.onWillUpdateProps);
onWillUnmount(this.onWillUnmount);
}
mounted() {
onMounted() {
window.$(this.el).on("show.datetimepicker", () => this.inputRef.el.select());
window.$(this.el).on("hide.datetimepicker", () => this.onDateChange());
window.$(this.el).on("error.datetimepicker", () => false); // Ignores datepicker errors
@@ -64,7 +76,7 @@ export class DatePicker extends Component {
this.updateInput();
}
willUpdateProps(nextProps) {
onWillUpdateProps(nextProps) {
const pickerParams = {};
for (const prop in nextProps) {
if (this.props[prop] !== nextProps[prop]) {
@@ -78,7 +90,7 @@ export class DatePicker extends Component {
this.bootstrapDateTimePicker(pickerParams);
}
willUnmount() {
onWillUnmount() {
window.$(this.el).off(); // Removes all jQuery events
this.bootstrapDateTimePicker("destroy");
@@ -229,7 +241,7 @@ DatePicker.props = {
},
optional: true,
},
calendarWeeks: Boolean,
calendarWeeks: { type: Boolean, optional: true },
format: { type: String, optional: true },
icons: {
type: Object,
@@ -244,14 +256,15 @@ DatePicker.props = {
today: String,
up: String,
},
optional: true,
},
keyBinds: { validate: (kb) => typeof kb === "object" || kb === null, optional: true },
locale: { type: String, optional: true },
maxDate: DateTime,
minDate: DateTime,
maxDate: { type: DateTime, optional: true },
minDate: { type: DateTime, optional: true },
readonly: { type: Boolean, optional: true },
useCurrent: Boolean,
widgetParent: String,
useCurrent: { type: Boolean, optional: true },
widgetParent: { type: String, optional: true },
};
DatePicker.template = "web.DatePicker";
@@ -6,7 +6,6 @@
<div class="o_datepicker" aria-atomic="true" t-att-id="datePickerId" data-target-input="nearest">
<input type="text"
class="o_datepicker_input o_input datetimepicker-input"
autofocus=""
t-att-name="props.name"
t-att-placeholder="props.placeholder"
t-attf-data-target="#{{ datePickerId }}"
@@ -2,9 +2,8 @@
import { registry } from "../registry";
import { memoize } from "../utils/functions";
import { useEffect } from "@web/core/utils/hooks";
const { useEnv, useSubEnv } = owl;
const { useEffect, useEnv, useSubEnv } = owl;
const debugRegistry = registry.category("debug");
const getAccessRights = memoize(async function getAccessRights(orm) {
@@ -1,5 +1,7 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { DebugMenuBasic } from "@web/core/debug/debug_menu_basic";
import { useCommand } from "@web/core/commands/command_hook";
import { useService } from "@web/core/utils/hooks";
@@ -53,3 +55,4 @@ export class DebugMenu extends DebugMenuBasic {
);
}
}
DebugMenu.components = { Dropdown, DropdownItem };
@@ -2,10 +2,11 @@
<templates xml:space="preserve">
<t t-name="web.DebugMenu" owl="1">
<Dropdown class="o_debug_manager"
<Dropdown class="'o_debug_manager'"
beforeOpen="getElements"
position="'bottom-end'"
togglerClass="`o-dropdown--narrow ${env.inDialog?'btn btn-link':''}`">
togglerClass="`o-dropdown--narrow ${env.inDialog?'btn btn-link':''}`"
>
<t t-set-slot="toggler">
<i class="oi oi-large oi-debug"/>
</t>
@@ -14,8 +15,9 @@
t-if="element.type == 'item'"
onSelected="element.callback"
href="element.href"
t-esc="element.description"
/>
>
<t t-esc="element.description"/>
</DropdownItem>
<div t-if="element.type == 'separator'" role="separator" class="dropdown-divider"/>
<t t-if="element.type == 'component'" t-component="element.Component" t-props="element.props"/>
</t>
@@ -1,6 +1,8 @@
/** @odoo-module **/
import { useEnvDebugContext } from "./debug_context";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const { Component } = owl;
@@ -13,4 +15,8 @@ export class DebugMenuBasic extends Component {
};
}
}
DebugMenuBasic.components = {
Dropdown,
DropdownItem,
};
DebugMenuBasic.template = "web.DebugMenu";
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.DebugMenu.SetDefaultFooter" owl="1">
<button class="btn btn-secondary" t-on-click="trigger('dialog-closed')">Close</button>
<button class="btn btn-secondary" t-on-click="() => this.trigger('dialog-closed')">Close</button>
<button class="btn btn-secondary" t-on-click="saveDefault">Save default</button>
</t>
@@ -14,7 +14,7 @@ export class ProfilingItem extends Component {
changeParam(param, ev) {
this.profiling.setParam(param, ev.target.value);
}
toggleParam(param, ev) {
toggleParam(param) {
const value = this.profiling.state.params.execution_context_qweb;
this.profiling.setParam(param, !value);
}
@@ -7,18 +7,18 @@
<div class="align-self-center">
<div t-if="profiling.state.isEnabled" class="alert alert-info py-2 mr-3">Recording...</div>
<span class="o_profiling_switch">
<span class="custom-control custom-switch" t-on-click.stop.prevent="profiling.toggleProfiling()">
<span class="custom-control custom-switch" t-on-click.stop.prevent="() => this.profiling.toggleProfiling()">
<input type="checkbox" class="custom-control-input" id="enable_profiling" t-att-checked="profiling.state.isEnabled"/>
<label class="custom-control-label">Enable profiling</label>
</span>
</span>
<t t-if="profiling.state.isEnabled">
<span class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="profiling.toggleCollector('sql')">
<span class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="() => this.profiling.toggleCollector('sql')">
<input type="checkbox" class="custom-control-input" id="profile_sql"
t-att-checked="profiling.isCollectorEnabled('sql')"/>
<label class="custom-control-label" for="profile_sql">Record sql</label>
</span>
<span class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="profiling.toggleCollector('traces_async')">
<span class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="() => this.profiling.toggleCollector('traces_async')">
<input type="checkbox" class="custom-control-input" id="profile_traces_async"
t-att-checked="profiling.isCollectorEnabled('traces_async')"/>
<label class="custom-control-label" for="profile_traces_async">Record traces</label>
@@ -27,7 +27,7 @@
<div class="input-group-prepend">
<div class="input-group-text">Interval</div>
</div>
<select class="profile_param form-control" t-on-change="changeParam('traces_async_interval')">
<select class="profile_param form-control" t-on-change="(ev) => this.changeParam('traces_async_interval', ev)">
<t t-set="interval" t-value="profiling.state.params.traces_async_interval"/>
<option value="">Default</option>
<option value="0.001" t-att-selected="interval === '0.001'">0.001</option>
@@ -36,12 +36,12 @@
<option value="1" t-att-selected="interval === '1'">1</option>
</select>
</div>
<span t-if="profiling.isCollectorEnabled('sql') || profiling.isCollectorEnabled('traces_async')" class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="toggleParam('execution_context_qweb')">
<span t-if="profiling.isCollectorEnabled('sql') || profiling.isCollectorEnabled('traces_async')" class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="(ev) => this.toggleParam('execution_context_qweb', ev)">
<input type="checkbox" class="custom-control-input" id="profile_execution_context_qweb"
t-att-checked="!!profiling.state.params.execution_context_qweb"/>
<label class="custom-control-label" for="profile_execution_context_qweb">Add qweb directive context</label>
</span>
<span class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="profiling.toggleCollector('qweb')">
<span class="o_profiling_switch custom-control custom-switch mt-2" t-on-click.stop.prevent="() => this.profiling.toggleCollector('qweb')">
<input type="checkbox" class="custom-control-input" id="profile_qweb"
t-att-checked="profiling.isCollectorEnabled('qweb')"/>
<label class="custom-control-label" for="profile_qweb">Record qweb</label>
@@ -24,7 +24,15 @@ const profilingService = {
const debugItem = document.querySelector(".o_debug_manager .dropdown-toggle");
if (state.isEnabled) {
recordingIcon = document.createElement("i");
recordingIcon.classList.add("o_recording", "badge", "badge-pill", "d-inline", "p-2", "bg-danger", "border");
recordingIcon.classList.add(
"o_recording",
"badge",
"badge-pill",
"d-inline",
"p-2",
"bg-danger",
"border"
);
debugItem.appendChild(recordingIcon);
} else if (recordingIcon) {
debugItem.removeChild(recordingIcon);
@@ -33,7 +41,7 @@ const profilingService = {
}
if (env.debug) {
env.bus.on("WEB_CLIENT_READY", null, updateDebugIcon);
env.bus.addEventListener("WEB_CLIENT_READY", updateDebugIcon);
}
async function setProfiling(params) {
+7 -2
View File
@@ -3,7 +3,7 @@
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { useActiveElement } from "../ui/ui_service";
const { Component, useRef, useSubEnv, xml } = owl;
const { Component, useRef, useChildSubEnv, xml } = owl;
export class Dialog extends Component {
setup() {
@@ -17,7 +17,7 @@ export class Dialog extends Component {
useHotkey("escape", () => {
this.close();
});
useSubEnv({ inDialog: true });
useChildSubEnv({ inDialog: true });
this.close = this.close.bind(this);
this.contentClass = this.constructor.contentClass;
this.fullscreen = this.constructor.fullscreen;
@@ -47,3 +47,8 @@ Dialog.technical = true;
Dialog.title = "Odoo";
Dialog.bodyTemplate = xml`<div/>`;
Dialog.footerTemplate = "web.DialogFooterDefault";
Dialog.props = {
close: Function,
isActive: { optional: true },
"*": true,
};
+1 -1
View File
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.Dialog" owl="1">
<div class="o_dialog">
<div class="o_dialog" t-att-class="{ o_inactive_modal: !props.isActive }" t-on-dialog-closed="close">
<div role="dialog" class="modal"
tabindex="-1"
t-att-class="{ o_technical_modal: technical, o_modal_full: fullscreen }"
@@ -6,7 +6,7 @@ const { Component, xml } = owl;
export class DialogContainer extends Component {
setup() {
this.props.bus.on("UPDATE", this, this.render);
this.props.bus.addEventListener("UPDATE", this.render.bind(this));
}
close(id) {
@@ -23,16 +23,18 @@ export class DialogContainer extends Component {
}
}
DialogContainer.components = { ErrorHandler, NotUpdatable };
//Legacy : The div wrapping the t-foreach, is placed to avoid owl to delete non-owl dialogs.
//This div can be removed after removing all legacy dialogs.
DialogContainer.template = xml`
<div class="o_dialog_container" t-att-class="{'modal-open': Object.keys(props.dialogs).length > 0}">
<t t-foreach="Object.values(props.dialogs)" t-as="dialog" t-key="dialog.id">
<NotUpdatable>
<ErrorHandler onError="(error) => this.handleError(error, dialog.id)">
<t t-component="dialog.class" t-props="dialog.props"
t-on-dialog-closed="dialog.props.close()"
t-att-class="{o_inactive_modal: !dialog_last}"/>
</ErrorHandler>
</NotUpdatable>
</t>
<div>
<t t-foreach="Object.values(props.dialogs)" t-as="dialog" t-key="dialog.id">
<NotUpdatable>
<ErrorHandler onError="(error) => this.handleError(error, dialog.id)">
<t t-component="dialog.class" t-props="dialog.props" isActive="dialog_last"/>
</ErrorHandler>
</NotUpdatable>
</t>
</div>
</div>
`;
`;
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { useBus, useEffect, useService } from "@web/core/utils/hooks";
import { useBus, useService } from "@web/core/utils/hooks";
import { usePosition } from "../position/position_hook";
import { useDropdownNavigation } from "./dropdown_navigation_hook";
import { localization } from "../l10n/localization";
@@ -8,12 +8,12 @@ import { localization } from "../l10n/localization";
const {
Component,
EventBus,
QWeb,
onWillStart,
useEffect,
useExternalListener,
useRef,
useState,
useSubEnv,
useChildSubEnv,
} = owl;
const DIRECTION_CARET_CLASS = {
@@ -59,7 +59,9 @@ export class Dropdown extends Component {
// Close on outside click listener
useExternalListener(window, "click", this.onWindowClicked);
// Listen to all dropdowns state changes
useBus(Dropdown.bus, "state-changed", this.onDropdownStateChanged);
useBus(Dropdown.bus, "state-changed", ({ detail }) =>
this.onDropdownStateChanged(detail)
);
}
// Set up UI active element related behavior ---------------------------
@@ -75,7 +77,7 @@ export class Dropdown extends Component {
// Set up nested dropdowns ---------------------------------------------
this.parentDropdown = this.env[DROPDOWN];
useSubEnv({
useChildSubEnv({
[DROPDOWN]: {
close: this.close.bind(this),
closeAllParents: () => {
@@ -273,6 +275,10 @@ export class Dropdown extends Component {
}
Dropdown.bus = new EventBus();
Dropdown.props = {
class: {
type: String,
optional: true,
},
toggler: {
type: String,
optional: true,
@@ -314,7 +320,9 @@ Dropdown.props = {
type: String,
optional: true,
},
slots: {
type: Object,
optional: true,
},
};
Dropdown.template = "web.Dropdown";
QWeb.registerComponent("Dropdown", Dropdown);
@@ -4,13 +4,20 @@
<t t-name="web.Dropdown" owl="1">
<div
class="o-dropdown dropdown"
t-att-class="{ show: state.open, 'o-dropdown--no-caret': !parentDropdown }"
t-attf-class="{{ directionCaretClass || '' }}"
t-att-class="props.class"
t-attf-class="
{{ directionCaretClass || ''}}
{{ state.open ? 'show' : ''}}
{{ !parentDropdown ? 'o-dropdown--no-caret' : '' }}
"
>
<button
t-if="props.toggler !== 'parent'"
class="dropdown-toggle"
t-attf-class="{{props.togglerClass || ''}} {{parentDropdown ? 'dropdown-item' : ''}}"
t-attf-class="
{{props.togglerClass || ''}}
{{parentDropdown ? 'dropdown-item' : ''}}
"
t-on-click="onTogglerClick"
t-on-mouseenter="onTogglerMouseEnter"
t-att-title="props.title"
@@ -20,7 +27,12 @@
>
<t t-slot="toggler" />
</button>
<div t-if="state.open" class="o-dropdown--menu dropdown-menu d-block" t-att-class="props.menuClass" t-ref="menuRef">
<div
t-if="state.open"
class="o-dropdown--menu dropdown-menu d-block"
t-att-class="props.menuClass"
t-ref="menuRef"
>
<t t-slot="default" />
</div>
</div>
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { DROPDOWN } from "./dropdown";
const { Component, QWeb } = owl;
const { Component } = owl;
/**
* @enum {string}
@@ -12,19 +12,6 @@ const ParentClosingMode = {
AllParents: "all",
};
/**
* @typedef DropdownItemSelectedEventDetail
* @property {*} payload
* @property {Object} dropdownClosingRequest
* @property {boolean} dropdownClosingRequest.isFresh
* @property {ParentClosingMode} dropdownClosingRequest.mode
*
* @typedef {CustomEvent<DropdownItemSelectedEventDetail>} DropdownItemSelectedEvent
*/
/**
* @extends Component
*/
export class DropdownItem extends Component {
/**
* Tells the parent dropdown that an item was selected and closes the
@@ -71,6 +58,10 @@ DropdownItem.props = {
type: Function,
optional: true,
},
class: {
type: [String, Object],
optional: true,
},
parentClosingMode: {
type: ParentClosingMode,
optional: true,
@@ -83,6 +74,10 @@ DropdownItem.props = {
type: String,
optional: true,
},
slots: {
type: Object,
optional: true,
},
title: {
type: String,
optional: true,
@@ -95,5 +90,3 @@ DropdownItem.props = {
DropdownItem.defaultProps = {
parentClosingMode: ParentClosingMode.AllParents,
};
QWeb.registerComponent("DropdownItem", DropdownItem);
@@ -6,6 +6,7 @@
t-tag="props.href ? 'a' : 'span'"
t-att-href="props.href"
class="dropdown-item"
t-att-class="props.class"
t-on-click="onClick"
t-att-title="props.title"
t-att-data-hotkey="props.hotkey"
@@ -1,11 +1,11 @@
/** @odoo-module */
import { useEffect, useService } from "@web/core/utils/hooks";
import { useService } from "@web/core/utils/hooks";
import { browser } from "../browser/browser";
import { localization } from "@web/core/l10n/localization";
import { scrollTo } from "../utils/scrolling";
const { useComponent, useRef } = owl;
const { useComponent, useEffect, useRef } = owl;
/**
* @typedef {{
@@ -1,14 +1,17 @@
/** @odoo-module **/
const { Component, xml } = owl;
const { Component, xml, onWillDestroy } = owl;
export class EffectContainer extends Component {
setup() {
this.effect = null;
this.props.bus.on("UPDATE", this, (effect) => {
this.effect = effect;
const listenerRef = this.props.bus.addEventListener("UPDATE", (ev) => {
this.effect = ev.detail;
this.render();
});
onWillDestroy(() => {
this.props.bus.removeEventListener("UPDATE", listenerRef);
});
}
removeEffect() {
this.effect = null;
@@ -4,7 +4,7 @@ import { registry } from "../registry";
import { EffectContainer } from "./effect_container";
import { RainbowMan } from "./rainbow_man";
const { Component, EventBus } = owl;
const { EventBus } = owl;
const effectRegistry = registry.category("effects");
@@ -25,8 +25,6 @@ const effectRegistry = registry.category("effects");
* Can be a string representation of html (prefer component if you want interactions in the DOM)
* @param {string} [params.img_url="/web/static/img/smile.svg"]
* The url of the image to display inside the rainbow
* @param {boolean} [params.messageIsHtml=false]
* Set to true if the message encodes html, s.t. it will be correctly inserted into the DOM.
* @param {"slow"|"medium"|"fast"|"no"} [params.fadeout="medium"]
* Delay for rainbowman to disappear
* 'fast' will make rainbowman dissapear quickly
@@ -58,7 +56,6 @@ function rainbowMan(env, params = {}) {
imgUrl: params.img_url || "/web/static/img/smile.svg",
fadeout: params.fadeout || "medium",
message,
messageIsHtml: params.messageIsHtml || false,
Component: params.Component,
props: params.props,
};
@@ -1,9 +1,8 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { useEffect } from "@web/core/utils/hooks";
const { Component, useExternalListener } = owl;
const { Component, useEffect, useExternalListener } = owl;
/**
* @typedef Common
@@ -16,7 +15,6 @@ const { Component, useExternalListener } = owl;
*
* @typedef Simple
* @property {string} message Message to be displayed on rainbowman card
* @property {boolean} [messageIsHtml=false]
*
* @typedef Custom
* @property {Component} Component
@@ -9,7 +9,7 @@
<path d="M310,170a140,140,0,0,0-280,0" style="stroke:#80D8FF;"/>
<path d="M330,170a160,160,0,0,0-320,0" style="stroke:#c794ba;"/>
</svg>
<div class="o_reward_stars" t-foreach="[1, 2, 3, 4]" t-as="star">
<div class="o_reward_stars" t-foreach="[1, 2, 3, 4]" t-as="star" t-key="star_index">
<t t-call="web.RainbowMan.Star">
<t t-set="class" t-value="star"/>
</t>
@@ -36,8 +36,7 @@
<div class="o_reward_msg_card">
<div class="o_reward_msg_content">
<t t-if="!props.Component">
<t t-if="props.messageIsHtml" t-raw="props.message" />
<t t-else="" t-esc="props.message" />
<t t-out="props.message"/>
</t>
<t t-else="" t-component="props.Component" t-props="props.props"/>
</div>
@@ -50,7 +50,7 @@
</div>
<button class="btn btn-link" t-on-click="state.showTraceback = !state.showTraceback">See details</button>
<button class="btn btn-link" t-on-click="() => { state.showTraceback = !state.showTraceback }">See details</button>
<div t-if="state.showTraceback and (props.name or props.message)" class="alert clearfix" style="background-color:#ececec;" role="alert">
<code t-if="props.name" t-esc="props.name"></code>
<p t-if="props.message" t-esc="props.message"></p>
@@ -2,7 +2,7 @@
import { useService } from "@web/core/utils/hooks";
const { Component, QWeb, useRef } = owl;
const { Component, useRef } = owl;
/**
* Custom file input
@@ -73,6 +73,7 @@ FileInput.defaultProps = {
accepted_file_extensions: "*",
action: "/web/binary/upload",
multi_upload: false,
onUpload: () => {},
};
FileInput.props = {
accepted_file_extensions: { type: String, optional: 1 },
@@ -80,7 +81,7 @@ FileInput.props = {
id: { type: Number, optional: 1 },
model: { type: String, optional: 1 },
multi_upload: { type: Boolean, optional: 1 },
onUpload: { type: Function, optional: 1 },
slots: { type: Object, optional: 1 },
};
FileInput.template = "web.FileInput";
QWeb.registerComponent("FileInput", FileInput);
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.FileInput" owl="1">
<span class="o_file_input" aria-atomic="true">
<span class="o_file_input" aria-atomic="true" t-on-uploaded="(ev) => props.onUpload(ev.detail.files)">
<span class="o_file_input_trigger" t-on-click.prevent="onTriggerClicked">
<t t-slot="default">
<button class="btn btn-primary">Choose File</button>
@@ -1,6 +1,8 @@
/** @odoo-module **/
import { useEffect, useService } from "@web/core/utils/hooks";
import { useService } from "@web/core/utils/hooks";
const { useEffect } = owl;
/**
* This hook will register/unregister the given registration
@@ -7,14 +7,9 @@ import { localization } from "./localization";
import { translatedTerms, _t } from "./translation";
import { session } from "@web/session";
const { config } = owl;
export const localizationService = {
dependencies: ["user"],
start: async (env, { user }) => {
// add "data-toolip" to the list of translatable attributes in owl templates
config.translatableAttributes.push("data-tooltip");
const cacheHashes = session.cache_hashes || {};
const translationsHash = cacheHashes.translations || new Date().getTime().toString();
const lang = user.lang || null;
@@ -42,7 +37,6 @@ export const localizationService = {
Object.setPrototypeOf(translatedTerms, terms);
env._t = _t;
env.qweb.translateFn = _t;
// Setup lang inside luxon. The locale codes received from the server contain "_", whereas
// the Intl codes use "-" (Unicode BCP 47). There's only one exception, which is locale
@@ -1,18 +1,8 @@
/** @odoo-module **/
const { Component, onWillUnmount } = owl;
const { Component } = owl;
export class Notification extends Component {
setup() {
if (this.props.onClose) {
onWillUnmount(() => this.props.onClose());
}
}
shouldUpdate() {
return false;
}
get className() {
let className;
switch (this.props.type) {
@@ -48,7 +38,6 @@ Notification.props = {
optional: true,
validate: (t) => ["warning", "danger", "success", "info"].includes(t),
},
messageIsHtml: { type: Boolean, optional: true },
className: { type: String, optional: true },
buttons: {
type: Array,
@@ -61,12 +50,12 @@ Notification.props = {
onClick: Function,
},
},
optional: true,
},
onClose: { type: Function, optional: true },
close: { type: Function },
};
Notification.defaultProps = {
buttons: [],
className: "",
messageIsHtml: false,
type: "warning",
};
@@ -6,17 +6,16 @@
role="alert" aria-live="assertive" aria-atomic="true">
<div class="o_notification_body">
<button type="button" class="close o_notification_close"
aria-label="Close" t-on-click="trigger('close')">
aria-label="Close" t-on-click="props.close">
<span class="d-inline" aria-hidden="true">×</span>
</button>
<div t-if="props.title" class="font-weight-bold o_notification_title mb-1" t-esc="props.title"/>
<div t-if="props.message" class="mr-auto o_notification_content">
<t t-if="props.messageIsHtml" t-raw="props.message"/>
<t t-else="" t-esc="props.message"/>
<t t-out="props.message"/>
</div>
<div t-if="props.buttons.length" class="mt-2 o_notification_buttons">
<button t-foreach="props.buttons" t-as="button" type="button"
t-attf-class="btn {{button.primary ? 'btn-primary' : 'btn-link'}}" t-on-click="button.onClick()">
<button t-foreach="props.buttons" t-as="button" type="button" t-key="button_index"
t-attf-class="btn {{button.primary ? 'btn-primary' : 'btn-link'}}" t-on-click="button.onClick">
<t t-if="button.icon">
<i t-if="button.icon.indexOf('fa-') === 0" role="img"
t-att-aria-label="button.name" t-att-title="button.name"
@@ -1,25 +1,22 @@
/** @odoo-module **/
import { Notification } from "./notification";
import { Transition } from "@web/core/transition";
const { Component, xml } = owl;
const { Component, xml, useState } = owl;
export class NotificationContainer extends Component {
setup() {
// this works, but then this component cannot be unmounted, then
// remounted. would need a destroyed hook different from willunmount
this.props.bus.on("UPDATE", this, this.render);
this.notifications = useState(this.props.notifications);
}
}
NotificationContainer.template = xml`
<div class="o_notification_manager">
<t t-foreach="props.notifications" t-as="notification" t-key="notification.id">
<Notification
t-props="notification.props"
t-transition="o_notification_fade"
t-on-close="notification.close()"
/>
<t t-foreach="notifications" t-as="notification" t-key="notification">
<Transition leaveDuration="0" name="'o_notification_fade'" t-slot-scope="transition">
<Notification t-props="notification_value.props" className="transition.className"/>
</Transition>
</t>
</div>`;
NotificationContainer.components = { Notification };
NotificationContainer.components = { Notification, Transition };
@@ -4,7 +4,7 @@ import { browser } from "../browser/browser";
import { registry } from "../registry";
import { NotificationContainer } from "./notification_container";
const { EventBus } = owl;
const { reactive } = owl;
const AUTOCLOSE_DELAY = 4000;
@@ -27,12 +27,11 @@ const AUTOCLOSE_DELAY = 4000;
export const notificationService = {
start() {
let notifId = 0;
let notifications = [];
const bus = new EventBus();
const notifications = reactive({});
registry.category("main_components").add("NotificationContainer", {
Component: NotificationContainer,
props: { bus, notifications },
props: { notifications },
});
/**
@@ -41,17 +40,17 @@ export const notificationService = {
*/
function add(message, options = {}) {
const id = ++notifId;
const props = Object.assign({}, options, { message });
const closeFn = () => close(id);
const props = Object.assign({}, options, { message, close: closeFn });
const sticky = props.sticky;
delete props.sticky;
const closeFn = () => close(id);
delete props.onClose;
const notification = {
id,
props,
close: closeFn,
onClose: options.onClose,
};
notifications.push(notification);
bus.trigger("UPDATE");
notifications[id] = notification;
if (!sticky) {
browser.setTimeout(closeFn, AUTOCLOSE_DELAY);
}
@@ -59,10 +58,12 @@ export const notificationService = {
}
function close(id) {
const index = notifications.findIndex((n) => n.id === id);
if (index > -1) {
notifications.splice(index, 1);
bus.trigger("UPDATE");
if (notifications[id]) {
const notification = notifications[id];
if (notification.onClose) {
notification.onClose();
}
delete notifications[id];
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ export class Pager extends Component {
this.inputRef = useRef("input");
useExternalListener(document, "mousedown", this.onClickAway, { capture: true });
this.forceFocus = useAutofocus();
this.forceFocus = useAutofocus("input");
}
/**
@@ -162,6 +162,6 @@ Pager.props = {
limit: Number,
total: Number,
onUpdate: Function,
isEditable: Boolean,
withAccessKey: Boolean,
isEditable: { type: Boolean, optional: true },
withAccessKey: { type: Boolean, optional: true },
};
+3 -3
View File
@@ -5,7 +5,7 @@
<nav class="o_pager" aria-label="Pager">
<span class="o_pager_counter mr-2" t-on-click.stop="">
<t t-if="state.isEditing">
<input type="text" class="o_pager_value o_input" t-ref="input" autofocus="" t-att-value="value" t-on-blur="onInputBlur" t-on-change="onInputChange" t-on-keydown.stop="onInputKeydown" />
<input type="text" class="o_pager_value o_input" t-ref="input" t-att-value="value" t-on-blur="onInputBlur" t-on-change="onInputChange" t-on-keydown.stop="onInputKeydown" />
</t>
<t t-else="">
<span class="o_pager_value" t-esc="value" t-on-click="onValueClick" />
@@ -15,8 +15,8 @@
</span>
<span class="btn-group" aria-atomic="true">
<!-- accesskeys not wanted in X2Many widgets -->
<button type="button" class="fa fa-chevron-left btn btn-secondary o_pager_previous px-2 rounded-left" aria-label="Previous" data-tooltip="Previous" tabindex="-1" t-att-data-hotkey="props.withAccessKey ? 'p' : false" t-att-disabled="state.isDisabled or isSinglePage" t-on-click.stop="navigate(-1)" />
<button type="button" class="fa fa-chevron-right btn btn-secondary o_pager_next px-2 rounded-right" aria-label="Next" data-tooltip="Next" tabindex="-1" t-att-data-hotkey="props.withAccessKey ? 'n' : false" t-att-disabled="state.isDisabled or isSinglePage" t-on-click.stop="navigate(1)" />
<button type="button" class="fa fa-chevron-left btn btn-secondary o_pager_previous px-2 rounded-left" aria-label="Previous" data-tooltip="Previous" tabindex="-1" t-att-data-hotkey="props.withAccessKey ? 'p' : false" t-att-disabled="state.isDisabled or isSinglePage" t-on-click.stop="() => this.navigate(-1)" />
<button type="button" class="fa fa-chevron-right btn btn-secondary o_pager_next px-2 rounded-right" aria-label="Next" data-tooltip="Next" tabindex="-1" t-att-data-hotkey="props.withAccessKey ? 'n' : false" t-att-disabled="state.isDisabled or isSinglePage" t-on-click.stop="() => this.navigate(1)" />
</span>
</nav>
</t>
@@ -18,6 +18,7 @@ Popover.defaultProps = {
position: "bottom",
};
Popover.props = {
close: { type: Function },
popoverClass: {
optional: true,
type: String,
@@ -25,6 +26,14 @@ Popover.props = {
position: {
type: String,
validate: (p) => ["top", "bottom", "left", "right"].includes(p),
optional: true,
},
target: HTMLElement,
slots: {
type: Object,
optional: true,
shape: {
default: { optional: true },
},
},
};
@@ -5,6 +5,7 @@
<div role="tooltip" class="o_popover"
t-att-class="props.popoverClass"
t-on-popover-compute="compute"
t-on-popover-closed="props.close"
>
<t t-slot="default" />
</div>
@@ -2,28 +2,26 @@
import { Popover } from "./popover";
const { Component, useExternalListener, useState, xml } = owl;
const { Component, onMounted, onWillUnmount, useExternalListener, useState, xml } = owl;
class PopoverController extends Component {
setup() {
this.state = useState({ displayed: false });
this.targetObserver = new MutationObserver(this.onTargetMutate.bind(this));
useExternalListener(window, "click", this.onClickAway, { capture: true });
onMounted(this.onMounted);
onWillUnmount(this.onWillUnmount);
}
mounted() {
onMounted() {
this.targetObserver.observe(this.target.parentElement, { childList: true });
}
willUnmount() {
onWillUnmount() {
this.targetObserver.disconnect();
}
shouldUpdate() {
return false;
}
get popoverProps() {
return {
close: this.props.close,
target: this.target,
position: this.props.position,
popoverClass: this.props.popoverClass,
@@ -57,14 +55,14 @@ PopoverController.defaultProps = {
closeOnClickAway: true,
};
PopoverController.template = xml/*xml*/ `
<Popover t-props="popoverProps" t-on-popover-closed="props.close()">
<t t-component="props.Component" t-props="props.props" />
<Popover t-props="popoverProps">
<t t-component="props.Component" t-props="props.props"/>
</Popover>
`;
export class PopoverContainer extends Component {
setup() {
this.props.bus.on("UPDATE", this, this.render);
this.props.bus.addEventListener("UPDATE", this.render.bind(this));
}
}
PopoverContainer.components = { PopoverController };
@@ -2,7 +2,7 @@
import { useService } from "@web/core/utils/hooks";
const { onWillUnmount, useComponent } = owl;
const { onWillUnmount, status, useComponent } = owl;
export function usePopover() {
const removeFns = new Set();
@@ -33,7 +33,7 @@ export function usePopover() {
const newOptions = Object.create(options);
newOptions.onClose = function () {
removeFns.delete(removeFn);
if (options.onClose && component.__owl__.status !== 5 /* DESTROYED */) {
if (options.onClose && status(component) !== "destroyed") {
options.onClose();
}
};
@@ -1,9 +1,8 @@
/** @odoo-module */
import { useEffect } from "@web/core/utils/hooks";
import { throttleForAnimation } from "../utils/timing";
const { onWillUnmount, useComponent, useExternalListener, useRef } = owl;
const { onWillUnmount, useComponent, useEffect, useExternalListener, useRef } = owl;
/**
* @typedef {{
+1 -1
View File
@@ -29,7 +29,7 @@ export class Registry extends EventBus {
this.elements = null;
this.entries = null;
this.on("UPDATE", this, () => {
this.addEventListener("UPDATE", () => {
this.elements = null;
this.entries = null;
});
+138
View File
@@ -0,0 +1,138 @@
/** @odoo-module */
import { browser } from "./browser/browser";
const { Component, useState, useEffect, xml, onWillUpdateProps, useComponent } = owl;
// Allows to disable transitions globally, useful for testing (and maybe for
// a reduced motion setting in the future?)
export const config = {
disabled: false,
};
/**
* Creates a transition to be used within the current component. Usage:
* --- in JS:
* this.transition = useTransition({ name: "myClass" });
* --- in XML:
* <div t-if="transition.shouldMount" t-att-class="transition.class"/>
*
* @param {Object} options
* @param {string} options.name the prefix to use for the transition classes
* @param {boolean} [options.onOff] whether to start the transition in the on or
* off state
* @param {number} [options.leaveDuration] the leaveDuration of the transition
* @param {Function} [options.onLeave] a function that will be called when the
* element will be removed in the next render cycle
* @returns {{ shouldMount, class }} an object containing two fields that
* indicate whether an element on which the transition is applied should be
* mounted and the class string that should be put on it
*/
export function useTransition({
name,
initialVisibility = true,
leaveDuration = 500,
onLeave = () => {},
}) {
const component = useComponent();
const state = useState({
shouldMount: initialVisibility,
stage: initialVisibility ? "enter" : "leave",
});
if (config.disabled) {
return {
get shouldMount() {
return state.shouldMount;
},
set shouldMount(val) {
state.shouldMount = val;
},
get className() {
return `${name} ${name}-enter-active`;
},
get stage() {
return "enter-active";
},
};
}
// We need to allow the element to be mounted in the enter state so that it
// will get the transition when we activate the enter-active class. This
// onNextPatch allows us to activate the class that we want the next time
// the component is patched.
let onNextPatch = null;
useEffect(() => {
if (onNextPatch) {
onNextPatch();
onNextPatch = null;
}
});
let prevState, timer;
const transition = {
get shouldMount() {
return state.shouldMount;
},
set shouldMount(newState) {
if (newState === prevState) {
return;
}
browser.clearTimeout(timer);
prevState = newState;
// when true - transition from enter to enter-active
// when false - transition from enter-active to leave, unmount after leaveDuration
if (newState) {
state.stage = "enter";
state.shouldMount = true;
// force a render here so that we get a patch even if the state didn't change
component.render();
onNextPatch = () => {
state.stage = "enter-active";
};
} else {
state.stage = "leave";
timer = browser.setTimeout(() => {
state.shouldMount = false;
onLeave();
}, leaveDuration);
}
},
get className() {
return `${name} ${name}-${state.stage}`;
},
get stage() {
return state.stage;
},
};
transition.shouldMount = initialVisibility;
return transition;
}
/**
* A higher order component that handles a transition to be used within its
* default slot. Generally, the useTransition hook is simpler to use, but the
* HOC has the advantage that it can be spawned as needed during the render (eg:
* in a t-foreach loop) without knowing at setup-time how many transitions need
* to be created. @see useTransition
*/
export class Transition extends Component {
setup() {
const { visible, leaveDuration, name, onLeave } = this.props;
this.transition = useTransition({
initialVisibility: visible,
leaveDuration,
name,
onLeave,
});
onWillUpdateProps(({ visible = true }) => {
this.transition.shouldMount = visible;
});
}
}
Transition.template = xml`<t t-slot="default" t-if="transition.shouldMount" className="transition.className"/>`;
Transition.props = {
name: String,
visible: { type: Boolean, optional: true },
leaveDuration: { type: Number, optional: true },
onLeave: { type: Function, optional: true },
slots: Object,
};
+2 -2
View File
@@ -42,8 +42,8 @@ export class BlockUI extends Component {
line2: "",
});
this.props.bus.on("BLOCK", this, this.block);
this.props.bus.on("UNBLOCK", this, this.unblock);
this.props.bus.addEventListener("BLOCK", this.block.bind(this));
this.props.bus.addEventListener("UNBLOCK", this.unblock.bind(this));
}
replaceMessage(index) {
+9 -7
View File
@@ -1,12 +1,12 @@
/** @odoo-module **/
import { useEffect, useService } from "@web/core/utils/hooks";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { debounce } from "@web/core/utils/timing";
import { BlockUI } from "./block_ui";
import { browser } from "@web/core/browser/browser";
const { Component, EventBus, useRef } = owl;
const { EventBus, useComponent, useEffect, useRef } = owl;
export const SIZES = { XS: 0, VSM: 1, SM: 2, MD: 3, LG: 4, XL: 5, XXL: 6 };
@@ -23,13 +23,15 @@ export const SIZES = { XS: 0, VSM: 1, SM: 2, MD: 3, LG: 4, XL: 5, XXL: 6 };
*/
export function useActiveElement(refName = null) {
const uiService = useService("ui");
const owner = refName ? useRef(refName) : Component.current;
const owner = refName ? useRef(refName) : useComponent();
useEffect(
() => {
uiService.activateElement(owner.el);
return () => uiService.deactivateElement(owner.el);
(el) => {
if (el) {
uiService.activateElement(el);
return () => uiService.deactivateElement(el);
}
},
() => []
() => [owner.el]
);
}
@@ -1,19 +1,22 @@
/** @odoo-module **/
const { Component, xml } = owl;
const { Component, onError, useComponent, xml } = owl;
export class NotUpdatable extends Component {
shouldUpdate() {
return false;
setup() {
const node = useComponent().__owl__;
node.patch = () => {};
node.updateAndRender = () => Promise.resolve();
}
}
NotUpdatable.template = xml`<t t-slot="default" />`;
export class ErrorHandler extends Component {
catchError(error) {
if (this.props.onError) {
setup() {
onError((error) => {
this.props.onError(error);
}
});
}
}
ErrorHandler.template = xml`<t t-slot="default" />`;
ErrorEvent.props = ["onError"];
+23 -86
View File
@@ -2,7 +2,7 @@
import { SERVICES_METADATA } from "@web/env";
const { onMounted, onPatched, onWillPatch, onWillUnmount, useComponent } = owl;
const { status, useComponent, useEffect, useRef } = owl;
/**
* This file contains various custom hooks.
@@ -24,37 +24,36 @@ const { onMounted, onPatched, onWillPatch, onWillUnmount, useComponent } = owl;
// -----------------------------------------------------------------------------
/**
* Focus a given selector as soon as it appears in the DOM and if it was not
* displayed before. If the selected target is an input|textarea, set the selection
* Focus a dom element with provided ref as soon as it appears in the DOM and if it was not
* displayed before. If it is an input|textarea, set the selection
* at the end.
*
* @param {Object} [params]
* @param {string} [params.selector='autofocus'] default: select the first element
* with an `autofocus` attribute.
* @param {string} [refName="autofocus"]
* @returns {Function} function that forces the focus on the next update if visible.
*/
export function useAutofocus(params = {}) {
export function useAutofocus(refName = "autofocus") {
const comp = useComponent();
// Prevent autofocus in mobile
if (comp.env.isSmall) {
return () => {};
}
const selector = params.selector || "[autofocus]";
// LEGACY
if (comp.env.device && comp.env.device.isMobileDevice) {
return () => {};
}
// LEGACY
let ref = useRef(refName);
let forceFocusCount = 0;
useEffect(
function autofocus() {
const target = comp.el.querySelector(selector);
if (target) {
target.focus();
if (["INPUT", "TEXTAREA"].includes(target.tagName)) {
const inputEl = target;
inputEl.selectionStart = inputEl.selectionEnd = inputEl.value.length;
(el) => {
if (el) {
el.focus();
if (["INPUT", "TEXTAREA"].includes(el.tagName)) {
el.selectionStart = el.selectionEnd = el.value.length;
}
}
},
() => [forceFocusCount]
() => [ref.el, forceFocusCount]
);
return function focusOnUpdate() {
forceFocusCount++; // force the effect to rerun on next patch
};
@@ -75,62 +74,14 @@ export function useBus(bus, eventName, callback) {
const component = useComponent();
useEffect(
() => {
bus.on(eventName, component, callback);
return () => bus.off(eventName, component);
const listener = callback.bind(component);
bus.addEventListener(eventName, listener);
return () => bus.removeEventListener(eventName, listener);
},
() => []
);
}
// -----------------------------------------------------------------------------
// useEffect
// -----------------------------------------------------------------------------
const NO_OP = () => {};
/**
* @callback Effect
* @param {...any} dependencies the dependencies computed by computeDependencies
* @returns {void|(()=>void)} a cleanup function that reverses the side
* effects of the effect callback.
*/
/**
* This hook will run a callback when a component is mounted and patched, and
* will run a cleanup function before patching and before unmounting the
* the component.
*
* @param {Effect} effect the effect to run on component mount and/or patch
* @param {()=>any[]} [computeDependencies=()=>[NaN]] a callback to compute
* dependencies that will decide if the effect needs to be cleaned up and
* run again. If the dependencies did not change, the effect will not run
* again. The default value returns an array containing only NaN because
* NaN !== NaN, which will cause the effect to rerun on every patch.
*/
export function useEffect(effect, computeDependencies = () => [NaN]) {
let cleanup, dependencies;
onMounted(() => {
dependencies = computeDependencies();
cleanup = effect(...dependencies) || NO_OP;
});
let shouldReapplyOnPatch = false;
onWillPatch(() => {
const newDeps = computeDependencies();
shouldReapplyOnPatch = newDeps.some((val, i) => val !== dependencies[i]);
if (shouldReapplyOnPatch) {
cleanup();
dependencies = newDeps;
}
});
onPatched(() => {
if (shouldReapplyOnPatch) {
cleanup = effect(...dependencies) || NO_OP;
}
});
onWillUnmount(() => cleanup());
}
// -----------------------------------------------------------------------------
// useListener
// -----------------------------------------------------------------------------
@@ -157,7 +108,7 @@ export function useEffect(effect, computeDependencies = () => [NaN]) {
*
* useListener('click', 'button', () => { console.log('clicked'); });
*
* Note: components that alter the event's target (e.g. Portal) are not
* Note: components that alter the event's target and the t-portal directive are not
* expected to behave as expected with event delegation.
*
* @param {string} eventName the name of the event
@@ -215,11 +166,11 @@ export function useListener(eventName, querySelector, handler, options = {}) {
function _protectMethod(component, caller, fn) {
return async (...args) => {
if (component.__owl__.status === 5 /* DESTROYED */) {
if (status(component) === "destroyed") {
throw new Error("Component is destroyed");
}
const result = await fn.call(caller, ...args);
return component.__owl__.status === 5 ? new Promise(() => {}) : result;
return status(component) === "destroyed" ? new Promise(() => {}) : result;
};
}
@@ -250,17 +201,3 @@ export function useService(serviceName) {
}
return service;
}
/**
* Executes "callback" when the component is being destroyed
* @param {Function} callback
*/
export function onDestroyed(callback) {
const component = useComponent();
const _destroy = component.__destroy;
component.__destroy = (...args) => {
_destroy.call(component, ...args);
// callback is called after super to guarantee the component is actually destroyed
callback();
};
}
@@ -0,0 +1,23 @@
/** @odoo-module **/
const { blockDom } = owl;
/**
* loads a template using getTemplate, and render the template immediately;
*
* @param {string} template
* @param {Object} context
* @returns string: the html of the template
*/
export function renderToString(template, context = {}) {
const app = renderToString.app;
if (!app) {
throw new Error("an app must be configured before using renderToString");
}
const templateFn = app.getTemplate(template);
const bdom = templateFn(context, {});
const div = document.createElement("div");
blockDom.mount(bdom, div);
return div.innerHTML;
}
@@ -1,5 +1,24 @@
/** @odoo-module **/
/**
* Escapes a string for HTML.
* Note that it doesn't work for escaping node attributes.
*
* @param {string | number} [str] the string to escape
* @returns an escaped string
*/
export function escape(str) {
if (str === undefined) {
return "";
}
if (typeof str === "number") {
return String(str);
}
const p = document.createElement("p");
p.textContent = str;
return p.innerHTML;
}
/**
* Escapes a string to use as a RegExp.
* @url https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
@@ -89,6 +108,7 @@ export function capitalize(s) {
return s ? s[0].toUpperCase() + s.slice(1) : "";
}
// prettier-ignore
const diacriticsMap = {
'\u0041': 'A','\u24B6': 'A','\uFF21': 'A','\u00C0': 'A','\u00C1': 'A','\u00C2': 'A','\u1EA6': 'A','\u1EA4': 'A','\u1EAA': 'A','\u1EA8': 'A',
'\u00C3': 'A','\u0100': 'A','\u0102': 'A','\u1EB0': 'A','\u1EAE': 'A','\u1EB4': 'A','\u1EB2': 'A','\u0226': 'A','\u01E0': 'A','\u00C4': 'A',
@@ -9,16 +9,11 @@
xml: <tag t-transition="o_notification_fade" />
*/
@mixin owl-fade($time, $name) {
.#{$name}_fade-enter-active,
.#{$name}_fade-active {
.#{$name}_fade {
transition: all $time;
}
.#{$name}_fade-enter {
opacity: 0;
}
.#{$name}_fade-leave-to {
opacity: 0;
}
}
+3 -4
View File
@@ -2,7 +2,7 @@
import { registry } from "./core/registry";
const { EventBus, QWeb } = owl;
const { EventBus } = owl;
// -----------------------------------------------------------------------------
// Types
@@ -29,7 +29,6 @@ const { EventBus, QWeb } = owl;
*/
export function makeEnv() {
return {
qweb: new QWeb(),
bus: new EventBus(),
services: {},
debug: odoo.debug,
@@ -59,12 +58,12 @@ export const SERVICES_METADATA = {};
*/
export async function startServices(env) {
const toStart = new Set();
serviceRegistry.on("UPDATE", null, async (payload) => {
serviceRegistry.addEventListener("UPDATE", async (ev) => {
// Wait for all synchronous code so that if new services that depend on
// one another are added to the registry, they're all present before we
// start them regardless of the order they're added to the registry.
await Promise.resolve();
const { operation, key: name, value: service } = payload;
const { operation, key: name, value: service } = ev.detail;
if (operation === "delete") {
// We hardly see why it would be usefull to remove a service.
// Furthermore we could encounter problems with dependencies.
+3 -5
View File
@@ -3,9 +3,8 @@ import { registry } from "@web/core/registry";
import legacyFieldRegistry from "web.field_registry";
import owlFieldRegistry from "web.field_registry_owl";
import { ComponentAdapter } from "web.OwlCompatibility";
import { useEffect } from "@web/core/utils/hooks";
const { Component, useRef, xml } = owl;
const { Component, useEffect, xml } = owl;
const fieldRegistry = registry.category("fields");
@@ -36,13 +35,12 @@ class Field extends Component {
setup() {
const { record, type, name } = this.props;
this.fieldRef = useRef("fieldRef");
this.FieldComponent = Field.getTangibleField({ record, type, name }).FieldClass;
}
}
Field.template = xml/* xml */ `
<t t-component="FieldComponent" t-props="props" class="o-field" t-key="props.record.id" t-ref="fieldRef"/>
<t t-component="FieldComponent" t-props="props" class="o-field" t-key="props.record.id"/>
`;
class FieldSupportsLegacy extends Field {
@@ -107,7 +105,7 @@ class FieldSupportsLegacy extends Field {
FieldSupportsLegacy.template = xml/* xml */ `<t>
<t t-if="!isOwlLegacy and !isLegacy" t-call="${Field.template}" />
<t t-elif="isOwlLegacy" t-component="FieldComponent" t-props="legacyProps" />
<t t-elif="isLegacy" t-component="ComponentAdapter" widgetArgs="widgetArgs" Component="FieldComponent" t-key="renderId" />
<ComponentAdapter t-elif="isLegacy" widgetArgs="widgetArgs" Component="FieldComponent" t-key="renderId" />
</t>
`;
FieldSupportsLegacy.components = { ComponentAdapter };
+49 -23
View File
@@ -6,12 +6,23 @@ import { ComponentAdapter } from "web.OwlCompatibility";
import { objectToQuery } from "../core/browser/router_service";
import { useDebugCategory } from "../core/debug/debug_context";
import { Dialog } from "../core/dialog/dialog";
import { useEffect, useService } from "@web/core/utils/hooks";
import { useService } from "@web/core/utils/hooks";
import { ViewNotFoundError } from "../webclient/actions/action_service";
import { cleanDomFromBootstrap, wrapSuccessOrFail } from "./utils";
import { cleanDomFromBootstrap, wrapSuccessOrFail, useLegacyRefs } from "./utils";
import { mapDoActionOptionAPI } from "./backend_utils";
import { setScrollPosition } from "@web/core/utils/scrolling";
const { Component, onMounted, useExternalListener, xml } = owl;
const {
Component,
onMounted,
onWillUnmount,
onWillUpdateProps,
status,
useEffect,
useExternalListener,
useComponent,
xml,
} = owl;
const warningDialogBodyTemplate = xml`<t t-esc="props.message"/>`;
@@ -24,6 +35,8 @@ class ActionAdapter extends ComponentAdapter {
this.notifications = useService("notification");
this.dialogs = useService("dialog");
this.wowlEnv = this.env;
const legacyRefs = useLegacyRefs();
legacyRefs.component = this;
// a legacy widget widget can push_state anytime including during its async rendering
// In Wowl, we want to have all states pushed during the same setTimeout.
// This is protected in legacy (backward compatibility) but should not e supported in Wowl
@@ -31,9 +44,7 @@ class ActionAdapter extends ComponentAdapter {
let originalUpdateControlPanel;
useEffect(
() => {
if (this.wowlEnv.setLegacyControllerWidget) {
this.wowlEnv.setLegacyControllerWidget(this.widget);
}
legacyRefs.widget = this.widget;
const query = this.widget.getState();
Object.assign(query, this.tempQuery);
this.tempQuery = null;
@@ -41,10 +52,11 @@ class ActionAdapter extends ComponentAdapter {
if (!this.wowlEnv.inDialog) {
this.pushState(query);
}
this.wowlEnv.bus.on("ACTION_MANAGER:UPDATE", this, () => {
const onActionManagerUpdate = () => {
this.env.bus.trigger("close_dialogs");
cleanDomFromBootstrap();
});
};
this.wowlEnv.bus.addEventListener("ACTION_MANAGER:UPDATE", onActionManagerUpdate);
originalUpdateControlPanel = this.__widget.updateControlPanel.bind(this.__widget);
this.__widget.updateControlPanel = (newProps) => {
this.wowlEnv.config.setDisplayName(this.__widget.getTitle());
@@ -54,7 +66,10 @@ class ActionAdapter extends ComponentAdapter {
return () => {
this.__widget.updateControlPanel = originalUpdateControlPanel;
this.wowlEnv.bus.off("ACTION_MANAGER:UPDATE", this);
this.wowlEnv.bus.removeEventListener(
"ACTION_MANAGER:UPDATE",
onActionManagerUpdate
);
};
},
() => []
@@ -62,6 +77,21 @@ class ActionAdapter extends ComponentAdapter {
useExternalListener(window, "click", () => {
cleanDomFromBootstrap();
});
onWillUpdateProps(() => {
if (this.widget === null) {
this.widget = this.__widget;
}
});
onWillUnmount(() => {
if (this.__widget && this.__widget.on_detach_callback) {
this.__widget.on_detach_callback();
}
});
this.onScrollTo = (payload) => {
setScrollPosition(this, { left: payload.left, top: payload.top });
};
}
get actionId() {
@@ -125,6 +155,8 @@ class ActionAdapter extends ComponentAdapter {
}
} else if (ev.name === "history_back") {
this.wowlEnv.config.historyBack();
} else if (ev.name === "scrollTo") {
this.onScrollTo(payload);
} else {
super._trigger_up(ev);
}
@@ -152,21 +184,15 @@ class ActionAdapter extends ComponentAdapter {
return this.__widget.canBeRemoved();
}
/**
* @override
*/
willUnmount() {
if (this.__widget && this.__widget.on_detach_callback) {
this.__widget.on_detach_callback();
}
super.willUnmount();
}
__destroy() {
if (this.actionService.__legacy__isActionInStack(this.actionId)) {
this.widget = null;
}
super.__destroy(...arguments);
}
get el() {
return (this.widget || this.__widget).el;
}
}
export class ClientActionAdapter extends ActionAdapter {
@@ -192,7 +218,7 @@ export class ClientActionAdapter extends ActionAdapter {
return this.props.widgetArgs[0].jsId;
}
async willStart() {
async onWillStart() {
if (this.props.widget) {
this.widget = this.props.widget;
this.widget.setParent(this);
@@ -201,7 +227,7 @@ export class ClientActionAdapter extends ActionAdapter {
}
return this.updateWidget();
}
return super.willStart();
return super.onWillStart();
}
/**
@@ -219,7 +245,7 @@ export class ClientActionAdapter extends ActionAdapter {
const magicReloadSymbol = Symbol("magicReload");
function useMagicLegacyReload() {
const comp = Component.current;
const comp = useComponent();
if (comp.props.widget && comp.props.widget[magicReloadSymbol]) {
return comp.props.widget[magicReloadSymbol];
}
@@ -283,7 +309,7 @@ export class ViewAdapter extends ActionAdapter {
return this.props.viewParams.action.jsId;
}
async willStart() {
async onWillStart() {
if (this.props.widget) {
this.widget = this.props.widget;
this.widget.setParent(this);
@@ -294,7 +320,7 @@ export class ViewAdapter extends ActionAdapter {
} else {
const view = new this.props.View(this.props.viewInfo, this.props.viewParams);
this.widget = await view.getController(this);
if (this.__owl__.status === 5 /* DESTROYED */) {
if (status(this) === "destroyed") {
// the component might have been destroyed meanwhile, but if so, `this.widget` wasn't
// destroyed by OwlCompatibility layer as it wasn't set yet, so destroy it now
if (!this.actionService.__legacy__isActionInStack(this.actionId)) {
@@ -6,7 +6,7 @@ import { formatDateTime, parseDateTime } from "@web/core/l10n/dates";
import { formatMany2one } from "@web/fields/formatters";
import { registry } from "@web/core/registry";
const { useState } = owl;
const { onWillStart, useState } = owl;
const debugRegistry = registry.category("debug");
@@ -14,9 +14,10 @@ class GetMetadataDialog extends Dialog {
setup() {
super.setup();
this.state = useState({});
onWillStart(this.onWillStart);
}
async willStart() {
async onWillStart() {
await this.getMetadata();
}
@@ -26,6 +26,7 @@ var FieldBoolean = AbstractField.extend({
change: '_onChange',
}),
supportedFieldTypes: ['boolean'],
isQuickEditable: true,
//--------------------------------------------------------------------------
// Public
@@ -25,7 +25,6 @@ odoo.define("web.commonEnv", function (require) {
const { _t } = require("web.translation");
const utils = require("web.utils");
const { QWeb } = owl;
const browser = {
clearInterval: window.clearInterval.bind(window),
clearTimeout: window.clearTimeout.bind(window),
@@ -50,7 +49,6 @@ odoo.define("web.commonEnv", function (require) {
bus,
device,
isDebug,
qweb: new QWeb({ translateFn: _t }),
services: {
ajaxJsonRPC() {
return jsonRpc(...arguments);
@@ -16,12 +16,12 @@
return new Promise((resolve, reject) => {
return this.env.services.rpc(...arguments)
.then(result => {
if (this.__owl__.status !== 5 /* not destroyed */) {
if (owl.status(this) !== "destroyed") {
resolve(result);
}
})
.catch(reason => {
if (this.__owl__.status !== 5) /* not destroyed */ {
if (owl.status(this) !== "destroyed") {
reject(reason);
}
});
@@ -35,11 +35,11 @@
* a widgetSymbol key in the environment, corresponding to the hook to call
* (see ComponentWrapper).
*/
const originalTrigger = Component.prototype.__trigger;
Component.prototype.__trigger = function (component, evType, payload) {
const originalTrigger = owl.Component.prototype.__trigger;
Component.prototype.__trigger = function (evType, payload) {
if (this.env[odoo.widgetSymbol]) {
this.env[odoo.widgetSymbol](evType);
}
originalTrigger.call(this, component, evType, payload);
originalTrigger.call(this, evType, payload);
};
})();
@@ -5,7 +5,13 @@ odoo.define('web.ActionMenus', function (require) {
const DropdownMenu = require('web.DropdownMenu');
const Registry = require('web.Registry');
const { Component } = owl;
const {
Component,
onMounted,
onPatched,
onWillStart,
onWillUpdateProps,
} = owl;
let registryActionId = 1;
@@ -22,23 +28,21 @@ odoo.define('web.ActionMenus', function (require) {
* @extends Component
*/
class ActionMenus extends Component {
async willStart() {
this.actionItems = await this._setActionItems(this.props);
this.printItems = await this._setPrintItems(this.props);
}
async willUpdateProps(nextProps) {
this.actionItems = await this._setActionItems(nextProps);
this.printItems = await this._setPrintItems(nextProps);
}
mounted() {
this._addTooltips();
}
patched() {
this._addTooltips();
setup() {
onWillStart(async () => {
this.actionItems = await this._setActionItems(this.props);
this.printItems = await this._setPrintItems(this.props);
});
onWillUpdateProps(async (nextProps) => {
this.actionItems = await this._setActionItems(nextProps);
this.printItems = await this._setPrintItems(nextProps);
});
onMounted(() => {
this._addTooltips();
});
onPatched(() => {
this._addTooltips();
});
}
//---------------------------------------------------------------------
@@ -14,7 +14,7 @@ odoo.define('web.CustomCheckbox', function (require) {
* value="boolean"
* disabled="boolean"
* text="'Change the label text'"
* t-on-change="_onValueChange"
* onChange="_onValueChange"
* />
*
* @extends Component
@@ -27,21 +27,30 @@ odoo.define('web.CustomCheckbox', function (require) {
* @param {boolean} [props.disabled=false]
* @param {string} [props.text]
*/
constructor() {
super(...arguments);
setup() {
this._id = `checkbox-comp-${utils.generateID()}`;
}
}
CustomCheckbox.defaultProps = {
onChange: () => {},
};
CustomCheckbox.props = {
id: {
type: [String, Number],
optional: 1,
},
class: {
type: String,
optional: 1,
},
disabled: {
type: Boolean,
optional: 1,
},
onChange: {
type: Function,
optional: 1,
},
value: {
type: Boolean,
optional: 1,
@@ -23,9 +23,7 @@ odoo.define('web.CustomFileInput', function (require) {
* @param {string} [props.multi_upload=false] Whether the input should allow
* to upload multiple files at once.
*/
constructor() {
super(...arguments);
setup() {
this.fileInputRef = useRef('file-input');
}
@@ -73,13 +71,16 @@ odoo.define('web.CustomFileInput', function (require) {
accepted_file_extensions: '*',
action: '/web/binary/upload',
multi_upload: false,
onUpload: () => {},
};
CustomFileInput.props = {
accepted_file_extensions: { type: String, optional: 1 },
action: { type: String, optional: 1 },
id: { type: Number, optional: 1 },
model: { type: String, optional: 1 },
multi_upload: { type: Boolean, optional: 1 },
accepted_file_extensions: { type: String, optional: true },
action: { type: String, optional: true },
id: { type: Number, optional: true },
model: { type: String, optional: true },
multi_upload: { type: Boolean, optional: true },
onUpload: { type: Function, optional: true },
slots: { type: Object, optional: true },
};
CustomFileInput.template = 'web.CustomFileInput';
@@ -3,9 +3,18 @@ odoo.define('web.DatePickerOwl', function (require) {
const field_utils = require('web.field_utils');
const time = require('web.time');
const { useAutofocus } = require('web.custom_hooks');
const { useAutofocus } = require("@web/core/utils/hooks");
const { Component, useExternalListener, useRef, useState } = owl;
const {
Component,
onMounted,
onPatched,
onWillUnmount,
onWillUpdateProps,
useExternalListener,
useRef,
useState,
} = owl;
let datePickerId = 0;
@@ -23,44 +32,52 @@ odoo.define('web.DatePickerOwl', function (require) {
* @extends Component
*/
class DatePicker extends Component {
constructor() {
super(...arguments);
this.inputRef = useRef('input');
setup() {
this.state = useState({ warning: false });
this.datePickerId = `o_datepicker_${datePickerId++}`;
this.typeOfDate = 'date';
useAutofocus();
useAutofocus("input");
useExternalListener(window, 'scroll', this._onWindowScroll);
}
mounted() {
$(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this));
$(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this));
$(this.el).on('error.datetimepicker', () => false);
this.inputRef = useRef('input');
const pickerOptions = Object.assign({ format: this.defaultFormat }, this.props);
this._datetimepicker(pickerOptions);
this.inputRef.el.value = this._formatDate(this.props.date);
}
onMounted(() => {
$(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this));
$(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this));
$(this.el).on('error.datetimepicker', () => false);
willUnmount() {
$(this.el).off('show.datetimepicker hide.datetimepicker error.datetimepicker');
this._datetimepicker('destroy');
}
const pickerOptions = Object.assign({ format: this.defaultFormat }, this.props);
this._datetimepicker(pickerOptions);
this.inputRef.el.value = this._formatDate(this.props.date);
});
willUpdateProps(nextProps) {
for (const prop in nextProps) {
if (prop == "onDateTimeChanged") {
continue;
onWillUnmount(() => {
$(this.el).off('show.datetimepicker hide.datetimepicker error.datetimepicker');
this._datetimepicker('destroy');
});
let newDateReceived = false;
onWillUpdateProps((nextProps) => {
for (const prop in nextProps) {
if (prop == "onDateTimeChanged") {
continue;
}
this._datetimepicker(prop, nextProps[prop]);
}
this._datetimepicker(prop, nextProps[prop]);
}
if (nextProps.date) {
this.inputRef.el.value = this._formatDate(nextProps.date);
}
if (nextProps.date) {
newDateReceived = true;
}
});
onPatched(() => {
if (newDateReceived) {
newDateReceived = false;
this.inputRef.el.value = this._formatDate(this.props.date);
}
});
}
//---------------------------------------------------------------------
@@ -210,7 +227,7 @@ odoo.define('web.DatePickerOwl', function (require) {
},
optional: 1,
},
calendarWeeks: Boolean,
calendarWeeks: { type: Boolean, optional: true },
format: { type: String, optional: 1 },
icons: {
type: Object,
@@ -225,14 +242,15 @@ odoo.define('web.DatePickerOwl', function (require) {
today: String,
up: String,
},
optional: true,
},
keyBinds: { validate: kb => typeof kb === 'object' || kb === null, optional: 1 },
locale: String,
maxDate: moment,
minDate: moment,
locale: { type: String, optional: true },
maxDate: { type: moment, optional: true },
minDate: { type: moment, optional: true },
readonly: { type: Boolean, optional: 1 },
useCurrent: Boolean,
widgetParent: String,
useCurrent: { type: Boolean, optional: true },
widgetParent: { type: String, optional: true },
};
DatePicker.template = "web.Legacy.DatePicker";
@@ -247,9 +265,8 @@ odoo.define('web.DatePickerOwl', function (require) {
* @extends DatePicker
*/
class DateTimePicker extends DatePicker {
constructor() {
super(...arguments);
setup() {
super.setup();
this.typeOfDate = 'datetime';
}
@@ -28,9 +28,7 @@ odoo.define('web.DropdownMenu', function (require) {
* @extends Component
*/
class DropdownMenu extends Component {
constructor() {
super(...arguments);
setup() {
this.dropdownMenu = useRef('dropdown');
this.state = useState({ open: false });
@@ -1,7 +1,7 @@
odoo.define('web.DropdownMenuItem', function (require) {
"use strict";
const { useListener } = require('web.custom_hooks');
const { useListener } = require("@web/core/utils/hooks");
const { Component, useExternalListener, useRef, useState } = owl;
@@ -27,9 +27,7 @@ odoo.define('web.DropdownMenuItem', function (require) {
* @extends Component
*/
class DropdownMenuItem extends Component {
constructor() {
super(...arguments);
setup() {
this.canBeOpened = Boolean(this.props.options && this.props.options.length);
this.fallbackFocusRef = useRef('fallback-focus');
@@ -1,9 +1,9 @@
odoo.define('web.Pager', function (require) {
"use strict";
const { useAutofocus } = require('web.custom_hooks');
const { useAutofocus } = require("@web/core/utils/hooks");
const { Component, useState } = owl;
const { Component, onWillUpdateProps, useState } = owl;
/**
* Pager
@@ -30,18 +30,18 @@ odoo.define('web.Pager', function (require) {
* @param {boolean} [props.withAccessKey] can be disabled, for example,
* for x2m widgets
*/
constructor() {
super(...arguments);
setup() {
this.state = useState({
disabled: false,
editing: false,
});
useAutofocus();
onWillUpdateProps(this.onWillUpdateProps);
}
async willUpdateProps() {
async onWillUpdateProps() {
this.state.editing = false;
this.state.disabled = false;
}
@@ -154,7 +154,7 @@ odoo.define('web.Pager', function (require) {
// have to disable the edition manually here.
this.state.editing = false;
}
this.trigger('pager-changed', { currentMinimum, limit });
this.props.onPagerChanged({ currentMinimum, limit });
}
//---------------------------------------------------------------------
@@ -212,11 +212,12 @@ odoo.define('web.Pager', function (require) {
};
Pager.props = {
currentMinimum: { type: Number, optional: 1 },
editable: Boolean,
editable: { type: Boolean, optional: true },
limit: { validate: l => !isNaN(l), optional: 1 },
size: { type: Number, optional: 1 },
validate: Function,
withAccessKey: Boolean,
validate: { type: Function, optional: true },
withAccessKey: { type: Boolean, optional: true },
onPagerChanged: Function,
};
Pager.template = 'web.legacy.Pager';
@@ -1,14 +1,15 @@
odoo.define("web.ComparisonMenu", function (require) {
"use strict";
const { Dropdown } = require("@web/core/dropdown/dropdown");
const { DropdownItem } = require("@web/core/dropdown/dropdown_item");
const { FACET_ICONS } = require("web.searchUtils");
const { useModel } = require("web.Model");
const { Component } = owl;
class ComparisonMenu extends Component {
constructor() {
super(...arguments);
setup() {
this.icon = FACET_ICONS.comparison;
this.model = useModel('searchModel');
}
@@ -29,6 +30,7 @@ odoo.define("web.ComparisonMenu", function (require) {
}
}
ComparisonMenu.template = "web.ComparisonMenu";
ComparisonMenu.components = { Dropdown, DropdownItem };
return ComparisonMenu;
});
@@ -11,7 +11,7 @@ odoo.define('web.ControlPanel', function (require) {
const SearchBar = require('web.SearchBar');
const { useModel } = require('web.Model');
const { Component, useRef, useSubEnv } = owl;
const { Component, onMounted, onPatched, onWillDestroy, onWillUpdateProps, useRef, useSubEnv } = owl;
/**
* TODO: remove this whole mechanism as soon as `cp_content` is completely removed.
@@ -96,15 +96,16 @@ odoo.define('web.ControlPanel', function (require) {
* @extends Component
*/
class ControlPanel extends Component {
constructor() {
super(...arguments);
setup() {
this.additionalContent = getAdditionalContent(this.props);
let subEnvView = this.props.view;
useSubEnv({
action: this.props.action,
searchModel: this.props.searchModel,
view: this.props.view,
get view() {
return subEnvView;
},
});
// Connect to the model
@@ -124,27 +125,40 @@ odoo.define('web.ControlPanel', function (require) {
this.fields = this._formatFields(this.props.fields);
this.sprintf = _.str.sprintf;
}
mounted() {
this._attachAdditionalContent();
}
onWillDestroy(() => {
const content = this.props.cp_content;
if (content) {
if (content.$buttons) {
content.$buttons.remove();
}
if (content.$searchview) {
content.$searchview.remove();
}
if (content.$pager) {
content.$pager.remove();
}
if (content.$searchview_buttons) {
content.$searchview_buttons.remove();
}
}
});
patched() {
this._attachAdditionalContent();
}
async willUpdateProps(nextProps) {
// Note: action and searchModel are not likely to change during
// the lifespan of a ControlPanel instance, so we only need to update
// the view information.
if ('view' in nextProps) {
this.env.view = nextProps.view;
}
if ('fields' in nextProps) {
this.fields = this._formatFields(nextProps.fields);
}
this.additionalContent = getAdditionalContent(nextProps);
// Cannot use useEffect. See prepareForFinish in owl_compatibility.js
onMounted(() => this._attachAdditionalContent());
onPatched(() => this._attachAdditionalContent());
onWillUpdateProps((nextProps) => {
// Note: action and searchModel are not likely to change during
// the lifespan of a ControlPanel instance, so we only need to update
// the view information.
if ("view" in nextProps) {
subEnvView = nextProps.view;
}
if ("fields" in nextProps) {
this.fields = this._formatFields(nextProps.fields);
}
this.additionalContent = getAdditionalContent(nextProps);
});
}
//---------------------------------------------------------------------
@@ -202,18 +216,18 @@ odoo.define('web.ControlPanel', function (require) {
};
ControlPanel.props = {
action: Object,
breadcrumbs: Array,
breadcrumbs: { type: Array, optional: true },
searchModel: ActionModel,
cp_content: { type: Object, optional: 1 },
fields: Object,
fields: { type: Object, optional: true },
pager: { validate: p => typeof p === 'object' || p === null, optional: 1 },
searchMenuTypes: Array,
searchMenuTypes: { type: Array, optional: true },
actionMenus: { validate: s => typeof s === 'object' || s === null, optional: 1 },
title: { type: String, optional: 1 },
view: { type: Object, optional: 1 },
views: Array,
withBreadcrumbs: Boolean,
withSearchBar: Boolean,
views: { type: Array, optional: true },
withBreadcrumbs: { type: Boolean, optional: true },
withSearchBar: { type: Boolean, optional: true },
};
ControlPanel.template = 'web.Legacy.ControlPanel';
@@ -1,8 +1,10 @@
odoo.define('web.CustomFavoriteItem', function (require) {
"use strict";
const { CheckBox } = require("@web/core/checkbox/checkbox");
const { Dropdown } = require("@web/core/dropdown/dropdown");
const FavoriteMenu = require('web.FavoriteMenu');
const { useAutofocus } = require('web.custom_hooks');
const { useAutofocus } = require("@web/core/utils/hooks");
const { useModel } = require('web.Model');
const { Component, useRef, useState } = owl;
@@ -29,9 +31,7 @@ odoo.define('web.CustomFavoriteItem', function (require) {
* and save the context to a new filter.
*/
class CustomFavoriteItem extends Component {
constructor() {
super(...arguments);
setup() {
const favId = favoriteId++;
this.useByDefaultId = `o_favorite_use_by_default_${favId}`;
this.shareAllUsersId = `o_favorite_share_all_users_${favId}`;
@@ -45,7 +45,7 @@ odoo.define('web.CustomFavoriteItem', function (require) {
isShared: false,
});
useAutofocus();
useAutofocus("description");
}
//---------------------------------------------------------------------
@@ -92,10 +92,9 @@ odoo.define('web.CustomFavoriteItem', function (require) {
/**
* @private
* @param {Event} ev change Event
* @param {boolean} checked
*/
onDefaultCheckboxChange(ev) {
const { checked } = ev.target;
onDefaultCheckboxChange(checked) {
this.state.isDefault = checked;
if (checked) {
this.state.isShared = false;
@@ -104,10 +103,9 @@ odoo.define('web.CustomFavoriteItem', function (require) {
/**
* @private
* @param {Event} ev change Event
* @param {boolean} checked
*/
onShareCheckboxChange(ev) {
const { checked } = ev.target;
onShareCheckboxChange(checked) {
this.state.isShared = checked;
if (checked) {
this.state.isDefault = false;
@@ -147,6 +145,7 @@ odoo.define('web.CustomFavoriteItem', function (require) {
CustomFavoriteItem.props = {};
CustomFavoriteItem.template = "web.CustomFavoriteItem";
CustomFavoriteItem.components = { CheckBox, Dropdown };
CustomFavoriteItem.groupNumber = 3; // have 'Save Current Search' in its own group
FavoriteMenu.registry.add('favorite-generator-menu', CustomFavoriteItem, 0);
@@ -1,6 +1,8 @@
odoo.define('web.CustomFilterItem', function (require) {
"use strict";
const { Dropdown } = require("@web/core/dropdown/dropdown");
const { DropdownItem } = require("@web/core/dropdown/dropdown_item");
const { DatePicker, DateTimePicker } = require('web.DatePickerOwl');
const Domain = require('web.Domain');
const { FIELD_OPERATORS, FIELD_TYPES } = require('web.searchUtils');
@@ -42,9 +44,7 @@ odoo.define('web.CustomFilterItem', function (require) {
* ]
*/
class CustomFilterItem extends Component {
constructor() {
super(...arguments);
setup() {
this.model = useModel('searchModel');
this.conditions = useState([]);
@@ -270,7 +270,7 @@ odoo.define('web.CustomFilterItem', function (require) {
}
}
CustomFilterItem.components = { DatePicker, DateTimePicker };
CustomFilterItem.components = { DatePicker, DateTimePicker, Dropdown };
CustomFilterItem.props = { fields: Object };
CustomFilterItem.template = "web.CustomFilterItem";
@@ -1,38 +0,0 @@
odoo.define('web.CustomGroupByItem', function (require) {
"use strict";
const { useModel } = require('web.Model');
const { Component, useState } = owl;
class CustomGroupByItem extends Component {
constructor() {
super(...arguments);
this.state = useState({
fieldName: this.props.fields[0].name,
});
this.model = useModel('searchModel');
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
*/
onApply() {
const field = this.props.fields.find(f => f.name === this.state.fieldName);
this.model.dispatch('createNewGroupBy', field);
}
}
CustomGroupByItem.template = "web.CustomGroupByItem";
CustomGroupByItem.props = {
fields: Array,
};
return CustomGroupByItem;
});
@@ -1,6 +1,8 @@
odoo.define('web.FavoriteMenu', function (require) {
"use strict";
const { Dropdown } = require("@web/core/dropdown/dropdown");
const { DropdownItem } = require("@web/core/dropdown/dropdown_item");
const Dialog = require('web.OwlDialog');
const { FACET_ICONS } = require("web.searchUtils");
const Registry = require('web.Registry');
@@ -18,8 +20,7 @@ odoo.define('web.FavoriteMenu', function (require) {
* the `web` module.
*/
class FavoriteMenu extends Component {
constructor() {
super(...arguments);
setup() {
this.icon = FACET_ICONS.favorite;
this.model = useModel('searchModel');
this.state = useState({ deletedFavorite: false });
@@ -81,7 +82,7 @@ odoo.define('web.FavoriteMenu', function (require) {
}
FavoriteMenu.registry = new Registry();
FavoriteMenu.components = { Dialog };
FavoriteMenu.components = { Dialog, Dropdown, DropdownItem };
FavoriteMenu.template = 'web.Legacy.FavoriteMenu';
return FavoriteMenu;
@@ -1,6 +1,8 @@
odoo.define('web.FilterMenu', function (require) {
"use strict";
const { Dropdown } = require("@web/core/dropdown/dropdown");
const { DropdownItem } = require("@web/core/dropdown/dropdown_item");
const CustomFilterItem = require('web.CustomFilterItem');
const { FACET_ICONS } = require("web.searchUtils");
const { useModel } = require('web.Model');
@@ -16,8 +18,7 @@ odoo.define('web.FilterMenu', function (require) {
*/
class FilterMenu extends Component {
constructor() {
super(...arguments);
setup() {
this.icon = FACET_ICONS.filter;
this.model = useModel('searchModel');
}
@@ -53,8 +54,15 @@ odoo.define('web.FilterMenu', function (require) {
}
FilterMenu.components = { CustomFilterItem };
FilterMenu.props = { fields: Object };
FilterMenu.defaultProps = {
class: "",
};
FilterMenu.props = {
fields: Object,
class: { String, optional: true },
};
FilterMenu.template = "web.legacy.FilterMenu";
FilterMenu.components = { CustomFilterItem, Dropdown, DropdownItem };
return FilterMenu;
});
@@ -1,7 +1,9 @@
odoo.define('web.GroupByMenu', function (require) {
"use strict";
const CustomGroupByItem = require('web.CustomGroupByItem');
const { Dropdown } = require("@web/core/dropdown/dropdown");
const { DropdownItem } = require("@web/core/dropdown/dropdown_item");
const { CustomGroupByItem } = require('@web/search/group_by_menu/custom_group_by_item');
const { FACET_ICONS, GROUPABLE_TYPES } = require('web.searchUtils');
const { useModel } = require('web.Model');
@@ -9,8 +11,7 @@ odoo.define('web.GroupByMenu', function (require) {
class GroupByMenu extends Component {
constructor() {
super(...arguments);
setup() {
this.icon = FACET_ICONS.groupBy;
this.fields = Object.values(this.props.fields)
@@ -64,9 +65,13 @@ odoo.define('web.GroupByMenu', function (require) {
this.model.dispatch('toggleFilter', itemId);
}
}
onAddCustomGroup(fieldName) {
const field = this.props.fields[fieldName];
this.model.dispatch("createNewGroupBy", field);
}
}
GroupByMenu.components = { CustomGroupByItem };
GroupByMenu.components = { CustomGroupByItem, Dropdown, DropdownItem };
GroupByMenu.props = { fields: Object };
GroupByMenu.template = "web.GroupByMenu";
@@ -3,11 +3,11 @@ odoo.define('web.SearchBar', function (require) {
const Domain = require('web.Domain');
const field_utils = require('web.field_utils');
const { useAutofocus } = require('web.custom_hooks');
const { useAutofocus } = require("@web/core/utils/hooks");
const { useModel } = require('web.Model');
const { fuzzyTest } = require('@web/core/utils/search');
const { Component, useExternalListener, useRef, useState } = owl;
const { Component, onMounted, onWillUnmount, useExternalListener, useRef, useState } = owl;
const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
let sourceId = 0;
@@ -37,10 +37,8 @@ odoo.define('web.SearchBar', function (require) {
* @extends Component
*/
class SearchBar extends Component {
constructor() {
super(...arguments);
this.focusOnUpdate = useAutofocus();
setup() {
this.focusOnUpdate = useAutofocus('search-input');
this.inputRef = useRef('search-input');
this.model = useModel('searchModel');
this.state = useState({
@@ -56,19 +54,19 @@ odoo.define('web.SearchBar', function (require) {
useExternalListener(window, 'click', this._onWindowClick);
useExternalListener(window, 'keydown', this._onWindowKeydown);
}
mounted() {
// 'search' will always patch the search bar, 'focus' will never.
this.env.searchModel.on('search', this, this.focusOnUpdate);
this.env.searchModel.on('focus-control-panel', this, () => {
this.inputRef.el.focus();
onMounted(() => {
// 'search' will always patch the search bar, 'focus' will never.
this.model.on('search', this, this.focusOnUpdate);
this.model.on('focus-control-panel', this, () => {
this.inputRef.el.focus();
});
});
}
willUnmount() {
this.env.searchModel.off('search', this);
this.env.searchModel.off('focus-control-panel', this);
onWillUnmount(() => {
this.model.off('search', this);
this.model.off('focus-control-panel', this);
});
}
//---------------------------------------------------------------------
@@ -485,7 +483,7 @@ odoo.define('web.SearchBar', function (require) {
fields: {},
};
SearchBar.props = {
fields: Object,
fields: { type: Object, optional: true },
};
SearchBar.template = 'web.Legacy.SearchBar';
@@ -1,116 +0,0 @@
odoo.define('web.custom_hooks', function (require) {
"use strict";
const { useEffect } = require("@web/core/utils/hooks");
const { Component } = owl;
/**
* Focus a given selector as soon as it appears in the DOM and if it was not
* displayed before. If the selected target is an input|textarea, set the selection
* at the end.
* @param {Object} [params]
* @param {string} [params.selector='autofocus'] default: select the first element
* with an `autofocus` attribute.
* @returns {Function} function that forces the focus on the next update if visible.
*/
function useAutofocus(params = {}) {
const comp = Component.current;
// Prevent autofocus in mobile
if (comp.env.device.isMobileDevice) {
return () => {};
}
const selector = params.selector || '[autofocus]';
let forceFocusCount = 0;
useEffect(function autofocus(target) {
if (target) {
target.focus();
if (["INPUT", "TEXTAREA"].includes(target.tagName)) {
const inputEl = target;
inputEl.selectionStart = inputEl.selectionEnd = inputEl.value.length;
}
}
}, () => [comp.el.querySelector(selector), forceFocusCount]);
return function focusOnUpdate() {
forceFocusCount++; // force the effect to rerun on next patch
};
}
/**
* The useListener hook offers an alternative to Owl's classical event
* registration mechanism (with attribute 't-on-eventName' in xml). It is
* especially useful for abstract components, meant to be extended by
* specific ones. If those abstract components need to define event handlers,
* but don't have any template (because the template completely depends on
* specific cases), then using the 't-on' mechanism isn't adequate, as the
* handlers would be lost by the template override. In this case, using this
* hook instead is more convenient.
*
* Example: navigation event handling in AbstractField
*
* Usage: like all Owl hooks, this function has to be called in the
* constructor of an Owl component:
*
* useListener('click', () => { console.log('clicked'); });
*
* An optional native query selector can be specified as second argument for
* event delegation. In this case, the handler is only called if the event
* is triggered on an element matching the given selector.
*
* useListener('click', 'button', () => { console.log('clicked'); });
*
* Note: components that alter the event's target (e.g. Portal) are not
* expected to behave as expected with event delegation.
*
* @param {string} eventName the name of the event
* @param {string} [querySelector] a JS native selector for event delegation
* @param {function} handler the event handler (will be bound to the component)
* @param {Object} [addEventListenerOptions] to be passed to addEventListener as options.
* Useful for listening in the capture phase
*/
function useListener(eventName, querySelector, handler, addEventListenerOptions) {
if (typeof arguments[1] !== 'string') {
querySelector = null;
handler = arguments[1];
addEventListenerOptions = arguments[2];
}
if (typeof handler !== 'function') {
throw new Error('The handler must be a function');
}
const comp = Component.current;
let boundHandler;
if (querySelector) {
boundHandler = function (ev) {
let el = ev.target;
let target;
while (el && !target) {
if (el.matches(querySelector)) {
target = el;
} else if (el === comp.el) {
el = null;
} else {
el = el.parentElement;
}
}
if (el) {
handler.call(comp, ev);
}
};
} else {
boundHandler = handler.bind(comp);
}
useEffect(() => {
comp.el.addEventListener(eventName, boundHandler, addEventListenerOptions);
return () => {
comp.el.removeEventListener(eventName, boundHandler, addEventListenerOptions);
};
}, () => []);
}
return {
useAutofocus,
useListener,
};
});
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { loadJS } from "web.ajax";
import { loadJS } from "@web/core/assets";
import BrowserDetection from "web.BrowserDetection";
import { _t } from "web.core";
/**
+1 -2
View File
@@ -183,10 +183,9 @@ function displayNotification(parent, action) {
links = links.map(({url, label}) => `<a href="${_.escape(url)}" target="_blank">${_.escape(label)}</a>`)
parent.displayNotification({
title, // no escape for the title because it is done in the template
message: sprintf(_.escape(message), ...links),
message: owl.markup(sprintf(_.escape(message), ...links)),
type,
sticky,
messageIsHtml: true, // dynamic parts of the message are escaped above
});
return next;
}
@@ -1,12 +1,13 @@
odoo.define('web.OwlDialog', function (require) {
"use strict";
const { Component, Portal, useRef } = owl;
const { Component, useRef, onMounted, onWillUnmount } = owl;
const SIZE_CLASSES = {
'extra-large': 'modal-xl',
'large': 'modal-lg',
'small': 'modal-sm',
};
const { renderToString } = require('@web/core/utils/render');
/**
* Dialog (owl version)
@@ -36,51 +37,53 @@ odoo.define('web.OwlDialog', function (require) {
* @param {boolean} [props.technical=true] If set to false, the modal will have
* the standard frontend style (use this for non-editor frontend features).
*/
constructor() {
super(...arguments);
setup() {
this.modalRef = useRef('modal');
this.dialogRef = useRef('dialog');
this.footerRef = useRef('modal-footer');
}
mounted() {
this.constructor.display(this);
this.env.bus.on('close_dialogs', this, this._close);
if (this.props.renderFooter) {
// Set up main button : will first look for an element with the
// 'btn-primary' class, then a 'btn' class, then the first button
// element.
let mainButton = this.footerRef.el.querySelector('.btn.btn-primary');
if (!mainButton) {
mainButton = this.footerRef.el.querySelector('.btn');
}
if (!mainButton) {
mainButton = this.footerRef.el.querySelector('button');
}
if (mainButton) {
this.mainButton = mainButton;
this.mainButton.addEventListener('keydown', this._onMainButtonKeydown.bind(this));
this.mainButton.focus();
}
if (this.props.dialogAPI) {
this.props.dialogAPI.close = this._close.bind(this);
}
this._removeTooltips();
onMounted(() => {
this.constructor.display(this);
// Notifies new webclient to adjust UI active element
this.env.bus.trigger("owl_dialog_mounted", this);
}
this.env.bus.on('close_dialogs', this, this._close);
willUnmount() {
// Notifies new webclient to adjust UI active element
this.env.bus.trigger("owl_dialog_willunmount", this);
if (this.props.renderFooter) {
// Set up main button : will first look for an element with the
// 'btn-primary' class, then a 'btn' class, then the first button
// element.
let mainButton = this.footerRef.el.querySelector('.btn.btn-primary');
if (!mainButton) {
mainButton = this.footerRef.el.querySelector('.btn');
}
if (!mainButton) {
mainButton = this.footerRef.el.querySelector('button');
}
if (mainButton) {
this.mainButton = mainButton;
this.mainButton.addEventListener('keydown', this._onMainButtonKeydown.bind(this));
this.mainButton.focus();
}
}
this.env.bus.off('close_dialogs', this, this._close);
this._removeTooltips();
this._removeTooltips();
// Notifies new webclient to adjust UI active element
this.env.bus.trigger("owl_dialog_mounted", this);
});
this.constructor.hide(this);
onWillUnmount(() => {
// Notifies new webclient to adjust UI active element
this.env.bus.trigger("owl_dialog_willunmount", this);
this.env.bus.off('close_dialogs', this, this._close);
this._removeTooltips();
this.constructor.hide(this);
});
}
//--------------------------------------------------------------------------
@@ -178,7 +181,7 @@ odoo.define('web.OwlDialog', function (require) {
$(this.mainButton)
.tooltip({
delay: { show: 200, hide: 0 },
title: () => this.env.qweb.renderToString('web.DialogButton.tooltip', {
title: () => renderToString('web.DialogButton.tooltip', {
title: this.mainButton.innerText.toUpperCase(),
}),
trigger: 'manual',
@@ -225,7 +228,13 @@ odoo.define('web.OwlDialog', function (require) {
// Activate last dialog and update body class
const lastDialog = this.displayed[this.displayed.length - 1];
if (lastDialog) {
lastDialog.el.focus();
if (lastDialog.el) {
// legacy dialog
lastDialog.el.focus();
} else {
// Owl dialog | LegacyAdaptedDialog
lastDialog.dialogRef.el.focus();
}
const modalEl = lastDialog.modalRef ?
// Owl dialog | LegacyAdaptedDialog
lastDialog.modalRef.el :
@@ -241,7 +250,6 @@ odoo.define('web.OwlDialog', function (require) {
Dialog.displayed = [];
Dialog.components = { Portal };
Dialog.defaultProps = {
backdrop: 'static',
renderFooter: true,
@@ -252,17 +260,26 @@ odoo.define('web.OwlDialog', function (require) {
title: "Odoo",
};
Dialog.props = {
backdrop: { validate: b => ['static', true, false].includes(b) },
backdrop: { validate: b => ['static', true, false].includes(b), optional: true },
contentClass: { type: String, optional: 1 },
fullscreen: { type: Boolean, optional: 1 },
onClosed: { type: Function, optional: 1 },
renderFooter: Boolean,
renderHeader: Boolean,
size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s) },
stopClicks: Boolean,
renderFooter: { type: Boolean, optional: true },
renderHeader: { type: Boolean, optional: true },
size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s), optional: true },
stopClicks: { type: Boolean, optional: true },
subtitle: { type: String, optional: 1 },
technical: Boolean,
title: String,
technical: { type: Boolean, optional: true },
title: { type: String, optional: true },
dialogAPI: { type: Object, optional: 1 },
slots: {
type: Object,
optional: 1,
shape: {
default: { optional: 1 },
buttons: { optional: 1 },
},
},
};
Dialog.template = 'web.OwlDialog';
+37 -30
View File
@@ -1,7 +1,7 @@
odoo.define('web.Popover', function (require) {
'use strict';
const { Component, Portal, QWeb, useRef, useState } = owl;
const { Component, status, onWillUnmount, useEffect, useRef, useState } = owl;
/**
* Popover
@@ -17,8 +17,7 @@ odoo.define('web.Popover', function (require) {
* @param {String} [props.position='bottom']
* @param {String} [props.title]
*/
constructor() {
super(...arguments);
setup() {
this.popoverRef = useRef('popover');
this.orderedPositions = ['top', 'bottom', 'left', 'right'];
this.state = useState({
@@ -39,20 +38,13 @@ odoo.define('web.Popover', function (require) {
* keeping the count of global handlers low.
*/
this._hasGlobalEventListeners = false;
}
mounted() {
this._compute();
}
patched() {
this._compute();
}
willUnmount() {
if (this._hasGlobalEventListeners) {
this._removeGlobalEventListeners();
}
useEffect(() => this._compute());
onWillUnmount(() => {
if (this._hasGlobalEventListeners) {
this._removeGlobalEventListeners();
}
});
}
//----------------------------------------------------------------------
@@ -82,7 +74,6 @@ odoo.define('web.Popover', function (require) {
if (this.props.onClosed) {
this.props.onClosed();
}
this.trigger('o-popover-closed');
}
/**
@@ -172,17 +163,19 @@ odoo.define('web.Popover', function (require) {
* @param {MouseEvent} ev
*/
_onClick(ev) {
const props = this.props;
this.state.displayed = !this.state.displayed;
if (this.state.displayed) {
if (this.props.onOpened) {
this.props.onOpened();
if (props.onOpened) {
props.onOpened();
}
this.trigger('o-popover-opened');
} else {
if (this.props.onClosed) {
this.props.onClosed();
if (props.onClosed) {
props.onClosed();
}
this.trigger('o-popover-closed');
}
if (!props.propagateClick) {
ev.stopPropagation();
}
}
@@ -206,9 +199,8 @@ odoo.define('web.Popover', function (require) {
/**
* @private
* @param {Event} ev
*/
_onPopoverClose(ev) {
_onPopoverClose() {
this._close();
}
@@ -230,7 +222,7 @@ odoo.define('web.Popover', function (require) {
* @param {Event} ev
*/
_onResizeWindow(ev) {
if (this.__owl__.status === 5 /* destroyed */) {
if (status(this) === "destroyed") {
return;
}
this._compute();
@@ -244,7 +236,7 @@ odoo.define('web.Popover', function (require) {
* @param {Event} ev
*/
_onScrollDocument(ev) {
if (this.__owl__.status === 5 /* destroyed */) {
if (status(this) === "destroyed") {
return;
}
this._compute();
@@ -320,12 +312,20 @@ odoo.define('web.Popover', function (require) {
}
Popover.components = { Portal };
Popover.template = 'Popover';
Popover.defaultProps = {
position: 'bottom',
propagateClick: true,
};
Popover.props = {
propagateClick: {
type: Boolean,
optional: true,
},
class: {
type: String,
optional: true,
},
onClosed: {
type: Function,
optional: true,
@@ -341,12 +341,19 @@ odoo.define('web.Popover', function (require) {
position: {
type: String,
validate: (p) => ['top', 'bottom', 'left', 'right'].includes(p),
optional: true,
},
title: { type: String, optional: true },
titleAttribute: { type: String, optional: true },
slots: {
type: Object,
optional: true,
shape: {
default: { optional: true },
opened: { optional: true },
},
},
};
QWeb.registerComponent('Popover', Popover);
return Popover;
});
@@ -247,7 +247,6 @@ var ServicesMixin = {
* @param {string} [options.type='warning'] 'info', 'success', 'warning', 'danger' or ''
* @param {boolean} [options.sticky=false]
* @param {string} [options.className]
* @param {boolean} [options.messageIsHtml=false]
*/
displayNotification: function (options) {
return this.call('notification', 'notify', options);
@@ -3,6 +3,7 @@ odoo.define('web.translation', function (require) {
"use strict";
var Class = require('web.Class');
const { _lt } = require("@web/core/l10n/translation");
var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase# */{
init: function() {
@@ -90,20 +91,6 @@ var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase#
* @returns {String} source translated into the current locale
*/
var _t = new TranslationDataBase().build_translation_function();
/**
* Lazy translation function, only performs the translation when actually
* printed (e.g. inserted into a template)
*
* Useful when defining translatable strings in code evaluated before the
* translation database is loaded, as class attributes or at the top-level of
* an OpenERP Web module
*
* @param {String} s string to translate
* @returns {Object} lazy translation object
*/
var _lt = function (s) {
return {toString: function () { return _t(s); }};
};
/** Setup jQuery timeago */
/*
@@ -255,11 +255,13 @@ _.escape = function escape(s) {
// TODO (?)
// * Markup.join / Markup#join => escapes items and returns a Markup
// * Markup#replace => automatically escapes the replacements (difficult impl)
class _Markup extends String {
[_.escapeMethod]() {
return this;
}
// get a reference to the internalMarkup class from owl
const _Markup = owl.markup('').constructor;
_Markup.prototype[_.escapeMethod] = function () {
return this;
}
// exposed for qweb2.js
window._Markup = _Markup;
@@ -2,10 +2,9 @@ odoo.define('web.AbstractFieldOwl', function (require) {
"use strict";
const field_utils = require('web.field_utils');
const { useListener } = require('web.custom_hooks');
const { useEffect } = require("@web/core/utils/hooks");
const { useListener } = require("@web/core/utils/hooks");
const { Component } = owl;
const { Component, onWillUpdateProps, useEffect } = owl;
/**
* This file defines the Owl version of the AbstractField. Specific fields
@@ -60,9 +59,7 @@ odoo.define('web.AbstractFieldOwl', function (require) {
* @param {string} [props.options.mode=readonly] should be 'readonly' or 'edit'
* @param {string} [props.options.viewType=default]
*/
constructor() {
super(...arguments);
setup() {
this._canQuickEdit = this.constructor.isQuickEditable;
this._isValid = true;
// this is the last value that was set by the user, unparsed. This is
@@ -73,33 +70,14 @@ odoo.define('web.AbstractFieldOwl', function (require) {
useListener('keydown', this._onKeydown);
useListener('navigation-move', this._onNavigationMove);
useEffect(() => this._applyDecorations());
}
/**
* Hack: studio tries to find the field with a selector base on its
* name, before it is mounted into the DOM. Ideally, this should be
* done in the onMounted hook, but in this case we are too late, and
* Studio finds nothing. As a consequence, the field can't be edited
* by clicking on its label (or on the row formed by the pair label-field).
*
* TODO: move this to mounted at some point?
*
* @override
*/
__patch() {
const res = super.__patch(...arguments);
this.el.setAttribute('name', this.name);
this.el.classList.add('o_field_widget');
this.el.classList.toggle('o_quick_editable', this._canQuickEdit);
return res;
}
/**
* @async
* @param {Object} [nextProps]
* @returns {Promise}
*/
async willUpdateProps(nextProps) {
this._lastSetValue = undefined;
return super.willUpdateProps(nextProps);
useEffect(() => {
this.el.setAttribute('name', this.name);
this.el.classList.add('o_field_widget');
this.el.classList.toggle('o_quick_editable', this._canQuickEdit);
});
onWillUpdateProps(() => {
this._lastSetValue = undefined;
});
}
//----------------------------------------------------------------------
@@ -5,6 +5,7 @@ odoo.define('web.basic_fields_owl', function (require) {
const CustomCheckbox = require('web.CustomCheckbox');
const { _lt } = require('web.translation');
const { onPatched } = owl;
/**
* FieldBadge displays the field's value inside a bootstrap pill badge.
@@ -27,11 +28,13 @@ odoo.define('web.basic_fields_owl', function (require) {
class FieldBoolean extends AbstractField {
patched() {
super.patched();
if (this.props.event && this.props.event.target === this) {
this.activate();
}
setup() {
super.setup();
onPatched(() => {
if (this.props.event && this.props.event.target === this) {
this.activate();
}
});
}
//----------------------------------------------------------------------
@@ -23,6 +23,9 @@ odoo.define('web._field_registry_owl', function (require) {
// Basic fields
registry
.add('badge', basicFields.FieldBadge)
.add('boolean', basicFields.FieldBoolean);
.add('badge', basicFields.FieldBadge);
// deactivate the owl FieldBoolean as it causes issues in the Settings form view
// if it is in an invisible block. This was a legacy implementation of the owl FieldBoolean
// anyway, and we're currently rewritting the basic views in owl.
// .add('boolean', basicFields.FieldBoolean);
});
@@ -4,6 +4,8 @@ odoo.define('web.FieldWrapper', function (require) {
const { ComponentWrapper } = require('web.OwlCompatibility');
const field_utils = require('web.field_utils');
const { App } = owl;
/**
* This file defines the FieldWrapper component, an extension of ComponentWrapper,
* needed to instanciate Owl fields inside legacy widgets. This component
@@ -66,8 +68,8 @@ odoo.define('web.FieldWrapper', function (require) {
* not the real field Component, which triggers the 'field-changed'
* event. This function writes the attribute on that field Component.
*/
mounted() {
super.mounted(...arguments);
on_attach_callback() {
super.on_attach_callback();
this.componentRef.comp.__node = this.__node;
}
@@ -124,9 +126,16 @@ odoo.define('web.FieldWrapper', function (require) {
// instantiated yet when the renderer first asks if it is set
// (only the wrapper is instantiated), so we instantiate one
// with the same props, get its 'isSet' status, and destroy it.
const c = new this.Component(null, this.props);
const isSet = c.isSet;
c.destroy();
const app = new App(null, {
templates: window.__OWL_TEMPLATES__,
env: this.env,
dev: this.env.isDebug(),
translateFn: owl.Component.env._t,
translatableAttributes: ["data-tooltip"],
});
const node = app.makeNode(this.Component, this.props);
const isSet = node.component.isSet;
app.destroy();
return isSet;
}
isValid() {
@@ -137,9 +146,16 @@ odoo.define('web.FieldWrapper', function (require) {
// instantiated yet when the renderer first asks if it is set
// (only the wrapper is instantiated), so we instantiate one
// with the same props, get its 'isValid' status, and destroy it.
const c = new this.Component(null, this.props);
const isValid = c.isValid;
c.destroy();
const app = new App(null, {
templates: window.__OWL_TEMPLATES__,
env: this.env,
dev: this.env.isDebug(),
translateFn: owl.Component.env._t,
translatableAttributes: ["data-tooltip"],
});
const node = app.makeNode(this.Component, this.props);
const isValid = node.component.isValid;
app.destroy();
return isValid;
}
removeInvalidClass() {
@@ -28,8 +28,7 @@ var KanbanRenderer = require('web.KanbanRenderer');
var ListRenderer = require('web.ListRenderer');
const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility');
const { sprintf, toBoolElse } = require("web.utils");
const { escape } = owl;
const { escape } = require("@web/core/utils/strings");
var _t = core._t;
var _lt = core._lt;
@@ -1100,7 +1099,6 @@ var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
navigation_move: '_onNavigationMove',
save_optional_fields: '_onSaveOrLoadOptionalFields',
load_optional_fields: '_onSaveOrLoadOptionalFields',
pager_changed: '_onPagerChanged',
}),
// We need to trigger the reset on every changes to be aware of the parent changes
@@ -1145,6 +1143,7 @@ var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
Promise.resolve();
},
withAccessKey: false,
onPagerChanged: this._onPagerChanged.bind(this),
};
var arch = this.view && this.view.arch;
if (arch) {
@@ -1776,9 +1775,7 @@ var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
* @private
* @param {OdooEvent} ev
*/
_onPagerChanged: function (ev) {
ev.stopPropagation();
const { currentMinimum, limit } = ev.data;
_onPagerChanged: function ({ currentMinimum, limit }) {
this._updateControlPanel({ currentMinimum, limit });
this.trigger_up('load', {
id: this.value.id,
+22 -46
View File
@@ -1,10 +1,11 @@
odoo.define("web.Model", function (require) {
"use strict";
const { groupBy, partitionBy } = require("web.utils");
const { groupBy } = require("web.utils");
const Registry = require("web.Registry");
const { useBus } = require("@web/core/utils/hooks");
const { Component, EventBus, Observer } = owl;
const { EventBus, onWillRender, useComponent } = owl;
const isNotNull = (val) => val !== null && val !== undefined;
/**
@@ -232,7 +233,7 @@ odoo.define("web.Model", function (require) {
this.dispatching = false;
this.extensions = [];
this.externalState = {};
this.mapping = {};
this.mapping = new WeakMap();
this.rev = 1;
const { name, registry } = this.constructor;
@@ -426,19 +427,9 @@ odoo.define("web.Model", function (require) {
* @see Context.__notifyComponents() in owl.js for explanation
* @private
*/
async _notifyComponents() {
_notifyComponents() {
const rev = ++this.rev;
const subscriptions = this.subscriptions.update || [];
const groups = partitionBy(subscriptions, (s) =>
s.owner ? s.owner.__owl__.depth : -1
);
for (let group of groups) {
const proms = group.map((sub) =>
sub.callback.call(sub.owner, rev)
);
Component.scheduler.flush();
await Promise.all(proms);
}
this.trigger("update", { rev });
}
}
@@ -452,47 +443,32 @@ odoo.define("web.Model", function (require) {
* @returns {model}
*/
function useModel(modelName) {
const component = Component.current;
const component = useComponent();
const model = component.env[modelName];
if (!(model instanceof Model)) {
throw new Error(`No Model found when connecting '${
component.name
}'`);
throw new Error(`No Model found when connecting '${component.constructor.name}'`);
}
const mapping = model.mapping;
const __owl__ = component.__owl__;
const componentId = __owl__.id;
if (!__owl__.observer) {
__owl__.observer = new Observer();
__owl__.observer.notifyCB = component.render.bind(component);
}
const currentCB = __owl__.observer.notifyCB;
__owl__.observer.notifyCB = function () {
if (model.rev > mapping[componentId]) {
model.mapping.set(component, 0);
const __render = component.render.bind(component);
component.render = function () {
if (model.rev > model.mapping.get(component)) {
return;
}
currentCB();
};
mapping[componentId] = 0;
const renderFn = __owl__.renderFn;
__owl__.renderFn = function (comp, params) {
mapping[componentId] = model.rev;
return renderFn(comp, params);
__render();
};
model.on("update", component, async (modelRev) => {
if (mapping[componentId] < modelRev) {
mapping[componentId] = modelRev;
await component.render();
}
onWillRender(() => {
model.mapping.set(component, model.rev);
});
const __destroy = component.__destroy;
component.__destroy = (parent) => {
model.off("update", component);
__destroy.call(component, parent);
};
useBus(model, "update", (ev) => {
const { rev } = ev.detail;
if (model.mapping.get(component) < rev) {
model.mapping.set(component, rev);
component.render();
}
});
return model;
}
@@ -1,8 +1,6 @@
odoo.define('web.OwlCompatibility', function () {
odoo.define('web.OwlCompatibility', function (require) {
"use strict";
const { Component, useRef, useSubEnv, xml } = owl;
/**
* This file defines the necessary tools for the transition phase where Odoo
* legacy widgets and Owl components will coexist. There are two possible
@@ -11,11 +9,24 @@ odoo.define('web.OwlCompatibility', function () {
* 2) A legacy widget has to instantiate Owl components
*/
const {
App,
Component,
onMounted,
onWillStart,
onWillUnmount,
onPatched,
onWillUpdateProps,
onWillDestroy,
useSubEnv,
xml,
status,
} = owl;
const widgetSymbol = odoo.widgetSymbol;
const children = new WeakMap(); // associates legacy widgets with their Owl children
const templateForLegacy = xml`<div/>`;
const templateForLegacy = xml`<t/>`;
const templateForOwl = xml`<t t-component="props.Component" t-props="childProps" />`;
/**
* Case 1) An Owl component has to instantiate legacy widgets
@@ -80,7 +91,7 @@ odoo.define('web.OwlCompatibility', function () {
*
* @override
*/
constructor(parent, props) {
constructor(props) {
if (!props.Component) {
throw Error(`ComponentAdapter: 'Component' prop is missing.`);
}
@@ -93,11 +104,78 @@ odoo.define('web.OwlCompatibility', function () {
ComponentAdapter.template = template;
super(...arguments);
this.template = template;
ComponentAdapter.template = null;
this.widget = null; // widget instance, if Component is a legacy widget
}
setup() {
onWillUpdateProps((nextProps) => {
if (this.widget) {
return this.updateWidget(nextProps);
}
});
let widgetIsAttached = false;
const insertWidget = () => {
this.removeEl();
if (!this.widget || !this.widget.el) {
return;
}
const node = this.__owl__.firstNode();
node.parentNode.insertBefore(this.widget.el, node);
this.widgetEl = this.widget.el;
widgetIsAttached = true;
};
onMounted(() => {
insertWidget();
if (this.widget && this.widget.on_attach_callback) {
this.widget.on_attach_callback();
}
});
onPatched(() => {
if (widgetIsAttached) {
this.renderWidget();
} else {
insertWidget();
}
});
onWillDestroy(() => this.__destroy(this.__owl__.parent.component));
onWillStart(this.onWillStart);
onWillUnmount(this.onWillUnmount);
}
onWillStart() {
if (!(this.props.Component.prototype instanceof Component)) {
this.widget = new this.props.Component(this, ...this.widgetArgs);
return this.widget._widgetRenderAndInsert(() => {});
}
}
onWillUnmount() {
this.removeEl();
if (this.widget && this.widget.on_detach_callback) {
this.widget.on_detach_callback();
}
}
removeEl() {
if (this.widgetEl) {
this.widgetEl.remove();
this.widgetEl = null;
}
}
__destroy() {
this.removeEl();
if (this.widget) {
this.widget.destroy();
}
}
get childProps() {
if (!this._childProps) {
this._childProps = Object.assign({}, this.props);
@@ -106,80 +184,6 @@ odoo.define('web.OwlCompatibility', function () {
return this._childProps;
}
/**
* Starts the legacy widget (not in the DOM yet)
*
* @override
*/
willStart() {
if (!(this.props.Component.prototype instanceof Component)) {
this.widget = new this.props.Component(this, ...this.widgetArgs);
return this.widget._widgetRenderAndInsert(() => {});
}
}
/**
* Updates the internal state of the legacy widget (but doesn't re-render
* it yet).
*
* @override
*/
willUpdateProps(nextProps) {
if (this.widget) {
return this.updateWidget(nextProps);
}
}
/**
* Hooks just before the actual patch to replace the fake div in the
* vnode by the actual node of the legacy widget. If the widget has to
* be re-render (because it has previously been updated), re-render it.
* This must be synchronous.
*
* @override
*/
__patch(target, vnode) {
if (this.widget) {
if (this.__owl__.vnode) { // not at first rendering
this.renderWidget();
}
vnode.elm = this.widget.el;
}
const result = super.__patch(...arguments);
if (this.widget && this.el !== this.widget.el) {
this.__owl__.vnode.elm = this.widget.el;
}
return result;
}
/**
* @override
*/
mounted() {
if (this.widget && this.widget.on_attach_callback) {
this.widget.on_attach_callback();
}
}
/**
* @override
*/
willUnmount() {
if (this.widget && this.widget.on_detach_callback) {
this.widget.on_detach_callback();
}
}
/**
* @override
*/
__destroy() {
super.__destroy(...arguments);
if (this.widget) {
this.widget.destroy();
}
}
/**
* Getter that translates the props (except 'Component') into the array
* of arguments used to initialize the legacy widget.
@@ -274,8 +278,27 @@ odoo.define('web.OwlCompatibility', function () {
this.trigger(evType.replace(/_/g, '-'), payload);
}
}
get el() {
if (this.widget) {
return this.widget.el;
}
return super.el;
}
}
function standaloneAdapter(props) {
const env = owl.Component.env;
const app = new App(null, {
templates: window.__OWL_TEMPLATES__,
env,
dev: env.isDebug(),
translatableAttributes: ["data-tooltip"],
translateFn: env._t,
});
const node = app.makeNode(ComponentAdapter, props);
return node.component;
}
/**
* Case 2) A legacy widget has to instantiate Owl components
@@ -341,154 +364,139 @@ odoo.define('web.OwlCompatibility', function () {
* the DOM (will be removed alongside this widget).
*/
destroy() {
for (const component of children.get(this) || []) {
component.env.qweb.off("update", component);
component.__destroy();
for (const wrapper of children.get(this) || []) {
wrapper.destroy();
}
children.delete(this);
},
};
class ComponentWrapper extends Component {
/**
* Stores the reference of the instance in the parent (in __components).
* Also creates a sub environment with a function that will be called
* just before events are triggered (see component_extension.js). This
* allows to add DOM event listeners on-the-fly, to redirect those Owl
* custom (yet DOM) events to legacy custom events (trigger_up).
*
* @override
* @param {Widget|null} parent
* @param {Component} Component this is a Class, not an instance
* @param {Object} props
*/
constructor(parent, Component, props) {
if (parent instanceof Component) {
throw new Error('ComponentWrapper must be used with a legacy Widget as parent');
//----------------------------------//
// Low-level coordination functions //
//----------------------------------//
/**
* Calls "callback" recursively on a ComponentNode's hierarchy
*
* @param {ComponentNode}
* @param {Boolean} childrenFirst whether to execute on the bottom-most child first
* @param {Function} callback
*/
function recursiveCall(node, childrenFirst = false, callback) {
if (!childrenFirst) {
callback(node);
}
for (const child of Object.values(node.children)) {
recursiveCall(child, childrenFirst, callback);
}
if (childrenFirst) {
callback(node);
}
}
/**
* Make the node able to distinguish between
* mounting in the DOM and mounting outside of if.
* @param {ComponentNode} node
*/
function prepareForFinish(node) {
const fiber = node.fiber;
const complete = fiber.complete;
fiber.complete = function() {
// if target is not in dom
// just trigger mounted hooks on the Proxy, not on any other node
if (!document.contains(this.target)) {
this.mounted = [this];
// We skipped a bunch of mounted calls.
// Following calls to patched may crash because of this.
// (e.g. useEffect dependencies set in mounted and used in patched)
}
super(null, props);
if (parent) {
this._register(parent);
complete.call(this);
};
}
const nodesToRemount = new WeakMap();
/**
* Pushed a node into the nodesToRemount WeakMap.
* The value is a callback that sets the node for remounting
* @param {ComponentNode} node
* @param {function} updateAndRender The original node's prototype's
*/
function setToRemount(node, updateAndRender) {
let toRemount = true;
node.mounted.push(() => {
toRemount = false;
});
node.updateAndRender = function (props, parentFiber) {
const res = updateAndRender.call(this, ...arguments);
const rootMounted = parentFiber.root.mounted;
if (toRemount && !rootMounted.includes(this.fiber)) {
rootMounted.push(this.fiber);
}
return res;
};
return () => toRemount = true;
}
/**
* Make the node able to remount its children nodes correctly
* Typically, in that case we don't call willPatch and patched hooks,
* rather, we want to call the mounted hooks
* @param {ComponentNode} mainNode
*/
function prepareForRemount(mainNode) {
const updateAndRender = mainNode.updateAndRender;
recursiveCall(mainNode, false, (node) => {
if (mainNode === node) {
return;
}
if (nodesToRemount.has(node)) {
nodesToRemount.get(node)();
return;
} else {
nodesToRemount.set(node, setToRemount(node, updateAndRender));
}
});
}
/**
* Registers a wrapper instance as a child of the given parent in the
* 'children' weakMap.
*
* @private
* @param {Widget} parent
*/
function registerWrapper(parent, wrapper) {
let parentChildren = children.get(parent);
if (!parentChildren) {
parentChildren = [];
children.set(parent, parentChildren);
}
parentChildren.push(wrapper);
}
/**
* The component class that will be instanciated between a legacy and an OWL 2 layer.
*/
class ProxyComponent extends Component {
setup() {
onMounted(() => {
this.props.mounted();
});
onPatched(() => {
this.props.patched();
});
onWillUnmount(() => {
// The current el will be change if we remount after unmounting
this._handledEvents = new Set();
});
this.parentWidget = this.props.parentWidget;
this._handledEvents = new Set();
useSubEnv({
[widgetSymbol]: this._addListener.bind(this)
});
this.parentWidget = parent;
this.Component = Component;
this.props = props || {};
this._handledEvents = new Set(); // Owl events we are redirecting
this.componentRef = useRef("component");
}
/**
* Calls __callMounted on itself and on each sub component (as this
* function isn't recursive) when the component is appended into the DOM.
*/
on_attach_callback() {
function recursiveCallMounted(component) {
const { status, currentFiber } = component.__owl__;
if (status === 2 && currentFiber && !currentFiber.isCompleted) {
// the component is rendered but another rendering is being done
// it would be foolish to declare the component and children as mounted
return;
}
if (
status !== 2 /* RENDERED */ &&
status !== 3 /* MOUNTED */ &&
status !== 4 /* UNMOUNTED */
) {
// Avoid calling mounted on a component that is not even
// rendered. Doing otherwise will lead to a crash if a
// specific mounted callback is legitimately relying on the
// component being mounted.
return;
}
for (const key in component.__owl__.children) {
recursiveCallMounted(component.__owl__.children[key]);
}
component.__callMounted();
}
recursiveCallMounted(this);
}
/**
* Calls __callWillUnmount to notify the component it will be unmounted.
*/
on_detach_callback() {
this.__callWillUnmount();
}
/**
* Overrides to remove the reference to this component in the parent.
*
* @override
*/
destroy() {
if (this.parentWidget) {
const parentChildren = children.get(this.parentWidget);
if (parentChildren) {
const index = parentChildren.indexOf(this);
children.get(this.parentWidget).splice(index, 1);
}
}
super.destroy();
}
/**
* Changes the parent of the wrapper component. This is a function of the
* legacy widgets (ParentedMixin), so we have to handle it someway.
* It simply removes the reference of this component in the current
* parent (if there was one), and adds the reference to the new one.
*
* We have at least one usecase for this: in views, the renderer is
* instantiated without parent, then a controller is instantiated with
* the renderer as argument, and finally, setParent is called to set the
* controller as parent of the renderer. This implies that Owl renderers
* can't trigger events in their constructor.
*
* @param {Widget} parent
*/
setParent(parent) {
if (parent instanceof Component) {
throw new Error('ComponentWrapper must be used with a legacy Widget as parent');
}
this._register(parent);
if (this.parentWidget) {
const parentChildren = children.get(this.parentWidget);
parentChildren.splice(parentChildren.indexOf(this), 1);
}
this.parentWidget = parent;
}
/**
* Updates the props and re-render the component.
*
* @async
* @param {Object} props
* @return {Promise}
*/
async update(props = {}) {
if (this.__owl__.status === 5 /* destroyed */) {
return new Promise(() => {});
}
Object.assign(this.props, props);
let prom;
if (this.__owl__.status === 3 /* mounted */) {
prom = this.render();
} else {
// we may not be in the DOM, but actually want to be redrawn
// (e.g. we were detached from the DOM, and now we're going to
// be re-attached, but we need to be reloaded first). In this
// case, we have to call 'mount' as Owl would skip the rendering
// if we simply call render.
prom = this.mount(...this._mountArgs);
}
return prom;
}
/**
* Adds an event handler that will redirect the given Owl event to an
* Odoo legacy event. This function is called just before the event is
@@ -498,7 +506,7 @@ odoo.define('web.OwlCompatibility', function () {
* @param {string} evType
*/
_addListener(evType) {
if (this.parentWidget && !this._handledEvents.has(evType)) {
if (this.parentWidget && !this._handledEvents.has(evType) && status(this) === "mounted") {
this._handledEvents.add(evType);
this.el.addEventListener(evType, ev => {
// as the WrappeComponent has the same root node as the
@@ -516,52 +524,216 @@ odoo.define('web.OwlCompatibility', function () {
});
}
}
}
ProxyComponent.template = xml`<t t-component="props.Component" t-props="props.props"/>`;
/**
* Registers this instance as a child of the given parent in the
* 'children' weakMap.
*
* @private
* @param {Widget} parent
*/
_register(parent) {
let parentChildren = children.get(parent);
if (!parentChildren) {
parentChildren = [];
children.set(parent, parentChildren);
class ComponentWrapper {
constructor(parent, Component, props) {
if (parent instanceof Component) {
throw new Error("ComponentWrapper must be used with a legacy Widget as parent");
}
parentChildren.push(this);
this.setParent(parent);
this.props = props;
this.Component = Component;
const env = owl.Component.env;
const appConfig = {
env,
templates: window.__OWL_TEMPLATES__,
dev: env.isDebug(),
translatableAttributes: ["data-tooltip"],
translateFn: env._t,
};
const app = new App(null, appConfig);
this.app = app;
this.node = this._makeOwlNode();
this.env = this.node.component.env;
app.root = this.node;
this.__owl__ = Object.create(this.node);
this.componentRef = { comp: null };
this.status = "new";
this.setup();
}
/**
* Stores mount target and position at first mount. That way, when updating
* while out of DOM, we know where and how to remount.
* @see update()
* @override
*/
_makeOwlNode() {
const onPatched = () => {
this.componentRef.comp = Object.values(this.node.children)[0].component;
if (this.renderResolve) {
this.renderResolve();
}
};
const props = {
props: this.props,
mounted: onPatched,
patched: onPatched,
Component: this.Component,
parentWidget: this.parentWidget,
};
return this.app.makeNode(ProxyComponent, props);
}
setup() {}
get el() {
return this.node.component.el;
}
//------------------//
// OWL 1 - like API //
//------------------//
async mount(target, options) {
if (options && options.position === 'self') {
throw new Error(
'Unsupported position: "self" is not allowed for wrapper components. ' +
'Contact the JS Framework team or open an issue if your use case is relevant.'
);
if (this.status === "mounted" || this.status === "willMount") {
return this.render();
} else if (this.status === "destroyed") {
return;
}
this._mountArgs = arguments;
return super.mount(...arguments);
if (target) {
this.target = target;
}
if (options) {
this.mountOptions = options;
}
if (this.status === "unmounted") {
prepareForRemount(this.node);
}
this.status = "willMount";
const prom = this.app.mountNode(this.node, this.target, this.mountOptions);
prepareForFinish(this.node);
await prom;
if (document.contains(this.target)) {
this.status = "mounted";
}
this.node.willStart = [];
// remove the promise.resolve from mounted callbacks
const mounted = this.node.mounted;
this.node.mounted = mounted.slice(0, mounted.length - 1);
return this;
}
//----------------------------------------------------------------------
// Getters
//----------------------------------------------------------------------
unmount() {
this.on_detach_callback();
this.el.remove();
this.node.bdom = null;
}
render() {
if (this.renderProm) {
this.node.render();
return this.renderProm;
}
this.renderProm = new Promise((resolve, reject) => {
this.renderResolve = resolve;
this.renderReject = reject;
}).then(() => {
this.renderProm = null;
this.renderResolve = null;
this.renderReject = null;
});
this.node.render();
return this.renderProm;
}
trigger() {
return this.node.component.trigger(...arguments);
}
destroy() {
if (["willMount", "unmounted"].includes(this.status)) {
recursiveCall(this.node, false, (node) => {
node.willUnmount = [];
});
}
this.app.destroy();
this.status = "destroyed";
}
//---------------------//
// API from legacy POV //
//---------------------//
get $el() {
return $(this.el);
}
on_attach_callback() {
if (!document.contains(this.el)) {
return;
}
if (this.status === "mounted") {
return;
}
recursiveCall(this.node, true, (node) => {
for (const cb of node.mounted) {
cb();
}
});
this.status = "mounted";
}
/**
* Calls willUnmount to notify the component it will be unmounted.
*/
on_detach_callback() {
if (this.status === "unmounted") {
return;
}
recursiveCall(this.node, false, (node) => {
// node.status might be "new" (0) here, if the component is
// currently being re-rendered, and a new component has just
// been instantiated, but as the rendering isn't completed, it
// isn't mounted yet
if (node.status === 1) {
const component = node.component;
for (const cb of node.willUnmount) {
cb.call(component);
}
}
});
this.node.status = 0;
this.status = "unmounted";
}
async update(nextProps) {
if (this.status === "destroyed") {
return;
}
const props = this.node.component.props.props;
const nextComponentProps = Object.assign({}, props, nextProps);
this.node.component.props.props = nextComponentProps;
this.props = nextComponentProps;
if (this.status === "unmounted") {
return this.mount(this.target);
} else {
return this.render();
}
}
setParent(parent) {
if (parent instanceof Component) {
throw new Error('ComponentWrapper must be used with a legacy Widget as parent');
}
if (parent) {
registerWrapper(parent, this);
}
if (this.parentWidget) {
const parentChildren = children.get(this.parentWidget);
parentChildren.splice(parentChildren.indexOf(this), 1);
}
this.parentWidget = parent;
if (this.node) {
this.node.component.parentWidget = parent;
}
}
}
ComponentWrapper.template = xml`<t t-component="Component" t-props="props" t-ref="component"/>`;
return {
ComponentAdapter,
ComponentWrapper,
WidgetAdapterMixin,
standaloneAdapter,
};
});
@@ -20,15 +20,18 @@ import {
mapLegacyEnvToWowlEnv,
makeLegacyRainbowManService,
} from "../../utils";
import { ComponentAdapter } from "web.OwlCompatibility";
import { standaloneAdapter } from "web.OwlCompatibility";
import { loadBundleTemplates } from "@web/core/assets";
import { makeEnv, startServices } from "@web/env";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { browser } from '@web/core/browser/browser';
import { jsonrpc } from '@web/core/network/rpc_service';
import { _t } from "@web/core/l10n/translation";
const serviceRegistry = registry.category("services");
const { Component, config, mount, whenReady } = owl;
const { Component, App, whenReady } = owl;
// Load localizations outside the PublicRoot to not wait for DOM ready (but
// wait for them in PublicRoot)
@@ -355,12 +358,10 @@ export const PublicRoot = publicWidget.RootWidget.extend({
},
});
/**
* Configure Owl with the public env
*/
config.mode = legacyEnv.isDebug() ? "dev" : "prod";
Component.env = legacyEnv;
owl.Component.env = legacyEnv;
/**
* This widget is important, because the tour manager needs a root widget in
@@ -404,17 +405,21 @@ export async function createPublicRoot(RootWidget) {
}, { force: true });
const wowlEnv = makeEnv();
wowlEnv.qweb.addTemplates(await loadBundleTemplates("web.assets_frontend"));
const templates = await loadBundleTemplates("web.assets_frontend");
window.__OWL_TEMPLATES__ = templates;
await startServices(wowlEnv);
mapLegacyEnvToWowlEnv(legacyEnv, wowlEnv);
const adapter = new ComponentAdapter(null, { Component }); // Used for _trigger_up compat layer
const publicRoot = new RootWidget(adapter);
// The root widget's parent is a standalone adapter so that it has _trigger_up
const publicRoot = new RootWidget(standaloneAdapter({ Component }));
await Promise.all([
mount(MainComponentsContainer, {
target: document.body,
new App(MainComponentsContainer, {
env: wowlEnv,
}),
dev: wowlEnv.debug,
templates: window.__OWL_TEMPLATES__,
translateFn: _t,
translatableAttributes: ["data-tooltip"],
}).mount(document.body),
publicRoot.attachTo(document.body),
]);
return publicRoot;
@@ -122,7 +122,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
if (this.withControlPanel) {
this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
}
if (!(this.renderer instanceof Component)) {
if (!(this.renderer instanceof ComponentWrapper)) {
this.renderer.on_attach_callback();
}
},
@@ -135,7 +135,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
if (this.withControlPanel) {
this.searchModel.off('get-controller-query-params', this);
}
if (!(this.renderer instanceof Component)) {
if (!(this.renderer instanceof ComponentWrapper)) {
this.renderer.on_detach_callback();
}
},
@@ -362,7 +362,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
* @private
*/
_startRenderer: function () {
if (this.renderer instanceof Component) {
if (this.renderer instanceof ComponentWrapper) {
return this.renderer.mount(this.$('.o_content')[0]);
}
return this.renderer.appendTo(this.$('.o_content'));
@@ -449,7 +449,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
* @return {Promise}
*/
_updateRendererState(state, params = {}) {
if (this.renderer instanceof Component) {
if (this.renderer instanceof ComponentWrapper) {
return this.renderer.update(state);
}
return this.renderer.updateState(state, params);
@@ -6,10 +6,9 @@
*
*/
import { renderToString } from '@web/core/utils/render';
import * as mvc from 'web.mvc';
const { Component } = owl;
// Renderers may display sample data when there is no real data to display. In
// this case the data is displayed with opacity and can't be clicked. Moreover,
// we also want to prevent the user from accessing DOM elements with TAB
@@ -154,12 +153,8 @@ export default mvc.Renderer.extend({
} else {
templateName = "web.NoContentHelper";
}
const template = document.createElement('template');
// FIXME: retrieve owl qweb instance via the env set on Component s.t.
// it also works in the tests (importing 'web.env' wouldn't). This
// won't be necessary as soon as this will be written in
const owlQWeb = Component.env.qweb;
template.innerHTML = owlQWeb.renderToString(templateName, context);
const innerHTML = renderToString(templateName, context);
const template = Object.assign(document.createElement("template"), { innerHTML });
this.el.append(template.content.firstChild);
},
/**
@@ -1,6 +1,6 @@
/** @odoo-module alias=web.AbstractRendererOwl **/
const { Component } = owl;
const { Component, onMounted, onPatched } = owl;
// Renderers may display sample data when there is no real data to display. In
// this case the data is displayed with opacity and can't be clicked. Moreover,
@@ -15,22 +15,20 @@ const FOCUSABLE_ELEMENTS = [
class AbstractRenderer extends Component {
constructor() {
super(...arguments);
setup() {
// Defines the elements suppressed when in demo data. This must be a list
// of DOM selectors matching view elements that will:
// 1. receive the 'o_sample_data_disabled' class (greyd out & no user events)
// 2. have themselves and any of their focusable children removed from the
// tab navigation
this.sampleDataTargets = [];
}
mounted() {
this._suppressFocusableElements();
}
patched() {
this._suppressFocusableElements();
onMounted(() => {
this._suppressFocusableElements();
});
onPatched(() => {
this._suppressFocusableElements();
});
}
/**
@@ -126,15 +126,10 @@ var AbstractView = Factory.extend({
// button when the graph view is embedded.
var isEmbedded = params.isEmbedded || false;
// The noContentHelper's message can be empty, i.e. either a real empty string
// or an empty html tag. In both cases, we consider the helper empty.
var help = params.noContentHelp || "";
var htmlHelp = document.createElement("div");
htmlHelp.innerHTML = help;
this.rendererParams = {
arch: this.arch,
isEmbedded: isEmbedded,
noContentHelp: htmlHelp.innerText.trim() ? help : "",
noContentHelp: params.noContentHelp,
};
this.controllerParams = {

Some files were not shown because too many files have changed in this diff Show More