diff --git a/addons/base/static/lib/qweb/qweb-test-extend.xml b/addons/base/static/lib/qweb/qweb-test-extend.xml new file mode 100644 index 00000000000..44fda920e43 --- /dev/null +++ b/addons/base/static/lib/qweb/qweb-test-extend.xml @@ -0,0 +1,32 @@ + + + + + +
  • 3
  • +
    + +
  • 2
  • +
    + +
  • 1
  • +
    +
    +
    + + + this.attr('class', 'main'); + + + +
    +
    + + END + + + +
  • CLONED TEMPLATE
  • +
    + +
    diff --git a/addons/base/static/lib/qweb/qweb-test.js.html b/addons/base/static/lib/qweb/qweb-test.js.html index b39527f0c13..fda837497c4 100644 --- a/addons/base/static/lib/qweb/qweb-test.js.html +++ b/addons/base/static/lib/qweb/qweb-test.js.html @@ -5,6 +5,7 @@ + diff --git a/addons/base/static/lib/qweb/qweb2.js b/addons/base/static/lib/qweb/qweb2.js index edf8f8b2b21..36d0dce09c4 100644 --- a/addons/base/static/lib/qweb/qweb2.js +++ b/addons/base/static/lib/qweb/qweb2.js @@ -77,6 +77,41 @@ var QWeb2 = { } return -1; }, + xml_node_to_string : function(node, childs_only) { + if (childs_only) { + var childs = node.childNodes, r = []; + for (var i = 0, ilen = childs.length; i < ilen; i++) { + r.push(this.xml_node_to_string(childs[i])); + } + return r.join(''); + } else { + if (node.xml !== undefined) { + return node.xml; + } else { + return (new XMLSerializer()).serializeToString(node); + } + } + }, + xpath_selectNodes : function(node, expr) { + if (node.selectNodes !== undefined) { + return node.selectNodes(expr); + } else { + var xpath = new XPathEvaluator(); + try { + var result = xpath.evaluate(expr, node, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); + } catch(error) { + this.exception("Error with XPath expression '" + expr + "' : " + error); + } + var r = []; + if (result != null) { + var element; + while (element = result.iterateNext()) { + r.push(element); + } + } + return r + } + }, call: function(context, template, old_dict, _import, callback) { var new_dict = this.extend({}, old_dict); new_dict['__caller__'] = old_dict['__template__']; @@ -157,8 +192,10 @@ QWeb2.Engine = (function() { this.templates_resources = []; // TODO: implement this.reload() this.templates = {}; this.compiled_templates = {}; + this.extend_templates = {}; this.dict = {}; this.tools = QWeb2.tools; + this.jQuery = window.jQuery; for (var i = 0; i < arguments.length; i++) { this.add_template(arguments[i]); } @@ -178,8 +215,25 @@ QWeb2.Engine = (function() { return this.tools.exception(node.innerText); } var name = node.getAttribute(this.prefix + '-name'); + var extend = node.getAttribute(this.prefix + '-extend'); + if (name && extend) { + // Clone template and extend it + if (!this.templates[extend]) { + return this.tools.exception("Can't clone undefined template " + extend); + } + this.templates[name] = this.templates[extend].cloneNode(true); + extend = name; + name = undefined; + } if (name) { this.templates[name] = node; + } else if (extend) { + delete(this.compiled_templates[extend]); + if (this.extend_templates[extend]) { + this.extend_templates[extend].push(node); + } else { + this.extend_templates[extend] = [node]; + } } } } @@ -256,15 +310,26 @@ QWeb2.Engine = (function() { "});"; }, render : function(template, dict) { - //console.time("QWeb render template " + template); + if (this.debug && window['console'] !== undefined) { + console.time("QWeb render template " + template); + } var r = this._render(template, dict); - //console.timeEnd("QWeb render template " + template); + if (this.debug && window['console'] !== undefined) { + console.timeEnd("QWeb render template " + template); + } return r; }, _render : function(template, dict) { if (this.compiled_templates[template]) { return this.compiled_templates[template].apply(this, [dict || {}]); } else if (this.templates[template]) { + var ext; + if (ext = this.extend_templates[template]) { + var extend_node; + while (extend_node = ext.shift()) { + this.extend(template, extend_node); + } + } var code = this.compile(this.templates[template]), tcompiled; try { // tcompiled = new Function(code); TODO: Check if could use this in order to avoid eval @@ -278,6 +343,53 @@ QWeb2.Engine = (function() { } else { return this.tools.exception("Template '" + template + "' not found"); } + }, + extend : function(template, extend_node) { + if (!this.jQuery) { + return this.tools.exception("Can't extend template " + template + " without jQuery"); + } + for (var i = 0, ilen = extend_node.childNodes.length; i < ilen; i++) { + var child = extend_node.childNodes[i]; + if (child.nodeType === 1) { + var xpath = child.getAttribute(this.prefix + '-xpath'), + jquery = child.getAttribute(this.prefix + '-jquery'), + operation = child.getAttribute(this.prefix + '-operation'), + target, + error_msg = "Error while extending template '" + template; + if (jquery) { + target = this.jQuery(jquery, this.templates[template]); + } else if (xpath) { + // NOTE: due to the XPath implementation, extending a template will only work once + // when using XPath because XPathResult won't match objects with other constructor than 'Element' + // As jQuery will create HTMLElements, xpath will ignore them then causing problems when a + // template has already been extended. XPath selectors will probably be removed in QWeb. + target = this.jQuery(this.tools.xpath_selectNodes(this.templates[template], xpath)); + } else { + this.tools.exception(error_msg + "No expression given"); + } + error_msg += "' (expression='" + (jquery || xpath) + "') : "; + var inner = this.tools.xml_node_to_string(child, true); + if (operation) { + var allowed_operations = "append,prepend,before,after,replace,inner".split(','); + if (this.tools.arrayIndexOf(allowed_operations, operation) == -1) { + this.tools.exception(error_msg + "Invalid operation : '" + operation + "'"); + } + operation = {'replace' : 'replaceWith', 'inner' : 'html'}[operation] || operation; + target[operation]($(inner)); + } else { + try { + var f = new Function(['$'], inner); + } catch(error) { + return this.tools.exception("Parse " + error_msg + error); + } + try { + f.apply(target, [this.jQuery]); + } catch(error) { + return this.tools.exception("Runtime " + error_msg + error); + } + } + } + } } }); return Engine; diff --git a/addons/base/static/src/css/base.css b/addons/base/static/src/css/base.css index 0ca1c995b33..6656dd42849 100644 --- a/addons/base/static/src/css/base.css +++ b/addons/base/static/src/css/base.css @@ -568,7 +568,6 @@ body.openerp { /* Form */ .openerp table.oe_frame td { color: #4c4c4c; - font-weight: bold; } .openerp .required.error { border: 1px solid #900; @@ -611,6 +610,9 @@ body.openerp { min-width: 90px; color: #1f1f1f; } +.openerp textarea { + resize:vertical; +} .openerp input[type="text"], .openerp input[type="password"], .openerp select, .openerp .button { height: 22px; } @@ -646,6 +648,12 @@ body.openerp { background-color: #F66; border: 1px solid #D00; } +.openerp div.oe-progressbar span { + position: absolute; + margin-left: 10px; + margin-top: 5px; + font-weight: bold; +} /* jQuery UI override */ .openerp .ui-widget { diff --git a/addons/base/static/src/js/form.js b/addons/base/static/src/js/form.js index 3a6ef174c8d..27beadbdecf 100644 --- a/addons/base/static/src/js/form.js +++ b/addons/base/static/src/js/form.js @@ -773,6 +773,14 @@ openerp.base.form.FieldProgressBar = openerp.base.form.Field.extend({ value: this.value, disabled: this.readonly }); + }, + set_value: function(value) { + this._super.apply(this, arguments); + var show_value = Number(value); + if (show_value === NaN) { + show_value = 0; + } + this.$element.find('div').progressbar('option', 'value', show_value).find('span').html(show_value + '%'); } }); diff --git a/addons/base/static/src/js/list.js b/addons/base/static/src/js/list.js index e9061fa7121..62d29da0cba 100644 --- a/addons/base/static/src/js/list.js +++ b/addons/base/static/src/js/list.js @@ -44,6 +44,7 @@ openerp.base.ListView = openerp.base.Controller.extend( * @borrows openerp.base.ActionExecutor#execute_action as #execute_action */ init: function(view_manager, session, element_id, dataset, view_id, options) { + var self = this; this._super(session, element_id); this.view_manager = view_manager; this.dataset = dataset; @@ -54,6 +55,35 @@ openerp.base.ListView = openerp.base.Controller.extend( this.rows = []; this.options = _.extend({}, this.defaults, options || {}); + + this.list = new openerp.base.ListView.List({ + options: this.options, + columns: this.columns, + rows: this.rows + }); + $(this.list).bind({ + 'selected': function (e, selection) { + self.$element.find('#oe-list-delete') + .toggle(!!selection.length); + }, + 'deleted': function (e, ids) { + self.do_delete(ids); + }, + 'action': function (e, action_name, id) { + var action = _.detect(self.columns, function (field) { + return field.name === action_name; + }); + if (!action) { return; } + // TODO: not supposed to reload everything, I think + self.execute_action( + action, self.dataset, self.session.action_manager, + id, self.do_reload); + }, + 'row_link': function (e, index) { + self.switch_to_record(index); + } + }); + }, /** * View startup method, the default behavior is to set the ``oe-listview`` @@ -99,7 +129,9 @@ openerp.base.ListView = openerp.base.Controller.extend( var fields = this.fields_view.fields; var domain_computer = openerp.base.form.compute_domain; - this.columns = _(this.fields_view.arch.children).chain() + + this.columns.splice(0, this.columns.length); + this.columns.push.apply(this.columns, _(this.fields_view.arch.children).chain() .map(function (field) { var name = field.attrs.name; var column = _.extend({id: name, tag: field.tag}, @@ -118,13 +150,14 @@ openerp.base.ListView = openerp.base.Controller.extend( column.attrs_for = function () { return {}; }; } return column; - }).value(); + }).value()); this.visible_columns = _.filter(this.columns, function (column) { return column.invisible !== '1'; }); if (!this.fields_view.sorted) { this.fields_view.sorted = {}; } + this.$element.html(QWeb.render("ListView", this)); // Head hook @@ -142,42 +175,7 @@ openerp.base.ListView = openerp.base.Controller.extend( }); var $table = this.$element.find('table'); - // Cell events - $table.delegate( - 'th.oe-record-selector', 'click', function (e) { - // TODO: ~linear performances, would a simple counter work? - if ($table.find('th.oe-record-selector input:checked').length) { - $table.find('#oe-list-delete').show(); - } else { - $table.find('#oe-list-delete').hide(); - } - // A click in the selection cell should not activate the - // linking feature - e.stopImmediatePropagation(); - }); - $table.delegate( - 'td.oe-field-cell button', 'click', function (e) { - e.stopImmediatePropagation(); - - var $cell = $(e.currentTarget).closest('td'); - - var col_index = $cell.prevAll('td').length; - var field = self.visible_columns[col_index]; - - var $row = $cell.parent('tr'); - var row = self.rows[$row.prevAll().length]; - - // TODO: we should probably only reload content, also maybe diff records or something, instead of replacing every single row - self.execute_action( - field, self.dataset, self.session.action_manager, - row.data.id.value, self.do_reload); - }); - $table.delegate( - 'td.oe-record-delete button', 'click', this.do_delete); - - // Global rows handlers - $table.delegate( - 'tr', 'click', this.on_select_row); + this.list.move_to($table); // sidebar stuff if (this.view_manager && this.view_manager.sidebar) { @@ -192,15 +190,15 @@ openerp.base.ListView = openerp.base.Controller.extend( * @param {Object} result filling result * @param {Array} [result.view] the new view (wrapped fields_view_get result) * @param {Array} result.records records the records to fill the list view with - * @returns {Promise} promise to the end of view rendering (list views are asynchronously filled for improved responsiveness) */ do_fill_table: function(result) { if (result.view) { this.on_loaded({fields_view: result.view}); } var records = result.records; - var $table = this.$element.find('table'); - this.rows = records; + + this.rows.splice(0, this.rows.length); + this.rows.push.apply(this.rows, records); // Keep current selected record, if it's still in our new search var current_record_id = this.dataset.ids[this.dataset.index]; @@ -214,46 +212,11 @@ openerp.base.ListView = openerp.base.Controller.extend( this.dataset.count = this.dataset.ids.length; var results = this.rows.length; - $table.find('.oe-pager-last').text(results); - $table.find('.oe-pager-total').text(results); + this.$element.find('table') + .find('.oe-pager-last').text(results).end() + .find('.oe-pager-total').text(results); - - // remove all data lines - var $old_body = $table.find('tbody'); - - // add new content - var columns = this.columns, - rows = this.rows, - options = this.options; - - // Paginate by groups of 50 for rendering - var PAGE_SIZE = 50, - bodies_count = Math.ceil(this.rows.length / PAGE_SIZE), - body = 0, - $body = $('').appendTo($table); - - var rendered = $.Deferred(); - var render_body = function () { - setTimeout(function () { - $body.append( - QWeb.render("ListView.rows", { - columns: columns, - rows: rows.slice(body*PAGE_SIZE, (body+1)*PAGE_SIZE), - options: options - })); - ++body; - if (body < bodies_count) { - render_body(); - } else { - rendered.resolve(); - } - }, 0); - }; - render_body(); - - return rendered.promise().then(function () { - $old_body.remove(); - }); + this.list.refresh(); }, /** * Used to handle a click on a table row, if no other handler caught the @@ -279,30 +242,6 @@ openerp.base.ListView = openerp.base.Controller.extend( } }, this)); }, - /** - * Base handler for clicking on a row, discovers the index of the record - * corresponding to the clicked row in the list view's dataset. - * - * Should not be overridden, use - * :js:func:`~openerp.base.ListView.switch_to_record` to customize the - * behavior of the list view when clicking on a row instead. - * - * @param {Object} event jQuery DOM event object - */ - on_select_row: function (event) { - var $target = $(event.currentTarget); - if (!$target.parent().is('tbody')) { - return; - } - // count number of preceding siblings to line clicked - var row = this.rows[$target.prevAll().length]; - - var index = _.indexOf(this.dataset.ids, row.data.id.value); - if (index == undefined || index === -1) { - return; - } - this.switch_to_record(index); - }, do_show: function () { this.$element.show(); if (this.hidden) { @@ -361,13 +300,35 @@ openerp.base.ListView = openerp.base.Controller.extend( /** * Handles the signal to delete a line from the DOM * - * @param e jQuery event object + * @param {Array} ids the id of the object to delete */ - do_delete: function (e) { - // don't link to forms - e.stopImmediatePropagation(); - this.dataset.unlink( - [this.rows[$(e.currentTarget).closest('tr').prevAll().length].data.id.value]); + do_delete: function (ids) { + if (!ids.length) { + return; + } + var self = this; + return $.when(this.dataset.unlink(ids)).then(function () { + _(self.rows).chain() + .map(function (row, index) { + return { + index: index, + id: row.data.id.value + };}) + .filter(function (record) { + return _.contains(ids, record.id); + }) + .sort(function (a, b) { + // sort in reverse index order, so we delete from the end + // and don't blow up the following indexes (leading to + // removing the wrong records from the visible list) + return b.index - a.index; + }) + .each(function (record) { + self.rows.splice(record.index, 1); + }); + // TODO only refresh modified rows + self.list.refresh(); + }); }, /** * Handles signal for the addition of a new record (can be a creation, @@ -383,10 +344,89 @@ openerp.base.ListView = openerp.base.Controller.extend( * Handles deletion of all selected lines */ do_delete_selected: function () { - var selection = this.get_selection(); - if (selection.length) { - this.dataset.unlink(selection); - } + this.do_delete( + this.list.get_selection()); + } + // TODO: implement reorder (drag and drop rows) +}); +_.extend(openerp.base.ListView.prototype, openerp.base.ActionExecutor); + +openerp.base.ListView.List = Class.extend( + /** @lends openerp.base.ListView.List# */{ + /** + * List display for the ListView, handles basic DOM events and transforms + * them in the relevant higher-level events, to which the list view (or + * other consumers) can subscribe. + * + * Events on this object are registered via jQuery. + * + * Available events: + * + * `selected` + * Triggered when a row is selected (using check boxes), provides an + * array of ids of all the selected records. + * `deleted` + * Triggered when deletion buttons are hit, provide an array of ids of + * all the records being marked for suppression. + * `action` + * Triggered when an action button is clicked, provides two parameters: + * + * * The name of the action to execute (as a string) + * * The id of the record to execute the action on + * `row_link` + * Triggered when a row of the table is clicked, provides the index (in + * the rows array) and id of the selected record to the handle function. + * + * @constructs + * @param {Object} opts display options, identical to those of :js:class:`openerp.base.ListView` + */ + init: function (opts) { + var self = this; + // columns, rows, options + + this.options = opts.options; + this.columns = opts.columns; + this.rows = opts.rows; + + this.$_element = $('') + .appendTo(document.body) + .delegate('th.oe-record-selector', 'click', function (e) { + e.stopPropagation(); + $(self).trigger('selected', [self.get_selection()]); + }) + .delegate('td.oe-record-delete button', 'click', function (e) { + e.stopPropagation(); + var $row = $(e.target).closest('tr'); + $(self).trigger('deleted', [[self.row_id($row)]]); + }) + .delegate('td.oe-field-cell button', 'click', function (e) { + e.stopPropagation(); + var $target = $(e.currentTarget), + field = $target.closest('td').data('field'), + record_id = self.row_id($target.closest('tr')); + + $(self).trigger('action', [field, record_id]); + }) + .delegate('tr', 'click', function (e) { + e.stopPropagation(); + $(self).trigger( + 'row_link', + [self.row_position(e.currentTarget), + self.row_id(e.currentTarget)]); + }); + }, + move_to: function (element) { + this.$current = this.$_element.clone(true).appendTo(element); + this.render(); + return this; + }, + render: function () { + this.$current.empty().append($(QWeb.render('ListView.rows', this))); + return this; + }, + refresh: function () { + this.render(); + return this; }, /** * Gets the ids of all currently selected records, if any @@ -397,15 +437,33 @@ openerp.base.ListView = openerp.base.Controller.extend( return []; } var rows = this.rows; - return this.$element.find('th.oe-record-selector input:checked') + return this.$current.find('th.oe-record-selector input:checked') .closest('tr').map(function () { return rows[$(this).prevAll().length].data.id.value; }).get(); + }, + /** + * Returns the index of the row in the list of rows. + * + * @param {Object} row the selected row + * @returns {Number} the position of the row in this.rows + */ + row_position: function (row) { + return $(row).prevAll().length; + }, + /** + * Returns the identifier of the object displayed in the provided table + * row + * + * @param {Object} row the selected table row + * @returns {Number|String} the identifier of the row's object + */ + row_id: function (row) { + return this.rows[this.row_position(row)].data.id.value; } - // TODO: implement sort (click on column headers), if sorted, the list can not be reordered anymore - // TODO: implement reorder (drag and drop rows) + // drag and drop + // editable? }); -_.extend(openerp.base.ListView.prototype, openerp.base.ActionExecutor); openerp.base.TreeView = openerp.base.Controller.extend({ }); diff --git a/addons/base/static/src/xml/base.xml b/addons/base/static/src/xml/base.xml index 9e8000c0f95..8675e59818d 100644 --- a/addons/base/static/src/xml/base.xml +++ b/addons/base/static/src/xml/base.xml @@ -208,7 +208,7 @@ + class="oe-field-cell" t-att-data-field="column.id"> @@ -365,7 +365,9 @@ t-att-class="'field_' + widget.type"/> -
    +
    + +