diff --git a/addons/web/static/src/js/env.js b/addons/web/static/src/js/env.js
index c223fcc91ee..72d14ae3e60 100644
--- a/addons/web/static/src/js/env.js
+++ b/addons/web/static/src/js/env.js
@@ -1,15 +1,15 @@
odoo.define("web.env", function (require) {
"use strict";
- const { _lt, _t, bus } = require("web.core");
- const { blockUI, unblockUI } = require("web.framework");
- const { device, isDebug } = require("web.config");
const { jsonRpc } = require('web.ajax');
+ const { device, isDebug } = require("web.config");
+ const { _lt, _t, bus, serviceRegistry } = require("web.core");
+ const dataManager = require('web.data_manager');
+ const { blockUI, unblockUI } = require("web.framework");
const rpc = require("web.rpc");
const session = require("web.session");
const utils = require("web.utils");
-
const qweb = new owl.QWeb({ translateFn: _t });
function ajaxJsonRPC() {
@@ -59,6 +59,64 @@ odoo.define("web.env", function (require) {
utils.set_cookie(...arguments);
}
+ // ServiceProvider
+ const services = {}; // dict containing deployed service instances
+ const UndeployedServices = {}; // dict containing classes of undeployed services
+ function _deployServices() {
+ let done = false;
+ while (!done) {
+ const serviceName = _.findKey(UndeployedServices, Service => {
+ // no missing dependency
+ return !_.some(Service.prototype.dependencies, depName => {
+ return !services[depName];
+ });
+ });
+ if (serviceName) {
+ const Service = UndeployedServices[serviceName];
+ // we created a patched version of the Service in which the 'trigger_up'
+ // function directly calls the requested service, instead of triggering
+ // a 'call_service' event up, which wouldn't work as services have
+ // no parent
+ const PatchedService = Service.extend({
+ _trigger_up: function (ev) {
+ this._super(...arguments);
+ if (!ev.is_stopped() && ev.name === 'call_service') {
+ const payload = ev.data;
+ let args = payload.args || [];
+ if (payload.service === 'ajax' && payload.method === 'rpc') {
+ // ajax service uses an extra 'target' argument for rpc
+ args = args.concat(ev.target);
+ }
+ const service = services[payload.service];
+ const result = service[payload.method].apply(service, args);
+ payload.callback(result);
+ }
+ },
+ });
+ const service = new PatchedService();
+ services[serviceName] = service;
+ delete UndeployedServices[serviceName];
+ service.start();
+ } else {
+ done = true;
+ }
+ }
+ }
+ _.each(serviceRegistry.map, (Service, serviceName) => {
+ if (serviceName in UndeployedServices) {
+ throw new Error(`Service ${serviceName} is already loaded.`);
+ }
+ UndeployedServices[serviceName] = Service;
+ });
+ serviceRegistry.onAdd((serviceName, Service) => {
+ if (serviceName in services || serviceName in UndeployedServices) {
+ throw new Error(`Service ${serviceName} is already loaded.`);
+ }
+ UndeployedServices[serviceName] = Service;
+ _deployServices();
+ });
+ _deployServices();
+
// There should be as much dependencies as possible in the env object.
// This will allow an easier testing of components.
// See https://github.com/odoo/owl/blob/master/doc/reference/environment.md#content-of-an-environment
@@ -67,10 +125,11 @@ odoo.define("web.env", function (require) {
_lt,
_t,
bus,
+ dataManager,
device,
isDebug,
qweb,
- services: {
+ services: Object.assign(services, {
ajaxJsonRPC,
blockUI,
getCookie,
@@ -80,7 +139,7 @@ odoo.define("web.env", function (require) {
rpc: performRPC,
setCookie,
unblockUI,
- },
+ }),
session,
};
});
diff --git a/addons/web/static/src/js/owl_compatibility.js b/addons/web/static/src/js/owl_compatibility.js
new file mode 100644
index 00000000000..e04e42c30b5
--- /dev/null
+++ b/addons/web/static/src/js/owl_compatibility.js
@@ -0,0 +1,258 @@
+odoo.define('web.OwlCompatibility', function () {
+ "use strict";
+
+ /**
+ * This file defines the necessary tools for the transition phase where Odoo
+ * legacy widgets and Owl components will coexist. There are two possible
+ * scenarios:
+ * 1) An Owl component has to instantiate legacy widgets
+ * 2) A legacy widget has to instantiate Owl components
+ */
+
+ const { Component, tags } = owl;
+
+ /**
+ * Case 1) An Owl component has to instantiate legacy widgets
+ * ----------------------------------------------------------
+ *
+ * The ComponentAdapter is an Owl component meant to be used as universal
+ * adapter for Owl components that embed Odoo legacy widgets (or dynamically
+ * both Owl components and Odoo legacy widgets), e.g.:
+ *
+ * Owl Component
+ * |
+ * ComponentAdapter (Owl component)
+ * |
+ * Legacy Widget(s) (or Owl component(s))
+ *
+ *
+ * The adapter takes the component/widget class as 'Component' prop, and the
+ * arguments (except first arg 'parent') to initialize it as props.
+ * For instance:
+ *
+ * will be translated to:
+ * const LegacyWidget = this.props.Component;
+ * const legacyWidget = new LegacyWidget(this, this.props.params);
+ *
+ * If more than one argument (in addition to 'parent') is given to initialize
+ * the legacy widget, the arguments order (to initialize the sub widget) has
+ * to be somehow specified. There are two alternatives. One can either (1)
+ * specify the prop 'widgetArgs', corresponding to the array of arguments,
+ * otherwise (2) a subclass of ComponentAdapter has to be defined. This
+ * subclass must override the 'widgetArgs' getter to translate arguments
+ * received as props to an array of arguments for the call to init.
+ * For instance:
+ * (1)
+ * (2) class SpecificAdapter extends ComponentAdapter {
+ * get widgetArgs() {
+ * return [this.props.firstArg, this.props.secondArg];
+ * }
+ * }
+ *
+ *
+ * If the legacy widget has to be updated when props change, one must define
+ * a subclass of ComponentAdapter to override 'update' and 'render'. The
+ * 'update' function takes the nextProps as argument, and should update the
+ * internal state of the widget (might be async, and return a Promise).
+ * However, to ensure that the DOM is updated all at once, it shouldn't do
+ * a re-rendering. This is the role of function 'render', which will be
+ * called just before patching the DOM, and which thus must be synchronous.
+ * For instance:
+ * class SpecificAdapter extends ComponentAdapter {
+ * update(nextProps) {
+ * return this.widget.updateState(nextProps);
+ * }
+ * render() {
+ * return this.widget.render();
+ * }
+ * }
+ */
+ class ComponentAdapter extends Component {
+ /**
+ * Creates the template on-the-fly, depending on the type of Component
+ * (legacy widget or Owl component).
+ *
+ * @override
+ */
+ constructor(parent, props) {
+ if (!props.Component) {
+ throw Error(`ComponentAdapter: 'Component' prop is missing.`);
+ }
+ let template;
+ if (!(props.Component.prototype instanceof Component)) {
+ template = tags.xml`
`;
+ } else {
+ let propsStr = '';
+ for (let p in props) {
+ if (p !== 'Component') {
+ propsStr += ` ${p}="props.${p}"`;
+ }
+ }
+ template = tags.xml``;
+ }
+ ComponentAdapter.template = template;
+ super(...arguments);
+ this.template = template;
+ ComponentAdapter.template = null;
+
+ this.widget = null; // widget instance, if Component is a legacy widget
+ }
+
+ /**
+ * 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.update(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(vnode) {
+ if (this.widget) {
+ if (this.__owl__.vnode) { // not at first rendering
+ this.render();
+ }
+ vnode.elm = this.widget.el;
+ }
+ return super.__patch(...arguments);
+ }
+
+ /**
+ * @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.
+ *
+ * Must be overriden if at least two props (other that Component) are
+ * given.
+ *
+ * @returns {Array}
+ */
+ get widgetArgs() {
+ if (this.props.widgetArgs) {
+ return this.props.widgetArgs;
+ }
+ const args = Object.keys(this.props);
+ args.splice(args.indexOf('Component'), 1);
+ if (args.length > 1) {
+ throw new Error(`ComponentAdapter has more than 1 argument, 'widgetArgs' must be overriden.`);
+ }
+ return args.map(a => this.props[a]);
+ }
+
+ /**
+ * Can be overriden to update the internal state of the widget when props
+ * change. To ensure that the DOM is updated at once, this function should
+ * not do a re-rendering (which should be done by 'render' instead).
+ *
+ * @param {Object} nextProps
+ * @returns {Promise}
+ */
+ update(/*nextProps*/) {
+ console.warn(`ComponentAdapter: Widget could not be updated, maybe override 'update' function?`);
+ }
+
+ /**
+ * Can be overriden to re-render the widget after an update. This
+ * function will be called just before patchin the DOM, s.t. the DOM is
+ * updated at once. It must be synchronous
+ */
+ render() {
+ console.warn(`ComponentAdapter: Widget could not be re-rendered, maybe override 'render' function?`);
+ }
+
+ /**
+ * Mocks _trigger_up to redirect Odoo legacy events to OWL events.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _trigger_up(ev) {
+ const evType = ev.name;
+ const payload = ev.data;
+ if (evType === 'call_service') {
+ let args = payload.args || [];
+ if (payload.service === 'ajax' && payload.method === 'rpc') {
+ // ajax service uses an extra 'target' argument for rpc
+ args = args.concat(ev.target);
+ }
+ const service = this.env.services[payload.service];
+ const result = service[payload.method].apply(service, args);
+ payload.callback(result);
+ } else if (evType === 'get_session') {
+ if (payload.callback) {
+ payload.callback(this.env.session);
+ }
+ } else if (evType === 'load_views') {
+ const params = {
+ model: payload.modelName,
+ context: payload.context,
+ views_descr: payload.views,
+ };
+ this.env.dataManager
+ .load_views(params, payload.options || {})
+ .then(payload.on_success);
+ } else if (evType === 'load_filters') {
+ return this.env.dataManager
+ .load_filters(payload)
+ .then(payload.on_success);
+ } else {
+ payload.__targetWidget = ev.target;
+ this.trigger(evType.replace(/_/g, '-'), payload);
+ }
+ }
+ }
+
+ return {
+ ComponentAdapter,
+ };
+});
diff --git a/addons/web/static/tests/helpers/test_env.js b/addons/web/static/tests/helpers/test_env.js
index 417350ef65b..fabe52ff444 100644
--- a/addons/web/static/tests/helpers/test_env.js
+++ b/addons/web/static/tests/helpers/test_env.js
@@ -1,8 +1,10 @@
odoo.define('web.test_env', async function (require) {
"use strict";
- const { buildQuery } = require("web.rpc");
+ const AbstractStorageService = require('web.AbstractStorageService');
const Bus = require("web.Bus");
+ const RamStorage = require('web.RamStorage');
+ const { buildQuery } = require("web.rpc");
const session = require('web.session');
/**
@@ -45,6 +47,10 @@ odoo.define('web.test_env', async function (require) {
*/
function makeTestEnvironment(env = {}, providedRPC = null) {
const proxiedEnv = _proxify(env, 'env');
+ const RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+ let testEnv = {};
const defaultEnv = {
_t: env._t || (s => s),
_lt: env._lt || (s => s),
@@ -54,11 +60,22 @@ odoo.define('web.test_env', async function (require) {
}, env.device),
qweb: new owl.QWeb({ templates: session.owlTemplates }),
services: Object.assign({
+ ajax: { // for legacy subwidgets
+ rpc() {
+ const prom = testEnv.session.rpc(...arguments);
+ prom.abort = function () {
+ throw new Error("Can't abort this request");
+ };
+ return prom;
+ },
+ },
getCookie() { },
rpc(params, options) {
const query = buildQuery(params);
return testEnv.session.rpc(query.route, query.params, options);
},
+ local_storage: new RamStorageService(),
+ session_storage: new RamStorageService(),
}, env.services),
session: Object.assign({
rpc(route, params, options) {
@@ -69,7 +86,7 @@ odoo.define('web.test_env', async function (require) {
},
}, env.session),
};
- const testEnv = Object.assign(proxiedEnv, defaultEnv);
+ testEnv = Object.assign(proxiedEnv, defaultEnv);
return testEnv;
}
diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js
index 447c39240a7..a9c07973566 100644
--- a/addons/web/static/tests/helpers/test_utils.js
+++ b/addons/web/static/tests/helpers/test_utils.js
@@ -121,6 +121,7 @@ return Promise.all([
return {
mock: {
addMockEnvironment: testUtilsMock.addMockEnvironment,
+ getMockedOwlEnv: testUtilsMock.getMockedOwlEnv,
intercept: testUtilsMock.intercept,
patch: testUtilsMock.patch,
patchDate: testUtilsMock.patchDate,
diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js
index b4c722b7de1..fe45bbb0e8e 100644
--- a/addons/web/static/tests/helpers/test_utils_mock.js
+++ b/addons/web/static/tests/helpers/test_utils_mock.js
@@ -10,14 +10,15 @@ odoo.define('web.test_utils_mock', function (require) {
* testUtils file.
*/
-var basic_fields = require('web.basic_fields');
-var config = require('web.config');
-var core = require('web.core');
-var dom = require('web.dom');
-var MockServer = require('web.MockServer');
-var session = require('web.session');
+const basic_fields = require('web.basic_fields');
+const config = require('web.config');
+const core = require('web.core');
+const dom = require('web.dom');
+const makeTestEnvironment = require('web.test_env');
+const MockServer = require('web.MockServer');
+const session = require('web.session');
-var DebouncedField = basic_fields.DebouncedField;
+const DebouncedField = basic_fields.DebouncedField;
//------------------------------------------------------------------------------
@@ -359,6 +360,66 @@ function fieldsViewGet(server, params) {
return fieldsView;
}
+/**
+ * Returns a mocked environment to be used by OWL components in tests.
+ *
+ * @param {Object} [params]
+ * @param {Object} [params.actions] the actions given to the mock server
+ * @param {Object} [params.archs] this archs given to the mock server
+ * @param {Object} [params.data] the business data given to the mock server
+ * @param {boolean} [params.debug]
+ * @param {function} [params.mockRPC]
+ * @returns {Object}
+ */
+function getMockedOwlEnv(params) {
+ params = params || {};
+ let Server = MockServer;
+ if (params.mockRPC) {
+ Server = MockServer.extend({ _performRpc: params.mockRPC });
+ }
+ const server = new Server(params.data, {
+ actions: params.actions,
+ archs: params.archs,
+ debug: params.debug,
+ });
+ const env = {
+ dataManager: {
+ load_action: (actionID, context) => {
+ return server.performRpc('/web/action/load', {
+ kwargs: {
+ action_id: actionID,
+ additional_context: context,
+ },
+ });
+ },
+ load_views: (params, options) => {
+ return server.performRpc('/web/dataset/call_kw/' + params.model, {
+ args: [],
+ kwargs: {
+ context: params.context,
+ options: options,
+ views: params.views_descr,
+ },
+ method: 'load_views',
+ model: params.model,
+ }).then(function (views) {
+ return _.mapObject(views, viewParams => {
+ return fieldsViewGet(server, viewParams);
+ });
+ });
+ },
+ load_filters: params => {
+ if (params.debug) {
+ console.log('[mock] load_filters', params);
+ }
+ return Promise.resolve([]);
+ },
+ },
+ session: params.session || {},
+ };
+ return makeTestEnvironment(env, server.performRpc.bind(server));
+}
+
/**
* intercepts an event bubbling up the widget hierarchy. The event intercepted
* must be a "custom event", i.e. an event generated by the method 'trigger_up'.
@@ -573,6 +634,7 @@ function patchSetTimeout() {
return {
addMockEnvironment: addMockEnvironment,
fieldsViewGet: fieldsViewGet,
+ getMockedOwlEnv: getMockedOwlEnv,
intercept: intercept,
patchDate: patchDate,
patch: patch,
diff --git a/addons/web/static/tests/owl_compatibility_tests.js b/addons/web/static/tests/owl_compatibility_tests.js
new file mode 100644
index 00000000000..b82eeb04857
--- /dev/null
+++ b/addons/web/static/tests/owl_compatibility_tests.js
@@ -0,0 +1,674 @@
+odoo.define('web.OwlCompatibilityTests', function (require) {
+ "use strict";
+
+ const { ComponentAdapter } = require('web.OwlCompatibility');
+ const testUtils = require('web.test_utils');
+ const Widget = require('web.Widget');
+
+ const getMockedOwlEnv = testUtils.mock.getMockedOwlEnv;
+ const makeTestPromise = testUtils.makeTestPromise;
+ const nextTick = testUtils.nextTick;
+
+ const { Component, tags, useState } = owl;
+ const { xml } = tags;
+
+ QUnit.module("Owl Compatibility", function () {
+ QUnit.module("ComponentAdapter");
+
+ QUnit.test("sub widget with no argument", async function (assert) {
+ assert.expect(1);
+
+ const MyWidget = Widget.extend({
+ start: function () {
+ this.$el.text('Hello World!');
+ }
+ });
+ class Parent extends Component {
+ constructor() {
+ super(...arguments);
+ this.MyWidget = MyWidget;
+ }
+ }
+ Parent.template = xml`
+