diff --git a/addons/web/static/src/js/chrome.js b/addons/web/static/src/js/chrome.js index 6051dcdb633..eaa4bcbfe1c 100644 --- a/addons/web/static/src/js/chrome.js +++ b/addons/web/static/src/js/chrome.js @@ -1084,7 +1084,7 @@ instance.web.UserMenu = instance.web.Widget.extend({ if (!self.session.uid) return; var func = new instance.web.Model("res.users").get_func("read"); - return func(self.session.uid, ["name", "company_id"]).then(function(res) { + return self.alive(func(self.session.uid, ["name", "company_id"])).then(function(res) { var topbar_name = res.name; if(instance.session.debug) topbar_name = _.str.sprintf("%s (%s)", topbar_name, instance.session.db); @@ -1303,7 +1303,7 @@ instance.web.WebClient = instance.web.Client.extend({ }, logo_edit: function(ev) { var self = this; - new instance.web.Model("res.users").get_func("read")(this.session.uid, ["company_id"]).then(function(res) { + new self.alive(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"; @@ -1324,7 +1324,7 @@ instance.web.WebClient = instance.web.Client.extend({ }, check_timezone: function() { var self = this; - return new instance.web.Model('res.users').call('read', [[this.session.uid], ['tz_offset']]).then(function(result) { + return self.alive(new instance.web.Model('res.users').call('read', [[this.session.uid], ['tz_offset']])).then(function(result) { var user_offset = result[0]['tz_offset']; var offset = -(new Date().getTimezoneOffset()); // _.str.sprintf()'s zero front padding is buggy with signed decimals, so doing it manually diff --git a/addons/web/static/src/js/corelib.js b/addons/web/static/src/js/corelib.js index edd23e14522..63e22672bd7 100644 --- a/addons/web/static/src/js/corelib.js +++ b/addons/web/static/src/js/corelib.js @@ -228,6 +228,38 @@ instance.web.ParentedMixin = { isDestroyed : function() { return this.__parentedDestroyed; }, + /** + 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. + */ + alive: function(promise, reject) { + var def = $.Deferred(); + var self = this; + promise.done(function() { + if (! self.isDestroyed()) { + if (! reject) + def.resolve.apply(def, arguments); + else + def.reject(); + } + }).fail(function() { + if (! self.isDestroyed()) { + if (! reject) + def.reject.apply(def, arguments); + else + def.reject(); + } + }); + return def.promise(); + }, /** * Inform the object it should destroy itself, releasing any * resource it could have reserved. @@ -495,16 +527,7 @@ instance.web.Controller = instance.web.Class.extend(instance.web.PropertiesMixin return false; }, rpc: function(url, data, options) { - var def = $.Deferred(); - var self = this; - instance.session.rpc(url, data, options).done(function() { - if (!self.isDestroyed()) - def.resolve.apply(def, arguments); - }).fail(function() { - if (!self.isDestroyed()) - def.reject.apply(def, arguments); - }); - return def.promise(); + return this.alive(instance.session.rpc(url, data, options)); } }); diff --git a/addons/web/static/src/js/search.js b/addons/web/static/src/js/search.js index 32f1d31d795..edbec1626b0 100644 --- a/addons/web/static/src/js/search.js +++ b/addons/web/static/src/js/search.js @@ -1466,12 +1466,15 @@ instance.web.search.ManyToOneField = instance.web.search.CharField.extend({ }, complete: function (needle) { var self = this; - // TODO: context // FIXME: "concurrent" searches (multiple requests, mis-ordered responses) + var context = instance.web.pyeval.eval( + 'contexts', [this.view.dataset.get_context()]); return this.model.call('name_search', [], { name: needle, + args: instance.web.pyeval.eval( + 'domains', this.attrs.domain ? [this.attrs.domain] : [], context), limit: 8, - context: {} + context: context }).then(function (results) { if (_.isEmpty(results)) { return null; } return [{label: self.attrs.string}].concat( diff --git a/addons/web/static/src/js/view_form.js b/addons/web/static/src/js/view_form.js index e84c21017b5..eb3c25680db 100644 --- a/addons/web/static/src/js/view_form.js +++ b/addons/web/static/src/js/view_form.js @@ -247,7 +247,7 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM do_load_state: function(state, warm) { if (state.id && this.datarecord.id != state.id) { - if (!this.dataset.get_id_index(state.id)) { + if (this.dataset.get_id_index(state.id) === null) { this.dataset.ids.push(state.id); } this.dataset.select_id(state.id); @@ -514,8 +514,8 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM // In case of a o2m virtual id, we should pass an empty ids list ids.push(self.datarecord.id); } - def = new instance.web.Model(self.dataset.model).call( - change_spec.method, [ids].concat(change_spec.args)); + def = self.alive(new instance.web.Model(self.dataset.model).call( + change_spec.method, [ids].concat(change_spec.args))); } else { def = $.when({}); } @@ -533,9 +533,9 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM var condition = fieldname + '=' + value_; if (value_) { - return new instance.web.Model('ir.values').call( + return self.alive(new instance.web.Model('ir.values').call( 'get_defaults', [self.model, condition] - ).then(function (results) { + )).then(function (results) { if (!results.length) { return response; } @@ -1193,6 +1193,10 @@ instance.web.form.FormRenderingEngine = instance.web.form.FormRenderingEngineInt $('button', doc).each(function() { $(this).attr('data-button-type', $(this).attr('type')).attr('type', 'button'); }); + // IE's html parser is also a css parser. How convenient... + $('board', doc).each(function() { + $(this).attr('layout', $(this).attr('style')); + }); return $('
').append(instance.web.xml_to_str(doc)); }, render_to: function($target) { @@ -2329,7 +2333,7 @@ instance.web.form.FieldUrl = instance.web.form.FieldChar.extend({ this._super(); } else { var tmp = this.get('value'); - var s = /(\w+):(.+)/.exec(tmp); + var s = /(\w+):(.+)|^\.{0,2}\//.exec(tmp); if (!s) { tmp = "http://" + this.get('value'); } @@ -2398,6 +2402,11 @@ instance.web.DateTimeWidget = instance.web.Widget.extend({ showButtonPanel: true, firstDay: Date.CultureInfo.firstDayOfWeek }); + // Some clicks in the datepicker dialog are not stopped by the + // datepicker and "bubble through", unexpectedly triggering the bus's + // click event. Prevent that. + this.picker('widget').click(function (e) { e.stopPropagation(); }); + this.$el.find('img.oe_datepicker_trigger').click(function() { if (self.get("effective_readonly") || self.picker('widget').is(':visible')) { self.$input.focus(); @@ -3063,6 +3072,15 @@ instance.web.form.FieldMany2One = instance.web.form.AbstractField.extend(instanc } } }); + + // Autocomplete close on dialog content scroll + var close_autocomplete = _.debounce(function() { + if (self.$input.autocomplete("widget").is(":visible")) { + self.$input.autocomplete("close"); + } + }, 50); + this.$input.closest(".ui-dialog .ui-dialog-content").on('scroll', this, close_autocomplete); + self.ed_def = $.Deferred(); self.uned_def = $.Deferred(); var ed_delay = 200; @@ -5230,10 +5248,20 @@ instance.web.form.FieldStatus = instance.web.form.AbstractField.extend({ this.options.clickable = this.options.clickable || (this.node.attrs || {}).clickable || false; this.options.visible = this.options.visible || (this.node.attrs || {}).statusbar_visible || false; this.set({value: false}); - this.field_manager.on("view_content_has_changed", this, this.render_value); - this.selection_mutex = new $.Mutex(); + this.selection = []; + this.set("selection", []); + this.selection_dm = new instance.web.DropMisordered(); }, start: function() { + this.field_manager.on("view_content_has_changed", this, this.calc_domain); + 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(); + }); if (this.options.clickable) { this.$el.on('click','li',this.on_click_stage); } @@ -5250,17 +5278,20 @@ instance.web.form.FieldStatus = instance.web.form.AbstractField.extend({ }, render_value: function() { var self = this; - self.selection_mutex.exec(function() { - return self.get_selection().done(function() { - var content = QWeb.render("FieldStatus.content", {widget: self}); - self.$el.html(content); - var colors = JSON.parse((self.node.attrs || {}).statusbar_colors || "{}"); - var color = colors[self.get('value')]; - if (color) { - self.$("oe_active").css("color", color); - } - }); - }); + var content = QWeb.render("FieldStatus.content", {widget: self}); + self.$el.html(content); + var colors = JSON.parse((self.node.attrs || {}).statusbar_colors || "{}"); + var color = colors[self.get('value')]; + if (color) { + self.$("oe_active").css("color", color); + } + }, + calc_domain: function() { + var d = instance.web.pyeval.eval('domain', this.build_domain()); + domain = ['|', ['id', '=', this.get('value')]].concat(d); + if (! _.isEqual(domain, this.get("evaluated_selection_domain"))) { + this.set("evaluated_selection_domain", domain); + } }, /** Get the selection and render it * selection: [[identifier, value_to_display], ...] @@ -5269,32 +5300,37 @@ instance.web.form.FieldStatus = instance.web.form.AbstractField.extend({ */ get_selection: function() { var self = this; - self.selection = []; - if (this.field.type == "many2one") { - var domain = []; - if(!_.isEmpty(this.field.domain) || !_.isEmpty(this.node.attrs.domain)) { - var d = instance.web.pyeval.eval('domain', self.build_domain()); - domain = ['|', ['id', '=', self.get('value')]].concat(d); - } - var ds = new instance.web.DataSetSearch(this, this.field.relation, self.build_context(), domain); - return ds.read_slice(['name'], {}).then(function (records) { - for(var i = 0; i < records.length; i++) { - self.selection.push([records[i].id, records[i].name]); - } - }); - } else { - // For field type selection filter values according to - // statusbar_visible attribute of the field. For example: - // statusbar_visible="draft,open". - var selection = this.field.selection; - for(var i=0; i < selection.length; i++) { - var key = selection[i][0]; - if(key == this.get('value') || !this.options.visible || this.options.visible.indexOf(key) != -1) { - this.selection.push(selection[i]); + var selection = []; + + var calculation = _.bind(function() { + if (this.field.type == "many2one") { + var domain = []; + var ds = new instance.web.DataSetSearch(this, this.field.relation, + self.build_context(), this.get("evaluated_selection_domain")); + return ds.read_slice(['name'], {}).then(function (records) { + for(var i = 0; i < records.length; i++) { + selection.push([records[i].id, records[i].name]); + } + }); + } else { + // For field type selection filter values according to + // statusbar_visible attribute of the field. For example: + // statusbar_visible="draft,open". + var select = this.field.selection; + for(var i=0; i < select.length; i++) { + var key = select[i][0]; + if(key == this.get('value') || !this.options.visible || this.options.visible.indexOf(key) != -1) { + selection.push(select[i]); + } } + return $.when(); } - return $.when(); - } + }, this); + this.selection_dm.add(calculation()).then(function () { + if (! _.isEqual(selection, self.get("selection"))) { + self.set("selection", selection); + } + }); }, on_click_stage: function (ev) { var self = this; @@ -5338,8 +5374,8 @@ instance.web.form.FieldMonetary = instance.web.form.FieldFloat.extend({ this.set({"currency_info": null}); return; } - return this.ci_dm.add(new instance.web.Model("res.currency").query(["symbol", "position"]) - .filter([["id", "=", self.get("currency")]]).first()).then(function(res) { + return this.ci_dm.add(self.alive(new instance.web.Model("res.currency").query(["symbol", "position"]) + .filter([["id", "=", self.get("currency")]]).first())).then(function(res) { self.set({"currency_info": res}); }); }, diff --git a/addons/web/static/src/js/view_list.js b/addons/web/static/src/js/view_list.js index 27e2c88daaf..d23bedf1d1c 100644 --- a/addons/web/static/src/js/view_list.js +++ b/addons/web/static/src/js/view_list.js @@ -2212,7 +2212,7 @@ instance.web.list.Binary = instance.web.list.Column.extend({ if (value && value.substr(0, 10).indexOf(' ') == -1) { download_url = "data:application/octet-stream;base64," + value; } else { - download_url = this.session.url('/web/binary/saveas', {model: options.model, field: this.id, id: options.id}); + download_url = instance.session.url('/web/binary/saveas', {model: options.model, field: this.id, id: options.id}); if (this.filename) { download_url += '&filename_field=' + this.filename; } diff --git a/addons/web/static/src/js/view_list_editable.js b/addons/web/static/src/js/view_list_editable.js index 6be83e7af52..f1bd7c4325e 100644 --- a/addons/web/static/src/js/view_list_editable.js +++ b/addons/web/static/src/js/view_list_editable.js @@ -132,6 +132,15 @@ openerp.web.list_editable = function (instance) { var self = this; // tree/@editable takes priority on everything else if present. var result = this._super(data, grouped); + + // In case current editor was started previously, also has to run + // when toggling from editable to non-editable in case form widgets + // have setup global behaviors expecting themselves to exist + // somehow. + this.editor.destroy(); + // Editor is not restartable due to formview not being restartable + this.editor = this.make_editor(); + if (this.editable()) { this.$el.addClass('oe_list_editable'); // FIXME: any hook available to ensure this is only done once? @@ -143,10 +152,6 @@ openerp.web.list_editable = function (instance) { e.preventDefault(); self.cancel_edition(); }); - this.editor.destroy(); - // Editor is not restartable due to formview not being - // restartable - this.editor = this.make_editor(); var editor_ready = this.editor.prependTo(this.$el) .done(this.proxy('setup_events')); diff --git a/addons/web/static/src/js/views.js b/addons/web/static/src/js/views.js index 31f3df4a746..f5638004b47 100644 --- a/addons/web/static/src/js/views.js +++ b/addons/web/static/src/js/views.js @@ -1547,13 +1547,22 @@ instance.web.json_node_to_xml = function(node, human_readable, indent) { } }; instance.web.xml_to_str = function(node) { + var str = ""; if (window.XMLSerializer) { - return (new XMLSerializer()).serializeToString(node); + str = (new XMLSerializer()).serializeToString(node); } else if (window.ActiveXObject) { - return node.xml; + str = node.xml; } 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 + // + // 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 + ">"; + }); + return str; }; /** diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index d56e0906270..4ceb3a468d5 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -119,7 +119,7 @@ - + @@ -1300,7 +1300,7 @@ File - /web/binary/upload_attachment + /web/binary/upload_attachment diff --git a/addons/web/static/test/search.js b/addons/web/static/test/search.js index 292954b1992..904af63f590 100644 --- a/addons/web/static/test/search.js +++ b/addons/web/static/test/search.js @@ -318,6 +318,28 @@ openerp.testing.section('defaults', { "facet value should match provided default's selection"); }); }); + test("M2O default: value array", {asserts: 2}, function (instance, $s, mock) { + var view = {inputs: []}, id = 5; + var f = new instance.web.search.ManyToOneField( + {attrs: {name: 'dummy', string: 'Dummy'}}, + {relation: 'dummy.model.name'}, + view); + mock('dummy.model.name:name_get', function (args) { + equal(args[0], id); + return [[id, "DumDumDum"]]; + }); + return f.facet_for_defaults({dummy: [id]}) + .done(function (facet) { + var model = facet; + if (!(model instanceof instance.web.search.Facet)) { + model = new instance.web.search.Facet(facet); + } + deepEqual( + model.values.toJSON(), + [{label: "DumDumDum", value: id}], + "should support default as a singleton"); + }); + }); test("M2O default: value", {asserts: 1}, function (instance, $s, mock) { var view = {inputs: []}, id = 4; var f = new instance.web.search.ManyToOneField( @@ -330,6 +352,15 @@ openerp.testing.section('defaults', { ok(!facet, "an invalid m2o default should yield a non-facet"); }); }); + test("M2O default: values", {rpc: false}, function (instance) { + var view = {inputs: []}; + var f = new instance.web.search.ManyToOneField( + {attrs: {name: 'dummy', string: 'Dummy'}}, + {relation: 'dummy.model.name'}, + view); + raises(function () { f.facet_for_defaults({dummy: [6, 7]}) }, + "should not accept multiple default values"); + }) }); openerp.testing.section('completions', { dependencies: ['web.search'], @@ -526,7 +557,7 @@ openerp.testing.section('completions', { return [[42, "choice 1"], [43, "choice @"]]; }); - var view = {inputs: []}; + var view = {inputs: [], dataset: {get_context: function () {}}}; var f = new instance.web.search.ManyToOneField( {attrs: {string: 'Dummy'}}, {relation: 'dummy.model'}, view); return f.complete("bob") @@ -555,7 +586,7 @@ openerp.testing.section('completions', { strictEqual(kwargs.name, 'bob'); return []; }); - var view = {inputs: []}; + var view = {inputs: [], dataset: {get_context: function () {}}}; var f = new instance.web.search.ManyToOneField( {attrs: {string: 'Dummy'}}, {relation: 'dummy.model'}, view); return f.complete("bob") @@ -563,6 +594,26 @@ openerp.testing.section('completions', { ok(!c, "no match should yield no completion"); }); }); + test("M2O filtered", {asserts: 2}, function (instance, $s, mock) { + mock('dummy.model:name_search', function (args, kwargs) { + deepEqual(args, [], "should have no positional arguments"); + deepEqual(kwargs, { + name: 'bob', + limit: 8, + args: [['foo', '=', 'bar']], + context: {flag: 1}, + }, "should use filtering domain"); + return [[42, "Match"]]; + }); + var view = { + inputs: [], + dataset: {get_context: function () { return {flag: 1}; }} + }; + var f = new instance.web.search.ManyToOneField( + {attrs: {string: 'Dummy', domain: '[["foo", "=", "bar"]]'}}, + {relation: 'dummy.model'}, view); + return f.complete("bob"); + }); }); openerp.testing.section('search-serialization', { dependencies: ['web.search'], diff --git a/addons/web_gantt/static/src/js/gantt.js b/addons/web_gantt/static/src/js/gantt.js index e6b12038126..f30fa6a83e6 100644 --- a/addons/web_gantt/static/src/js/gantt.js +++ b/addons/web_gantt/static/src/js/gantt.js @@ -24,8 +24,8 @@ instance.web_gantt.GanttView = instance.web.View.extend({ var self = this; this.fields_view = fields_view_get; this.$el.addClass(this.fields_view.arch.attrs['class']); - return new instance.web.Model(this.dataset.model) - .call('fields_get').then(function (fields) { + return self.alive(new instance.web.Model(this.dataset.model) + .call('fields_get')).then(function (fields) { self.fields = fields; self.has_been_loaded.resolve(); }); diff --git a/addons/web_graph/static/src/js/graph.js b/addons/web_graph/static/src/js/graph.js index 35ad0aaaea8..0ae3f7c1508 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 obj.call("fields_view_get", [view_id, 'graph']).then(function(tmp) { + return this.alive(obj.call("fields_view_get", [view_id, 'graph']).then(function(tmp) { view_get = tmp; fields = view_get['fields']; var toload = _.select(group_by, function(x) { return fields[x] === undefined }); @@ -368,7 +368,7 @@ instance.web_graph.GraphView = instance.web.View.extend({ 'ticks': _.map(ticks, function(el, key) { return [el, key] }) }; return res; - }); + })); }, // Render the graph and update menu styles diff --git a/addons/web_kanban/static/src/js/kanban.js b/addons/web_kanban/static/src/js/kanban.js index fc504ae337a..651fcbc4019 100644 --- a/addons/web_kanban/static/src/js/kanban.js +++ b/addons/web_kanban/static/src/js/kanban.js @@ -237,7 +237,7 @@ instance.web_kanban.KanbanView = instance.web.View.extend({ self.$el.toggleClass('oe_kanban_grouped_by_m2o', self.grouped_by_m2o); var grouping_fields = self.group_by ? [self.group_by].concat(_.keys(self.aggregates)) : undefined; var grouping = new instance.web.Model(self.dataset.model, context, domain).query().group_by(grouping_fields); - return $.when(grouping).done(function(groups) { + return self.alive($.when(grouping)).done(function(groups) { if (groups) { self.do_process_groups(groups); } else {