diff --git a/addons/web/__openerp__.py b/addons/web/__openerp__.py index 678eacd7f3e..269aa13d58e 100644 --- a/addons/web/__openerp__.py +++ b/addons/web/__openerp__.py @@ -26,6 +26,7 @@ This module provides the core of the OpenERP Web Client. "static/lib/spinjs/spin.js", "static/lib/jquery.autosize/jquery.autosize.js", "static/lib/jquery.blockUI/jquery.blockUI.js", + "static/lib/jquery.placeholder/jquery.placeholder.js", "static/lib/jquery.ui/js/jquery-ui-1.9.1.custom.js", "static/lib/jquery.ui.timepicker/js/jquery-ui-timepicker-addon.js", "static/lib/jquery.ui.notify/js/jquery.notify.js", diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 87af7474daf..8f1855f205d 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -15,6 +15,8 @@ import simplejson import time import urllib import urllib2 +import urlparse +import xmlrpclib import zlib from xml.etree import ElementTree from cStringIO import StringIO @@ -91,16 +93,50 @@ def db_list(req): dbs = [i for i in dbs if re.match(r, i)] return dbs -def db_monodb(req): - # if only one db exists, return it else return False +def db_monodb_redirect(req): + db = False + redirect = False + + # 1 try the db in the url + db_url = req.params.get('db') + if db_url: + return (db_url, False) + try: dbs = db_list(req) - if len(dbs) == 1: - return dbs[0] except Exception: # ignore access denied - pass - return False + dbs = [] + + # 2 use the database from the cookie if it's listable and still listed + cookie_db = req.httprequest.cookies.get('last_used_database') + if cookie_db in dbs: + db = cookie_db + + # 3 use the first db + if dbs and not db: + db = dbs[0] + + # redirect to the chosen db if multiple are available + if db and len(dbs) > 1: + query = dict(urlparse.parse_qsl(req.httprequest.query_string, keep_blank_values=True)) + query.update({ 'db': db }) + redirect = req.httprequest.path + '?' + urllib.urlencode(query) + return (db, redirect) + +def db_monodb(req): + # if only one db exists, return it else return False + return db_monodb_redirect(req)[0] + +def redirect_with_hash(req, url, code=303): + if req.httprequest.user_agent.browser == 'msie': + try: + version = float(req.httprequest.user_agent.version) + if version < 10: + return "" % url + except Exception: + pass + return werkzeug.utils.redirect(url, code) def module_topological_sort(modules): """ Return a list of module names sorted so that their dependencies of the @@ -291,6 +327,10 @@ def manifest_glob(req, extension, addons=None, db=None): return r def manifest_list(req, extension, mods=None, db=None): + """ list ressources to load specifying either: + mods: a comma separated string listing modules + db: a database name (return all installed modules in that database) + """ if not req.debug: path = '/web/webclient/' + extension if mods is not None: @@ -299,12 +339,7 @@ def manifest_list(req, extension, mods=None, db=None): path += '?' + urllib.urlencode({'db': db}) return [path] files = manifest_glob(req, extension, addons=mods, db=db) - i_am_diabetic = req.httprequest.environ["QUERY_STRING"].count("no_sugar") >= 1 or \ - req.httprequest.environ.get('HTTP_REFERER', '').count("no_sugar") >= 1 - if i_am_diabetic: - return [wp for _fp, wp in files] - else: - return ['%s?debug=%s' % (wp, os.path.getmtime(fp)) for fp, wp in files] + return [wp for _fp, wp in files] def get_last_modified(files): """ Returns the modification time of the most recently modified @@ -534,6 +569,10 @@ class Home(openerpweb.Controller): @openerpweb.httprequest def index(self, req, s_action=None, db=None, **kw): + db, redir = db_monodb_redirect(req) + if redir: + return redirect_with_hash(req, redir) + js = "\n ".join('' % i for i in manifest_list(req, 'js', db=db)) css = "\n ".join('' % i for i in manifest_list(req, 'css', db=db)) @@ -869,7 +908,7 @@ class Session(openerpweb.Controller): """ saved_actions = req.httpsession.get('saved_actions') if not saved_actions: - saved_actions = {"next":0, "actions":{}} + saved_actions = {"next":1, "actions":{}} req.httpsession['saved_actions'] = saved_actions # we don't allow more than 10 stored actions if len(saved_actions["actions"]) >= 10: diff --git a/addons/web/doc/search_view.rst b/addons/web/doc/search_view.rst index 16a91c5e9ac..12b0eaf3104 100644 --- a/addons/web/doc/search_view.rst +++ b/addons/web/doc/search_view.rst @@ -107,6 +107,12 @@ formatted differently). If an input *may* fetch multiple completion items, it *should* prefix those with a section title using its own name. This has no technical consequence but is clearer for users. +.. note:: + + If a field is :js:func:`invisible + `, its completion function will + *not* be called. + Providing drawer/supplementary UI +++++++++++++++++++++++++++++++++ @@ -145,6 +151,11 @@ started only once (per view). dynamically collects, lays out and renders filters? => exercises drawer thingies +.. note:: + + An :js:func:`invisible ` input + will not be inserted into the drawer. + Converting from facet objects +++++++++++++++++++++++++++++ diff --git a/addons/web/http.py b/addons/web/http.py index ed917c00563..05069574da1 100644 --- a/addons/web/http.py +++ b/addons/web/http.py @@ -92,6 +92,14 @@ class WebRequest(object): if not self.session: self.session = session.OpenERPSession() self.httpsession[self.session_id] = self.session + + # set db/uid trackers - they're cleaned up at the WSGI + # dispatching phase in openerp.service.wsgi_server.application + if self.session._db: + threading.current_thread().dbname = self.session._db + if self.session._uid: + threading.current_thread().uid = self.session._uid + self.context = self.params.pop('context', {}) self.debug = self.params.pop('debug', False) is not False # Determine self.lang diff --git a/addons/web/static/lib/datejs/globalization/ar-SY.js b/addons/web/static/lib/datejs/globalization/ar-SY.js index 317881211e6..b504a21d324 100644 --- a/addons/web/static/lib/datejs/globalization/ar-SY.js +++ b/addons/web/static/lib/datejs/globalization/ar-SY.js @@ -11,8 +11,8 @@ Date.CultureInfo = { firstLetterDayNames: ["أ", "ا", "ث", "أ", "خ", "ج", "س"], /* Month Name Strings */ - monthNames: ["كانون الثاني", "شباط", "آذار", "نيسان", "أيار", "حزيران", "تموز", "آب", "أيلول", "تشرين الأول", "تشرين الثاني", "كانون الأول"], - abbreviatedMonthNames: ["كانون الثاني", "شباط", "آذار", "نيسان", "أيار", "حزيران", "تموز", "آب", "أيلول", "تشرين الأول", "تشرين الثاني", "كانون الأول"], + monthNames: ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"], + abbreviatedMonthNames: ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"], /* AM/PM Designators */ amDesignator: "ص", @@ -82,18 +82,18 @@ Date.CultureInfo = { * As well, please review the list of "Future Strings" section below. */ regexPatterns: { - jan: /^كانون الثاني/i, - feb: /^شباط/i, - mar: /^آذار/i, - apr: /^نيسان/i, - may: /^أيار/i, - jun: /^حزيران/i, - jul: /^تموز/i, - aug: /^آب/i, - sep: /^أيلول/i, - oct: /^تشرين الأول/i, - nov: /^تشرين الثاني/i, - dec: /^كانون الأول/i, + jan: /^يناير/i, + feb: /^فبراير/i, + mar: /^مارس/i, + apr: /^أبريل/i, + may: /^مايو/i, + jun: /^يونيو/i, + jul: /^يوليو/i, + aug: /^أغسطس/i, + sep: /^سبتمبر/i, + oct: /^أكتوبر/i, + nov: /^نوفمبر/i, + dec: /^ديسمبر/i, sun: /^الاحد/i, mon: /^ا(1)?/i, @@ -192,4 +192,4 @@ Date.CultureInfo = { * end end * long long * short short - */ \ No newline at end of file + */ diff --git a/addons/web/static/lib/jquery.placeholder/jquery.placeholder.js b/addons/web/static/lib/jquery.placeholder/jquery.placeholder.js new file mode 100755 index 00000000000..d8051fa269d --- /dev/null +++ b/addons/web/static/lib/jquery.placeholder/jquery.placeholder.js @@ -0,0 +1,157 @@ +/*! http://mths.be/placeholder v2.0.7 by @mathias */ +;(function(window, document, $) { + + var isInputSupported = 'placeholder' in document.createElement('input'), + isTextareaSupported = 'placeholder' in document.createElement('textarea'), + prototype = $.fn, + valHooks = $.valHooks, + hooks, + placeholder; + + if (isInputSupported && isTextareaSupported) { + + placeholder = prototype.placeholder = function() { + return this; + }; + + placeholder.input = placeholder.textarea = true; + + } else { + + placeholder = prototype.placeholder = function() { + var $this = this; + $this + .filter((isInputSupported ? 'textarea' : ':input') + '[placeholder]') + .not('.placeholder') + .bind({ + 'focus.placeholder': clearPlaceholder, + 'blur.placeholder': setPlaceholder + }) + .data('placeholder-enabled', true) + .trigger('blur.placeholder'); + return $this; + }; + + placeholder.input = isInputSupported; + placeholder.textarea = isTextareaSupported; + + hooks = { + 'get': function(element) { + var $element = $(element); + return $element.data('placeholder-enabled') && $element.hasClass('placeholder') ? '' : element.value; + }, + 'set': function(element, value) { + var $element = $(element); + if (!$element.data('placeholder-enabled')) { + return element.value = value; + } + if (value == '') { + element.value = value; + // Issue #56: Setting the placeholder causes problems if the element continues to have focus. + if (element != document.activeElement) { + // We can't use `triggerHandler` here because of dummy text/password inputs :( + setPlaceholder.call(element); + } + } else if ($element.hasClass('placeholder')) { + clearPlaceholder.call(element, true, value) || (element.value = value); + } else { + element.value = value; + } + // `set` can not return `undefined`; see http://jsapi.info/jquery/1.7.1/val#L2363 + return $element; + } + }; + + isInputSupported || (valHooks.input = hooks); + isTextareaSupported || (valHooks.textarea = hooks); + + $(function() { + // Look for forms + $(document).delegate('form', 'submit.placeholder', function() { + // Clear the placeholder values so they don't get submitted + var $inputs = $('.placeholder', this).each(clearPlaceholder); + setTimeout(function() { + $inputs.each(setPlaceholder); + }, 10); + }); + }); + + // Clear placeholder values upon page reload + $(window).bind('beforeunload.placeholder', function() { + $('.placeholder').each(function() { + this.value = ''; + }); + }); + + } + + function args(elem) { + // Return an object of element attributes + var newAttrs = {}, + rinlinejQuery = /^jQuery\d+$/; + $.each(elem.attributes, function(i, attr) { + if (attr.specified && !rinlinejQuery.test(attr.name)) { + newAttrs[attr.name] = attr.value; + } + }); + return newAttrs; + } + + function clearPlaceholder(event, value) { + var input = this, + $input = $(input); + if (input.value == $input.attr('placeholder') && $input.hasClass('placeholder')) { + if ($input.data('placeholder-password')) { + $input = $input.hide().next().show().attr('id', $input.removeAttr('id').data('placeholder-id')); + // If `clearPlaceholder` was called from `$.valHooks.input.set` + if (event === true) { + return $input[0].value = value; + } + $input.focus(); + } else { + input.value = ''; + $input.removeClass('placeholder'); + input == document.activeElement && input.select(); + } + } + } + + function setPlaceholder() { + var $replacement, + input = this, + $input = $(input), + $origInput = $input, + id = this.id; + if (input.value == '') { + if (input.type == 'password') { + if (!$input.data('placeholder-textinput')) { + try { + $replacement = $input.clone().attr({ 'type': 'text' }); + } catch(e) { + $replacement = $('').attr($.extend(args(this), { 'type': 'text' })); + } + $replacement + .removeAttr('name') + .data({ + 'placeholder-password': true, + 'placeholder-id': id + }) + .bind('focus.placeholder', clearPlaceholder); + $input + .data({ + 'placeholder-textinput': $replacement, + 'placeholder-id': id + }) + .before($replacement); + } + $input = $input.removeAttr('id').hide().prev().attr('id', id).show(); + // Note: `$input[0] != input` now! + } + $input.addClass('placeholder'); + $input[0].value = $input.attr('placeholder'); + } else { + $input.removeClass('placeholder'); + } + } + +}(this, document, jQuery)); \ No newline at end of file diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index 550a746b7ad..8544cf0dd3c 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -1436,7 +1436,7 @@ $sheet-padding: 16px .oe_searchview_drawer position: absolute - z-index: 100 + z-index: 2 // detach drawer from field slightly margin-top: 4px top: 100% @@ -1832,6 +1832,12 @@ $sheet-padding: 16px .oe_form .oe_form_field_text width: 100% + .oe_form_text_content + text-overflow: ellipsis + display: inline-block + white-space: pre-wrap + overflow-x: hidden + width: 100% .oe_form_field_char input, .oe_form_field_url input, .oe_form_field_email input, @@ -2502,6 +2508,9 @@ div.ui-widget-overlay // Internet Explorer 9+ specifics {{{ .openerp_ie + .placeholder + color: $tag-border !important + font-style: italic !important .oe_form_field_boolean input background: #fff .db_option_table .oe_form_field_selection @@ -2517,6 +2526,8 @@ div.ui-widget-overlay button.oe_highlight padding-top: 0 padding-bottom: 0 + .oe_view_manager_view_kanban + display: table-cell .oe_view_manager_buttons button.oe_write_full padding-top: 0 diff --git a/addons/web/static/src/js/chrome.js b/addons/web/static/src/js/chrome.js index eaa4bcbfe1c..c3f9f6af357 100644 --- a/addons/web/static/src/js/chrome.js +++ b/addons/web/static/src/js/chrome.js @@ -530,7 +530,16 @@ instance.web.DatabaseManager = instance.web.Widget.extend({ 'login': 'admin', 'password': form_obj['create_admin_pwd'], 'login_successful': function() { - self.do_action("reload"); + var action = { + type: "ir.actions.client", + tag: 'reload', + params: { + url_search : { + db: form_obj['db_name'], + }, + } + }; + self.do_action(action); }, }, _push_me: false, @@ -641,6 +650,11 @@ instance.web.client_actions.add("database_manager", "instance.web.DatabaseManage instance.web.Login = instance.web.Widget.extend({ template: "Login", remember_credentials: true, + events: { + 'change input[name=db],select[name=db]': function(ev) { + this.set('database_selector', $(ev.currentTarget).val()); + }, + }, init: function(parent, action) { this._super(parent); @@ -652,18 +666,18 @@ instance.web.Login = instance.web.Widget.extend({ if (_.isEmpty(this.params)) { this.params = $.bbq.getState(true); } + if (action && action.params && action.params.db) { + this.params.db = action.params.db; + } else if ($.deparam.querystring().db) { + this.params.db = $.deparam.querystring().db; + } + if (this.params.db) { + this.selected_db = this.params.db; + } if (this.params.login_successful) { this.on('login_successful', this, this.params.login_successful); } - - if (this.has_local_storage && this.remember_credentials) { - this.selected_db = localStorage.getItem('last_db_login_success'); - this.selected_login = localStorage.getItem('last_login_login_success'); - if (jQuery.deparam(jQuery.param.querystring()).debug !== undefined) { - this.selected_password = localStorage.getItem('last_password_login_success'); - } - } }, start: function() { var self = this; @@ -671,10 +685,10 @@ instance.web.Login = instance.web.Widget.extend({ self.$el.find('.oe_login_manage_db').click(function() { self.do_action("database_manager"); }); + self.on('change:database_selector', this, function() { + this.database_selected(this.get('database_selector')); + }); var d = $.when(); - if ($.deparam.querystring().db) { - self.params.db = $.deparam.querystring().db; - } if ($.param.fragment().token) { self.params.token = $.param.fragment().token; } @@ -682,16 +696,44 @@ instance.web.Login = instance.web.Widget.extend({ if (self.params.db && self.params.login && self.params.password) { d = self.do_login(self.params.db, self.params.login, self.params.password); } else { - if (self.params.db) { - self.on_db_loaded([self.params.db]) - } else { - d = self.rpc("/web/database/get_list", {}).done(self.on_db_loaded).fail(self.on_db_failed); - } + d = self.rpc("/web/database/get_list", {}) + .done(self.on_db_loaded) + .fail(self.on_db_failed) + .always(function() { + if (self.selected_db && self.has_local_storage && self.remember_credentials) { + self.$("[name=login]").val(localStorage.getItem(self.selected_db + '|last_login') || ''); + if (self.session.debug) { + self.$("[name=password]").val(localStorage.getItem(self.selected_db + '|last_password') || ''); + } + } + }); } return d; }, + remember_last_used_database: function(db) { + // This cookie will be used server side in order to avoid db reloading on first visit + var ttl = 24 * 60 * 60 * 365; + document.cookie = [ + 'last_used_database=' + db, + 'path=/', + 'max-age=' + ttl, + 'expires=' + new Date(new Date().getTime() + ttl * 1000).toGMTString() + ].join(';'); + }, + database_selected: function(db) { + var params = $.deparam.querystring(); + params.db = db; + this.remember_last_used_database(db); + this.$('.oe_login_dbpane').empty().text(_t('Loading...')); + this.$('[name=login], [name=password]').prop('readonly', true); + instance.web.redirect('/?' + $.param(params)); + }, on_db_loaded: function (result) { + var self = this; this.db_list = result; + if (!this.selected_db) { + this.selected_db = result[0]; + } this.$("[name=db]").replaceWith(QWeb.render('Login.dblist', { db_list: this.db_list, selected_db: this.selected_db})); if(this.db_list.length === 0) { this.do_action("database_manager"); @@ -732,17 +774,11 @@ instance.web.Login = instance.web.Widget.extend({ self.hide_error(); self.$(".oe_login_pane").fadeOut("slow"); return this.session.session_authenticate(db, login, password).then(function() { - if (self.has_local_storage) { - if(self.remember_credentials) { - localStorage.setItem('last_db_login_success', db); - localStorage.setItem('last_login_login_success', login); - if (jQuery.deparam(jQuery.param.querystring()).debug !== undefined) { - localStorage.setItem('last_password_login_success', password); - } - } else { - localStorage.setItem('last_db_login_success', ''); - localStorage.setItem('last_login_login_success', ''); - localStorage.setItem('last_password_login_success', ''); + self.remember_last_used_database(db); + if (self.has_local_storage && self.remember_credentials) { + localStorage.setItem(db + '|last_login', login); + if (self.session.debug) { + localStorage.setItem(db + '|last_password', password); } } self.trigger('login_successful'); @@ -800,6 +836,9 @@ instance.web.Reload = function(parent, action) { var sobj = $.deparam(l.search.substr(1)); sobj.ts = new Date().getTime(); + if (params.url_search) { + sobj = _.extend(sobj, params.url_search); + } var search = '?' + $.param(sobj); var hash = l.hash; @@ -1303,7 +1342,7 @@ instance.web.WebClient = instance.web.Client.extend({ }, logo_edit: function(ev) { var self = this; - new self.alive(instance.web.Model("res.users").get_func("read")(this.session.uid, ["company_id"])).then(function(res) { + self.alive(new instance.web.Model("res.users").get_func("read")(this.session.uid, ["company_id"])).then(function(res) { self.rpc("/web/action/load", { action_id: "base.action_res_company_form" }).done(function(result) { result.res_id = res['company_id'][0]; result.target = "new"; diff --git a/addons/web/static/src/js/corelib.js b/addons/web/static/src/js/corelib.js index 90a0eb0a5a0..c4bb81117f8 100644 --- a/addons/web/static/src/js/corelib.js +++ b/addons/web/static/src/js/corelib.js @@ -232,13 +232,17 @@ instance.web.ParentedMixin = { Utility method to only execute asynchronous actions if the current object has not been destroyed. - @param {Promise} promise The promise representing the asynchronous action. - @param {bool} reject Defaults to false. If true, the returned promise will be - rejected with no arguments if the current object is destroyed. If false, - the returned promise will never be resolved nor rejected. - @returns {Promise} A promise that will mirror the given promise if everything goes - fine but will either be rejected with no arguments or never resolved if the - current object is destroyed. + @param {$.Deferred} promise The promise representing the asynchronous + action. + @param {bool} [reject=false] If true, the returned promise will be + rejected with no arguments if the current + object is destroyed. If false, the + returned promise will never be resolved + or rejected. + @returns {$.Deferred} A promise that will mirror the given promise if + everything goes fine but will either be rejected + with no arguments or never resolved if the + current object is destroyed. */ alive: function(promise, reject) { var def = $.Deferred(); diff --git a/addons/web/static/src/js/coresetup.js b/addons/web/static/src/js/coresetup.js index 43d292b66db..dedb4a49710 100644 --- a/addons/web/static/src/js/coresetup.js +++ b/addons/web/static/src/js/coresetup.js @@ -89,6 +89,10 @@ instance.web.Session = instance.web.JsonRPC.extend( /** @lends instance.web.Sess }); }, session_is_valid: function() { + var db = $.deparam.querystring().db; + if (db && this.db !== db) { + return false; + } return !!this.uid; }, /** diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/data.js index bbff633c687..5f56c4bc5f9 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/data.js @@ -481,9 +481,12 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin, * Creates a new record in db * * @param {Object} data field values to set on the new record + * @param {Object} options Dictionary that can contain the following keys: + * - readonly_fields: Values from readonly fields that were updated by + * on_changes. Only used by the BufferedDataSet to make the o2m work correctly. * @returns {$.Deferred} */ - create: function(data) { + create: function(data, options) { return this._model.call('create', [data], {context: this.get_context()}); }, /** @@ -491,8 +494,10 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin, * * @param {Number|String} id identifier for the record to alter * @param {Object} data field values to write into the record - * @param {Function} callback function called with operation result - * @param {Function} error_callback function called in case of write error + * @param {Object} options Dictionary that can contain the following keys: + * - context: The context to use in the server-side call. + * - readonly_fields: Values from readonly fields that were updated by + * on_changes. Only used by the BufferedDataSet to make the o2m work correctly. * @returns {$.Deferred} */ write: function (id, data, options) { @@ -732,10 +737,13 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({ self.last_default_get = res; }); }, - create: function(data) { - var cached = {id:_.uniqueId(this.virtual_id_prefix), values: data, - defaults: this.last_default_get}; - this.to_create.push(_.extend(_.clone(cached), {values: _.clone(cached.values)})); + create: function(data, options) { + var cached = { + id:_.uniqueId(this.virtual_id_prefix), + values: _.extend({}, data, (options || {}).readonly_fields || {}), + defaults: this.last_default_get + }; + this.to_create.push(_.extend(_.clone(cached), {values: _.clone(data)})); this.cache.push(cached); return $.Deferred().resolve(cached.id).promise(); }, @@ -762,7 +770,7 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({ cached = {id: id, values: {}}; this.cache.push(cached); } - $.extend(cached.values, record.values); + $.extend(cached.values, _.extend({}, record.values, (options || {}).readonly_fields || {})); if (dirty) this.trigger("dataset_changed", id, data, options); return $.Deferred().resolve(true).promise(); @@ -860,8 +868,16 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({ } return completion.promise(); }, - call_button: function (method, args) { - var id = args[0][0], index; + /** + * Invalidates caching of a record in the dataset to ensure the next read + * of that record will hit the server. + * + * Of use when an action is going to remote-alter a record which will then + * need to be reloaded, e.g. action button. + * + * @param {Object} id record to remove from the BDS's cache + */ + evict_record: function (id) { for(var i=0, len=this.cache.length; i} filters elements of the group - * @param {instance.web.SearchView} view view in which the filters are contained + * @param {instance.web.SearchView} parent parent in which the filters are contained */ - init: function (filters, view) { + init: function (filters, parent) { // If all filters are group_by and we're not initializing a GroupbyGroup, // create a GroupbyGroup instead of the current FilterGroup if (!(this instanceof instance.web.search.GroupbyGroup) && _(filters).all(function (f) { - return f.attrs.context && f.attrs.context.group_by; })) { - return new instance.web.search.GroupbyGroup(filters, view); + if (!f.attrs.context) { return false; } + var c = instance.web.pyeval.eval('context', f.attrs.context); + return !_.isEmpty(c.group_by);})) { + return new instance.web.search.GroupbyGroup(filters, parent); } - this._super(view); + this._super(parent); this.filters = filters; this.view.query.on('add remove change reset', this.proxy('search_change')); }, @@ -1096,6 +1137,7 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in var self = this; item = item.toLowerCase(); var facet_values = _(this.filters).chain() + .filter(function (filter) { return filter.visible(); }) .filter(function (filter) { var at = { string: filter.attrs.string || '', @@ -1122,8 +1164,8 @@ instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends in instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({ icon: 'w', completion_label: _lt("Group by: %s"), - init: function (filters, view) { - this._super(filters, view); + init: function (filters, parent) { + this._super(filters, parent); // Not flanders: facet unicity is handled through the // (category, field) pair of facet attributes. This is all well and // good for regular filter groups where a group matches a facet, but for @@ -1131,8 +1173,8 @@ instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({ // view which proxies to the first GroupbyGroup, so it can be used // for every GroupbyGroup and still provides the various methods needed // by the search view. Use weirdo name to avoid risks of conflicts - if (!this.getParent()._s_groupby) { - this.getParent()._s_groupby = { + if (!this.view._s_groupby) { + this.view._s_groupby = { help: "See GroupbyGroup#init", get_context: this.proxy('get_context'), get_domain: this.proxy('get_domain'), @@ -1141,14 +1183,14 @@ instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({ } }, match_facet: function (facet) { - return facet.get('field') === this.getParent()._s_groupby; + return facet.get('field') === this.view._s_groupby; }, make_facet: function (values) { return { category: _t("GroupBy"), icon: this.icon, values: values, - field: this.getParent()._s_groupby + field: this.view._s_groupby }; } }); @@ -1166,10 +1208,10 @@ instance.web.search.Filter = instance.web.search.Input.extend(/** @lends instanc * @extends instance.web.search.Input * * @param node - * @param view + * @param parent */ - init: function (node, view) { - this._super(view); + init: function (node, parent) { + this._super(parent); this.load_attrs(node.attrs); }, facet_for: function () { return $.when(null); }, @@ -1185,10 +1227,10 @@ instance.web.search.Field = instance.web.search.Input.extend( /** @lends instanc * * @param view_section * @param field - * @param view + * @param parent */ - init: function (view_section, field, view) { - this._super(view); + init: function (view_section, field, parent) { + this._super(parent); this.load_attrs(_.extend({}, field, view_section.attrs)); }, facet_for: function (value) { @@ -1228,7 +1270,7 @@ instance.web.search.Field = instance.web.search.Input.extend( /** @lends instanc * * @param {String} name the field's name * @param {String} operator the field's operator (either attribute-specified or default operator for the field - * @param {Number|String} value parsed value for the field + * @param {Number|String} facet parsed value for the field * @returns {Array} domain to include in the resulting search */ make_domain: function (name, operator, facet) { @@ -1460,8 +1502,8 @@ instance.web.search.DateTimeField = instance.web.search.DateField.extend(/** @le }); instance.web.search.ManyToOneField = instance.web.search.CharField.extend({ default_operator: {}, - init: function (view_section, field, view) { - this._super(view_section, field, view); + init: function (view_section, field, parent) { + this._super(view_section, field, parent); this.model = new instance.web.Model(this.attrs.relation); }, complete: function (needle) { @@ -1673,6 +1715,13 @@ instance.web.search.CustomFilters = instance.web.search.Input.extend({ if (!_.isEmpty(results.group_by)) { results.context.group_by = results.group_by; } + // Don't save user_context keys in the custom filter, otherwise end + // up with e.g. wrong uid or lang stored *and used in subsequent + // reqs* + var ctx = results.context; + _(_.keys(instance.session.user_context)).each(function (key) { + delete ctx[key]; + }); var filter = { name: $name.val(), user_id: private_filter ? instance.session.uid : false, @@ -1702,22 +1751,28 @@ instance.web.search.Filters = instance.web.search.Input.extend({ var running_count = 0; // get total filters count var is_group = function (i) { return i instanceof instance.web.search.FilterGroup; }; - var filters_count = _(this.view.controls).chain() + var visible_filters = _(this.view.controls).chain().reject(function (group) { + return _(_(group.children).filter(is_group)).isEmpty() + || group.modifiers.invisible; + }); + var filters_count = visible_filters + .pluck('children') .flatten() .filter(is_group) .map(function (i) { return i.filters.length; }) .sum() .value(); - var col1 = [], col2 = _(this.view.controls).map(function (inputs, group) { - var filters = _(inputs).filter(is_group); - return { - name: group === 'null' ? "q " + _t("Filters") : "w " + group, - filters: filters, - length: _(filters).chain().map(function (i) { - return i.filters.length; }).sum().value() - }; - }); + var col1 = [], col2 = visible_filters.map(function (group) { + var filters = _(group.children).filter(is_group); + return { + name: _.str.sprintf("%s %s", + group.icon, group.name), + filters: filters, + length: _(filters).chain().map(function (i) { + return i.filters.length; }).sum().value() + }; + }).value(); while (col2.length) { // col1 + group should be smaller than col2 + group diff --git a/addons/web/static/src/js/view_form.js b/addons/web/static/src/js/view_form.js index ccfc17a0288..cd27bd68b27 100644 --- a/addons/web/static/src/js/view_form.js +++ b/addons/web/static/src/js/view_form.js @@ -810,7 +810,8 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM try { var form_invalid = false, values = {}, - first_invalid_field = null; + first_invalid_field = null, + readonly_values = {}; for (var f in self.fields) { if (!self.fields.hasOwnProperty(f)) { continue; } f = self.fields[f]; @@ -819,11 +820,15 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM if (!first_invalid_field) { first_invalid_field = f; } - } else if (f.name !== 'id' && !f.get("readonly") && (!self.datarecord.id || f._dirty_flag)) { + } else if (f.name !== 'id' && (!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 - values[f.name] = f.get_value(); + if (!f.get("readonly")) { + values[f.name] = f.get_value(); + } else { + readonly_values[f.name] = f.get_value(); + } } } if (form_invalid) { @@ -836,7 +841,7 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM var save_deferral; if (!self.datarecord.id) { // Creation save - save_deferral = self.dataset.create(values).then(function(r) { + save_deferral = self.dataset.create(values, {readonly_fields: readonly_values}).then(function(r) { return self.record_created(r, prepend_on_create); }, null); } else if (_.isEmpty(values)) { @@ -844,7 +849,7 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM save_deferral = $.Deferred().resolve({}).promise(); } else { // Write save - save_deferral = self.dataset.write(self.datarecord.id, values, {}).then(function(r) { + save_deferral = self.dataset.write(self.datarecord.id, values, {readonly_fields: readonly_values}).then(function(r) { return self.record_saved(r); }, null); } @@ -1887,10 +1892,11 @@ instance.web.form.WidgetButton = instance.web.form.FormWidget.extend({ if (this.node.attrs.icon && (! /\//.test(this.node.attrs.icon))) { this.node.attrs.icon = '/web/static/src/img/icons/' + this.node.attrs.icon + '.png'; } - this.view.on('view_content_has_changed', this, this.check_disable); }, start: function() { this._super.apply(this, arguments); + this.view.on('view_content_has_changed', this, this.check_disable); + this.check_disable(); this.$el.click(this.on_click); if (this.node.attrs.help || instance.session.debug) { this.do_attach_tooltip(); @@ -1916,18 +1922,20 @@ instance.web.form.WidgetButton = instance.web.form.FormWidget.extend({ modal: true, buttons: [ {text: _t("Cancel"), click: function() { - def.resolve(); $(this).dialog("close"); } }, {text: _t("Ok"), click: function() { - self.on_confirmed().done(function() { - def.resolve(); + var self2 = this; + self.on_confirmed().always(function() { + $(self2).dialog("close"); }); - $(this).dialog("close"); } } - ] + ], + beforeClose: function() { + def.resolve(); + }, }); return def.promise(); } else { @@ -2218,6 +2226,18 @@ instance.web.form.ReinitializeFieldMixin = _.extend({}, instance.web.form.Reini }, }); +/** + Some hack to make placeholders work in ie9. +*/ +if ($.browser.msie && $.browser.version === "9.0") { + document.addEventListener("DOMNodeInserted",function(event){ + var nodename = event.target.nodeName.toLowerCase(); + if ( nodename === "input" || nodename == "textarea" ) { + $(event.target).placeholder(); + } + }); +} + instance.web.form.FieldChar = instance.web.form.AbstractField.extend(instance.web.form.ReinitializeFieldMixin, { template: 'FieldChar', widget_class: 'oe_form_field_char', @@ -2382,7 +2402,6 @@ instance.web.DateTimeWidget = instance.web.Widget.extend({ type_of_date: "datetime", events: { 'change .oe_datepicker_master': 'change_datetime', - 'dragstart img.oe_datepicker_trigger': function () { return false; }, }, init: function(parent) { this._super(parent); @@ -2546,42 +2565,45 @@ instance.web.form.FieldText = instance.web.form.AbstractField.extend(instance.we }, 'change textarea': 'store_dom_value', }, - init: function (field_manager, node) { - this._super(field_manager, node); - }, initialize_content: function() { var self = this; - this.$textarea = this.$el.find('textarea'); - this.auto_sized = false; - this.default_height = this.$textarea.css('height'); - if (this.get("effective_readonly")) { - this.$textarea.attr('disabled', 'disabled'); + if (! this.get("effective_readonly")) { + this.$textarea = this.$el.find('textarea'); + this.auto_sized = false; + this.default_height = this.$textarea.css('height'); + if (this.get("effective_readonly")) { + this.$textarea.attr('disabled', 'disabled'); + } + this.setupFocus(this.$textarea); + } else { + this.$textarea = undefined; } - this.setupFocus(this.$textarea); }, commit_value: function () { - this.store_dom_value(); + if (! this.get("effective_readonly") && this.$textarea) { + this.store_dom_value(); + } return this._super(); }, store_dom_value: function () { - if (!this.get('effective_readonly') && this.$('textarea').length) { - this.internal_set_value( - instance.web.parse_value( - this.$textarea.val(), - this)); - } + this.internal_set_value(instance.web.parse_value(this.$textarea.val(), this)); }, render_value: function() { - var show_value = instance.web.format_value(this.get('value'), this, ''); - if (show_value === '') { - this.$textarea.css('height', parseInt(this.default_height)+"px"); - } - this.$textarea.val(show_value); - if (! this.auto_sized) { - this.auto_sized = true; - this.$textarea.autosize(); + if (! this.get("effective_readonly")) { + var show_value = instance.web.format_value(this.get('value'), this, ''); + if (show_value === '') { + this.$textarea.css('height', parseInt(this.default_height)+"px"); + } + this.$textarea.val(show_value); + if (! this.auto_sized) { + this.auto_sized = true; + this.$textarea.autosize(); + } else { + this.$textarea.trigger("autosize"); + } } else { - this.$textarea.trigger("autosize"); + var txt = this.get("value"); + this.$(".oe_form_text_content").text(txt); } }, is_syntax_valid: function() { @@ -2599,14 +2621,18 @@ instance.web.form.FieldText = instance.web.form.AbstractField.extend(instance.we return this.get('value') === '' || this._super(); }, focus: function($el) { - this.$textarea[0].focus(); + if (!this.get("effective_readonly") && this.$textarea) { + this.$textarea[0].focus(); + } }, set_dimensions: function (height, width) { this._super(height, width); - this.$textarea.css({ - width: width, - minHeight: height - }); + if (!this.get("effective_readonly") && this.$textarea) { + this.$textarea.css({ + width: width, + minHeight: height + }); + } }, }); @@ -2970,8 +2996,6 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instanc e.stopPropagation(); } }, - 'dragstart .oe_m2o_drop_down_button img': function () { return false; }, - 'dragstart .oe_m2o_cm_button': function () { return false; } }, init: function(field_manager, node) { this._super(field_manager, node); @@ -3002,6 +3026,26 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instanc if (!this.get("effective_readonly")) this.render_editable(); }, + destroy_content: function () { + if (this.$drop_down) { + this.$drop_down.off('click'); + delete this.$drop_down; + } + if (this.$input) { + this.$input.closest(".ui-dialog .ui-dialog-content").off('scroll'); + this.$input.off('keyup blur autocompleteclose autocompleteopen ' + + 'focus focusout change keydown'); + delete this.$input; + } + if (this.$follow_button) { + this.$follow_button.off('blur focus click'); + delete this.$follow_button; + } + }, + destroy: function () { + this.destroy_content(); + return this._super(); + }, init_error_displayer: function() { // nothing }, @@ -3263,7 +3307,7 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instanc }, focus: function () { if (!this.get('effective_readonly')) { - this.$input[0].focus(); + this.$input && this.$input[0].focus(); } }, _quick_create: function() { @@ -3682,8 +3726,8 @@ instance.web.form.One2ManyViewManager = instance.web.ViewManager.extend({ var pop = new instance.web.form.FormOpenPopup(this); pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), { title: _t("Open: ") + self.o2m.string, - create_function: function(data) { - return self.o2m.dataset.create(data).done(function(r) { + create_function: function(data, options) { + return self.o2m.dataset.create(data, options).done(function(r) { self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r])); self.o2m.dataset.trigger("dataset_changed", r); }); @@ -3742,8 +3786,12 @@ instance.web.form.One2ManyListView = instance.web.ListView.extend({ this.o2m.trigger_on_change(); }, is_valid: function () { - var form = this.editor.form; - + var editor = this.editor; + var form = editor.form; + // If no edition is pending, the listview can not be invalid (?) + if (!editor.record) { + return true + } // If the form has not been modified, the view can only be valid // NB: is_dirty will also be set on defaults/onchanges/whatever? // oe_form_dirty seems to only be set on actual user actions @@ -3773,11 +3821,11 @@ instance.web.form.One2ManyListView = instance.web.ListView.extend({ title: _t("Create: ") + self.o2m.string, initial_view: "form", alternative_form_view: self.o2m.field.views ? self.o2m.field.views["form"] : undefined, - create_function: function(data, callback, error_callback) { - return self.o2m.dataset.create(data).done(function(r) { + create_function: function(data, options) { + return self.o2m.dataset.create(data, options).done(function(r) { self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r])); self.o2m.dataset.trigger("dataset_changed", r); - }).done(callback).fail(error_callback); + }); }, read_function: function() { return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments); @@ -3990,6 +4038,7 @@ instance.web.form.FieldMany2ManyTags = instance.web.form.AbstractField.extend(in if (this.get("effective_readonly")) return; var self = this; + var ignore_blur = false; self.$text = this.$("textarea"); self.$text.textext({ plugins : 'tags arrow autocomplete', @@ -4008,6 +4057,7 @@ instance.web.form.FieldMany2ManyTags = instance.web.form.AbstractField.extend(in if (data.id) { self.add_id(data.id); } else { + ignore_blur = true; data.action(); } }, @@ -4058,10 +4108,13 @@ instance.web.form.FieldMany2ManyTags = instance.web.form.AbstractField.extend(in self.$text .focusin(function () { self.trigger('focused'); + ignore_blur = false; }) .focusout(function() { self.$text.trigger("setInputData", ""); - self.trigger('blurred'); + if (!ignore_blur) { + self.trigger('blurred'); + } }).keydown(function(e) { if (e.which === $.ui.keyCode.TAB && self._drop_shown) { self.$text.textext()[0].autocomplete().selectFromDropdown(); @@ -4268,6 +4321,7 @@ instance.web.form.Many2ManyListView = instance.web.ListView.extend(/** @lends in }); } }, + is_action_enabled: function () { return true; }, }); instance.web.form.FieldMany2ManyKanban = instance.web.form.AbstractField.extend(instance.web.form.CompletionFieldMixin, { @@ -4502,9 +4556,9 @@ instance.web.form.AbstractFormPopup = instance.web.Widget.extend({ this.created_elements = []; this.dataset = new instance.web.ProxyDataSet(this, this.model, this.context); this.dataset.read_function = this.options.read_function; - this.dataset.create_function = function(data, sup) { + this.dataset.create_function = function(data, options, sup) { var fct = self.options.create_function || sup; - return fct.call(this, data).done(function(r) { + return fct.call(this, data, options).done(function(r) { self.trigger('create_completed saved', r); self.created_elements.push(r); }); @@ -5216,11 +5270,11 @@ instance.web.form.FieldStatus = instance.web.form.AbstractField.extend({ this.calc_domain(); this.on("change:value", this, this.get_selection); this.on("change:evaluated_selection_domain", this, this.get_selection); - this.get_selection(); this.on("change:selection", this, function() { this.selection = this.get("selection"); this.render_value(); }); + this.get_selection(); if (this.options.clickable) { this.$el.on('click','li',this.on_click_stage); } @@ -5339,10 +5393,10 @@ instance.web.form.FieldMonetary = instance.web.form.FieldFloat.extend({ }); }, parse_value: function(val, def) { - return instance.web.parse_value(val, {type: "float"}, def); + return instance.web.parse_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def); }, format_value: function(val, def) { - return instance.web.format_value(val, {type: "float"}, def); + return instance.web.format_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def); }, }); diff --git a/addons/web/static/src/js/view_list.js b/addons/web/static/src/js/view_list.js index d23bedf1d1c..b69ec6dbdca 100644 --- a/addons/web/static/src/js/view_list.js +++ b/addons/web/static/src/js/view_list.js @@ -321,9 +321,9 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi .appendTo($this.empty()) .click(function (e) {e.stopPropagation();}) .append('' + - '' + '' + '' + + '' + '') .change(function () { var val = parseInt($select.val(), 10); diff --git a/addons/web/static/src/js/view_list_editable.js b/addons/web/static/src/js/view_list_editable.js index f1bd7c4325e..4e042b60c5d 100644 --- a/addons/web/static/src/js/view_list_editable.js +++ b/addons/web/static/src/js/view_list_editable.js @@ -96,7 +96,8 @@ openerp.web.list_editable = function (instance) { }); }, editable: function () { - return !this.options.disable_editable_mode + return !this.grouped + && !this.options.disable_editable_mode && (this.fields_view.arch.attrs.editable || this._context_editable || this.options.editable); diff --git a/addons/web/static/src/js/views.js b/addons/web/static/src/js/views.js index f5638004b47..1ba65f7ec18 100644 --- a/addons/web/static/src/js/views.js +++ b/addons/web/static/src/js/views.js @@ -1446,11 +1446,11 @@ instance.web.View = instance.web.Widget.extend({ * Performs a fields_view_get and apply postprocessing. * return a {$.Deferred} resolved with the fvg * - * @param {Object} [args] + * @param {Object} args * @param {String|Object} args.model instance.web.Model instance or string repr of the model - * @param {null|Object} args.context context if args.model is a string - * @param {null|Number} args.view_id id of the view to be loaded, default view if null - * @param {null|String} args.view_type type of view to be loaded if view_id is null + * @param {Object} [args.context] context if args.model is a string + * @param {Number} [args.view_id] id of the view to be loaded, default view if null + * @param {String} [args.view_type] type of view to be loaded if view_id is null * @param {Boolean} [args.toolbar=false] get the toolbar definition */ instance.web.fields_view_get = function(args) { @@ -1555,12 +1555,17 @@ instance.web.xml_to_str = function(node) { } else { throw new Error(_t("Could not serialize XML")); } - // Browsers won't deal with self closing tags except br, hr, input, ... - // http://stackoverflow.com/questions/97522/what-are-all-the-valid-self-closing-elements-in-xhtml-as-implemented-by-the-maj - // + // Browsers won't deal with self closing tags except void elements: + // http://www.w3.org/TR/html-markup/syntax.html + var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' '); + // The following regex is a bit naive but it's ok for the xmlserializer output str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function(match, tag, attrs) { - return "<" + tag + attrs + ">"; + if (void_elements.indexOf(tag) < 0) { + return "<" + tag + attrs + ">"; + } else { + return match; + } }); return str; }; diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 26b1cc09019..70d74d05747 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -40,7 +40,7 @@

- var message = d.message ? d.message : d.error.data.message; + var message = d.message ? d.message : d.error.data.fault_code; d.html_error = context.engine.tools.html_escape(message) .replace(/\n/g, '
');
@@ -71,9 +71,9 @@

  • Username
  • -
  • +
  • Password
  • -
  • +
@@ -118,7 +118,8 @@ you will be able to install your first application.

- By default, the master password is 'admin' if you did not changed it. + By default, the master password is 'admin'. This password + is required to created, delete dump or restore databases.

@@ -129,7 +130,9 @@ - + @@ -1047,17 +1050,22 @@
- + + + + + +
@@ -1076,8 +1084,9 @@ t-att-name="widget.name" t-att-placeholder="placeholder" class="oe_datepicker_master" - /> + /> @@ -1111,7 +1120,7 @@ / + class="oe_m2o_cm_button oe_e" draggable="false">/
- +
@@ -1515,7 +1524,7 @@
    -
  • @@ -1603,15 +1612,6 @@ - - - - - - - - -
    @@ -1842,7 +1842,7 @@
diff --git a/addons/web/static/test/search.js b/addons/web/static/test/search.js index 904af63f590..5c02d1f16db 100644 --- a/addons/web/static/test/search.js +++ b/addons/web/static/test/search.js @@ -1,4 +1,4 @@ -openerp.testing.section('query', { +openerp.testing.section('search.query', { dependencies: ['web.search'] }, function (test) { test('Adding a facet to the query creates a facet and a value', function (instance) { @@ -180,7 +180,7 @@ var makeSearchView = function (instance, dummy_widget_attributes, defaults) { }); return view; }; -openerp.testing.section('defaults', { +openerp.testing.section('search.defaults', { dependencies: ['web.search'], rpc: 'mock', templates: true, @@ -362,7 +362,7 @@ openerp.testing.section('defaults', { "should not accept multiple default values"); }) }); -openerp.testing.section('completions', { +openerp.testing.section('search.completions', { dependencies: ['web.search'], rpc: 'mock', templates: true @@ -615,7 +615,7 @@ openerp.testing.section('completions', { return f.complete("bob"); }); }); -openerp.testing.section('search-serialization', { +openerp.testing.section('search.serialization', { dependencies: ['web.search'], rpc: 'mock', templates: true @@ -855,11 +855,11 @@ openerp.testing.section('search-serialization', { test('FilterGroup', {asserts: 6}, function (instance) { var view = {inputs: [], query: {on: function () {}}}; var filter_a = new instance.web.search.Filter( - {attrs: {name: 'a', context: 'c1', domain: 'd1'}}, view); + {attrs: {name: 'a', context: '{"c1": True}', domain: 'd1'}}, view); var filter_b = new instance.web.search.Filter( - {attrs: {name: 'b', context: 'c2', domain: 'd2'}}, view); + {attrs: {name: 'b', context: '{"c2": True}', domain: 'd2'}}, view); var filter_c = new instance.web.search.Filter( - {attrs: {name: 'c', context: 'c3', domain: 'd3'}}, view); + {attrs: {name: 'c', context: '{"c3": True}', domain: 'd3'}}, view); var group = new instance.web.search.FilterGroup( [filter_a, filter_b, filter_c], view); return group.facet_for_defaults({a: true, c: true}) @@ -880,7 +880,7 @@ openerp.testing.section('search-serialization', { equal(context.__ref, 'compound_context', "context should be compound"); deepEqual(context.__contexts, [ - 'c1', 'c3' + '{"c1": True}', '{"c3": True}' ], "context should merge all filter contexts"); ok(!context.get_eval_context(), "context should have no evaluation context"); }); @@ -922,7 +922,7 @@ openerp.testing.section('search-serialization', { return $.when(t1, t2); }); }); -openerp.testing.section('removal', { +openerp.testing.section('search.removal', { dependencies: ['web.search'], rpc: 'mock', templates: true @@ -945,7 +945,7 @@ openerp.testing.section('removal', { }); }); }); -openerp.testing.section('drawer', { +openerp.testing.section('search.drawer', { dependencies: ['web.search'], rpc: 'mock', templates: true @@ -961,7 +961,7 @@ openerp.testing.section('drawer', { }); }); }); -openerp.testing.section('filters', { +openerp.testing.section('search.filters', { dependencies: ['web.search'], rpc: 'mock', templates: true, @@ -1046,7 +1046,104 @@ openerp.testing.section('filters', { }); }); }); -openerp.testing.section('saved_filters', { +openerp.testing.section('search.groupby', { + dependencies: ['web.search'], + rpc: 'mock', + templates: true, +}, function (test) { + test('basic', { + asserts: 7, + setup: function (instance, $s, mock) { + mock('dummy.model:fields_view_get', function () { + return { + type: 'search', + fields: {}, + arch: [ + '', + '', + '', + '', + '' + ].join(''), + } + }); + } + }, function (instance, $fix) { + var view = makeSearchView(instance); + return view.appendTo($fix) + .done(function () { + // 3 filters, 1 filtergroup group, 1 custom filter, 1 advanced, 1 Filters + equal(view.inputs.length, 7, + 'should have 7 inputs total'); + var group = _.find(view.inputs, function (f) { + return f instanceof instance.web.search.GroupbyGroup + }); + ok(group, "should have a GroupbyGroup input"); + strictEqual(group.getParent(), view, + "group's parent should be view"); + + group.toggle(group.filters[0]); + group.toggle(group.filters[2]); + + var results = view.build_search_data(); + deepEqual(results.errors, [], "should have no errors"); + deepEqual(results.domains, [], "should have no domain"); + deepEqual(results.contexts, [ + new instance.web.CompoundContext( + "{'group_by': 'foo'}", "{'group_by': 'baz'}") + ], "should have compound contexts"); + deepEqual(results.groupbys, [ + "{'group_by': 'foo'}", + "{'group_by': 'baz'}" + ], "should have sequence of contexts") + }); + }); + test('unified multiple groupby groups', { + asserts: 4, + setup: function (instance, $s, mock) { + mock('dummy.model:fields_view_get', function () { + return { + type: 'search', + fields: {}, + arch: [ + '', + '', + '', + '', + '', + '', + '' + ].join(''), + } + }); + } + }, function (instance, $fix) { + var view = makeSearchView(instance); + return view.appendTo($fix) + .done(function () { + // 3 filters, 3 filtergroups, 1 custom filter, 1 advanced, 1 Filters + equal(view.inputs.length, 9, "should have 9 inputs total"); + + var groups = _.filter(view.inputs, function (f) { + return f instanceof instance.web.search.GroupbyGroup + }); + equal(groups.length, 3, "should have 3 GroupbyGroups"); + + groups[0].toggle(groups[0].filters[0]); + groups[2].toggle(groups[2].filters[0]); + equal(view.query.length, 1, + "should have unified groupby groups in single facet"); + deepEqual(view.build_search_data(), { + errors: [], + domains: [], + contexts: [new instance.web.CompoundContext( + "{'group_by': 'foo'}", "{'group_by': 'baz'}")], + groupbys: [ "{'group_by': 'foo'}", "{'group_by': 'baz'}" ], + }, "should only have contexts & groupbys in search data"); + }); + }); +}); +openerp.testing.section('search.filters.saved', { dependencies: ['web.search'], rpc: 'mock', templates: true @@ -1127,8 +1224,30 @@ openerp.testing.section('saved_filters', { "should have selected second filter"); }); }); + test('creation', {asserts: 2}, function (instance, $fix, mock) { + // force a user context + instance.session.user_context = {foo: 'bar'}; + + var view = makeSearchView(instance); + var done = $.Deferred(); + mock('ir.filters:get_filters', function () { return []; }); + mock('ir.filters:create_or_replace', function (args) { + var filter = args[0]; + deepEqual(filter.context, {}, "should have empty context"); + deepEqual(filter.domain, [], "should have empty domain"); + done.resolve(); + }); + return view.appendTo($fix) + .then(function () { + $fix.find('.oe_searchview_custom input#oe_searchview_custom_input') + .text("filter name") + .end() + .find('.oe_searchview_custom button').click(); + return done.promise(); + }); + }); }); -openerp.testing.section('advanced', { +openerp.testing.section('search.advanced', { dependencies: ['web.search'], rpc: 'mock', templates: true @@ -1209,3 +1328,159 @@ openerp.testing.section('advanced', { }); // TODO: UI tests? }); +openerp.testing.section('search.invisible', { + dependencies: ['web.search'], + rpc: 'mock', + templates: true, +}, function (test) { + var registerTestField = function (instance, methods) { + instance.web.search.fields.add('test', 'instance.testing.TestWidget'); + instance.testing = { + TestWidget: instance.web.search.Field.extend(methods), + }; + }; + var makeView = function (instance, mock, fields, arch, defaults) { + mock('ir.filters:get_filters', function () { return []; }); + mock('test.model:fields_get', function () { return fields; }); + mock('test.model:fields_view_get', function () { + return { type: 'search', fields: fields, arch: arch }; + }); + var ds = new instance.web.DataSet(null, 'test.model'); + return new instance.web.SearchView(null, ds, false, defaults); + }; + // Invisible fields should not auto-complete + test('invisible-field-no-autocomplete', {asserts: 1}, function (instance, $fix, mock) { + registerTestField(instance, { + complete: function () { + return $.when([{label: this.attrs.string}]); + }, + }); + var view = makeView(instance, mock, { + field0: {type: 'test', string: 'Field 0'}, + field1: {type: 'test', string: 'Field 1'}, + }, ['', + '', + '', + ''].join()); + return view.appendTo($fix) + .then(function () { + var done = $.Deferred(); + view.complete_global_search({term: 'test'}, function (comps) { + done.resolve(comps); + }); + return done; + }).then(function (completions) { + deepEqual(completions, [{label: 'Field 0'}], + "should only complete the visible field"); + }); + }); + // Invisible filters should not appear in the drawer + test('invisible-filter-no-drawer', {asserts: 4}, function (instance, $fix, mock) { + var view = makeView(instance, mock, {}, [ + '', + '', + '', + ''].join()); + return view.appendTo($fix) + .then(function () { + var $fs = $fix.find('.oe_searchview_filters ul'); + strictEqual($fs.children().length, + 1, + "should only display one filter"); + strictEqual(_.str.trim($fs.children().text()), + "filter 0", + "should only display filter 0"); + var done = $.Deferred(); + view.complete_global_search({term: 'filter'}, function (comps) { + done.resolve(); + strictEqual(comps.length, 1, "should only complete visible filter"); + strictEqual(comps[0].label, "Filter on: filter 0", + "should complete filter 0"); + }); + return done; + }); + }); + // Invisible filter groups should not appear in the drawer + // Group invisibility should be inherited by children + test('group-invisibility', {asserts: 6}, function (instance, $fix, mock) { + registerTestField(instance, { + complete: function () { + return $.when([{label: this.attrs.string}]); + }, + }); + var view = makeView(instance, mock, { + field0: {type: 'test', string: 'Field 0'}, + field1: {type: 'test', string: 'Field 1'}, + }, [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '' + ].join('')); + return view.appendTo($fix) + .then(function () { + strictEqual($fix.find('.oe_searchview_filters h3').length, + 1, + "should only display one group"); + strictEqual($fix.find('.oe_searchview_filters h3').text(), + 'w Visibles', + "should only display the Visibles group (and its icon char)"); + + var $fs = $fix.find('.oe_searchview_filters ul'); + strictEqual($fs.children().length, 1, + "should only have one filter in the drawer"); + strictEqual(_.str.trim($fs.text()), "Filter 0", + "should have filter 0 as sole filter"); + + var done = $.Deferred(); + view.complete_global_search({term: 'filter'}, function (compls) { + done.resolve(); + strictEqual(compls.length, 2, + "should have 2 completions"); + deepEqual(_.pluck(compls, 'label'), + ['Field 0', 'Filter on: Filter 0'], + "should complete on field 0 and filter 0"); + }); + return done; + }); + }); + // Default on invisible fields should still work, for fields and filters both + test('invisible-defaults', {asserts: 1}, function (instance, $fix, mock) { + var view = makeView(instance, mock, { + field: {type: 'char', string: "Field"}, + field2: {type: 'char', string: "Field 2"}, + }, [ + '', + '', + '', + '', + '', + '', + '', + '' + ].join(''), {field: "foo", filter: true}); + + return view.appendTo($fix) + .then(function () { + deepEqual(view.build_search_data(), { + errors: [], + groupbys: [], + contexts: [], + domains: [ + // Generated from field + [['field', 'ilike', 'foo']], + // generated from filter + "[['whee', '=', '42']]" + ], + }, "should yield invisible fields selected by defaults"); + }); + }); +}); diff --git a/addons/web_graph/static/src/css/graph.css b/addons/web_graph/static/src/css/graph.css index a9fff591e39..2fd4b5953cf 100644 --- a/addons/web_graph/static/src/css/graph.css +++ b/addons/web_graph/static/src/css/graph.css @@ -1,5 +1,5 @@ .openerp a.dropdown-menu-icon { - z-index: 10; + z-index: 1; position: absolute; color: #4c4c4c; right: 8px; @@ -23,7 +23,7 @@ padding: 8px; border: 1px solid #afafb6; background: white; - z-index: 900; + z-index: 1; min-width: 160px; overflow-x: hidden; -moz-border-radius: 3px; diff --git a/addons/web_graph/static/src/js/graph.js b/addons/web_graph/static/src/js/graph.js index 0ae3f7c1508..8710b840209 100644 --- a/addons/web_graph/static/src/js/graph.js +++ b/addons/web_graph/static/src/js/graph.js @@ -246,7 +246,7 @@ instance.web_graph.GraphView = instance.web.View.extend({ var result = []; var ticks = {}; - return this.alive(obj.call("fields_view_get", [view_id, 'graph']).then(function(tmp) { + return this.alive(obj.call("fields_view_get", [view_id, 'graph', context]).then(function(tmp) { view_get = tmp; fields = view_get['fields']; var toload = _.select(group_by, function(x) { return fields[x] === undefined }); diff --git a/addons/web_kanban/static/src/css/kanban.css b/addons/web_kanban/static/src/css/kanban.css index eaba760292c..a8ed25694ba 100644 --- a/addons/web_kanban/static/src/css/kanban.css +++ b/addons/web_kanban/static/src/css/kanban.css @@ -7,6 +7,15 @@ background: url(/web/static/src/img/form_sheetbg.png); width: 100%; } +.openerp .oe_kanban_view .oe_kanban_group_length { + text-align: center; + display: none; +} +.openerp .oe_kanban_view .oe_kanban_group_length .oe_tag { + position: relative; + top: 8px; + font-weight: bold; +} .openerp .oe_kanban_view .ui-sortable-placeholder { border: 1px solid rgba(0, 0, 0, 0.1); visibility: visible !important; @@ -92,15 +101,6 @@ overflow: hidden; text-overflow: ellipsis; } -.openerp .oe_kanban_view .oe_kanban_group_length { - text-align: center; - display: none; -} -.openerp .oe_kanban_view .oe_kanban_group_length .oe_tag { - position: relative; - top: +8px; - font-weight: bold; -} .openerp .oe_kanban_view .oe_fold_column .oe_kanban_group_length { position: absolute; top: -1px; @@ -108,9 +108,6 @@ float: right; display: block; } -.openerp .oe_kanban_view .oe_kanban_header:hover .oe_kanban_group_length { - display: none; -} .openerp .oe_kanban_view.oe_kanban_grouped .oe_kanban_column, .openerp .oe_kanban_view.oe_kanban_grouped .oe_kanban_group_header { width: 185px; min-width: 185px; @@ -150,8 +147,7 @@ .openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_title, .openerp .oe_kanban_view .oe_kanban_group_folded.oe_kanban_column *, .openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_aggregates, .openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_add { display: none; } -.openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_title_vertical, -.openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_length { +.openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_title_vertical, .openerp .oe_kanban_view .oe_kanban_group_folded .oe_kanban_group_length { display: block; } .openerp .oe_kanban_view .oe_kanban_group_folded .oe_dropdown_kanban { diff --git a/addons/web_kanban/static/src/css/kanban.sass b/addons/web_kanban/static/src/css/kanban.sass index ec964c8819f..2182fb693ea 100644 --- a/addons/web_kanban/static/src/css/kanban.sass +++ b/addons/web_kanban/static/src/css/kanban.sass @@ -130,8 +130,8 @@ position: absolute top: -1px right: -14px - display: block float: right + display: block &.oe_kanban_grouped .oe_kanban_column, .oe_kanban_group_header width: 185px @@ -197,7 +197,7 @@ top: -8px .oe_kanban_header .oe_dropdown_toggle top: -2px - height: 14px; + height: 14px .oe_kanban_card, .oe_dropdown_toggle cursor: pointer display: inline-block
+ +
- +