From 0df9968433389e0d504276cd711473c2eef937ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Sun, 5 Mar 2017 09:32:37 +0100 Subject: [PATCH] [ADD] web: introduce new RPC framework With this commit, we introduce a new way to perform a RPC. During the history of the Odoo web client, many ways to do a rpc have been introduced, for various reasons. Let us mention the Model class (to perform a call on a specific method from a model, the web.ajax module (low level primitive), the dataset.call method, the this.rpc method (was removed at some point), the session.rpc method and maybe some others. With this commit, we reintroduce the this.rpc method, with a 'mid-level' API: not really high level (not automatic domain/context calculations), but still higher level than direct web.ajax calls. This new method is intended to replace all other RPCs in Odoo web client. Of course, web.ajax will still be available, but it is discouraged to use it directly. Using the rpc method means that the rpcs are done through an event bubbling up, which means that the call is testable. --- addons/web/static/src/js/core/rpc.js | 273 +++++++++++++++++++++++ addons/web/static/test/core/rpc_tests.js | 203 +++++++++++++++++ addons/web/views/webclient_templates.xml | 4 +- 3 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 addons/web/static/src/js/core/rpc.js create mode 100644 addons/web/static/test/core/rpc_tests.js diff --git a/addons/web/static/src/js/core/rpc.js b/addons/web/static/src/js/core/rpc.js new file mode 100644 index 00000000000..76d416ec110 --- /dev/null +++ b/addons/web/static/src/js/core/rpc.js @@ -0,0 +1,273 @@ +odoo.define('web.rpc', function (require) { +"use strict"; + +var Class = require('web.Class'); + +var BaseRPCBuilder = Class.extend({ + /** + * @param {Widget} parent the rpc will go through this widget + * @param {Object} params + * @param {string} [params.model] + * @param {string} [params.method] + * @param {string} [params.route] + */ + init: function (parent, params) { + this.parent = parent; + this._route = params.route; + if (!this._route) { + this._route = '/web/dataset/call_kw/' + params.model + '/' + params.method; + } + this._model = params.model; + this._method = params.method; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {Array} args + * @returns {BaseRPCBuilder} + */ + args: function (args) { + if (!(args instanceof Array)) { + throw new Error("Arguments should be an array"); + } + this._args = args; + return this; + }, + /** + * @param {Object} options + * @return {Deferred<*>} + */ + exec: function (options) { + if (options && options.callback) { + return options.callback(this._getRoute(), this._getArgs(), options.callbackOptions); + } + return this.parent.call('ajax', 'rpc', this._getRoute(), this._getArgs(), options || {}); + }, + /** + * @param {Object} kwargs + * @returns {BaseRPCBuilder} + */ + kwargs: function (kwargs) { + this._kwargs = kwargs; + return this; + }, + /** + * @param {Object} context + * @returns {BaseRPCBuilder} + */ + withContext: function (context) { + this._context = context; + return this; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Object} + */ + _getArgs: function () { + var kwargs = _.extend({}, this._kwargs); + if (this._context) { + kwargs.context = this._context; + } + return { + method: this._method, + model: this._model, + args: this._args || [], + kwargs: kwargs, + }; + }, + /** + * @private + * @returns {string} + */ + _getRoute: function () { + return this._route; + }, +}); + +var SearchRPCBuilder = BaseRPCBuilder.extend({ + init: function () { + this._super.apply(this, arguments); + this._route = '/web/dataset/search_read'; + this._orderBy = false; + this._domain = []; + this._fields = false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {Object[]} orderBy + * @returns {BaseRPCBuilder} + */ + orderBy: function (orderBy) { + // todo: serialize properly this + this._orderBy = this._serializeSort(orderBy); + return this; + }, + /** + * @param {Object} context + * @returns {BaseRPCBuilder} + */ + withContext: function (context) { + this._context = context; + return this; + }, + /** + * @param {Object} domain + * @returns {BaseRPCBuilder} + */ + withDomain: function (domain) { + this._domain = domain; + return this; + }, + /** + * @param {string[]} fields + * @returns {BaseRPCBuilder} + */ + withFields: function (fields) { + this._fields = fields; + return this; + }, + /** + * @param {Object} limit + * @returns {BaseRPCBuilder} + */ + withLimit: function (limit) { + this._limit = limit; + return this; + }, + /** + * @param {Object} offset + * @returns {BaseRPCBuilder} + */ + withOffset: function (offset) { + this._offset = offset; + return this; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @returns {Object} + */ + _getArgs: function () { + return { + context: this._context || {}, + domain: this._domain, + fields: this._fields, + limit: this._limit, + model: this._model, + sort: this._orderBy + }; + }, + /** + * Helper method, generates a string to describe a ordered by sequence for + * SQL. + * + * For example, [{name: 'foo'}, {name: 'bar', asc: false}] will + * be converted into 'foo ASC, bar DESC' + * + * @param {Object[]} orderBy list of objects {name:..., [asc: ...]} + * @returns {string} + */ + _serializeSort: function (orderBy) { + return _.map(orderBy, function (order) { + return order.name + (order.asc !== false ? ' ASC' : ' DESC'); + }).join(', '); + }, +}); + +var ReadGroupRPCBuilder = SearchRPCBuilder.extend({ + init: function () { + this._super.apply(this, arguments); + this._route = '/web/dataset/call_kw/' + this._model + '/read_group'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * We need to make sure that arguments are properly set. + * @override + * @returns {Deferred<*>} + */ + exec: function () { + if (!this._groupBy) { + throw new Error("read_group must have a group_by argument"); + } + return this._super.apply(this, arguments); + }, + /** + * @param {string[]} groupBy + * @returns {BaseRPCBuilder} + */ + groupBy: function (groupBy) { + // todo: serialize properly this + this._groupBy = groupBy; + return this; + }, + /** + * @param {boolean} lazy + * @returns {BaseRPCBuilder} + */ + lazy: function (lazy) { + // todo: serialize properly this + this._lazy = lazy; + return this; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @returns {Object} + */ + _getArgs: function () { + var kwargs = _.extend({}, this._kwargs, { + context: this._context || {}, + domain: this._domain || [], + fields: this._fields, + groupby: this._groupBy, + lazy: !!this._lazy, + orderby: this._orderBy, + }); + return { + args: this._args || [], + kwargs: kwargs, + method: 'read_group', + model: this._model, + }; + }, +}); + +return { + builders: { + default: BaseRPCBuilder, + search_read: SearchRPCBuilder, + read_group: ReadGroupRPCBuilder, + }, + query: function (params) { + var Builder = this.builders[params.method] || this.builders.default; + return new Builder(params.parent, params); + }, +}; + +}); diff --git a/addons/web/static/test/core/rpc_tests.js b/addons/web/static/test/core/rpc_tests.js new file mode 100644 index 00000000000..beec3fce831 --- /dev/null +++ b/addons/web/static/test/core/rpc_tests.js @@ -0,0 +1,203 @@ +odoo.define('web.rpc_tests', function (require) { +"use strict"; + +var rpc = require('web.rpc'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +function createQuery (params) { + var widget = new Widget(); + + if (params.mockRPC) { + testUtils.intercept(widget, 'call_service', function (event) { + if (event.data.service === 'ajax') { + params.mockRPC(event.data.args[0], JSON.parse(JSON.stringify(event.data.args[1]))); + } + }); + + } + + return rpc.query({ + method: params.method, + model: params.model, + route: params.route, + parent: widget, + }); +} + +QUnit.module('core', {}, function () { + + QUnit.module('RPC Builder'); + + QUnit.test('basic rpc (route)', function (assert) { + assert.expect(1); + var query = createQuery({ + route: '/my/route', + mockRPC: function (route) { + assert.strictEqual(route, '/my/route', + "should call the proper route"); + } + }); + query.exec(); + }); + + QUnit.test('basic rpc, with no context', function (assert) { + assert.expect(1); + var query = createQuery({ + route: '/my/route', + mockRPC: function (route, args) { + assert.notOk('context' in args.kwargs, + "does not automatically add a context"); + } + }); + query.exec(); + }); + + QUnit.test('basic rpc, with context', function (assert) { + assert.expect(1); + var query = createQuery({ + route: '/my/route', + mockRPC: function (route, args) { + assert.deepEqual(args.kwargs.context, {a: 1}, + "properly transfer the context"); + } + }); + query.withContext({a: 1}).exec(); + }); + + QUnit.test('basic rpc, with context, part 2', function (assert) { + assert.expect(1); + var query = createQuery({ + route: '/my/route', + mockRPC: function (route, args) { + assert.deepEqual(args.kwargs.context, {a: 1}, + "properly transfer the context"); + } + }); + query.kwargs({context: {a: 1}}).exec(); + }); + + QUnit.test('basic rpc (method of model)', function (assert) { + assert.expect(3); + var query = createQuery({ + model: 'partner', + method: 'test', + mockRPC: function (route, args) { + assert.strictEqual(route, '/web/dataset/call_kw/partner/test', + "should call the proper route"); + assert.strictEqual(args.model, 'partner', + "should correctly specify the model"); + assert.strictEqual(args.method, 'test', + "should correctly specify the method"); + } + }); + query.exec(); + }); + + QUnit.test('rpc with args and kwargs', function (assert) { + assert.expect(4); + var query = createQuery({ + model: 'partner', + method: 'test', + mockRPC: function (route, args) { + assert.strictEqual(route, '/web/dataset/call_kw/partner/test', + "should call the proper route"); + assert.strictEqual(args.args[0], 'arg1', + "should call with correct args"); + assert.strictEqual(args.args[1], 2, + "should call with correct args"); + assert.strictEqual(args.kwargs.k, 78, + "should call with correct kargs"); + } + }); + query + .args(['arg1', 2]) + .kwargs({k: 78}) + .exec(); + }); + + QUnit.test('rpc with context', function (assert) { + assert.expect(1); + var query = createQuery({ + model: 'partner', + method: 'test', + mockRPC: function (route, args) { + assert.deepEqual(args.kwargs.context, { a: 'hello' }, + "should have correct context"); + }, + }); + query + .withContext({a: 'hello'}) + .exec(); + }); + + QUnit.test('search_read', function (assert) { + assert.expect(1); + var query = createQuery({ + model: 'partner', + method: 'search_read', + mockRPC: function (route, args) { + assert.deepEqual(args, { + context: {}, + domain: ['a', '=', 1], + fields: ['name'], + limit: 32, + model: 'partner', + sort: 'yop ASC, aa DESC', + }, "should have correct args"); + }, + }); + query + .withDomain(['a', '=', 1]) + .withFields(['name']) + .withLimit(32) + .orderBy([{name: 'yop', asc: true}, {name: 'aa', asc: false}]) + .exec(); + }); + + QUnit.test('read_group', function (assert) { + assert.expect(2); + var query = createQuery({ + model: 'partner', + method: 'read_group', + mockRPC: function (route, args) { + assert.deepEqual(args, { + args: [], + kwargs: { + context: {abc: 'def'}, + domain: ['a', '=', 1], + fields: ['name'], + groupby: ['product_id'], + lazy: true, + orderby: false, + }, + method: 'read_group', + model: 'partner', + }, "should have correct args"); + assert.equal(route, '/web/dataset/call_kw/partner/read_group', + "should call correct route"); + }, + }); + query + .withDomain(['a', '=', 1]) + .withFields(['name']) + .groupBy(['product_id']) + .withContext({abc: 'def'}) + .lazy(true) + .exec(); + }); + + QUnit.test('search_read with no domain, nor fields', function (assert) { + assert.expect(2); + createQuery({ + model: 'partner', + method: 'search_read', + mockRPC: function (route, args) { + assert.deepEqual(args.domain, [], "should have [] as default domain"); + assert.strictEqual(args.fields, false, "should have false as default fields"); + }, + }).exec(); + }); +}); + +}); \ No newline at end of file diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 8792b7c988a..295001ccf94 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -69,10 +69,10 @@ + - @@ -141,7 +141,6 @@ - @@ -446,6 +445,7 @@ +