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 @@
+