diff --git a/addons/web/common/http.py b/addons/web/common/http.py index 71a280f27eb..9294808fda4 100644 --- a/addons/web/common/http.py +++ b/addons/web/common/http.py @@ -405,14 +405,14 @@ def session_context(request, storage_path, session_cookie='sessionid'): #---------------------------------------------------------- addons_module = {} addons_manifest = {} -controllers_class = {} +controllers_class = [] controllers_object = {} controllers_path = {} class ControllerType(type): def __init__(cls, name, bases, attrs): super(ControllerType, cls).__init__(name, bases, attrs) - controllers_class["%s.%s" % (cls.__module__, cls.__name__)] = cls + controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls)) class Controller(object): __metaclass__ = ControllerType @@ -440,12 +440,12 @@ class Root(object): self.root = '/web/webclient/home' self.config = options - if self.config.backend == 'local': - conn = LocalConnector() - else: - conn = openerplib.get_connector(hostname=self.config.server_host, - port=self.config.server_port) - self.config.connector = conn + if not hasattr(self.config, 'connector'): + if self.config.backend == 'local': + self.config.connector = LocalConnector() + else: + self.config.connector = openerplib.get_connector( + hostname=self.config.server_host, port=self.config.server_port) self.session_cookie = 'sessionid' self.addons = {} @@ -526,7 +526,7 @@ class Root(object): addons_module[module] = m addons_manifest[module] = manifest statics['/%s/static' % module] = path_static - for k, v in controllers_class.items(): + for k, v in controllers_class: if k not in controllers_object: o = v() controllers_object[k] = o diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 1cdeb50cf25..9b4c8335e35 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -909,11 +909,6 @@ class Menu(openerpweb.Controller): class DataSet(openerpweb.Controller): _cp_path = "/web/dataset" - @openerpweb.jsonrequest - def fields(self, req, model): - return {'fields': req.session.model(model).fields_get(False, - req.session.eval_context(req.context))} - @openerpweb.jsonrequest def search_read(self, req, model, fields=False, offset=0, limit=False, domain=None, sort=None): return self.do_search_read(req, model, fields, offset, limit, domain, sort) @@ -949,7 +944,6 @@ class DataSet(openerpweb.Controller): if fields and fields == ['id']: # shortcut read if we only want the ids return { - 'ids': ids, 'length': length, 'records': [{'id': id} for id in ids] } @@ -957,46 +951,10 @@ class DataSet(openerpweb.Controller): records = Model.read(ids, fields or False, context) records.sort(key=lambda obj: ids.index(obj['id'])) return { - 'ids': ids, 'length': length, 'records': records } - - @openerpweb.jsonrequest - def read(self, req, model, ids, fields=False): - return self.do_search_read(req, model, ids, fields) - - @openerpweb.jsonrequest - def get(self, req, model, ids, fields=False): - return self.do_get(req, model, ids, fields) - - def do_get(self, req, model, ids, fields=False): - """ Fetches and returns the records of the model ``model`` whose ids - are in ``ids``. - - The results are in the same order as the inputs, but elements may be - missing (if there is no record left for the id) - - :param req: the JSON-RPC2 request object - :type req: openerpweb.JsonRequest - :param model: the model to read from - :type model: str - :param ids: a list of identifiers - :type ids: list - :param fields: a list of fields to fetch, ``False`` or empty to fetch - all fields in the model - :type fields: list | False - :returns: a list of records, in the same order as the list of ids - :rtype: list - """ - Model = req.session.model(model) - records = Model.read(ids, fields, req.session.eval_context(req.context)) - - record_map = dict((record['id'], record) for record in records) - - return [record_map[id] for id in ids if record_map.get(id)] - @openerpweb.jsonrequest def load(self, req, model, id, fields): m = req.session.model(model) @@ -1006,23 +964,6 @@ class DataSet(openerpweb.Controller): value = r[0] return {'value': value} - @openerpweb.jsonrequest - def create(self, req, model, data): - m = req.session.model(model) - r = m.create(data, req.session.eval_context(req.context)) - return {'result': r} - - @openerpweb.jsonrequest - def save(self, req, model, id, data): - m = req.session.model(model) - r = m.write([id], data, req.session.eval_context(req.context)) - return {'result': r} - - @openerpweb.jsonrequest - def unlink(self, req, model, ids=()): - Model = req.session.model(model) - return Model.unlink(ids, req.session.eval_context(req.context)) - def call_common(self, req, model, method, args, domain_id=None, context_id=None): has_domain = domain_id is not None and domain_id < len(args) has_context = context_id is not None and context_id < len(args) @@ -1098,19 +1039,7 @@ class DataSet(openerpweb.Controller): @openerpweb.jsonrequest def exec_workflow(self, req, model, id, signal): - r = req.session.exec_workflow(model, id, signal) - return {'result': r} - - @openerpweb.jsonrequest - def default_get(self, req, model, fields): - Model = req.session.model(model) - return Model.default_get(fields, req.session.eval_context(req.context)) - - @openerpweb.jsonrequest - def name_search(self, req, model, search_str, domain=[], context={}): - m = req.session.model(model) - r = m.name_search(search_str+'%', domain, '=ilike', context) - return {'result': r} + return req.session.exec_workflow(model, id, signal) class DataGroup(openerpweb.Controller): _cp_path = "/web/group" diff --git a/addons/web/static/lib/qunit/qunit.css b/addons/web/static/lib/qunit/qunit.css old mode 100755 new mode 100644 index bcecc4c0daf..58101ea34ce --- a/addons/web/static/lib/qunit/qunit.css +++ b/addons/web/static/lib/qunit/qunit.css @@ -1,9 +1,9 @@ /** - * QUnit v1.2.0 - A JavaScript Unit Testing Framework + * QUnit v1.4.0pre - A JavaScript Unit Testing Framework * * http://docs.jquery.com/QUnit * - * Copyright (c) 2011 John Resig, Jörn Zaefferer + * Copyright (c) 2012 John Resig, Jörn Zaefferer * Dual licensed under the MIT (MIT-LICENSE.txt) * or GPL (GPL-LICENSE.txt) licenses. */ @@ -54,6 +54,10 @@ color: #fff; } +#qunit-header label { + display: inline-block; +} + #qunit-banner { height: 5px; } @@ -223,4 +227,6 @@ position: absolute; top: -10000px; left: -10000px; + width: 1000px; + height: 1000px; } diff --git a/addons/web/static/lib/qunit/qunit.js b/addons/web/static/lib/qunit/qunit.js old mode 100755 new mode 100644 index 6d2a8a7b8ab..b71381313c7 --- a/addons/web/static/lib/qunit/qunit.js +++ b/addons/web/static/lib/qunit/qunit.js @@ -1,9 +1,9 @@ /** - * QUnit v1.2.0 - A JavaScript Unit Testing Framework + * QUnit v1.4.0pre - A JavaScript Unit Testing Framework * * http://docs.jquery.com/QUnit * - * Copyright (c) 2011 John Resig, Jörn Zaefferer + * Copyright (c) 2012 John Resig, Jörn Zaefferer * Dual licensed under the MIT (MIT-LICENSE.txt) * or GPL (GPL-LICENSE.txt) licenses. */ @@ -13,8 +13,11 @@ var defined = { setTimeout: typeof window.setTimeout !== "undefined", sessionStorage: (function() { + var x = "qunit-test-string"; try { - return !!sessionStorage.getItem; + sessionStorage.setItem(x, x); + sessionStorage.removeItem(x); + return true; } catch(e) { return false; } @@ -25,11 +28,10 @@ var testId = 0, toString = Object.prototype.toString, hasOwn = Object.prototype.hasOwnProperty; -var Test = function(name, testName, expected, testEnvironmentArg, async, callback) { +var Test = function(name, testName, expected, async, callback) { this.name = name; this.testName = testName; this.expected = expected; - this.testEnvironmentArg = testEnvironmentArg; this.async = async; this.callback = callback; this.assertions = []; @@ -62,6 +64,10 @@ Test.prototype = { runLoggingCallbacks( 'moduleStart', QUnit, { name: this.module } ); + } else if (config.autorun) { + runLoggingCallbacks( 'moduleStart', QUnit, { + name: this.module + } ); } config.current = this; @@ -69,9 +75,6 @@ Test.prototype = { setup: function() {}, teardown: function() {} }, this.moduleTestEnvironment); - if (this.testEnvironmentArg) { - extend(this.testEnvironment, this.testEnvironmentArg); - } runLoggingCallbacks( 'testStart', QUnit, { name: this.testName, @@ -274,17 +277,12 @@ var QUnit = { }, test: function(testName, expected, callback, async) { - var name = '' + testName + '', testEnvironmentArg; + var name = '' + escapeInnerText(testName) + ''; if ( arguments.length === 2 ) { callback = expected; expected = null; } - // is 2nd argument a testEnvironment? - if ( expected && typeof expected === 'object') { - testEnvironmentArg = expected; - expected = null; - } if ( config.currentModule ) { name = '' + config.currentModule + ": " + name; @@ -294,7 +292,7 @@ var QUnit = { return; } - var test = new Test(name, testName, expected, testEnvironmentArg, async, callback); + var test = new Test(name, testName, expected, async, callback); test.module = config.currentModule; test.moduleTestEnvironment = config.currentModuleTestEnviroment; test.queue(); @@ -312,6 +310,9 @@ var QUnit = { * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" ); */ ok: function(a, msg) { + if (!config.current) { + throw new Error("ok() assertion outside test context, was " + sourceFromStacktrace(2)); + } a = !!a; var details = { result: a, @@ -447,9 +448,14 @@ var QUnit = { QUnit.constructor = F; })(); -// Backwards compatibility, deprecated -QUnit.equals = QUnit.equal; -QUnit.same = QUnit.deepEqual; +// deprecated; still export them to window to provide clear error messages +// next step: remove entirely +QUnit.equals = function() { + throw new Error("QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead"); +}; +QUnit.same = function() { + throw new Error("QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead"); +}; // Maintain internal state var config = { @@ -513,8 +519,7 @@ if ( typeof exports === "undefined" || typeof require === "undefined" ) { extend(window, QUnit); window.QUnit = QUnit; } else { - extend(exports, QUnit); - exports.QUnit = QUnit; + module.exports = QUnit; } // define these after exposing globals to keep them in these QUnit namespace only @@ -536,6 +541,16 @@ extend(QUnit, { semaphore: 0 }); + var qunit = id( "qunit" ); + if ( qunit ) { + qunit.innerHTML = + '

' + escapeInnerText( document.title ) + '

' + + '

' + + '
' + + '

' + + '
    '; + } + var tests = id( "qunit-tests" ), banner = id( "qunit-banner" ), result = id( "qunit-testresult" ); @@ -564,15 +579,15 @@ extend(QUnit, { /** * Resets the test setup. Useful for tests that modify the DOM. * - * If jQuery is available, uses jQuery's html(), otherwise just innerHTML. + * If jQuery is available, uses jQuery's replaceWith(), otherwise use replaceChild */ reset: function() { - if ( window.jQuery ) { - jQuery( "#qunit-fixture" ).html( config.fixture ); - } else { - var main = id( 'qunit-fixture' ); - if ( main ) { - main.innerHTML = config.fixture; + var main = id( 'qunit-fixture' ); + if ( main ) { + if ( window.jQuery ) { + jQuery( main ).replaceWith( config.fixture.cloneNode(true) ); + } else { + main.parentNode.replaceChild(config.fixture.cloneNode(true), main); } } }, @@ -636,6 +651,9 @@ extend(QUnit, { }, push: function(result, actual, expected, message) { + if (!config.current) { + throw new Error("assertion outside test context, was " + sourceFromStacktrace()); + } var details = { result: result, message: message, @@ -645,21 +663,22 @@ extend(QUnit, { message = escapeInnerText(message) || (result ? "okay" : "failed"); message = '' + message + ""; - expected = escapeInnerText(QUnit.jsDump.parse(expected)); - actual = escapeInnerText(QUnit.jsDump.parse(actual)); - var output = message + ''; - if (actual != expected) { - output += ''; - output += ''; - } + var output = message; if (!result) { + expected = escapeInnerText(QUnit.jsDump.parse(expected)); + actual = escapeInnerText(QUnit.jsDump.parse(actual)); + output += '
    Expected:
    ' + expected + '
    Result:
    ' + actual + '
    Diff:
    ' + QUnit.diff(expected, actual) +'
    '; + if (actual != expected) { + output += ''; + output += ''; + } var source = sourceFromStacktrace(); if (source) { details.source = source; output += ''; } + output += "
    Expected:
    ' + expected + '
    Result:
    ' + actual + '
    Diff:
    ' + QUnit.diff(expected, actual) +'
    Source:
    ' + escapeInnerText(source) + '
    "; } - output += ""; runLoggingCallbacks( 'log', QUnit, details ); @@ -779,7 +798,7 @@ QUnit.load = function() { var main = id('qunit-fixture'); if ( main ) { - config.fixture = main.innerHTML; + config.fixture = main.cloneNode(true); } if (config.autostart) { @@ -847,6 +866,15 @@ function done() { ].join(" "); } + // clear own sessionStorage items if all tests passed + if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) { + for (var key in sessionStorage) { + if (sessionStorage.hasOwnProperty(key) && key.indexOf("qunit-") === 0 ) { + sessionStorage.removeItem(key); + } + } + } + runLoggingCallbacks( 'done', QUnit, { failed: config.stats.bad, passed: passed, @@ -881,16 +909,21 @@ function validTest( name ) { // so far supports only Firefox, Chrome and Opera (buggy) // could be extended in the future to use something like https://github.com/csnover/TraceKit -function sourceFromStacktrace() { +function sourceFromStacktrace(offset) { + offset = offset || 3; try { throw new Error(); } catch ( e ) { if (e.stacktrace) { // Opera - return e.stacktrace.split("\n")[6]; + return e.stacktrace.split("\n")[offset + 3]; } else if (e.stack) { // Firefox, Chrome - return e.stack.split("\n")[4]; + var stack = e.stack.split("\n"); + if (/^error$/i.test(stack[0])) { + stack.shift(); + } + return stack[offset]; } else if (e.sourceURL) { // Safari, PhantomJS // TODO sourceURL points at the 'throw new Error' line above, useless @@ -989,6 +1022,7 @@ function fail(message, exception, callback) { if ( typeof console !== "undefined" && console.error && console.warn ) { console.error(message); console.error(exception); + console.error(exception.stack); console.warn(callback.toString()); } else if ( window.opera && opera.postError ) { @@ -1368,9 +1402,9 @@ QUnit.jsDump = (function() { var ret = [ ]; QUnit.jsDump.up(); for ( var key in map ) { - var val = map[key]; + var val = map[key]; ret.push( QUnit.jsDump.parse(key,'key') + ': ' + QUnit.jsDump.parse(val, undefined, stack)); - } + } QUnit.jsDump.down(); return join( '{', ret, '}' ); }, @@ -1594,4 +1628,5 @@ QUnit.diff = (function() { }; })(); -})(this); +// get at whatever the global object is, like window in browsers +})( (function() {return this}).call() ); diff --git a/addons/web/static/src/css/base.css b/addons/web/static/src/css/base.css index fc384fd9fbf..854946cdf97 100644 --- a/addons/web/static/src/css/base.css +++ b/addons/web/static/src/css/base.css @@ -21,16 +21,13 @@ color: #4c4c4c; font-size: 13px; background: white; -} -.openerp a { - text-decoration: none; -} - -.openerp { /* http://www.quirksmode.org/dom/inputfile.html * http://stackoverflow.com/questions/2855589/replace-input-type-file-by-an-image */ } +.openerp a { + text-decoration: none; +} .openerp table { padding: 0; font-size: 13px; @@ -1620,6 +1617,15 @@ height: auto; line-height: 16px; } +.openerp .oe_listview_nocontent > img { + float: left; + margin-right: 1.5em; +} +.openerp .oe_listview_nocontent > div { + overflow: hidden; + padding: 6px; + font-size: 125%; +} .openerp .oe-listview-content { width: 100%; } @@ -1777,23 +1783,10 @@ font-weight: bold; } .openerp .oe_layout_debugging .oe_form_group { - border: 2px dashed red; + outline: 2px dashed red; } .openerp .oe_layout_debugging .oe_form_group_cell { - border: 1px solid blue; - padding-bottom: 1em; -} -.openerp .oe_layout_debugging .oe_layout_debug_cell { - color: white; - background: #669966; - font-size: 80%; - text-align: center; -} -.openerp .oe_layout_debugging .oe_layout_debug_cell { - display: block; -} -.openerp .oe_layout_debug_cell { - display: none; + outline: 1px solid blue; } .openerp .oe_debug_view { float: left; diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index b2373c7782b..98caeafbb29 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -78,12 +78,10 @@ $colour4: #8a89ba color: #4c4c4c font-size: 13px background: white + // }}} + // Tag reset {{{ a text-decoration: none - // }}} - -.openerp - // Tag reset {{{ table padding: 0 font-size: 13px @@ -1366,6 +1364,15 @@ $colour4: #8a89ba // }}} // ListView {{{ + .oe_listview_nocontent + > img + float: left + margin-right: 1.5em + > div + // don't encroach on my arrow + overflow: hidden + padding: 6px + font-size: 125% .oe-listview-content width: 100% thead, tfoot @@ -1480,23 +1487,12 @@ $colour4: #8a89ba .oe_tooltip_technical_title font-weight: bold // }}} - // Debugging stuff {{{ .oe_layout_debugging .oe_form_group - border: 2px dashed red + outline: 2px dashed red .oe_form_group_cell - border: 1px solid blue - padding-bottom: 1em - .oe_layout_debug_cell - color: white - background: #696 - font-size: 80% - text-align: center - .oe_layout_debug_cell - display: block - .oe_layout_debug_cell - display: none + outline: 1px solid blue .oe_debug_view float: left @@ -1529,4 +1525,3 @@ $colour4: #8a89ba // au BufWritePost,FileWritePost *.sass :!sass --style expanded --line-numbers > "%:p:r.css" // vim:tabstop=4:shiftwidth=4:softtabstop=4:fdm=marker: - diff --git a/addons/web/static/src/img/list_empty_arrow.png b/addons/web/static/src/img/list_empty_arrow.png new file mode 100644 index 00000000000..b0b1372adfd Binary files /dev/null and b/addons/web/static/src/img/list_empty_arrow.png differ diff --git a/addons/web/static/src/img/topbar-avatar.png b/addons/web/static/src/img/topbar-avatar.png deleted file mode 100644 index a2c70ea7d49..00000000000 Binary files a/addons/web/static/src/img/topbar-avatar.png and /dev/null differ diff --git a/addons/web/static/src/js/corelib.js b/addons/web/static/src/js/corelib.js index 5475f4ad53c..cbd28aca8a3 100644 --- a/addons/web/static/src/js/corelib.js +++ b/addons/web/static/src/js/corelib.js @@ -1390,6 +1390,7 @@ instance.web.Connection = instance.web.CallbackEnabled.extend( /** @lends instan // an invalid session or no session at all), refresh session data // (should not change, but just in case...) _.extend(self, { + session_id: result.session_id, db: result.db, username: result.login, uid: result.uid, diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/data.js index 4f18a4c53d9..2dc66aaa135 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/data.js @@ -18,7 +18,428 @@ instance.web.serialize_sort = function (criterion) { }).join(', '); }; -instance.web.DataGroup = instance.web.OldWidget.extend( /** @lends instance.web.DataGroup# */{ +instance.web.Query = instance.web.Class.extend({ + init: function (model, fields) { + this._model = model; + this._fields = fields; + this._filter = []; + this._context = {}; + this._limit = false; + this._offset = 0; + this._order_by = []; + }, + clone: function (to_set) { + to_set = to_set || {}; + var q = new instance.web.Query(this._model, this._fields); + q._context = this._context; + q._filter = this._filter; + q._limit = this._limit; + q._offset = this._offset; + q._order_by = this._order_by; + + for(var key in to_set) { + if (!to_set.hasOwnProperty(key)) { continue; } + switch(key) { + case 'filter': + q._filter = new instance.web.CompoundDomain( + q._filter, to_set.filter); + break; + case 'context': + q._context = new instance.web.CompoundContext( + q._context, to_set.context); + break; + case 'limit': + case 'offset': + case 'order_by': + q['_' + key] = to_set[key]; + } + } + return q; + }, + _execute: function () { + var self = this; + return instance.connection.rpc('/web/dataset/search_read', { + model: this._model.name, + fields: this._fields || false, + domain: this._model.domain(this._filter), + context: this._model.context(this._context), + offset: this._offset, + limit: this._limit, + sort: instance.web.serialize_sort(this._order_by) + }).pipe(function (results) { + self._count = results.length; + return results.records; + }, null); + }, + /** + * Fetches the first record matching the query, or null + * + * @returns {jQuery.Deferred} + */ + first: function () { + var self = this; + return this.clone({limit: 1})._execute().pipe(function (records) { + delete self._count; + if (records.length) { return records[0]; } + return null; + }); + }, + /** + * Fetches all records matching the query + * + * @returns {jQuery.Deferred>} + */ + all: function () { + return this._execute(); + }, + /** + * Fetches the number of records matching the query in the database + * + * @returns {jQuery.Deferred} + */ + count: function () { + if (this._count != undefined) { return $.when(this._count); } + return this._model.call( + 'search_count', [this._filter], { + context: this._model.context(this._context)}); + }, + /** + * Performs a groups read according to the provided grouping criterion + * + * @param {String|Array} grouping + * @returns {jQuery.Deferred> | null} + */ + group_by: function (grouping) { + if (grouping === undefined) { + return null; + } + + if (!(grouping instanceof Array)) { + grouping = _.toArray(arguments); + } + if (_.isEmpty(grouping)) { return null; } + + var self = this; + return this._model.call('read_group', { + groupby: grouping, + fields: _.uniq(grouping.concat(this._fields || [])), + domain: this._model.domain(this._filter), + context: this._model.context(this._context), + offset: this._offset, + limit: this._limit, + orderby: instance.web.serialize_sort(this._order_by) || false + }).pipe(function (results) { + return _(results).map(function (result) { + return new instance.web.data.Group( + self._model.name, grouping[0], result); + }); + }); + }, + /** + * Creates a new query with the union of the current query's context and + * the new context. + * + * @param context context data to add to the query + * @returns {openerp.web.Query} + */ + context: function (context) { + if (!context) { return this; } + return this.clone({context: context}); + }, + /** + * Creates a new query with the union of the current query's filter and + * the new domain. + * + * @param domain domain data to AND with the current query filter + * @returns {openerp.web.Query} + */ + filter: function (domain) { + if (!domain) { return this; } + return this.clone({filter: domain}); + }, + /** + * Creates a new query with the provided limit replacing the current + * query's own limit + * + * @param {Number} limit maximum number of records the query should retrieve + * @returns {openerp.web.Query} + */ + limit: function (limit) { + return this.clone({limit: limit}); + }, + /** + * Creates a new query with the provided offset replacing the current + * query's own offset + * + * @param {Number} offset number of records the query should skip before starting its retrieval + * @returns {openerp.web.Query} + */ + offset: function (offset) { + return this.clone({offset: offset}); + }, + /** + * Creates a new query with the provided ordering parameters replacing + * those of the current query + * + * @param {String...} fields ordering clauses + * @returns {openerp.web.Query} + */ + order_by: function (fields) { + if (fields === undefined) { return this; } + if (!(fields instanceof Array)) { + fields = _.toArray(arguments); + } + if (_.isEmpty(fields)) { return this; } + return this.clone({order_by: fields}); + } +}); + +instance.web.Model = instance.web.Class.extend(/** @lends openerp.web.Model# */{ + /** + * @constructs instance.web.Model + * @extends instance.web.Class + * + * @param {String} model_name name of the OpenERP model this object is bound to + * @param {Object} [context] + * @param {Array} [domain] + */ + init: function (model_name, context, domain) { + this.name = model_name; + this._context = context || {}; + this._domain = domain || []; + }, + /** + * @deprecated does not allow to specify kwargs, directly use call() instead + */ + get_func: function (method_name) { + var self = this; + return function () { + return self.call(method_name, _.toArray(arguments)); + }; + }, + /** + * Call a method (over RPC) on the bound OpenERP model. + * + * @param {String} method name of the method to call + * @param {Array} [args] positional arguments + * @param {Object} [kwargs] keyword arguments + * @returns {jQuery.Deferred<>} call result + */ + call: function (method, args, kwargs) { + args = args || []; + kwargs = kwargs || {}; + if (!_.isArray(args)) { + // call(method, kwargs) + kwargs = args; + args = []; + } + return instance.connection.rpc('/web/dataset/call_kw', { + model: this.name, + method: method, + args: args, + kwargs: kwargs + }); + }, + /** + * Fetches a Query instance bound to this model, for searching + * + * @param {Array} [fields] fields to ultimately fetch during the search + * @returns {openerp.web.Query} + */ + query: function (fields) { + return new instance.web.Query(this, fields); + }, + /** + * Executes a signal on the designated workflow, on the bound OpenERP model + * + * @param {Number} id workflow identifier + * @param {String} signal signal to trigger on the workflow + */ + exec_workflow: function (id, signal) { + return instance.connection.rpc('/web/dataset/exec_workflow', { + model: this.name, + id: id, + signal: signal + }); + }, + /** + * Fetches the model's domain, combined with the provided domain if any + * + * @param {Array} [domain] to combine with the model's internal domain + * @returns The model's internal domain, or the AND-ed union of the model's internal domain and the provided domain + */ + domain: function (domain) { + if (!domain) { return this._domain; } + return new instance.web.CompoundDomain( + this._domain, domain); + }, + /** + * Fetches the combination of the user's context and the domain context, + * combined with the provided context if any + * + * @param {Object} [context] to combine with the model's internal context + * @returns The union of the user's context and the model's internal context, as well as the provided context if any. In that order. + */ + context: function (context) { + return new instance.web.CompoundContext( + instance.connection.user_context, this._context, context || {}); + }, + /** + * Button action caller, needs to perform cleanup if an action is returned + * from the button (parsing of context and domain, and fixup of the views + * collection for act_window actions) + * + * FIXME: remove when evaluator integrated + */ + call_button: function (method, args) { + return instance.connection.rpc('/web/dataset/call_button', { + model: this.name, + method: method, + domain_id: null, + context_id: args.length - 1, + args: args || [] + }); + }, +}); + +instance.web.Traverser = instance.web.Class.extend(/** @lends openerp.web.Traverser# */{ + /** + * @constructs instance.web.Traverser + * @extends instance.web.Class + * + * @param {instance.web.Model} model instance this traverser is bound to + */ + init: function (model) { + this._model = model; + this._index = 0; + }, + + /** + * Gets and sets the current index + * + * @param {Number} [idx] + * @returns {Number} current index + */ + index: function (idx) { + if (idx) { this._index = idx; } + return this._index; + }, + /** + * Returns the model this traverser is currently bound to + * + * @returns {openerp.web.Model} + */ + model: function () { + return this._model; + }, + /** + * Fetches the size of the backing model's match + * + * @returns {Deferred} deferred count + */ + size: function () { + return this._model.query().count(); + }, + + /** + * Record at the current index for the collection, fails if there is no + * record at the current index. + * + * @returns {Deferred<>} + */ + current: function (fields) { + return this._model.query(fields).first().pipe(function (record) { + if (record == null) { + return $.Deferred() + .reject('No record at index' + this._index) + .promise(); + } + return record; + }); + }, + next: function (fields) { + var self = this; + this._index++; + return this.size().pipe(function (s) { + if (self._index >= s) { + self._index = 0; + } + return self.current(fields); + }); + }, + previous: function (fields) { + var self = this; + this._index--; + if (this._index < 0) { + return this.size().pipe(function (s) { + self._index = s-1; + return self.current(fields); + }); + } + return this.current(fields); + } + +}); + +/** + * Utility objects, should never need to be instantiated from outside of this + * module + * + * @namespace + */ +instance.web.data = { + Group: instance.web.Class.extend(/** @lends openerp.web.data.Group# */{ + /** + * @constructs instance.web.data.Group + * @extends instance.web.Class + */ + init: function (model, grouping_field, read_group_group) { + // In cases where group_by_no_leaf and no group_by, the result of + // read_group has aggregate fields but no __context or __domain. + // Create default (empty) values for those so that things don't break + var fixed_group = _.extend( + {__context: {group_by: []}, __domain: []}, + read_group_group); + + var aggregates = {}; + _(fixed_group).each(function (value, key) { + if (key.indexOf('__') === 0 + || key === grouping_field + || key === grouping_field + '_count') { + return; + } + aggregates[key] = value || 0; + }); + + this.model = new instance.web.Model( + model, fixed_group.__context, fixed_group.__domain); + + var group_size = fixed_group[grouping_field + '_count'] || fixed_group.__count || 0; + var leaf_group = fixed_group.__context.group_by.length === 0; + this.attributes = { + grouped_on: grouping_field, + // if terminal group (or no group) and group_by_no_leaf => use group.__count + length: group_size, + value: fixed_group[grouping_field], + // A group is open-able if it's not a leaf in group_by_no_leaf mode + has_children: !(leaf_group && fixed_group.__context['group_by_no_leaf']), + + aggregates: aggregates + }; + }, + get: function (key) { + return this.attributes[key]; + }, + subgroups: function () { + return this.model.query().group_by(this.model.context().group_by); + }, + query: function () { + return this.model.query.apply(this.model, arguments); + } + }) +}; + +instance.web.DataGroup = instance.web.OldWidget.extend( /** @lends openerp.web.DataGroup# */{ /** * Management interface between views and grouped collections of OpenERP * records. @@ -41,182 +462,52 @@ instance.web.DataGroup = instance.web.OldWidget.extend( /** @lends instance.web */ init: function(parent, model, domain, context, group_by, level) { this._super(parent, null); - if (group_by) { - if (group_by.length || context['group_by_no_leaf']) { - return new instance.web.ContainerDataGroup( this, model, domain, context, group_by, level); - } else { - return new instance.web.GrouplessDataGroup( this, model, domain, context, level); - } - } - - this.model = model; + this.model = new instance.web.Model(model, context, domain); + this.group_by = group_by; this.context = context; this.domain = domain; this.level = level || 0; }, - cls: 'DataGroup' -}); -instance.web.ContainerDataGroup = instance.web.DataGroup.extend( /** @lends instance.web.ContainerDataGroup# */ { - /** - * - * @constructs instance.web.ContainerDataGroup - * @extends instance.web.DataGroup - * - * @param session - * @param model - * @param domain - * @param context - * @param group_by - * @param level - */ - init: function (parent, model, domain, context, group_by, level) { - this._super(parent, model, domain, context, null, level); - - this.group_by = group_by; - }, - /** - * The format returned by ``read_group`` is absolutely dreadful: - * - * * A ``__context`` key provides future grouping levels - * * A ``__domain`` key provides the domain for the next search - * * The current grouping value is provided through the name of the - * current grouping name e.g. if currently grouping on ``user_id``, then - * the ``user_id`` value for this group will be provided through the - * ``user_id`` key. - * * Similarly, the number of items in the group (not necessarily direct) - * is provided via ``${current_field}_count`` - * * Other aggregate fields are just dumped there - * - * This function slightly improves the grouping records by: - * - * * Adding a ``grouped_on`` property providing the current grouping field - * * Adding a ``value`` and a ``length`` properties which replace the - * ``$current_field`` and ``${current_field}_count`` ones - * * Moving aggregate values into an ``aggregates`` property object - * - * Context and domain keys remain as-is, they should not be used externally - * but in case they're needed... - * - * @param {Object} group ``read_group`` record - */ - transform_group: function (group) { - var field_name = this.group_by[0]; - // In cases where group_by_no_leaf and no group_by, the result of - // read_group has aggregate fields but no __context or __domain. - // Create default (empty) values for those so that things don't break - var fixed_group = _.extend( - {__context: {group_by: []}, __domain: []}, - group); - - var aggregates = {}; - _(fixed_group).each(function (value, key) { - if (key.indexOf('__') === 0 - || key === field_name - || key === field_name + '_count') { - return; - } - aggregates[key] = value || 0; - }); - - var group_size = fixed_group[field_name + '_count'] || fixed_group.__count || 0; - var leaf_group = fixed_group.__context.group_by.length === 0; - return { - __context: fixed_group.__context, - __domain: fixed_group.__domain, - - grouped_on: field_name, - // if terminal group (or no group) and group_by_no_leaf => use group.__count - length: group_size, - value: fixed_group[field_name], - // A group is openable if it's not a leaf in group_by_no_leaf mode - openable: !(leaf_group && this.context['group_by_no_leaf']), - - aggregates: aggregates - }; - }, - fetch: function (fields) { - // internal method - var d = new $.Deferred(); - var self = this; - - this.rpc('/web/group/read', { - model: this.model, - context: this.context, - domain: this.domain, - fields: _.uniq(this.group_by.concat(fields)), - group_by_fields: this.group_by, - sort: instance.web.serialize_sort(this.sort) - }, function () { }).then(function (response) { - var data_groups = _(response).map( - _.bind(self.transform_group, self)); - self.groups = data_groups; - d.resolveWith(self, [data_groups]); - }, function () { - d.rejectWith.apply(d, [self, arguments]); - }); - return d.promise(); - }, - /** - * The items of a list have the following properties: - * - * ``length`` - * the number of records contained in the group (and all of its - * sub-groups). This does *not* provide the size of the "next level" - * of the group, unless the group is terminal (no more groups within - * it). - * ``grouped_on`` - * the name of the field this level was grouped on, this is mostly - * used for display purposes, in order to know the name of the current - * level of grouping. The ``grouped_on`` should be the same for all - * objects of the list. - * ``value`` - * the value which led to this group (this is the value all contained - * records have for the current ``grouped_on`` field name). - * ``aggregates`` - * a mapping of other aggregation fields provided by ``read_group`` - * - * @param {Array} fields the list of fields to aggregate in each group, can be empty - * @param {Function} ifGroups function executed if any group is found (DataGroup.group_by is non-null and non-empty), called with a (potentially empty) list of groups as parameters. - * @param {Function} ifRecords function executed if there is no grouping left to perform, called with a DataSet instance as parameter - */ list: function (fields, ifGroups, ifRecords) { var self = this; - this.fetch(fields).then(function (group_records) { - ifGroups(_(group_records).map(function (group) { - var child_context = _.extend({}, self.context, group.__context); + $.when(this.model.query(fields) + .order_by(this.sort) + .group_by(this.group_by)).then(function (groups) { + if (!groups) { + ifRecords(_.extend( + new instance.web.DataSetSearch( + self, self.model.name, + self.model.context(), + self.model.domain()), + {_sort: self.sort})); + return; + } + ifGroups(_(groups).map(function (group) { + var child_context = _.extend( + {}, self.model.context(), group.model.context()); return _.extend( new instance.web.DataGroup( - self, self.model, group.__domain, - child_context, child_context.group_by, + self, self.model.name, group.model.domain(), + child_context, group.model._context.group_by, self.level + 1), - group, {sort: self.sort}); + { + __context: child_context, + __domain: group.model.domain(), + grouped_on: group.get('grouped_on'), + length: group.get('length'), + value: group.get('value'), + openable: group.get('has_children'), + aggregates: group.get('aggregates') + }, {sort: self.sort}); })); }); } }); -instance.web.GrouplessDataGroup = instance.web.DataGroup.extend( /** @lends instance.web.GrouplessDataGroup# */ { - /** - * - * @constructs instance.web.GrouplessDataGroup - * @extends instance.web.DataGroup - * - * @param session - * @param model - * @param domain - * @param context - * @param level - */ - init: function (parent, model, domain, context, level) { - this._super(parent, model, domain, context, null, level); - }, - list: function (fields, ifGroups, ifRecords) { - ifRecords(_.extend( - new instance.web.DataSetSearch(this, this.model), - {domain: this.domain, context: this.context, _sort: this.sort})); - } -}); -instance.web.StaticDataGroup = instance.web.GrouplessDataGroup.extend( /** @lends instance.web.StaticDataGroup# */ { +instance.web.ContainerDataGroup = instance.web.DataGroup.extend({ }); +instance.web.GrouplessDataGroup = instance.web.DataGroup.extend({ }); + +instance.web.StaticDataGroup = instance.web.GrouplessDataGroup.extend( /** @lends openerp.web.StaticDataGroup# */ { /** * A specialization of groupless data groups, relying on a single static * dataset as its records provider. @@ -233,7 +524,7 @@ instance.web.StaticDataGroup = instance.web.GrouplessDataGroup.extend( /** @lend } }); -instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.DataSet# */{ +instance.web.DataSet = instance.web.OldWidget.extend( /** @lends openerp.web.DataSet# */{ /** * DateaManagement interface between views and the collection of selected * OpenERP records (represents the view's state?) @@ -249,6 +540,7 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D this.context = context || {}; this.index = null; this._sort = []; + this._model = new instance.web.Model(model, context); }, previous: function () { this.index -= 1; @@ -296,13 +588,11 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ read_ids: function (ids, fields, options) { - var options = options || {}; - return this.rpc('/web/dataset/get', { - model: this.model, - ids: ids, - fields: fields, - context: this.get_context(options.context) - }); + options = options || {}; + // TODO: reorder results to match ids list + return this._model.call('read', + [ids, fields || false], + {context: this._model.context(options.context)}); }, /** * Read a slice of the records represented by this DataSet, based on its @@ -315,7 +605,14 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ read_slice: function (fields, options) { - return null; + var self = this; + options = options || {}; + return this._model.query(fields) + .limit(options.limit || false) + .offset(options.offset || 0) + .all().then(function (records) { + self.ids = _(records).pluck('id'); + }); }, /** * Reads the current dataset record (from its index) @@ -325,18 +622,11 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ read_index: function (fields, options) { - var def = $.Deferred(); - if (_.isEmpty(this.ids)) { - def.reject(); - } else { - fields = fields || false; - this.read_ids([this.ids[this.index]], fields, options).then(function(records) { - def.resolve(records[0]); - }, function() { - def.reject.apply(def, arguments); - }); - } - return def.promise(); + options = options || {}; + return this.read_ids([this.ids[this.index]], fields, options).pipe(function (records) { + if (_.isEmpty(records)) { return $.Deferred().reject().promise(); } + return records[0]; + }); }, /** * Reads default values for the current model @@ -346,12 +636,9 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ default_get: function(fields, options) { - var options = options || {}; - return this.rpc('/web/dataset/default_get', { - model: this.model, - fields: fields, - context: this.get_context(options.context) - }); + options = options || {}; + return this._model.call('default_get', + [fields], {context: this._model.context(options.context)}); }, /** * Creates a new record in db @@ -362,11 +649,10 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ create: function(data, callback, error_callback) { - return this.rpc('/web/dataset/create', { - model: this.model, - data: data, - context: this.get_context() - }, callback, error_callback); + return this._model.call('create', + [data], {context: this._model.context()}) + .pipe(function (r) { return {result: r}; }) + .then(callback, error_callback); }, /** * Saves the provided data in an existing db record @@ -379,12 +665,10 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D */ write: function (id, data, options, callback, error_callback) { options = options || {}; - return this.rpc('/web/dataset/save', { - model: this.model, - id: id, - data: data, - context: this.get_context(options.context) - }, callback, error_callback); + return this._model.call('write', + [[id], data], {context: this._model.context(options.context)}) + .pipe(function (r) { return {result: r}}) + .then(callback, error_callback); }, /** * Deletes an existing record from the database @@ -394,9 +678,9 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @param {Function} error_callback function called in case of deletion error */ unlink: function(ids, callback, error_callback) { - var self = this; - return this.call_and_eval("unlink", [ids, this.get_context()], null, 1, - callback, error_callback); + return this._model.call('unlink', + [ids], {context: this._model.context()}) + .then(callback, error_callback); }, /** * Calls an arbitrary RPC method @@ -408,11 +692,7 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ call: function (method, args, callback, error_callback) { - return this.rpc('/web/dataset/call', { - model: this.model, - method: method, - args: args || [] - }, callback, error_callback); + return this._model.call(method, args).then(callback, error_callback); }, /** * Calls an arbitrary method, with more crazy @@ -431,9 +711,7 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D method: method, domain_id: domain_index == undefined ? null : domain_index, context_id: context_index == undefined ? null : context_index, - args: args || [], - // FIXME: API which does not suck for aborting requests in-flight - aborter: this + args: args || [] }, callback, error_callback); }, /** @@ -446,13 +724,8 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ call_button: function (method, args, callback, error_callback) { - return this.rpc('/web/dataset/call_button', { - model: this.model, - method: method, - domain_id: null, - context_id: args.length - 1, - args: args || [] - }, callback, error_callback); + return this._model.call_button(method, args) + .then(callback, error_callback); }, /** * Fetches the "readable name" for records, based on intrinsic rules @@ -462,7 +735,9 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ name_get: function(ids, callback) { - return this.call_and_eval('name_get', [ids, this.get_context()], null, 1, callback); + return this._model.call('name_get', + [ids], {context: this._model.context()}) + .then(callback); }, /** * @@ -474,29 +749,30 @@ instance.web.DataSet = instance.web.OldWidget.extend( /** @lends instance.web.D * @returns {$.Deferred} */ name_search: function (name, domain, operator, limit, callback) { - return this.call_and_eval('name_search', - [name || '', domain || false, operator || 'ilike', this.get_context(), limit || 0], - 1, 3, callback); + return this._model.call('name_search', { + name: name || '', + args: domain || false, + operator: operator || 'ilike', + context: this._model.context(), + limit: limit || 0 + }).then(callback); }, /** * @param name * @param callback */ name_create: function(name, callback) { - return this.call_and_eval('name_create', [name, this.get_context()], null, 1, callback); + return this._model.call('name_create', + [name], {context: this._model.context()}) + .then(callback); }, exec_workflow: function (id, signal, callback) { - return this.rpc('/web/dataset/exec_workflow', { - model: this.model, - id: id, - signal: signal - }, callback); + return this._model.exec_workflow(id, signal) + .pipe(function (result) { return { result: result }; }) + .then(callback); }, get_context: function(request_context) { - if (request_context) { - return new instance.web.CompoundContext(this.context, request_context); - } - return this.context; + return this._model.context(request_context); }, /** * Reads or changes sort criteria on the dataset. @@ -560,7 +836,7 @@ instance.web.DataSetStatic = instance.web.DataSet.extend({ this.set_ids(_.without.apply(null, [this.ids].concat(ids))); } }); -instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends instance.web.DataSetSearch */{ +instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends openerp.web.DataSetSearch */{ /** * @constructs instance.web.DataSetSearch * @extends instance.web.DataSet @@ -573,11 +849,9 @@ instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends instance.we init: function(parent, model, context, domain) { this._super(parent, model, context); this.domain = domain || []; - this.offset = 0; - this._length; - // subset records[offset:offset+limit] - // is it necessary ? + this._length = null; this.ids = []; + this._model = new instance.web.Model(model, context, domain); }, /** * Read a slice of the records represented by this DataSet, based on its @@ -594,32 +868,29 @@ instance.web.DataSetSearch = instance.web.DataSet.extend(/** @lends instance.we read_slice: function (fields, options) { options = options || {}; var self = this; - var offset = options.offset || 0; - return this.rpc('/web/dataset/search_read', { - model: this.model, - fields: fields || false, - domain: this.get_domain(options.domain), - context: this.get_context(options.context), - sort: this.sort(), - offset: offset, - limit: options.limit || false - }).pipe(function (result) { - self.ids = result.ids; - self.offset = offset; - self._length = result.length; - return result.records; + var q = this._model.query(fields || false) + .filter(options.domain) + .context(options.context) + .offset(options.offset || 0) + .limit(options.limit || false); + q = q.order_by.apply(q, this._sort); + + return q.all().then(function (records) { + // FIXME: not sure about that one, *could* have discarded count + q.count().then(function (count) { self._length = count; }); + self.ids = _(records).pluck('id'); }); }, get_domain: function (other_domain) { - if (other_domain) { - return new instance.web.CompoundDomain(this.domain, other_domain); - } - return this.domain; + this._model.domain(other_domain); }, unlink: function(ids, callback, error_callback) { var self = this; return this._super(ids, function(result) { - self.ids = _.without.apply(_, [self.ids].concat(ids)); + self.ids = _(self.ids).difference(ids); + if (self._length) { + self._length -= 1; + } if (this.index !== null) { self.index = self.index <= self.ids.length - 1 ? self.index : (self.ids.length > 0 ? self.ids.length -1 : 0); @@ -848,34 +1119,6 @@ instance.web.ProxyDataSet = instance.web.DataSetSearch.extend({ on_unlink: function(ids) {} }); -instance.web.Model = instance.web.CallbackEnabled.extend({ - init: function(model_name) { - this._super(); - this.model_name = model_name; - }, - rpc: function() { - var c = instance.connection; - return c.rpc.apply(c, arguments); - }, - /* - * deprecated because it does not allow to specify kwargs, directly use call() instead - */ - get_func: function(method_name) { - var self = this; - return function() { - return self.call(method_name, _.toArray(arguments), {}); - }; - }, - call: function (method, args, kwargs) { - return this.rpc('/web/dataset/call_kw', { - model: this.model_name, - method: method, - args: args, - kwargs: kwargs - }); - } -}); - instance.web.CompoundContext = instance.web.Class.extend({ init: function () { this.__ref = "compound_context"; @@ -921,6 +1164,46 @@ instance.web.CompoundDomain = instance.web.Class.extend({ return this.__eval_context; } }); + +instance.web.DropMisordered = instance.web.Class.extend(/** @lends openerp.web.DropMisordered# */{ + /** + * @constructs instance.web.DropMisordered + * @extends instance.web.Class + * + * @param {Boolean} [failMisordered=false] whether mis-ordered responses should be failed or just ignored + */ + init: function (failMisordered) { + // local sequence number, for requests sent + this.lsn = 0; + // remote sequence number, seqnum of last received request + this.rsn = -1; + this.failMisordered = failMisordered || false; + }, + /** + * Adds a deferred (usually an async request) to the sequencer + * + * @param {$.Deferred} deferred to ensure add + * @returns {$.Deferred} + */ + add: function (deferred) { + var res = $.Deferred(); + + var self = this, seq = this.lsn++; + deferred.then(function () { + if (seq > self.rsn) { + self.rsn = seq; + res.resolve.apply(res, arguments); + } else if (self.failMisordered) { + res.reject(); + } + }, function () { + res.reject.apply(res, arguments); + }); + + return res.promise(); + } +}); + }; // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: diff --git a/addons/web/static/src/js/formats.js b/addons/web/static/src/js/formats.js index 107a923b625..640ba4d5a93 100644 --- a/addons/web/static/src/js/formats.js +++ b/addons/web/static/src/js/formats.js @@ -111,7 +111,7 @@ instance.web.format_value = function (value, descriptor, value_if_empty) { return value_if_empty === undefined ? '' : value_if_empty; } var l10n = _t.database.parameters; - switch (descriptor.widget || descriptor.type) { + switch (descriptor.type || (descriptor.field && descriptor.field.type)) { case 'id': return value.toString(); case 'integer': @@ -171,7 +171,7 @@ instance.web.parse_value = function (value, descriptor, value_if_empty) { case "": return value_if_empty === undefined ? false : value_if_empty; } - switch (descriptor.widget || descriptor.type) { + switch (descriptor.type || (descriptor.field && descriptor.field.type)) { case 'integer': var tmp; do { diff --git a/addons/web/static/src/js/search.js b/addons/web/static/src/js/search.js index 18ac6b78de5..c2df866b5c6 100644 --- a/addons/web/static/src/js/search.js +++ b/addons/web/static/src/js/search.js @@ -1403,7 +1403,6 @@ instance.web.search.ExtendedSearchProposition = instance.web.OldWidget.extend(/* var type = field.type; var obj = instance.web.search.custom_filters.get_object(type); if(obj === null) { - console.log('Unknow field type ' + e.key); obj = instance.web.search.custom_filters.get_object("char"); } this.value = new (obj) (this); diff --git a/addons/web/static/src/js/test_support.js b/addons/web/static/src/js/test_support.js new file mode 100644 index 00000000000..d46366326b7 --- /dev/null +++ b/addons/web/static/src/js/test_support.js @@ -0,0 +1,79 @@ +openerp.test_support = { + setup_connection: function (connection) { + var origin = location.protocol+"//"+location.host; + _.extend(connection, { + origin: origin, + prefix: origin, + server: origin, // keep chs happy + //openerp.web.qweb.default_dict['_s'] = this.origin; + rpc_function: connection.rpc_json, + session_id: false, + uid: false, + username: false, + user_context: {}, + db: false, + openerp_entreprise: false, +// this.module_list = openerp._modules.slice(); +// this.module_loaded = {}; +// _(this.module_list).each(function (mod) { +// self.module_loaded[mod] = true; +// }); + context: {}, + shortcuts: [], + active_id: null + }); + return connection.session_reload(); + }, + module: function (title, tested_core, nonliterals) { + var conf = QUnit.config.openerp = {}; + QUnit.module(title, { + setup: function () { + QUnit.stop(); + var oe = conf.openerp = window.openerp.init(); + window.openerp.web[tested_core](oe); + var done = openerp.test_support.setup_connection(oe.connection); + if (nonliterals) { + done = done.pipe(function () { + return oe.connection.rpc('/tests/add_nonliterals', { + domains: nonliterals.domains || [], + contexts: nonliterals.contexts || [] + }).then(function (r) { + oe.domains = r.domains; + oe.contexts = r.contexts; + }); + }); + } + done.always(QUnit.start) + .then(function () { + conf.openerp = oe; + }, function (e) { + QUnit.test(title, function () { + console.error(e); + QUnit.ok(false, 'Could not obtain a session:' + e.debug); + }); + }); + } + }); + }, + test: function (title, fn) { + var conf = QUnit.config.openerp; + QUnit.test(title, function () { + QUnit.stop(); + fn(conf.openerp); + }); + }, + expect: function (promise, fn) { + promise.always(QUnit.start) + .done(function () { QUnit.ok(false, 'RPC requests should not succeed'); }) + .fail(function (e) { + if (e.code !== 200) { + QUnit.equal(e.code, 200, 'Testing connector should raise RPC faults'); + if (typeof console !== 'undefined' && console.error) { + console.error(e.data.debug); + } + return; + } + fn(e.data.fault_code); + }) + } +}; diff --git a/addons/web/static/src/js/view_form.js b/addons/web/static/src/js/view_form.js index 2ca19687cec..47cee79ffcb 100644 --- a/addons/web/static/src/js/view_form.js +++ b/addons/web/static/src/js/view_form.js @@ -24,7 +24,6 @@ instance.web.FormView = instance.web.View.extend({ * @param {instance.web.DataSet} dataset the dataset this view will work with * @param {String} view_id the identifier of the OpenERP view object * @param {Object} options - * - sidebar : [true|false] * - resize_textareas : [true|false|max_height] * * @property {instance.web.Registry} registry=instance.web.form.widgets widgets registry for this form view instance @@ -109,7 +108,7 @@ instance.web.FormView = instance.web.View.extend({ self.on_pager_action(action); }); - if (!this.sidebar && this.options.sidebar) { + if (!this.sidebar && this.options.$sidebar) { this.sidebar = new instance.web.Sidebar(this); this.sidebar.appendTo(this.$sidebar); if(this.fields_view.toolbar) { @@ -247,7 +246,7 @@ instance.web.FormView = instance.web.View.extend({ } _(this.fields).each(function (field, f) { - field.reset(); + field._dirty_flag = false; var result = field.set_value(self.datarecord[f] || false); set_values.push(result); }); @@ -258,7 +257,7 @@ instance.web.FormView = instance.web.View.extend({ _.each(self.fields_order, function(field_name) { if (record[field_name] !== undefined) { var field = self.fields[field_name]; - field.dirty = true; + field._dirty_flag = true; self.do_onchange(field); } }); @@ -281,9 +280,6 @@ instance.web.FormView = instance.web.View.extend({ }, on_form_changed: function() { this.trigger("view_content_has_changed"); - _.each(this.get_widgets(), function(w) { - w.update_dom(); - }); }, do_notify_change: function() { this.$element.addClass('oe_form_dirty'); @@ -469,7 +465,7 @@ instance.web.FormView = instance.web.View.extend({ var value_ = result.value[f]; if (field.get_value() != value_) { field.set_value(value_); - field.dirty = true; + field._dirty_flag = true; if (!_.contains(processed, field.name)) { this.do_onchange(field, processed); } @@ -611,11 +607,10 @@ instance.web.FormView = instance.web.View.extend({ f = self.fields[f]; if (!f.is_valid()) { form_invalid = true; - f.update_dom(true); if (!first_invalid_field) { first_invalid_field = f; } - } else if (f.name !== 'id' && !f.get("readonly") && (!self.datarecord.id || f.is_dirty())) { + } else if (f.name !== 'id' && !f.get("readonly") && (!self.datarecord.id || f._dirty_flag)) { // Special case 'id' field, do not save this field // on 'create' : save all non readonly fields // on 'edit' : save non readonly modified fields @@ -623,20 +618,23 @@ instance.web.FormView = instance.web.View.extend({ } } if (form_invalid) { + self.set({'display_invalid_fields': true}); first_invalid_field.focus(); self.on_invalid(); return $.Deferred().reject(); } else { + self.set({'display_invalid_fields': false}); var save_deferral; if (!self.datarecord.id) { //console.log("FormView(", self, ") : About to create", values); save_deferral = self.dataset.create(values).pipe(function(r) { return self.on_created(r, undefined, prepend_on_create); }, null); - } else if (_.isEmpty(values)) { + } else if (_.isEmpty(values) && ! self.force_dirty) { //console.log("FormView(", self, ") : Nothing to save"); save_deferral = $.Deferred().resolve({}).promise(); } else { + self.force_dirty = false; //console.log("FormView(", self, ") : About to save", values); save_deferral = self.dataset.write(self.datarecord.id, values, {}).pipe(function(r) { return self.on_saved(r); @@ -754,7 +752,7 @@ instance.web.FormView = instance.web.View.extend({ }, is_dirty: function() { return _.any(this.fields, function (value_) { - return value_.is_dirty(); + return value_._dirty_flag; }); }, is_interactible_record: function() { @@ -853,6 +851,14 @@ instance.web.FormView = instance.web.View.extend({ if (this.get_field(name).translate) { this.translatable_fields.push(field); } + field.on('changed_value', this, function() { + field._dirty_flag = true; + if (field.is_syntax_valid()) { + this.do_onchange(field); + this.on_form_changed(true); + this.do_notify_change(); + } + }); }, get_field: function(field_name) { return this.fields_view.fields[field_name]; @@ -947,10 +953,10 @@ instance.web.form.FormRenderingEngine = instance.web.Class.extend({ }, toggle_layout_debugging: function() { if (!this.$target.has('.oe_layout_debug_cell:first').length) { + this.$target.find('[title]').removeAttr('title'); this.$target.find('.oe_form_group_cell').each(function() { - var text = 'W:' + ($(this).attr('width') || '') + ' - C:' + $(this).attr('colspan'), - $span = $('').text(text); - $span.prependTo($(this)); + var text = 'W:' + ($(this).attr('width') || '') + ' - C:' + $(this).attr('colspan'); + $(this).attr('title', text); }); } this.$target.toggleClass('oe_layout_debugging'); @@ -1209,7 +1215,6 @@ instance.web.form.FormDialog = instance.web.Dialog.extend({ start: function() { this._super(); this.form = new instance.web.FormView(this, this.dataset, this.view_id, { - sidebar: false, pager: false }); this.form.appendTo(this.$element); @@ -1380,8 +1385,6 @@ instance.web.form.Widget = instance.web.Widget.extend(_.extend({}, instance.web. } this.set(to_set); }, - update_dom: function() { - }, do_attach_tooltip: function(widget, trigger, options) { widget = widget || this; trigger = trigger || this.$element; @@ -1464,6 +1467,7 @@ instance.web.form.WidgetButton = instance.web.form.Widget.extend({ // TODO fme: provide enter key binding to widgets this.view.default_focus_button = this; } + this.view.on('view_content_has_changed', this, this.check_disable); }, start: function() { this._super.apply(this, arguments); @@ -1510,6 +1514,7 @@ instance.web.form.WidgetButton = instance.web.form.Widget.extend({ } }; if (!this.node.attrs.special) { + this.view.force_dirty = true; return this.view.recursive_save().pipe(exec_action); } else { return exec_action(); @@ -1530,10 +1535,6 @@ instance.web.form.WidgetButton = instance.web.form.Widget.extend({ self.view.reload(); }); }, - update_dom: function() { - this._super.apply(this, arguments); - this.check_disable(); - }, check_disable: function() { var disabled = (this.force_disabled || !this.view.is_interactible_record()); this.$element.prop('disabled', disabled); @@ -1546,7 +1547,8 @@ instance.web.form.WidgetButton = instance.web.form.Widget.extend({ * able to provide the features necessary for the fields to work. * * Properties: - * - ... + * - display_invalid_fields : if true, all fields where is_valid() return true should + * be displayed as invalid. * Events: * - view_content_has_changed : when the values of the fields have changed. When * this event is triggered all fields should reprocess their modifiers. @@ -1574,7 +1576,7 @@ instance.web.form.FieldManagerInterface = { * - force_readonly: boolean, When it is true, the field should always appear * in read only mode, no matter what the value of the "readonly" property can be. * Events: - * - ... + * - changed_value: triggered to inform the view to check on_changes * */ instance.web.form.FieldInterface = { @@ -1623,7 +1625,21 @@ instance.web.form.FieldInterface = { * Inform the current object of the id it should use to match a html