diff --git a/addons/web/static/src/js/chrome.js b/addons/web/static/src/js/chrome.js index a74fe190271..b3591f534f2 100644 --- a/addons/web/static/src/js/chrome.js +++ b/addons/web/static/src/js/chrome.js @@ -57,9 +57,7 @@ instance.web.Dialog = instance.web.Widget.extend({ init: function (parent, options, content) { var self = this; this._super(parent); - if (content) { - this.$element = content instanceof $ ? content : $(content); - } + this.setElement(content || this.make(this.tagName)); this.dialog_options = { modal: true, destroy_on_close: true, @@ -589,6 +587,12 @@ instance.web.Login = instance.web.Widget.extend({ self.$(".oe_login_pane").fadeIn("fast"); self.$element.addClass("oe_login_invalid"); }); + }, + show: function () { + this.$element.show(); + }, + hide: function () { + this.$element.hide(); } }); instance.web.client_actions.add("login", "instance.web.Login"); @@ -880,12 +884,11 @@ instance.web.Client = instance.web.Widget.extend({ }, start: function() { var self = this; - return instance.connection.session_bind(this.origin).then(function() { + return instance.connection.session_bind(this.origin).pipe(function() { var $e = $(QWeb.render(self._template, {})); - self.$element.replaceWith($e); - self.$element = $e; + self.replaceElement($e); self.bind_events(); - self.show_common(); + return self.show_common(); }); }, bind_events: function() { diff --git a/addons/web/static/src/js/corelib.js b/addons/web/static/src/js/corelib.js index a092b670ef1..846ae3e4758 100644 --- a/addons/web/static/src/js/corelib.js +++ b/addons/web/static/src/js/corelib.js @@ -491,23 +491,19 @@ instance.web.CallbackEnabledMixin = _.extend({}, instance.web.PropertiesMixin, { * * The semantics of this precisely replace closing over the method call. * - * @param {String} method_name name of the method to invoke + * @param {String|Function} method function or name of the method to invoke * @returns {Function} proxied method */ - proxy: function (method_name) { + proxy: function (method) { var self = this; return function () { - return self[method_name].apply(self, arguments); + var fn = (typeof method === 'string') ? self[method] : method; + return fn.apply(self, arguments); } } }); instance.web.WidgetMixin = _.extend({},instance.web.CallbackEnabledMixin, { - /** - * Tag name when creating a default $element. - * @type string - */ - tagName: 'div', /** * Constructs the widget and sets its parent if a parent is given. * @@ -517,14 +513,9 @@ instance.web.WidgetMixin = _.extend({},instance.web.CallbackEnabledMixin, { * @param {instance.web.Widget} parent Binds the current instance to the given Widget instance. * When that widget is destroyed by calling destroy(), the current instance will be * destroyed too. Can be null. - * @param {String} element_id Deprecated. Sets the element_id. Only useful when you want - * to bind the current Widget to an already existing part of the DOM, which is not compatible - * with the DOM insertion methods provided by the current implementation of Widget. So - * for new components this argument should not be provided any more. */ init: function(parent) { instance.web.CallbackEnabledMixin.init.call(this); - this.$element = $(document.createElement(this.tagName)); this.setParent(parent); }, /** @@ -534,7 +525,7 @@ instance.web.WidgetMixin = _.extend({},instance.web.CallbackEnabledMixin, { _.each(this.getChildren(), function(el) { el.destroy(); }); - if(this.$element != null) { + if(this.$element) { this.$element.remove(); } instance.web.PropertiesMixin.destroy.call(this); @@ -613,6 +604,7 @@ instance.web.WidgetMixin = _.extend({},instance.web.CallbackEnabledMixin, { * @returns {jQuery.Deferred} */ start: function() { + return $.when(); } }); @@ -673,6 +665,12 @@ instance.web.CallbackEnabled = instance.web.Class.extend(instance.web.CallbackEn * That will kill the widget in a clean way and erase its content from the dom. */ instance.web.Widget = instance.web.Class.extend(instance.web.WidgetMixin, { + // Backbone-ish API + tagName: 'div', + id: null, + className: null, + attributes: {}, + events: {}, /** * The name of the QWeb template that will be used for rendering. Must be * redefined in subclasses or the default render() method can not be used. @@ -689,13 +687,11 @@ instance.web.Widget = instance.web.Class.extend(instance.web.WidgetMixin, { * @param {instance.web.Widget} parent Binds the current instance to the given Widget instance. * When that widget is destroyed by calling destroy(), the current instance will be * destroyed too. Can be null. - * @param {String} element_id Deprecated. Sets the element_id. Only useful when you want - * to bind the current Widget to an already existing part of the DOM, which is not compatible - * with the DOM insertion methods provided by the current implementation of Widget. So - * for new components this argument should not be provided any more. */ init: function(parent) { instance.web.WidgetMixin.init.call(this,parent); + // FIXME: this should not be + this.setElement(this._make_descriptive()); this.session = instance.connection; }, /** @@ -704,20 +700,120 @@ instance.web.Widget = instance.web.Class.extend(instance.web.WidgetMixin, { * key that references `this`. */ renderElement: function() { - var rendered = null; - if (this.template) - rendered = instance.web.qweb.render(this.template, {widget: this}); - if (_.str.trim(rendered)) { - var elem = $(rendered); - this.$element.replaceWith(elem); - this.$element = elem; + var $el; + if (this.template) { + $el = $(_.str.trim(instance.web.qweb.render( + this.template, {widget: this}))); + } else { + $el = this._make_descriptive(); } + this.replaceElement($el); + }, + + /** + * Re-sets the widget's root element and replaces the old root element + * (if any) by the new one in the DOM. + * + * @param {HTMLElement | jQuery} $el + * @returns {*} this + */ + replaceElement: function ($el) { + var $oldel = this.$element; + this.setElement($el); + if ($oldel && !$oldel.is(this.$element)) { + $oldel.replaceWith(this.$element); + } + return this; }, /** - * Shortcut for $element.find() like backbone + * Re-sets the widget's root element (el/$el/$element). + * + * Includes: + * * re-delegating events + * * re-binding sub-elements + * * if the widget already had a root element, replacing the pre-existing + * element in the DOM + * + * @param {HTMLElement | jQuery} element new root element for the widget + * @return {*} this */ - "$": function() { - return this.$element.find.apply(this.$element,arguments); + setElement: function (element) { + // NB: completely useless, as WidgetMixin#init creates a $element + // always + if (this.$element) { + this.undelegateEvents(); + } + + this.$element = (element instanceof $) ? element : $(element); + this.el = this.$element[0]; + + this.delegateEvents(); + + return this; + }, + /** + * Utility function to build small DOM elements. + * + * @param {String} tagName name of the DOM element to create + * @param {Object} [attributes] map of DOM attributes to set on the element + * @param {String} [content] HTML content to set on the element + * @return {Element} + */ + make: function (tagName, attributes, content) { + var el = document.createElement(tagName); + if (!_.isEmpty(attributes)) { + $(el).attr(attributes); + } + if (content) { + $(el).html(content); + } + return el; + }, + /** + * Makes a potential root element from the declarative builder of the + * widget + * + * @return {jQuery} + * @private + */ + _make_descriptive: function () { + var attrs = _.extend({}, this.attributes || {}); + if (this.id) { attrs.id = this.id; } + if (this.className) { attrs['class'] = this.className; } + return $(this.make(this.tagName, attrs)); + }, + delegateEvents: function () { + var events = this.events; + if (_.isEmpty(events)) { return; } + + for(var key in events) { + if (!events.hasOwnProperty(key)) { continue; } + + var method = this.proxy(events[key]); + + var match = /^(\S+)(\s+(.*))?$/.exec(key); + var event = match[1]; + var selector = match[3]; + + event += '.widget_events'; + if (!selector) { + this.$element.on(event, method); + } else { + this.$element.on(event, selector, method); + } + } + }, + undelegateEvents: function () { + this.$element.off('.widget_events'); + }, + /** + * Shortcut for ``this.$element.find(selector)`` + * + * @param {String} selector CSS selector, rooted in $el + * @returns {jQuery} selector match + */ + $: function(selector) { + return this.$element.find(selector); }, /** * Informs the action manager to do an action. This supposes that diff --git a/addons/web/static/src/js/coresetup.js b/addons/web/static/src/js/coresetup.js index 24441dc509f..12eeb1d79b4 100644 --- a/addons/web/static/src/js/coresetup.js +++ b/addons/web/static/src/js/coresetup.js @@ -19,15 +19,14 @@ instance.web.OldWidget = instance.web.Widget.extend({ this._super(parent); this.element_id = element_id; this.element_id = this.element_id || _.uniqueId('widget-'); + var tmp = document.getElementById(this.element_id); - this.$element = tmp ? $(tmp) : $(document.createElement(this.tagName)); + this.setElement(tmp || this._make_descriptive()); }, renderElement: function() { var rendered = this.render(); if (rendered) { - var elem = $(rendered); - this.$element.replaceWith(elem); - this.$element = elem; + this.replaceElement($(rendered)); } return this; }, diff --git a/addons/web/static/src/js/search.js b/addons/web/static/src/js/search.js index c3e7ad72dcb..f9cf10e76f4 100644 --- a/addons/web/static/src/js/search.js +++ b/addons/web/static/src/js/search.js @@ -861,13 +861,13 @@ instance.web.search.Invalid = instance.web.Class.extend( /** @lends instance.web ); } }); -instance.web.search.Widget = instance.web.OldWidget.extend( /** @lends instance.web.search.Widget# */{ +instance.web.search.Widget = instance.web.Widget.extend( /** @lends instance.web.search.Widget# */{ template: null, /** * Root class of all search widgets * * @constructs instance.web.search.Widget - * @extends instance.web.OldWidget + * @extends instance.web.Widget * * @param view the ancestor view of this widget */ diff --git a/addons/web/static/src/js/view_form.js b/addons/web/static/src/js/view_form.js index 1f693acbc7f..99df34d6c6c 100644 --- a/addons/web/static/src/js/view_form.js +++ b/addons/web/static/src/js/view_form.js @@ -101,7 +101,9 @@ instance.web.FormView = instance.web.View.extend(instance.web.form.FieldManagerM w.off('focused blurred'); w.destroy(); }); - this.$element.off('.formBlur'); + if (this.$element) { + this.$element.off('.formBlur'); + } this._super(); }, on_loaded: function(data) { @@ -3076,7 +3078,6 @@ instance.web.form.FieldOne2Many = instance.web.form.AbstractField.extend({ this.views = views; this.viewmanager = new instance.web.form.One2ManyViewManager(this, this.dataset, views, {}); - this.viewmanager.$element.addClass("oe_view_manager_one2many"); this.viewmanager.o2m = self; var once = $.Deferred().then(function() { self.init_form_last_update.resolve(); @@ -4304,7 +4305,7 @@ instance.web.form.FieldReference = instance.web.form.AbstractField.extend(instan this.selection.view = this.view; this.selection.set({force_readonly: this.get('effective_readonly')}); this.selection.on("change:value", this, this.on_selection_changed); - this.selection.$element = $(".oe_form_view_reference_selection", this.$element); + this.selection.setElement(this.$(".oe_form_view_reference_selection")); this.selection.renderElement(); this.selection.start(); this.selection @@ -4317,7 +4318,7 @@ instance.web.form.FieldReference = instance.web.form.AbstractField.extend(instan this.m2o.view = this.view; this.m2o.set({force_readonly: this.get("effective_readonly")}); this.m2o.on("change:value", this, this.data_changed); - this.m2o.$element = $(".oe_form_view_reference_m2o", this.$element); + this.m2o.setElement(this.$(".oe_form_view_reference_m2o")); this.m2o.renderElement(); this.m2o.start(); this.m2o diff --git a/addons/web/static/src/js/view_list_editable.js b/addons/web/static/src/js/view_list_editable.js index ca889f68d8d..6c38495d0dc 100644 --- a/addons/web/static/src/js/view_list_editable.js +++ b/addons/web/static/src/js/view_list_editable.js @@ -96,9 +96,6 @@ openerp.web.list_editable = function (instance) { }, on_loaded: function (data, grouped) { var self = this; - if (this.editor) { - this.editor.destroy(); - } // tree/@editable takes priority on everything else if present. var result = this._super(data, grouped); if (this.editable()) { @@ -118,6 +115,7 @@ openerp.web.list_editable = function (instance) { self.start_edition(); } }); + this.editor.destroy(); // Editor is not restartable due to formview not being // restartable this.editor = this.make_editor(); diff --git a/addons/web/static/src/js/views.js b/addons/web/static/src/js/views.js index e47981a4284..2b79701ba4d 100644 --- a/addons/web/static/src/js/views.js +++ b/addons/web/static/src/js/views.js @@ -423,7 +423,7 @@ instance.web.ViewManager = instance.web.Widget.extend({ .find('.oe_view_manager_switch a').filter('[data-view-type="' + view_type + '"]') .parent().addClass('active'); - $.when(view_promise).then(function () { + return $.when(view_promise).then(function () { _.each(_.keys(self.views), function(view_name) { var controller = self.views[view_name].controller; if (controller) { @@ -445,7 +445,6 @@ instance.web.ViewManager = instance.web.Widget.extend({ } }); }); - return view_promise; }, do_create_view: function(view_type) { // Lazy loading of views @@ -1175,7 +1174,7 @@ instance.web.View = instance.web.Widget.extend({ this.view_id = view_id; this.set_default_options(options); }, - start: function() { + start: function () { return this.load_view(); }, load_view: function() { diff --git a/addons/web/static/test/Widget.js b/addons/web/static/test/Widget.js new file mode 100644 index 00000000000..55d9e7e7ad1 --- /dev/null +++ b/addons/web/static/test/Widget.js @@ -0,0 +1,237 @@ +$(document).ready(function () { + var $fix = $('#qunit-fixture'); + var mod = { + setup: function () { + instance = window.openerp.init([]); + window.openerp.web.corelib(instance); + + instance.web.qweb = new QWeb2.Engine(); + instance.web.qweb.add_template( + '' + + '' + + '
    ' + + '
  1. ' + + '' + + '' + + '
  2. ' + + '
' + + '
' + + '' + + '

' + + '
' + + '
'); + } + }; + var instance; + + module('Widget.proxy', mod); + test('(String)', function () { + var W = instance.web.Widget.extend({ + exec: function () { + this.executed = true; + } + }); + var w = new W; + var fn = w.proxy('exec'); + fn(); + ok(w.executed, 'should execute the named method in the right context'); + }); + test('(String)(*args)', function () { + var W = instance.web.Widget.extend({ + exec: function (arg) { + this.executed = arg; + } + }); + var w = new W; + var fn = w.proxy('exec'); + fn(42); + ok(w.executed, "should execute the named method in the right context"); + equal(w.executed, 42, "should be passed the proxy's arguments"); + }); + test('(String), include', function () { + // the proxy function should handle methods being changed on the class + // and should always proxy "by name", to the most recent one + var W = instance.web.Widget.extend({ + exec: function () { + this.executed = 1; + } + }); + var w = new W; + var fn = w.proxy('exec'); + W.include({ + exec: function () { this.executed = 2; } + }); + + fn(); + equal(w.executed, 2, "should be lazily resolved"); + }); + + test('(Function)', function () { + var w = new (instance.web.Widget.extend({ })); + + var fn = w.proxy(function () { this.executed = true; }); + fn(); + ok(w.executed, "should set the function's context (like Function#bind)"); + }); + test('(Function)(*args)', function () { + var w = new (instance.web.Widget.extend({ })); + + var fn = w.proxy(function (arg) { this.executed = arg; }); + fn(42); + equal(w.executed, 42, "should be passed the proxy's arguments"); + }); + + module('Widget.renderElement', mod); + test('no template, default', function () { + var w = new (instance.web.Widget.extend({ })); + + ok(!w.$element, "should not initially have a root element"); + w.renderElement(); + ok(w.$element, "should have generated a root element"); + strictEqual(w.$element, w.$element, "should provide $element alias"); + ok(w.$element.is(w.el), "should provide raw DOM alias"); + + equal(w.el.nodeName, 'DIV', "should have generated the default element"); + equal(w.el.attributes.length, 0, "should not have generated any attribute"); + ok(_.isEmpty(w.$element.html(), "should not have generated any content")); + }); + test('no template, custom tag', function () { + var w = new (instance.web.Widget.extend({ + tagName: 'ul' + })); + w.renderElement(); + + equal(w.el.nodeName, 'UL', "should have generated the custom element tag"); + }); + test('no template, @id', function () { + var w = new (instance.web.Widget.extend({ + id: 'foo' + })); + w.renderElement(); + + equal(w.el.attributes.length, 1, "should have one attribute"); + equal(w.$element.attr('id'), 'foo', "should have generated the id attribute"); + equal(w.el.id, 'foo', "should also be available via property"); + }); + test('no template, @className', function () { + var w = new (instance.web.Widget.extend({ + className: 'oe_some_class' + })); + w.renderElement(); + + equal(w.el.className, 'oe_some_class', "should have the right property"); + equal(w.$element.attr('class'), 'oe_some_class', "should have the right attribute"); + }); + test('no template, bunch of attributes', function () { + var w = new (instance.web.Widget.extend({ + attributes: { + 'id': 'some_id', + 'class': 'some_class', + 'data-foo': 'data attribute', + 'clark': 'gable', + 'spoiler': 'snape kills dumbledore' + } + })); + w.renderElement(); + + equal(w.el.attributes.length, 5, "should have all the specified attributes"); + + equal(w.el.id, 'some_id'); + equal(w.$element.attr('id'), 'some_id'); + + equal(w.el.className, 'some_class'); + equal(w.$element.attr('class'), 'some_class'); + + equal(w.$element.attr('data-foo'), 'data attribute'); + equal(w.$element.data('foo'), 'data attribute'); + + equal(w.$element.attr('clark'), 'gable'); + equal(w.$element.attr('spoiler'), 'snape kills dumbledore'); + }); + + test('template', function () { + var w = new (instance.web.Widget.extend({ + template: 'test.widget.template' + })); + w.renderElement(); + + equal(w.el.nodeName, 'OL'); + equal(w.$element.children().length, 5); + equal(w.el.textContent, '01234'); + }); + + module('Widget.$', mod); + test('basic-alias', function () { + var w = new (instance.web.Widget.extend({ + template: 'test.widget.template' + })); + w.renderElement(); + + ok(w.$('li:eq(3)').is(w.$element.find('li:eq(3)')), + "should do the same thing as calling find on the widget root"); + }); + + module('Widget.events', mod); + test('delegate', function () { + var a = []; + var w = new (instance.web.Widget.extend({ + template: 'test.widget.template', + events: { + 'click': function () { + a[0] = true; + strictEqual(this, w, "should trigger events in widget") + }, + 'click li.class-3': 'class3', + 'change input': function () { a[2] = true; } + }, + class3: function () { a[1] = true; } + })); + w.renderElement(); + + w.$element.click(); + w.$('li:eq(3)').click(); + w.$('input:last').val('foo').change(); + + for(var i=0; i<3; ++i) { + ok(a[i], "should pass test " + i); + } + }); + test('undelegate', function () { + var clicked = false, newclicked = false; + var w = new (instance.web.Widget.extend({ + template: 'test.widget.template', + events: { 'click li': function () { clicked = true; } } + })); + w.renderElement(); + w.$element.on('click', 'li', function () { newclicked = true }); + + w.$('li').click(); + ok(clicked, "should trigger bound events"); + ok(newclicked, "should trigger bound events"); + clicked = newclicked = false; + + w.undelegateEvents(); + w.$('li').click(); + ok(!clicked, "undelegate should unbind events delegated"); + ok(newclicked, "undelegate should only unbind events it created"); + }); + + module('Widget.renderElement', mod); + test('repeated', function () { + var w = new (instance.web.Widget.extend({ + template: 'test.widget.template-value' + })); + w.value = 42; + w.appendTo($fix) + .always(start) + .done(function () { + equal($fix.find('p').text(), '42', "DOM fixture should contain initial value"); + equal(w.$element.text(), '42', "should set initial value"); + w.value = 36; + w.renderElement(); + equal($fix.find('p').text(), '36', "DOM fixture should use new value"); + equal(w.$element.text(), '36', "should set new value"); + }); + }); +}); diff --git a/addons/web/static/test/test.html b/addons/web/static/test/test.html index cab1586f240..95518dbc8c6 100644 --- a/addons/web/static/test/test.html +++ b/addons/web/static/test/test.html @@ -61,5 +61,6 @@ + diff --git a/addons/web_calendar/static/src/js/calendar.js b/addons/web_calendar/static/src/js/calendar.js index 59ca58bec86..ae92585f67e 100644 --- a/addons/web_calendar/static/src/js/calendar.js +++ b/addons/web_calendar/static/src/js/calendar.js @@ -492,15 +492,17 @@ instance.web_calendar.Sidebar = instance.web.Widget.extend({ } }); instance.web_calendar.SidebarFilter = instance.web.Widget.extend({ + events: { + 'change input:checkbox': 'on_filter_click' + }, init: function(parent, view) { this._super(parent); this.view = view; - this.$element.delegate('input:checkbox', 'change', this.on_filter_click); }, on_events_loaded: function(filters) { var selected_filters = this.view.selected_filters.slice(0); this.$element.html(QWeb.render('CalendarView.sidebar.responsible', { filters: filters })); - this.$element.find('div.oe_calendar_responsible input').each(function() { + this.$('div.oe_calendar_responsible input').each(function() { if (_.indexOf(selected_filters, $(this).val()) > -1) { $(this).click(); } @@ -511,7 +513,7 @@ instance.web_calendar.SidebarFilter = instance.web.Widget.extend({ responsibles = [], $e = $(e.target); this.view.selected_filters = []; - this.$element.find('div.oe_calendar_responsible input:checked').each(function() { + this.$('div.oe_calendar_responsible input:checked').each(function() { responsibles.push($(this).val()); self.view.selected_filters.push($(this).val()); }); diff --git a/addons/web_dashboard/static/src/js/dashboard.js b/addons/web_dashboard/static/src/js/dashboard.js index a19151af33c..fea775d4ee8 100644 --- a/addons/web_dashboard/static/src/js/dashboard.js +++ b/addons/web_dashboard/static/src/js/dashboard.js @@ -226,6 +226,8 @@ instance.web.form.DashBoard = instance.web.form.FormWidget.extend({ } }, renderElement: function() { + this._super(); + var check = _.detect(this.node.children, function(column, column_index) { return _.detect(column.children,function(element){ return element.tag === "action"? element: false; diff --git a/addons/web_kanban/static/src/js/kanban.js b/addons/web_kanban/static/src/js/kanban.js index c00aa7838d1..69e97384b03 100644 --- a/addons/web_kanban/static/src/js/kanban.js +++ b/addons/web_kanban/static/src/js/kanban.js @@ -720,9 +720,7 @@ instance.web_kanban.KanbanRecord = instance.web.OldWidget.extend({ this.view.dataset.read_ids([this.id], this.view.fields_keys.concat(['__last_update'])).then(function(records) { if (records.length) { self.set_record(records[0]); - var $render = $(self.render()); - self.$element.replaceWith($render); - self.$element = $render; + this.replaceElement($(self.render())); self.$element.data('widget', self); self.bind_events(); self.group.compute_cards_auto_height(); diff --git a/addons/web_mobile/static/src/js/chrome_mobile.js b/addons/web_mobile/static/src/js/chrome_mobile.js index a507e3b5943..029e4bce49b 100644 --- a/addons/web_mobile/static/src/js/chrome_mobile.js +++ b/addons/web_mobile/static/src/js/chrome_mobile.js @@ -48,8 +48,7 @@ instance.web_mobile.Login = instance.web.OldWidget.extend({ jQuery("#oe_header").children().remove(); this.rpc("/web/database/get_list", {}, function(result) { self.db_list = result.db_list; - $('#'+self.element_id).html(self.render(self)); - self.$element = $('#'+self.element_id); + this.setElement($('#'+self.element_id).html(self.render(self))); if(self.session.db!=""){ self.$element.find("#database").val(self.session.db); } diff --git a/addons/web_process/static/src/js/process.js b/addons/web_process/static/src/js/process.js index bb32d300be8..60259f26b64 100644 --- a/addons/web_process/static/src/js/process.js +++ b/addons/web_process/static/src/js/process.js @@ -3,12 +3,13 @@ openerp.web_process = function (instance) { _t = instance.web._t; instance.web.ViewManager.include({ start: function() { - this._super(); + var _super = this._super(); this.process_check(); this.process_help = this.action ? this.action.help : 'Help: Not Defined'; this.model = this.dataset.model; if(this.action) this.process_model = this.action.res_model; else this.process_model = this.model; + return _super; }, process_check: function() { var self = this, diff --git a/doc/addons.rst b/doc/addons.rst index da1f1f28769..35878b04430 100644 --- a/doc/addons.rst +++ b/doc/addons.rst @@ -113,103 +113,6 @@ initializing the addon. Creating new standard roles --------------------------- -Widget -++++++ - -This is the base class for all visual components. It provides a number of -services for the management of a DOM subtree: - -* Rendering with QWeb - -* Parenting-child relations - -* Life-cycle management (including facilitating children destruction when a - parent object is removed) - -* DOM insertion, via jQuery-powered insertion methods. Insertion targets can - be anything the corresponding jQuery method accepts (generally selectors, - DOM nodes and jQuery objects): - - :js:func:`~openerp.base.Widget.appendTo` - Renders the widget and inserts it as the last child of the target, uses - `.appendTo()`_ - - :js:func:`~openerp.base.Widget.prependTo` - Renders the widget and inserts it as the first child of the target, uses - `.prependTo()`_ - - :js:func:`~openerp.base.Widget.insertAfter` - Renders the widget and inserts it as the preceding sibling of the target, - uses `.insertAfter()`_ - - :js:func:`~openerp.base.Widget.insertBefore` - Renders the widget and inserts it as the following sibling of the target, - uses `.insertBefore()`_ - -:js:class:`~openerp.base.Widget` inherits from -:js:class:`~openerp.base.SessionAware`, so subclasses can easily access the -RPC layers. - -Subclassing Widget -~~~~~~~~~~~~~~~~~~ - -:js:class:`~openerp.base.Widget` is subclassed in the standard manner (via the -:js:func:`~openerp.base.Class.extend` method), and provides a number of -abstract properties and concrete methods (which you may or may not want to -override). Creating a subclass looks like this: - -.. code-block:: javascript - - var MyWidget = openerp.base.Widget.extend({ - // QWeb template to use when rendering the object - template: "MyQWebTemplate", - - init: function(parent) { - this._super(parent); - // insert code to execute before rendering, for object - // initialization - }, - start: function() { - this._super(); - // post-rendering initialization code, at this point - // ``this.$element`` has been initialized - this.$element.find(".my_button").click(/* an example of event binding * /); - - // if ``start`` is asynchronous, return a promise object so callers - // know when the object is done initializing - return this.rpc(/* … */) - } - }); - -The new class can then be used in the following manner: - -.. code-block:: javascript - - // Create the instance - var my_widget = new MyWidget(this); - // Render and insert into DOM - my_widget.appendTo(".some-div"); - -After these two lines have executed (and any promise returned by ``appendTo`` -has been resolved if needed), the widget is ready to be used. - -.. note:: the insertion methods will start the widget themselves, and will - return the result of :js:func:`~openerp.base.Widget.start()`. - - If for some reason you do not want to call these methods, you will - have to first call :js:func:`~openerp.base.Widget.render()` on the - widget, then insert it into your DOM and start it. - -If the widget is not needed anymore (because it's transient), simply terminate -it: - -.. code-block:: javascript - - my_widget.stop(); - -will unbind all DOM events, remove the widget's content from the DOM and -destroy all widget data. - Views +++++ @@ -541,18 +444,6 @@ Python .. _promise object: http://api.jquery.com/deferred.promise/ -.. _.appendTo(): - http://api.jquery.com/appendTo/ - -.. _.prependTo(): - http://api.jquery.com/prependTo/ - -.. _.insertAfter(): - http://api.jquery.com/insertAfter/ - -.. _.insertBefore(): - http://api.jquery.com/insertBefore/ - .. _Rosetta: .. _Launchpad's own translation tool: https://help.launchpad.net/Translations diff --git a/doc/index.rst b/doc/index.rst index 8608af59d3d..0c98815797b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -16,6 +16,7 @@ Contents: async rpc + widget search-view list-view diff --git a/doc/widget.rst b/doc/widget.rst new file mode 100644 index 00000000000..ddfe525227d --- /dev/null +++ b/doc/widget.rst @@ -0,0 +1,274 @@ +User Interaction: Widget +======================== + +This is the base class for all visual components. It corresponds to an MVC +view. It provides a number of services to handle a section of a page: + +* Rendering with QWeb + +* Parenting-child relations + +* Life-cycle management (including facilitating children destruction when a + parent object is removed) + +* DOM insertion, via jQuery-powered insertion methods. Insertion targets can + be anything the corresponding jQuery method accepts (generally selectors, + DOM nodes and jQuery objects): + + :js:func:`~openerp.base.Widget.appendTo` + Renders the widget and inserts it as the last child of the target, uses + `.appendTo()`_ + + :js:func:`~openerp.base.Widget.prependTo` + Renders the widget and inserts it as the first child of the target, uses + `.prependTo()`_ + + :js:func:`~openerp.base.Widget.insertAfter` + Renders the widget and inserts it as the preceding sibling of the target, + uses `.insertAfter()`_ + + :js:func:`~openerp.base.Widget.insertBefore` + Renders the widget and inserts it as the following sibling of the target, + uses `.insertBefore()`_ + +* Backbone-compatible shortcuts + +DOM Root +-------- + +A :js:class:`~openerp.web.Widget` is responsible for a section of the +page materialized by the DOM root of the widget. The DOM root is +available via the :js:attr:`~openerp.web.Widget.el` and +:js:attr:`~openerp.web.Widget.$element` attributes, which are +respectively the raw DOM Element and the jQuery wrapper around the DOM +element. + +There are two main ways to define and generate this DOM root: + +.. js:attribute:: openerp.web.Widget.template + + Should be set to the name of a QWeb template (a + :js:class:`String`). If set, the template will be rendered after + the widget has been initialized but before it has been + started. The root element generated by the template will be set as + the DOM root of the widget. + +.. js:attribute:: openerp.web.Widget.tagName + + Used if the widget has no template defined. Defaults to ``div``, + will be used as the tag name to create the DOM element to set as + the widget's DOM root. It is possible to further customize this + generated DOM root with the following attributes: + + .. js:attribute:: openerp.web.Widget.id + + Used to generate an ``id`` attribute on the generated DOM + root. + + .. js:attribute:: openerp.web.Widget.className + + Used to generate a ``class`` attribute on the generated DOM root. + + .. js:attribute:: openerp.web.Widget.attributes + + Mapping (object literal) of attribute names to attribute + values. Each of these k:v pairs will be set as a DOM attribute + on the generated DOM root. + + None of these is used in case a template is specified on the widget. + +The DOM root can also be defined programmatically by overridding + +.. js:function:: openerp.web.Widget.renderElement + + Renders the widget's DOM root and sets it. The default + implementation will render a set template or generate an element + as described above, and will call + :js:func:`~openerp.web.Widget.setElement` on the result. + + Any override to :js:func:`~openerp.web.Widget.renderElement` which + does not call its ``_super`` **must** call + :js:func:`~openerp.web.Widget.setElement` with whatever it + generated or the widget's behavior is undefined.r + + .. note:: + + The default :js:func:`~openerp.web.Widget.renderElement` can + be called repeatedly, it will *replace* the previous DOM root + (using ``replaceWith``). However, this requires that the + widget correctly sets and unsets its events (and children + widgets). Generally, + :js:func:`~openerp.web.Widget.renderElement` should not be + called repeatedly unless the widget advertizes this feature. + +Accessing DOM content +~~~~~~~~~~~~~~~~~~~~~ + +Because a widget is only responsible for the content below its DOM +root, there is a shortcut for selecting sub-sections of a widget's +DOM: + +.. js:function:: openerp.web.Widget.$(selector) + + Applies the CSS selector specified as parameter to the widget's + DOM root. + + .. code-block:: javascript + + this.$(selector); + + is functionally identical to: + + .. code-block:: javascript + + this.$element.find(selector); + + :param String selector: CSS selector + :returns: jQuery object + + .. note:: this helper method is compatible with + ``Backbone.View.$`` + +Resetting the DOM root +~~~~~~~~~~~~~~~~~~~~~~ + +.. js:function:: openerp.web.Widget.setElement(element) + + Re-sets the widget's DOM root to the provided element, also + handles re-setting the various aliases of the DOM root as well as + unsetting and re-setting delegated events. + + :param Element element: a DOM element or jQuery object to set as + the widget's DOM root + + .. note:: should be mostly compatible with `Backbone's + setElement`_ + +DOM events handling +------------------- + +A widget will generally need to respond to user action within its +section of the page. This entails binding events to DOM elements. + +To this end, :js:class:`~openerp.web.Widget` provides an shortcut: + +.. js:attribute:: openerp.web.Widget.events + + Events are a mapping of ``event selector`` (an event name and a + CSS selector separated by a space) to a callback. The callback can + be either a method name in the widget or a function. In either + case, the ``this`` will be set to the widget. + + The selector is used for jQuery's `event delegation`_, the + callback will only be triggered for descendants of the DOM root + matching the selector [0]_. If the selector is left out (only an + event name is specified), the event will be set directly on the + widget's DOM root. + +.. js:function:: openerp.web.Widget.delegateEvents + + This method is in charge of binding + :js:attr:`~openerp.web.Widget.events` to the DOM. It is + automatically called after setting the widget's DOM root. + + It can be overridden to set up more complex events than the + :js:attr:`~openerp.web.Widget.events` map allows, but the parent + should always be called (or :js:attr:`~openerp.web.Widget.events` + won't be handled correctly). + +.. js:function:: openerp.web.Widget.undelegateEvents + + This method is in charge of unbinding + :js:attr:`~openerp.web.Widget.events` from the DOM root when the + widget is destroyed or the DOM root is reset, in order to avoid + leaving "phantom" events. + + It should be overridden to un-set any event set in an override of + :js:func:`~openerp.web.Widget.delegateEvents`. + +.. note:: this behavior should be compatible with `Backbone's + delegateEvents`_, apart from not accepting any argument. + +Subclassing Widget +------------------ + +:js:class:`~openerp.base.Widget` is subclassed in the standard manner (via the +:js:func:`~openerp.base.Class.extend` method), and provides a number of +abstract properties and concrete methods (which you may or may not want to +override). Creating a subclass looks like this: + +.. code-block:: javascript + + var MyWidget = openerp.base.Widget.extend({ + // QWeb template to use when rendering the object + template: "MyQWebTemplate", + + init: function(parent) { + this._super(parent); + // insert code to execute before rendering, for object + // initialization + }, + start: function() { + this._super(); + // post-rendering initialization code, at this point + // ``this.$element`` has been initialized + this.$element.find(".my_button").click(/* an example of event binding * /); + + // if ``start`` is asynchronous, return a promise object so callers + // know when the object is done initializing + return this.rpc(/* … */) + } + }); + +The new class can then be used in the following manner: + +.. code-block:: javascript + + // Create the instance + var my_widget = new MyWidget(this); + // Render and insert into DOM + my_widget.appendTo(".some-div"); + +After these two lines have executed (and any promise returned by ``appendTo`` +has been resolved if needed), the widget is ready to be used. + +.. note:: the insertion methods will start the widget themselves, and will + return the result of :js:func:`~openerp.base.Widget.start()`. + + If for some reason you do not want to call these methods, you will + have to first call :js:func:`~openerp.base.Widget.render()` on the + widget, then insert it into your DOM and start it. + +If the widget is not needed anymore (because it's transient), simply terminate +it: + +.. code-block:: javascript + + my_widget.destroy(); + +will unbind all DOM events, remove the widget's content from the DOM and +destroy all widget data. + +.. [0] not all DOM events are compatible with events delegation + +.. _.appendTo(): + http://api.jquery.com/appendTo/ + +.. _.prependTo(): + http://api.jquery.com/prependTo/ + +.. _.insertAfter(): + http://api.jquery.com/insertAfter/ + +.. _.insertBefore(): + http://api.jquery.com/insertBefore/ + +.. _event delegation: + http://api.jquery.com/delegate/ + +.. _Backbone's setElement: + http://backbonejs.org/#View-setElement + +.. _Backbone's delegateEvents: + http://backbonejs.org/#View-delegateEvents +